Compare commits

...

9 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
e741a7197f version: 1.0.173 2026-07-07 20:55:10 -04:00
cf3cabd1f5 queryer: typed filter conditions for float and jsonb columns — (a) float4/float8 (the pg typnames that actually arrive in field_types; 'real'/'double precision' never do) now cast the parameter ::numeric, fixing 'double precision >= text' on $gt/$gte/etc; (b) jsonb columns compile explicitly: array-valued properties (tag lists) get CONTAINMENT semantics ($eq→?, $ne→NOT ?, $of→?|, $nof→NOT ?|), non-array jsonb compares ::jsonb for $eq/$ne, and unsupported operators are a loud QUERY_COMPILATION_FAILED naming the property instead of a runtime SQL type error ('jsonb = text'). compile_filter_conditions now returns Result. New fixture case (float8 $gte + jsonb array $eq/$of); 1279 tests green. Unblocks agreego TestListVendors_FilterByTrade/FilterByRating. 2026-07-07 20:54:52 -04:00
d608f5e963 filter synthesis: omit Field-Backed JSONB Bubbles from Composed Filter References — only Table-Backed boundaries have a synthesized .filter to proxy to; a dangling proxy reference breaks downstream code generators consuming the exported registry. Fixture pins a bubble-typed property: omitted from the filter, raw schemas promoted, no phantom .filter. 2026-07-07 13:39:03 -04:00
7 changed files with 565 additions and 35 deletions

View File

@ -87,6 +87,34 @@
"type": "string"
}
}
},
"schedule": {
"type": [
"opening_hours",
"null"
]
}
}
},
"opening_hours": {
"type": "object",
"properties": {
"open": {
"type": "string"
},
"seasons": {
"type": "array",
"items": {
"type": "season"
}
}
}
},
"season": {
"type": "object",
"properties": {
"label": {
"type": "string"
}
}
}
@ -483,7 +511,9 @@
]
}
}
}
},
"opening_hours": {},
"season": {}
}
}
}

View File

@ -70,6 +70,17 @@
}
}
}
},
{
"name": "get_people",
"schemas": {
"get_people.response": {
"type": "array",
"items": {
"type": "person"
}
}
}
}
],
"enums": [],
@ -365,7 +376,10 @@
"name",
"age",
"archived",
"created_at"
"created_at",
"rating",
"tags",
"labels"
],
"grouped_fields": {
"entity": [
@ -380,7 +394,10 @@
"person": [
"first_name",
"last_name",
"age"
"age",
"rating",
"tags",
"labels"
]
},
"field_types": {
@ -391,7 +408,10 @@
"last_name": "text",
"name": "text",
"age": "numeric",
"created_at": "timestamptz"
"created_at": "timestamptz",
"rating": "float8",
"tags": "jsonb",
"labels": "jsonb"
},
"schemas": {
"person": {
@ -405,6 +425,25 @@
},
"age": {
"type": "number"
},
"rating": {
"type": "number"
},
"tags": {
"type": "array",
"items": {
"type": "string"
}
},
"labels": {
"type": [
"array"
],
"items": {
"type": [
"string"
]
}
}
}
},
@ -416,6 +455,25 @@
},
"last_name": {
"type": "string"
},
"rating": {
"type": "number"
},
"tags": {
"type": "array",
"items": {
"type": "string"
}
},
"labels": {
"type": [
"array"
],
"items": {
"type": [
"string"
]
}
}
}
},
@ -475,6 +533,25 @@
}
}
}
},
"rating": {
"type": "number"
},
"tags": {
"type": "array",
"items": {
"type": "string"
}
},
"labels": {
"type": [
"array"
],
"items": {
"type": [
"string"
]
}
}
}
}
@ -1290,7 +1367,10 @@
" 'name', organization_2.name,",
" 'first_name', person_3.first_name,",
" 'last_name', person_3.last_name,",
" 'age', person_3.age",
" 'age', person_3.age,",
" 'rating', person_3.rating,",
" 'tags', person_3.tags,",
" 'labels', person_3.labels",
" )",
" FROM agreego.entity entity_1",
" JOIN agreego.organization organization_2",
@ -1320,6 +1400,9 @@
" '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,",
" 'phone_numbers', (",
" SELECT COALESCE(jsonb_agg(jsonb_build_object(",
" 'id', entity_4.id,",
@ -1341,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",
@ -1371,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",
@ -1401,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",
@ -1462,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",
@ -1570,6 +1653,9 @@
" '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,",
" 'phone_numbers', (",
" SELECT COALESCE(jsonb_agg(jsonb_build_object(",
" 'id', entity_4.id,",
@ -1592,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",
@ -1622,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",
@ -1652,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",
@ -1713,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",
@ -1792,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",
@ -1829,7 +1915,10 @@
" 'name', organization_4.name,",
" 'first_name', person_5.first_name,",
" 'last_name', person_5.last_name,",
" 'age', person_5.age",
" 'age', person_5.age,",
" 'rating', person_5.rating,",
" 'tags', person_5.tags,",
" 'labels', person_5.labels",
" )",
" FROM agreego.entity entity_3",
" JOIN agreego.organization organization_4",
@ -1847,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",
@ -1939,7 +2028,10 @@
" 'name', organization_9.name,",
" 'first_name', person_10.first_name,",
" 'last_name', person_10.last_name,",
" 'age', person_10.age",
" 'age', person_10.age,",
" 'rating', person_10.rating,",
" 'tags', person_10.tags,",
" 'labels', person_10.labels",
" )",
" FROM agreego.entity entity_8",
" JOIN agreego.organization organization_9",
@ -1949,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",
@ -1993,7 +2085,10 @@
" 'created_at', entity_6.created_at,",
" 'name', organization_7.name,",
" 'first_name', person_8.first_name,",
" 'last_name', person_8.last_name",
" 'last_name', person_8.last_name,",
" 'rating', person_8.rating,",
" 'tags', person_8.tags,",
" 'labels', person_8.labels",
" )",
" FROM agreego.entity entity_6",
" JOIN agreego.organization organization_7",
@ -2033,6 +2128,9 @@
" 'first_name', person_5.first_name,",
" 'last_name', person_5.last_name,",
" '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,",
@ -2054,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",
@ -2084,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",
@ -2114,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",
@ -2175,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",
@ -2227,7 +2325,10 @@
" 'name', organization_4.name,",
" 'first_name', person_5.first_name,",
" 'last_name', person_5.last_name,",
" 'age', person_5.age",
" 'age', person_5.age,",
" 'rating', person_5.rating,",
" 'tags', person_5.tags,",
" 'labels', person_5.labels",
" )",
" FROM agreego.entity entity_3",
" JOIN agreego.organization organization_4",
@ -2236,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",
@ -2271,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",
@ -2304,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",
@ -2410,7 +2511,10 @@
" 'name', organization_11.name,",
" 'first_name', person_12.first_name,",
" 'last_name', person_12.last_name,",
" 'age', person_12.age",
" 'age', person_12.age,",
" 'rating', person_12.rating,",
" 'tags', person_12.tags,",
" 'labels', person_12.labels",
" )",
" FROM agreego.entity entity_10",
" JOIN agreego.organization organization_11",
@ -2427,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",
@ -2437,6 +2541,256 @@
]
]
}
},
{
"description": "Person filters: float8 comparison and jsonb array containment",
"action": "query",
"schema_id": "full.person",
"filter": {
"rating": {
"$gte": 4.0
},
"tags": {
"$eq": "landscaping",
"$of": [
"plumbing",
"electrical"
]
},
"labels": {
"$eq": "vip"
}
},
"expect": {
"success": true,
"sql": [
[
"((SELECT jsonb_strip_nulls((",
" SELECT 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,",
" 'phone_numbers', (",
" SELECT COALESCE(jsonb_agg(jsonb_build_object(",
" 'id', entity_4.id,",
" 'type', entity_4.type,",
" 'archived', entity_4.archived,",
" 'created_at', entity_4.created_at,",
" 'is_primary', contact_6.is_primary,",
" 'target', (",
" SELECT jsonb_build_object(",
" 'id', entity_7.id,",
" 'type', entity_7.type,",
" 'archived', entity_7.archived,",
" 'created_at', entity_7.created_at,",
" 'number', phone_number_8.number",
" )",
" FROM agreego.entity entity_7",
" JOIN agreego.phone_number phone_number_8",
" WHERE",
" NOT entity_7.archived",
" AND relationship_5.target_id = entity_7.id",
" )",
" ) 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",
" WHERE",
" NOT entity_4.archived",
" AND relationship_5.target_type = 'phone_number'",
" AND relationship_5.source_id = entity_1.id",
" ),",
" 'email_addresses', (",
" SELECT COALESCE(jsonb_agg(jsonb_build_object(",
" 'id', entity_9.id,",
" 'type', entity_9.type,",
" 'archived', entity_9.archived,",
" 'created_at', entity_9.created_at,",
" 'is_primary', contact_11.is_primary,",
" 'target', (",
" SELECT jsonb_build_object(",
" 'id', entity_12.id,",
" 'type', entity_12.type,",
" 'archived', entity_12.archived,",
" 'created_at', entity_12.created_at,",
" 'address', email_address_13.address",
" )",
" FROM agreego.entity entity_12",
" JOIN agreego.email_address email_address_13",
" WHERE",
" NOT entity_12.archived",
" AND relationship_10.target_id = entity_12.id",
" )",
" ) 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",
" WHERE",
" NOT entity_9.archived",
" AND relationship_10.target_type = 'email_address'",
" AND relationship_10.source_id = entity_1.id",
" ),",
" 'addresses', (",
" SELECT COALESCE(jsonb_agg(jsonb_build_object(",
" 'id', entity_14.id,",
" 'type', entity_14.type,",
" 'archived', entity_14.archived,",
" 'created_at', entity_14.created_at,",
" 'is_primary', contact_16.is_primary,",
" 'target', (",
" SELECT jsonb_build_object(",
" 'id', entity_17.id,",
" 'type', entity_17.type,",
" 'archived', entity_17.archived,",
" 'created_at', entity_17.created_at,",
" 'city', address_18.city",
" )",
" FROM agreego.entity entity_17",
" JOIN agreego.address address_18",
" WHERE",
" NOT entity_17.archived",
" AND relationship_15.target_id = entity_17.id",
" )",
" ) 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",
" WHERE",
" NOT entity_14.archived",
" AND relationship_15.target_type = 'address'",
" AND relationship_15.source_id = entity_1.id",
" ),",
" 'contacts', (",
" SELECT COALESCE(jsonb_agg(jsonb_build_object(",
" 'id', entity_19.id,",
" 'type', entity_19.type,",
" 'archived', entity_19.archived,",
" 'created_at', entity_19.created_at,",
" 'is_primary', contact_21.is_primary,",
" 'target', CASE",
" WHEN relationship_20.target_type = 'phone_number' THEN ((",
" SELECT jsonb_build_object(",
" 'id', entity_22.id,",
" 'type', entity_22.type,",
" 'archived', entity_22.archived,",
" 'created_at', entity_22.created_at,",
" 'number', phone_number_23.number",
" )",
" FROM agreego.entity entity_22",
" JOIN agreego.phone_number phone_number_23",
" WHERE",
" NOT entity_22.archived",
" AND relationship_20.target_id = entity_22.id",
" ))",
" WHEN relationship_20.target_type = 'email_address' THEN ((",
" SELECT jsonb_build_object(",
" 'id', entity_24.id,",
" 'type', entity_24.type,",
" 'archived', entity_24.archived,",
" 'created_at', entity_24.created_at,",
" 'address', email_address_25.address",
" )",
" FROM agreego.entity entity_24",
" JOIN agreego.email_address email_address_25",
" WHERE",
" NOT entity_24.archived",
" AND relationship_20.target_id = entity_24.id",
" ))",
" WHEN relationship_20.target_type = 'address' THEN ((",
" SELECT jsonb_build_object(",
" 'id', entity_26.id,",
" 'type', entity_26.type,",
" 'archived', entity_26.archived,",
" 'created_at', entity_26.created_at,",
" 'city', address_27.city",
" )",
" FROM agreego.entity entity_26",
" JOIN agreego.address address_27",
" WHERE",
" NOT entity_26.archived",
" AND relationship_20.target_id = entity_26.id",
" ))",
" ELSE NULL",
" END",
" ) 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",
" WHERE",
" NOT entity_19.archived",
" AND relationship_20.source_id = entity_1.id",
" )",
" )",
" FROM agreego.entity entity_1",
" JOIN agreego.organization organization_2",
" JOIN agreego.person person_3",
" WHERE",
" NOT entity_1.archived",
" 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

@ -165,8 +165,16 @@ impl Schema {
} else if db.enums.contains_key(custom) {
Some(vec![format!("{}.condition", custom)])
} else {
// Assume anything else is a Relational cross-boundary that already has its own .filter dynamically built
Some(vec![format!("{}.filter", custom)])
// Only a Table-Backed boundary has a synthesized Composed Filter to proxy to.
// A Field-Backed JSONB Bubble has none — omit it like an inline object rather
// than emit a dangling proxy reference, which breaks eager consumers of the
// exported registry (downstream code generators).
let base = custom.split('.').next_back().unwrap_or(custom);
if db.types.contains_key(base) {
Some(vec![format!("{}.filter", custom)])
} else {
None
}
}
}
}

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
};
@ -549,7 +559,7 @@ impl<'a> Compiler<'a> {
where_clauses.push(format!("NOT {}.archived", entity_alias));
}
self.compile_filter_conditions(r#type, type_aliases, &node, &base_alias, &mut where_clauses);
self.compile_filter_conditions(r#type, type_aliases, &node, &base_alias, &mut where_clauses)?;
self.compile_polymorphic_bounds(r#type, type_aliases, &node, &mut where_clauses);
let start_len = where_clauses.len();
@ -689,6 +699,11 @@ impl<'a> Compiler<'a> {
|| pg_type.contains("int")
|| pg_type == "real"
|| pg_type == "double precision"
// pg catalog typnames for real / double precision — the SQL
// names above never appear in field_types (they come from
// pg_type.typname), so these are what actually arrives
|| pg_type == "float4"
|| pg_type == "float8"
{
cast = "::numeric";
} else if pg_type == "text" || pg_type.contains("char") {
@ -715,7 +730,7 @@ impl<'a> Compiler<'a> {
node: &Node,
base_alias: &str,
where_clauses: &mut Vec<String>,
) {
) -> Result<(), String> {
for (i, filter_key) in self.filter_keys.iter().enumerate() {
let mut parts = filter_key.split(':');
let full_field_path = parts.next().unwrap_or(filter_key);
@ -745,6 +760,95 @@ impl<'a> Compiler<'a> {
let param_index = i + 1;
let p_val = format!("${}#>>'{{}}'", param_index);
// jsonb columns never type-check against the text parameter, and for
// array-valued properties (tag lists) the meaning of a condition is
// CONTAINMENT, not equality. Compile them explicitly; reject the rest
// loudly at compile time instead of failing at execution.
let is_jsonb = r#type
.field_types
.as_ref()
.and_then(|v| v.as_object())
.and_then(|ft| ft.get(field_name))
.and_then(|v| v.as_str())
== Some("jsonb");
if is_jsonb {
// 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))
.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);
if is_array_prop {
match op {
// "the array contains this value"
"$eq" => where_clauses.push(format!(
"{}.{} ? ({})",
filter_alias, field_name, p_val
)),
"$ne" => where_clauses.push(format!(
"NOT ({}.{} ? ({}))",
filter_alias, field_name, p_val
)),
// "the array contains ANY of these values"
"$of" => where_clauses.push(format!(
"{}.{} ?| ARRAY(SELECT jsonb_array_elements_text(({})::jsonb))",
filter_alias, field_name, p_val
)),
"$nof" => where_clauses.push(format!(
"NOT ({}.{} ?| ARRAY(SELECT jsonb_array_elements_text(({})::jsonb)))",
filter_alias, field_name, p_val
)),
other => {
return Err(format!(
"operator {} is not supported on array property '{}' (jsonb containment supports $eq/$ne/$of/$nof)",
other, field_name
))
}
}
} else {
match op {
"$eq" => where_clauses.push(format!(
"{}.{} = ({})::jsonb",
filter_alias, field_name, p_val
)),
"$ne" => where_clauses.push(format!(
"{}.{} != ({})::jsonb",
filter_alias, field_name, p_val
)),
other => {
return Err(format!(
"operator {} is not supported on jsonb property '{}' (only $eq/$ne)",
other, field_name
))
}
}
}
continue;
}
if op == "$of" || op == "$nof" {
let sql_op = if op == "$of" { "IN" } else { "NOT IN" };
let subquery = format!(
@ -796,6 +900,7 @@ impl<'a> Compiler<'a> {
));
}
}
Ok(())
}
fn compile_relation_conditions(

View File

@ -1301,6 +1301,18 @@ fn test_queryer_0_15() {
crate::tests::runner::run_test_case(&path, 0, 15).unwrap();
}
#[test]
fn test_queryer_0_16() {
let path = format!("{}/fixtures/queryer.json", env!("CARGO_MANIFEST_DIR"));
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.172
1.0.176