Compare commits

..

6 Commits

Author SHA1 Message Date
ddc86f1ba0 version: 1.0.176 2026-07-07 22:53:59 -04:00
562e52e0eb queryer: deterministic result order — array aggregations emit ORDER BY entity.created_at, entity.id (creation order, id tiebreak; every entity-family type has the entity alias), fixing run-to-run row-order variance (flaky reads like invoice-lines order, unstable grids). Also: the snapshot formatter silently DROPPED aggregate ORDER BY clauses when re-rendering parsed SQL — now rendered (format_function_clauses), so snapshots can't hide ordering changes. 1280 tests green. 2026-07-07 22:53:48 -04:00
569eb1d2ea version: 1.0.175 2026-07-07 21:38:14 -04:00
6cebce3740 queryer: array containment fires on punc-response paths — compile_array hands the entity node the ITEMS PROXY as its schema (no properties), so property-shape checks that read node.schema silently missed; fall back to the type's own schema (Type.schemas[name]). Fixture adds get_people punc + containment/cast filters through it. Live case: get_vendors trade filter still compiled =::jsonb after 1.0.174. 2026-07-07 21:38:08 -04:00
d3de3961c1 version: 1.0.174 2026-07-07 21:18:37 -04:00
d0ae6eeddd queryer: array-property detection handles the compiled list form — engine-compiled schemas normalize to type:["array"] (SchemaTypeOrArray::Multiple), so the containment branch only fired for hand-written Single("array") schemas and live jsonb array filters fell through to =::jsonb ('Token invalid' at execution). Accept both encodings (array, [array], [array,null]). Fixture pins the list form (labels). Found live: agreego vendor.trade filter. 2026-07-07 21:18:31 -04:00
5 changed files with 202 additions and 43 deletions

View File

@ -70,6 +70,17 @@
}
}
}
},
{
"name": "get_people",
"schemas": {
"get_people.response": {
"type": "array",
"items": {
"type": "person"
}
}
}
}
],
"enums": [],
@ -367,7 +378,8 @@
"archived",
"created_at",
"rating",
"tags"
"tags",
"labels"
],
"grouped_fields": {
"entity": [
@ -384,7 +396,8 @@
"last_name",
"age",
"rating",
"tags"
"tags",
"labels"
]
},
"field_types": {
@ -397,7 +410,8 @@
"age": "numeric",
"created_at": "timestamptz",
"rating": "float8",
"tags": "jsonb"
"tags": "jsonb",
"labels": "jsonb"
},
"schemas": {
"person": {
@ -420,6 +434,16 @@
"items": {
"type": "string"
}
},
"labels": {
"type": [
"array"
],
"items": {
"type": [
"string"
]
}
}
}
},
@ -440,6 +464,16 @@
"items": {
"type": "string"
}
},
"labels": {
"type": [
"array"
],
"items": {
"type": [
"string"
]
}
}
}
},
@ -508,6 +542,16 @@
"items": {
"type": "string"
}
},
"labels": {
"type": [
"array"
],
"items": {
"type": [
"string"
]
}
}
}
}
@ -1325,7 +1369,8 @@
" 'last_name', person_3.last_name,",
" 'age', person_3.age,",
" 'rating', person_3.rating,",
" 'tags', person_3.tags",
" 'tags', person_3.tags,",
" 'labels', person_3.labels",
" )",
" FROM agreego.entity entity_1",
" JOIN agreego.organization organization_2",
@ -1357,6 +1402,7 @@
" 'age', person_3.age,",
" 'rating', person_3.rating,",
" 'tags', person_3.tags,",
" 'labels', person_3.labels,",
" 'phone_numbers', (",
" SELECT COALESCE(jsonb_agg(jsonb_build_object(",
" 'id', entity_4.id,",
@ -1378,7 +1424,7 @@
" NOT entity_7.archived",
" AND relationship_5.target_id = entity_7.id",
" )",
" )), '[]'::jsonb)",
" ) ORDER BY entity_4.created_at, entity_4.id), '[]'::jsonb)",
" FROM agreego.entity entity_4",
" JOIN agreego.relationship relationship_5",
" JOIN agreego.contact contact_6",
@ -1408,7 +1454,7 @@
" NOT entity_12.archived",
" AND relationship_10.target_id = entity_12.id",
" )",
" )), '[]'::jsonb)",
" ) ORDER BY entity_9.created_at, entity_9.id), '[]'::jsonb)",
" FROM agreego.entity entity_9",
" JOIN agreego.relationship relationship_10",
" JOIN agreego.contact contact_11",
@ -1438,7 +1484,7 @@
" NOT entity_17.archived",
" AND relationship_15.target_id = entity_17.id",
" )",
" )), '[]'::jsonb)",
" ) ORDER BY entity_14.created_at, entity_14.id), '[]'::jsonb)",
" FROM agreego.entity entity_14",
" JOIN agreego.relationship relationship_15",
" JOIN agreego.contact contact_16",
@ -1499,7 +1545,7 @@
" ))",
" ELSE NULL",
" END",
" )), '[]'::jsonb)",
" ) ORDER BY entity_19.created_at, entity_19.id), '[]'::jsonb)",
" FROM agreego.entity entity_19",
" JOIN agreego.relationship relationship_20",
" JOIN agreego.contact contact_21",
@ -1609,6 +1655,7 @@
" 'age', person_3.age,",
" 'rating', person_3.rating,",
" 'tags', person_3.tags,",
" 'labels', person_3.labels,",
" 'phone_numbers', (",
" SELECT COALESCE(jsonb_agg(jsonb_build_object(",
" 'id', entity_4.id,",
@ -1631,7 +1678,7 @@
" AND phone_number_8.number ILIKE $32 #>> '{}'",
" AND relationship_5.target_id = entity_7.id",
" )",
" )), '[]'::jsonb)",
" ) ORDER BY entity_4.created_at, entity_4.id), '[]'::jsonb)",
" FROM agreego.entity entity_4",
" JOIN agreego.relationship relationship_5",
" JOIN agreego.contact contact_6",
@ -1661,7 +1708,7 @@
" NOT entity_12.archived",
" AND relationship_10.target_id = entity_12.id",
" )",
" )), '[]'::jsonb)",
" ) ORDER BY entity_9.created_at, entity_9.id), '[]'::jsonb)",
" FROM agreego.entity entity_9",
" JOIN agreego.relationship relationship_10",
" JOIN agreego.contact contact_11",
@ -1691,7 +1738,7 @@
" NOT entity_17.archived",
" AND relationship_15.target_id = entity_17.id",
" )",
" )), '[]'::jsonb)",
" ) ORDER BY entity_14.created_at, entity_14.id), '[]'::jsonb)",
" FROM agreego.entity entity_14",
" JOIN agreego.relationship relationship_15",
" JOIN agreego.contact contact_16",
@ -1752,7 +1799,7 @@
" ))",
" ELSE NULL",
" END",
" )), '[]'::jsonb)",
" ) ORDER BY entity_19.created_at, entity_19.id), '[]'::jsonb)",
" FROM agreego.entity entity_19",
" JOIN agreego.relationship relationship_20",
" JOIN agreego.contact contact_21",
@ -1831,7 +1878,7 @@
" NOT entity_4.archived",
" AND relationship_2.target_id = entity_4.id",
" )",
" )), '[]'::jsonb)",
" ) ORDER BY entity_1.created_at, entity_1.id), '[]'::jsonb)",
" FROM agreego.entity entity_1",
" JOIN agreego.relationship relationship_2",
" JOIN agreego.contact contact_3",
@ -1870,7 +1917,8 @@
" 'last_name', person_5.last_name,",
" 'age', person_5.age,",
" 'rating', person_5.rating,",
" 'tags', person_5.tags",
" 'tags', person_5.tags,",
" 'labels', person_5.labels",
" )",
" FROM agreego.entity entity_3",
" JOIN agreego.organization organization_4",
@ -1888,7 +1936,7 @@
" 'order_id', order_line_7.order_id,",
" 'product', order_line_7.product,",
" 'price', order_line_7.price",
" )), '[]'::jsonb)",
" ) ORDER BY entity_6.created_at, entity_6.id), '[]'::jsonb)",
" FROM agreego.entity entity_6",
" JOIN agreego.order_line order_line_7",
" WHERE",
@ -1982,7 +2030,8 @@
" 'last_name', person_10.last_name,",
" 'age', person_10.age,",
" 'rating', person_10.rating,",
" 'tags', person_10.tags",
" 'tags', person_10.tags,",
" 'labels', person_10.labels",
" )",
" FROM agreego.entity entity_8",
" JOIN agreego.organization organization_9",
@ -1992,7 +2041,7 @@
" AND entity_8.id = entity_1.id",
" ))",
" ELSE NULL",
" END), '[]'::jsonb)",
" END ORDER BY entity_1.created_at, entity_1.id), '[]'::jsonb)",
" FROM agreego.entity entity_1",
" JOIN agreego.organization organization_2",
" WHERE",
@ -2038,7 +2087,8 @@
" 'first_name', person_8.first_name,",
" 'last_name', person_8.last_name,",
" 'rating', person_8.rating,",
" 'tags', person_8.tags",
" 'tags', person_8.tags,",
" 'labels', person_8.labels",
" )",
" FROM agreego.entity entity_6",
" JOIN agreego.organization organization_7",
@ -2080,6 +2130,7 @@
" 'age', person_5.age,",
" 'rating', person_5.rating,",
" 'tags', person_5.tags,",
" 'labels', person_5.labels,",
" 'phone_numbers', (",
" SELECT COALESCE(jsonb_agg(jsonb_build_object(",
" 'id', entity_6.id,",
@ -2101,7 +2152,7 @@
" NOT entity_9.archived",
" AND relationship_7.target_id = entity_9.id",
" )",
" )), '[]'::jsonb)",
" ) ORDER BY entity_6.created_at, entity_6.id), '[]'::jsonb)",
" FROM agreego.entity entity_6",
" JOIN agreego.relationship relationship_7",
" JOIN agreego.contact contact_8",
@ -2131,7 +2182,7 @@
" NOT entity_14.archived",
" AND relationship_12.target_id = entity_14.id",
" )",
" )), '[]'::jsonb)",
" ) ORDER BY entity_11.created_at, entity_11.id), '[]'::jsonb)",
" FROM agreego.entity entity_11",
" JOIN agreego.relationship relationship_12",
" JOIN agreego.contact contact_13",
@ -2161,7 +2212,7 @@
" NOT entity_19.archived",
" AND relationship_17.target_id = entity_19.id",
" )",
" )), '[]'::jsonb)",
" ) ORDER BY entity_16.created_at, entity_16.id), '[]'::jsonb)",
" FROM agreego.entity entity_16",
" JOIN agreego.relationship relationship_17",
" JOIN agreego.contact contact_18",
@ -2222,7 +2273,7 @@
" ))",
" ELSE NULL",
" END",
" )), '[]'::jsonb)",
" ) ORDER BY entity_21.created_at, entity_21.id), '[]'::jsonb)",
" FROM agreego.entity entity_21",
" JOIN agreego.relationship relationship_22",
" JOIN agreego.contact contact_23",
@ -2276,7 +2327,8 @@
" 'last_name', person_5.last_name,",
" 'age', person_5.age,",
" 'rating', person_5.rating,",
" 'tags', person_5.tags",
" 'tags', person_5.tags,",
" 'labels', person_5.labels",
" )",
" FROM agreego.entity entity_3",
" JOIN agreego.organization organization_4",
@ -2285,7 +2337,7 @@
" NOT entity_3.archived",
" AND order_2.customer_id = person_5.id",
" )",
" )), '[]'::jsonb)",
" ) ORDER BY entity_1.created_at, entity_1.id), '[]'::jsonb)",
" FROM agreego.entity entity_1",
" JOIN agreego.order order_2",
" WHERE",
@ -2320,7 +2372,7 @@
" 'kind', widget_2.kind",
" ))",
" ELSE NULL",
" END), '[]'::jsonb)",
" END ORDER BY entity_1.created_at, entity_1.id), '[]'::jsonb)",
" FROM agreego.entity entity_1",
" JOIN agreego.widget widget_2",
" WHERE",
@ -2353,7 +2405,7 @@
" 'created_at', entity_3.created_at,",
" 'invoice_id', invoice_line_4.invoice_id,",
" 'price', invoice_line_4.price",
" )), '[]'::jsonb)",
" ) ORDER BY entity_3.created_at, entity_3.id), '[]'::jsonb)",
" FROM agreego.entity entity_3",
" JOIN agreego.invoice_line invoice_line_4",
" WHERE",
@ -2461,7 +2513,8 @@
" 'last_name', person_12.last_name,",
" 'age', person_12.age,",
" 'rating', person_12.rating,",
" 'tags', person_12.tags",
" 'tags', person_12.tags,",
" 'labels', person_12.labels",
" )",
" FROM agreego.entity entity_10",
" JOIN agreego.organization organization_11",
@ -2478,7 +2531,7 @@
" NOT entity_3.archived",
" AND order_2.counterparty_id = entity_3.id",
" )",
" )), '[]'::jsonb)",
" ) ORDER BY entity_1.created_at, entity_1.id), '[]'::jsonb)",
" FROM agreego.entity entity_1",
" JOIN agreego.order order_2",
" WHERE",
@ -2503,6 +2556,9 @@
"plumbing",
"electrical"
]
},
"labels": {
"$eq": "vip"
}
},
"expect": {
@ -2521,6 +2577,7 @@
" 'age', person_3.age,",
" 'rating', person_3.rating,",
" 'tags', person_3.tags,",
" 'labels', person_3.labels,",
" 'phone_numbers', (",
" SELECT COALESCE(jsonb_agg(jsonb_build_object(",
" 'id', entity_4.id,",
@ -2542,7 +2599,7 @@
" NOT entity_7.archived",
" AND relationship_5.target_id = entity_7.id",
" )",
" )), '[]'::jsonb)",
" ) ORDER BY entity_4.created_at, entity_4.id), '[]'::jsonb)",
" FROM agreego.entity entity_4",
" JOIN agreego.relationship relationship_5",
" JOIN agreego.contact contact_6",
@ -2572,7 +2629,7 @@
" NOT entity_12.archived",
" AND relationship_10.target_id = entity_12.id",
" )",
" )), '[]'::jsonb)",
" ) ORDER BY entity_9.created_at, entity_9.id), '[]'::jsonb)",
" FROM agreego.entity entity_9",
" JOIN agreego.relationship relationship_10",
" JOIN agreego.contact contact_11",
@ -2602,7 +2659,7 @@
" NOT entity_17.archived",
" AND relationship_15.target_id = entity_17.id",
" )",
" )), '[]'::jsonb)",
" ) ORDER BY entity_14.created_at, entity_14.id), '[]'::jsonb)",
" FROM agreego.entity entity_14",
" JOIN agreego.relationship relationship_15",
" JOIN agreego.contact contact_16",
@ -2663,7 +2720,7 @@
" ))",
" ELSE NULL",
" END",
" )), '[]'::jsonb)",
" ) ORDER BY entity_19.created_at, entity_19.id), '[]'::jsonb)",
" FROM agreego.entity entity_19",
" JOIN agreego.relationship relationship_20",
" JOIN agreego.contact contact_21",
@ -2677,13 +2734,63 @@
" JOIN agreego.person person_3",
" WHERE",
" NOT entity_1.archived",
" AND person_3.rating >= ($1 #>> '{}')::NUMERIC",
" AND person_3.tags ? ($2 #>> '{)",
" AND person_3.labels ? ($1 #>> '{)",
" AND person_3.rating >= ($2 #>> '{}')::NUMERIC",
" AND person_3.tags ? ($3 #>> '{)",
" AND person_3.tags ?| ARRAY()",
" ))))"
]
]
}
},
{
"description": "Punc response items proxy: jsonb array containment filter falls back to the type schema",
"action": "query",
"schema_id": "get_people.response",
"filter": {
"tags": {
"$eq": "landscaping"
},
"labels": {
"$of": [
"vip",
"board"
]
},
"rating": {
"$gte": 4.0
}
},
"expect": {
"success": true,
"sql": [
[
"((SELECT jsonb_strip_nulls((",
" SELECT COALESCE(jsonb_agg(jsonb_build_object(",
" 'id', entity_1.id,",
" 'type', entity_1.type,",
" 'archived', entity_1.archived,",
" 'created_at', entity_1.created_at,",
" 'name', organization_2.name,",
" 'first_name', person_3.first_name,",
" 'last_name', person_3.last_name,",
" 'age', person_3.age,",
" 'rating', person_3.rating,",
" 'tags', person_3.tags,",
" 'labels', person_3.labels",
" ) ORDER BY entity_1.created_at, entity_1.id), '[]'::jsonb)",
" FROM agreego.entity entity_1",
" JOIN agreego.organization organization_2",
" JOIN agreego.person person_3",
" WHERE",
" NOT entity_1.archived",
" AND person_3.labels ?| ARRAY()",
" AND person_3.rating >= ($2 #>> '{}')::NUMERIC",
" AND person_3.tags ? ($3 #>> '{)",
" ))))"
]
]
}
}
]
}

View File

@ -234,7 +234,17 @@ impl<'a> Compiler<'a> {
let where_clauses = self.compile_where_clause(r#type, &table_aliases, node)?;
let selection = if is_array {
format!("COALESCE(jsonb_agg({}), '[]'::jsonb)", jsonb_obj_sql)
// Deterministic order: aggregation over an unordered heap made result
// order vary run-to-run (flaky reads, unstable grids). Creation order
// with id as the tiebreaker; the entity alias exists for every
// entity-family type.
match table_aliases.get("entity") {
Some(entity_alias) => format!(
"COALESCE(jsonb_agg({} ORDER BY {}.created_at, {}.id), '[]'::jsonb)",
jsonb_obj_sql, entity_alias, entity_alias
),
None => format!("COALESCE(jsonb_agg({}), '[]'::jsonb)", jsonb_obj_sql),
}
} else {
jsonb_obj_sql
};
@ -762,17 +772,32 @@ impl<'a> Compiler<'a> {
.and_then(|v| v.as_str())
== Some("jsonb");
if is_jsonb {
let is_array_prop = node
// The node schema may be a punc-response proxy without properties —
// fall back to the type's own schema. Compiled schemas normalize to
// the list form ("type": ["array"]), hand-written ones may use the
// single form — accept both.
let prop_schema = node
.schema
.obj
.properties
.as_ref()
.and_then(|p| p.get(field_name))
.map(|ps| {
matches!(
&ps.obj.type_,
Some(crate::database::object::SchemaTypeOrArray::Single(t)) if t == "array"
)
.cloned()
.or_else(|| {
r#type
.schemas
.get(&r#type.name)
.and_then(|s| s.obj.properties.as_ref())
.and_then(|p| p.get(field_name))
.cloned()
});
let is_array_prop = prop_schema
.map(|ps| match &ps.obj.type_ {
Some(crate::database::object::SchemaTypeOrArray::Single(t)) => t == "array",
Some(crate::database::object::SchemaTypeOrArray::Multiple(ts)) => {
ts.iter().any(|t| t == "array") && ts.iter().all(|t| t == "array" || t == "null")
}
None => false,
})
.unwrap_or(false);

View File

@ -1307,6 +1307,12 @@ fn test_queryer_0_16() {
crate::tests::runner::run_test_case(&path, 0, 16).unwrap();
}
#[test]
fn test_queryer_0_17() {
let path = format!("{}/fixtures/queryer.json", env!("CARGO_MANIFEST_DIR"));
crate::tests::runner::run_test_case(&path, 0, 17).unwrap();
}
#[test]
fn test_polymorphism_0_0() {
let path = format!("{}/fixtures/polymorphism.json", env!("CARGO_MANIFEST_DIR"));

View File

@ -389,6 +389,7 @@ impl SqlFormatter {
i += 2;
}
self.indent -= 2;
self.format_function_clauses(list);
self.push_line(")");
} else {
for (i, arg) in list.args.iter().enumerate() {
@ -396,6 +397,7 @@ impl SqlFormatter {
self.format_function_arg(arg);
self.push_str(comma);
}
self.format_function_clauses(list);
self.push_str(")");
}
} else {
@ -403,6 +405,25 @@ impl SqlFormatter {
}
}
// Aggregate clauses (e.g. jsonb_agg(x ORDER BY y)) — without this the
// snapshot silently drops the ORDER BY the compiler emits.
fn format_function_clauses(&mut self, list: &sqlparser::ast::FunctionArgumentList) {
for clause in &list.clauses {
if let sqlparser::ast::FunctionArgumentClause::OrderBy(order) = clause {
self.push_str(" ORDER BY ");
for (i, ob) in order.iter().enumerate() {
if i > 0 {
self.push_str(", ");
}
self.format_expr(&ob.expr);
if let Some(asc) = ob.options.asc {
self.push_str(if asc { " ASC" } else { " DESC" });
}
}
}
}
}
fn format_function_arg(&mut self, arg: &FunctionArg) {
match arg {
FunctionArg::Unnamed(sqlparser::ast::FunctionArgExpr::Expr(expr)) => self.format_expr(expr),

View File

@ -1 +1 @@
1.0.173
1.0.176