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
15 changed files with 826 additions and 832 deletions

View File

@ -229,42 +229,6 @@ Traits are reusable, non-generating schema fragments used to share properties an
* **Scalars / Arrays / Items**: Host definitions completely override included traits. * **Scalars / Arrays / Items**: Host definitions completely override included traits.
* The `"include"` keyword is stripped, and `"traits"` maps are omitted from serialization. * The `"include"` keyword is stripped, and `"traits"` maps are omitted from serialization.
### Static Relation Constraints (Kind Constraints)
When modeling relational properties on a schema, a developer can define a specialized subset of a related table by applying static property constraints via the `const` or `enum` validation keywords.
For example, given a general `attachment` table containing a `kind` column (e.g. `'cover'`, `'thumbnail'`, `'document'`), you can define a `cover.attachment` schema that narrows the type using a static `const` assertion:
```json
"cover.attachment": {
"type": "attachment",
"properties": {
"kind": {
"const": "cover"
}
}
}
```
A parent entity can then define a relationship using this constrained schema under a local property name (e.g. `cover_attachment`):
```json
"cover_attachment": {
"properties": {
"cover_attachment": {
"type": [
"cover.attachment",
"null"
]
}
}
}
```
**What it does:**
1. **Validation (L1)**: During payload validation (`jspg_validate`), any incoming object mapped to the constrained property is validated against the static rules (e.g. throwing `CONST_VIOLATED` if `kind` is not `"cover"`).
2. **Query Generation (L0)**: When fetching data via `jspg_query`, the Queryer automatically detects the static constraint and compiles it into the SQL subquery's `WHERE` clause (e.g. adding `AND attachment_X.kind = 'cover'`). This produces a pre-filtered view of the related entities natively at the database level.
--- ---
## 3. Database ## 3. Database
@ -350,7 +314,6 @@ The Queryer transforms Postgres into a pre-compiled Semantic Query Engine, desig
* **Multi-Table Branching**: If the Physical Table is a parent to other tables (e.g. `organization` has variations `["organization", "bot", "person"]`), the compiler generates a dynamic `CASE WHEN type = '...' THEN ...` query, expanding into sub-queries for each variation. To ensure safe resolution, the compiler dynamically evaluates correlation boundaries: it attempts standard Relational Edge discovery first. If no explicit relational edge exists (indicating pure Table Inheritance rather than a standard foreign-key graph relationship), it safely invokes a **Table Parity Fallback**. This generates an explicit ID correlation constraint (`AND inner.id = outer.id`), perfectly binding the structural variations back to the parent row to eliminate Cartesian products. * **Multi-Table Branching**: If the Physical Table is a parent to other tables (e.g. `organization` has variations `["organization", "bot", "person"]`), the compiler generates a dynamic `CASE WHEN type = '...' THEN ...` query, expanding into sub-queries for each variation. To ensure safe resolution, the compiler dynamically evaluates correlation boundaries: it attempts standard Relational Edge discovery first. If no explicit relational edge exists (indicating pure Table Inheritance rather than a standard foreign-key graph relationship), it safely invokes a **Table Parity Fallback**. This generates an explicit ID correlation constraint (`AND inner.id = outer.id`), perfectly binding the structural variations back to the parent row to eliminate Cartesian products.
* **Single-Table Bypass**: If the Physical Table is a leaf node with only one variation (e.g. `person` has variations `["person"]`), the compiler cleanly bypasses `CASE` generation and compiles a simple `SELECT` across the base table, as all schema extensions (e.g. `light.person`, `full.person`) are guaranteed to reside in the exact same physical row. * **Single-Table Bypass**: If the Physical Table is a leaf node with only one variation (e.g. `person` has variations `["person"]`), the compiler cleanly bypasses `CASE` generation and compiles a simple `SELECT` across the base table, as all schema extensions (e.g. `light.person`, `full.person`) are guaranteed to reside in the exact same physical row.
* **Polymorphic Relation Type Filtering**: When a relationship maps to a polymorphic target with variations, the Queryer compiles an `IN` clause containing all allowed table variations (e.g., `counterparty_type IN ('bot', 'organization', 'person')`) rather than matching the base type literal, ensuring all polymorphic types are loaded correctly. * **Polymorphic Relation Type Filtering**: When a relationship maps to a polymorphic target with variations, the Queryer compiles an `IN` clause containing all allowed table variations (e.g., `counterparty_type IN ('bot', 'organization', 'person')`) rather than matching the base type literal, ensuring all polymorphic types are loaded correctly.
* **Static Relation Constraints (Kind Constraints)**: When a relationship (such as a nested object or array) is defined with a schema that constrains a field value statically using a `const` or `enum` keyword (for example, `kind` constrained to `"cover"` in a `cover_attachment`), the Queryer automatically extracts these static assertions during AST compilation. It injects them directly as static filters into the SQL subquery's `WHERE` clause (e.g. `AND attachment.kind = 'cover'`), allowing developers to query pre-filtered subsets of related tables natively through the schema.
--- ---

View File

@ -87,6 +87,34 @@
"type": "string" "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"
} }
} }
} }
@ -211,6 +239,13 @@
"gender": {}, "gender": {},
"gender.condition": { "gender.condition": {
"type": "condition", "type": "condition",
"compiledPropertyNames": [
"kind",
"$eq",
"$ne",
"$of",
"$nof"
],
"properties": { "properties": {
"$eq": { "$eq": {
"type": [ "type": [
@ -224,15 +259,6 @@
"null" "null"
] ]
}, },
"$of": {
"type": [
"array",
"null"
],
"items": {
"type": "gender"
}
},
"$nof": { "$nof": {
"type": [ "type": [
"array", "array",
@ -241,197 +267,158 @@
"items": { "items": {
"type": "gender" "type": "gender"
} }
}
}, },
"compiledPropertyNames": [ "$of": {
"kind", "type": [
"$eq", "array",
"$ne", "null"
"$of", ],
"$nof" "items": {
] "type": "gender"
}
}
}
}, },
"person": {}, "person": {},
"person.filter": { "person.filter": {
"type": "filter",
"properties": {
"first_name": {
"type": [
"string.condition",
"null"
],
"compiledPropertyNames": [ "compiledPropertyNames": [
"kind", "first_name",
"$eq" "age",
] "billing_address",
}, "gender",
"age": { "birth_date",
"type": [ "uuid_field",
"integer.condition", "tags",
"null" "ad_hoc",
],
"compiledPropertyNames": [
"kind",
"$eq"
]
},
"billing_address": {
"type": [
"address.filter",
"null"
],
"compiledPropertyNames": [
"city",
"$and", "$and",
"$or" "$or"
],
"properties": {
"$and": {
"items": {
"compiledPropertyNames": [
"first_name",
"age",
"billing_address",
"gender",
"birth_date",
"uuid_field",
"tags",
"ad_hoc",
"$and",
"$or"
],
"type": "person.filter"
},
"type": [
"array",
"null"
] ]
}, },
"gender": { "$or": {
"type": [ "items": {
"gender.condition",
"null"
],
"compiledPropertyNames": [ "compiledPropertyNames": [
"kind", "first_name",
"$eq", "age",
"$ne", "billing_address",
"$of", "gender",
"$nof" "birth_date",
] "uuid_field",
"tags",
"ad_hoc",
"$and",
"$or"
],
"type": "person.filter"
}, },
"birth_date": {
"type": [ "type": [
"date.condition", "array",
"null" "null"
],
"compiledPropertyNames": [
"kind",
"$eq"
]
},
"uuid_field": {
"type": [
"uuid.condition",
"null"
],
"compiledPropertyNames": [
"kind",
"$eq"
]
},
"tags": {
"type": [
"string.condition",
"null"
],
"compiledPropertyNames": [
"kind",
"$eq"
] ]
}, },
"ad_hoc": { "ad_hoc": {
"type": [ "compiledPropertyNames": [
"filter", "foo"
"null"
], ],
"properties": { "properties": {
"foo": { "foo": {
"type": [ "type": [
"string.condition", "string.condition",
"null" "null"
],
"compiledPropertyNames": [
"kind",
"$eq"
] ]
} }
}, },
"compiledPropertyNames": [
"foo"
]
},
"$and": {
"type": [ "type": [
"array", "filter",
"null" "null"
],
"items": {
"type": "person.filter",
"compiledPropertyNames": [
"first_name",
"age",
"billing_address",
"gender",
"birth_date",
"uuid_field",
"tags",
"ad_hoc",
"$and",
"$or"
] ]
}
}, },
"$or": { "age": {
"type": [ "type": [
"array", "integer.condition",
"null"
]
},
"billing_address": {
"type": [
"address.filter",
"null"
]
},
"birth_date": {
"type": [
"date.condition",
"null"
]
},
"uuid_field": {
"type": [
"uuid.condition",
"null"
]
},
"first_name": {
"type": [
"string.condition",
"null"
]
},
"gender": {
"type": [
"gender.condition",
"null"
]
},
"tags": {
"type": [
"string.condition",
"null" "null"
],
"items": {
"type": "person.filter",
"compiledPropertyNames": [
"first_name",
"age",
"billing_address",
"gender",
"birth_date",
"uuid_field",
"tags",
"ad_hoc",
"$and",
"$or"
] ]
}
} }
}, },
"compiledPropertyNames": [ "type": "filter"
"first_name",
"age",
"billing_address",
"gender",
"birth_date",
"uuid_field",
"tags",
"ad_hoc",
"$and",
"$or"
]
}, },
"address": {}, "address": {},
"address.filter": { "address.filter": {
"type": "filter", "type": "filter",
"properties": {
"city": {
"type": [
"string.condition",
"null"
],
"compiledPropertyNames": [ "compiledPropertyNames": [
"kind", "city",
"$eq" "$and",
] "$or"
}, ],
"properties": {
"$and": { "$and": {
"type": [ "type": [
"array", "array",
"null" "null"
], ],
"items": { "items": {
"type": "address.filter",
"compiledPropertyNames": [ "compiledPropertyNames": [
"city", "city",
"$and", "$and",
"$or" "$or"
] ],
"type": "address.filter"
} }
}, },
"$or": { "$or": {
@ -440,20 +427,21 @@
"null" "null"
], ],
"items": { "items": {
"type": "address.filter",
"compiledPropertyNames": [ "compiledPropertyNames": [
"city", "city",
"$and", "$and",
"$or" "$or"
] ],
} "type": "address.filter"
} }
}, },
"compiledPropertyNames": [ "city": {
"city", "type": [
"$and", "string.condition",
"$or" "null"
] ]
}
}
}, },
"condition": {}, "condition": {},
"filter": {}, "filter": {},
@ -464,47 +452,28 @@
"search": {}, "search": {},
"search.filter": { "search.filter": {
"type": "filter", "type": "filter",
"properties": {
"kind": {
"type": [
"string.condition",
"null"
],
"compiledPropertyNames": [
"kind",
"$eq"
]
},
"name": {
"type": [
"string.condition",
"null"
],
"compiledPropertyNames": [
"kind",
"$eq"
]
},
"filter": {
"type": [
"$kind.filter",
"null"
]
},
"$and": {
"type": [
"array",
"null"
],
"items": {
"type": "search.filter",
"compiledPropertyNames": [ "compiledPropertyNames": [
"kind", "kind",
"name", "name",
"filter", "filter",
"$and", "$and",
"$or" "$or"
] ],
"properties": {
"$and": {
"type": [
"array",
"null"
],
"items": {
"compiledPropertyNames": [
"kind",
"name",
"filter",
"$and",
"$or"
],
"type": "search.filter"
} }
}, },
"$or": { "$or": {
@ -513,25 +482,38 @@
"null" "null"
], ],
"items": { "items": {
"type": "search.filter",
"compiledPropertyNames": [ "compiledPropertyNames": [
"kind", "kind",
"name", "name",
"filter", "filter",
"$and", "$and",
"$or" "$or"
],
"type": "search.filter"
}
},
"filter": {
"type": [
"$kind.filter",
"null"
]
},
"kind": {
"type": [
"string.condition",
"null"
]
},
"name": {
"type": [
"string.condition",
"null"
] ]
} }
} }
}, },
"compiledPropertyNames": [ "opening_hours": {},
"kind", "season": {}
"name",
"filter",
"$and",
"$or"
]
}
} }
} }
} }

View File

@ -191,9 +191,6 @@
}, },
{ {
"name": "request", "name": "request",
"field_types": {
"inv": "jsonb"
},
"schemas": { "schemas": {
"request": { "request": {
"type": "object", "type": "object",

File diff suppressed because it is too large Load Diff

1
flow
View File

@ -36,6 +36,7 @@ pgrx-down() {
info "Taking pgrx down..." info "Taking pgrx down..."
} }
build() { build() {
local version local version
version=$(get-version) || return $? version=$(get-version) || return $?

View File

@ -29,23 +29,12 @@ impl Schema {
if db.types.contains_key(&base_type_name) { if db.types.contains_key(&base_type_name) {
parent_type_name = Some(base_type_name); parent_type_name = Some(base_type_name);
} }
} else if let Some(t_or_arr) = &self.obj.type_ { } else if let Some(crate::database::object::SchemaTypeOrArray::Single(t)) = &self.obj.type_ {
match t_or_arr { // 3. Nested graphs trust their explicit struct pointer reference
crate::database::object::SchemaTypeOrArray::Single(t) => {
if !crate::database::object::is_primitive_type(t) { if !crate::database::object::is_primitive_type(t) {
parent_type_name = Some(t.split('.').next_back().unwrap_or(t).to_string()); parent_type_name = Some(t.split('.').next_back().unwrap_or(t).to_string());
} }
} }
crate::database::object::SchemaTypeOrArray::Multiple(types) => {
for t in types {
if !crate::database::object::is_primitive_type(t) {
parent_type_name = Some(t.split('.').next_back().unwrap_or(t).to_string());
break;
}
}
}
}
}
if let Some(p_type) = parent_type_name { if let Some(p_type) = parent_type_name {
// Proceed only if the resolved table physically exists within the Postgres Type hierarchy // Proceed only if the resolved table physically exists within the Postgres Type hierarchy
@ -57,9 +46,9 @@ impl Schema {
let mut is_array = false; let mut is_array = false;
// Structurally unpack the inner target entity if the object maps to an array list // Structurally unpack the inner target entity if the object maps to an array list
if let Some(t_or_arr) = &prop_schema.obj.type_ { if let Some(crate::database::object::SchemaTypeOrArray::Single(t)) =
match t_or_arr { &prop_schema.obj.type_
crate::database::object::SchemaTypeOrArray::Single(t) => { {
if t == "array" { if t == "array" {
is_array = true; is_array = true;
if let Some(items) = &prop_schema.obj.items { if let Some(items) = &prop_schema.obj.items {
@ -67,36 +56,16 @@ impl Schema {
} }
} }
} }
crate::database::object::SchemaTypeOrArray::Multiple(types) => {
if types.contains(&"array".to_string()) {
is_array = true;
if let Some(items) = &prop_schema.obj.items {
target_schema = items.clone();
}
}
}
}
}
// Determine the physical Postgres table backing the nested child schema recursively // Determine the physical Postgres table backing the nested child schema recursively
if let Some(family) = &target_schema.obj.family { if let Some(family) = &target_schema.obj.family {
child_type_name = Some(family.split('.').next_back().unwrap_or(family).to_string()); child_type_name = Some(family.split('.').next_back().unwrap_or(family).to_string());
} else if let Some(t_or_arr) = &target_schema.obj.type_ { } else if let Some(crate::database::object::SchemaTypeOrArray::Single(t)) =
match t_or_arr { &target_schema.obj.type_
crate::database::object::SchemaTypeOrArray::Single(t) => { {
if !crate::database::object::is_primitive_type(t) { if !crate::database::object::is_primitive_type(t) {
child_type_name = Some(t.split('.').next_back().unwrap_or(t).to_string()); child_type_name = Some(t.split('.').next_back().unwrap_or(t).to_string());
} }
}
crate::database::object::SchemaTypeOrArray::Multiple(types) => {
for t in types {
if !crate::database::object::is_primitive_type(t) {
child_type_name = Some(t.split('.').next_back().unwrap_or(t).to_string());
break;
}
}
}
}
} else if let Some(arr) = &target_schema.obj.one_of { } else if let Some(arr) = &target_schema.obj.one_of {
if let Some(first) = arr.first() { if let Some(first) = arr.first() {
if let Some(crate::database::object::SchemaTypeOrArray::Single(t)) = &first.obj.type_ if let Some(crate::database::object::SchemaTypeOrArray::Single(t)) = &first.obj.type_

View File

@ -165,8 +165,16 @@ impl Schema {
} else if db.enums.contains_key(custom) { } else if db.enums.contains_key(custom) {
Some(vec![format!("{}.condition", custom)]) Some(vec![format!("{}.condition", custom)])
} else { } else {
// Assume anything else is a Relational cross-boundary that already has its own .filter dynamically built // 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)]) Some(vec![format!("{}.filter", custom)])
} else {
None
}
} }
} }
} }

View File

@ -40,7 +40,6 @@ impl Schema {
} }
let mut props = IndexMap::new(); let mut props = IndexMap::new();
let mut inherited_edges = IndexMap::new();
// 1. Resolve INHERITANCE dependencies first // 1. Resolve INHERITANCE dependencies first
if let Some(crate::database::object::SchemaTypeOrArray::Single(t)) = &self.obj.type_ { if let Some(crate::database::object::SchemaTypeOrArray::Single(t)) = &self.obj.type_ {
@ -50,9 +49,6 @@ impl Schema {
if let Some(p_props) = parent.obj.compiled_properties.get() { if let Some(p_props) = parent.obj.compiled_properties.get() {
props.extend(p_props.clone()); props.extend(p_props.clone());
} }
if let Some(p_edges) = parent.obj.compiled_edges.get() {
inherited_edges.extend(p_edges.clone());
}
} }
} }
} }
@ -81,12 +77,6 @@ impl Schema {
if !crate::database::object::is_primitive_type(t) && !t.starts_with('$') { if !crate::database::object::is_primitive_type(t) && !t.starts_with('$') {
if let Some(parent) = db.schemas.get(t).cloned() { if let Some(parent) = db.schemas.get(t).cloned() {
parent.as_ref().compile(db, t, t.clone(), errors); parent.as_ref().compile(db, t, t.clone(), errors);
if let Some(p_props) = parent.obj.compiled_properties.get() {
props.extend(p_props.clone());
}
if let Some(p_edges) = parent.obj.compiled_edges.get() {
inherited_edges.extend(p_edges.clone());
}
} }
} }
} }
@ -126,10 +116,7 @@ impl Schema {
let _ = self.obj.compiled_property_names.set(names); let _ = self.obj.compiled_property_names.set(names);
// 5. Compute Edges natively // 5. Compute Edges natively
let mut schema_edges = self.compile_edges(db, root_id, &path, &props, errors); let schema_edges = self.compile_edges(db, root_id, &path, &props, errors);
for (k, v) in inherited_edges {
schema_edges.entry(k).or_insert(v);
}
let _ = self.obj.compiled_edges.set(schema_edges); let _ = self.obj.compiled_edges.set(schema_edges);
// 5. Build our inline children properties recursively NOW! (Depth-first search) // 5. Build our inline children properties recursively NOW! (Depth-first search)

View File

@ -1,6 +1,5 @@
use crate::database::Database; use crate::database::Database;
use indexmap::IndexMap; use indexmap::IndexMap;
use serde_json::Value;
use std::sync::Arc; use std::sync::Arc;
pub struct Compiler<'a> { pub struct Compiler<'a> {
@ -87,9 +86,8 @@ impl<'a> Compiler<'a> {
.next_back() .next_back()
.unwrap_or(family_target); .unwrap_or(family_target);
resolved_type = self.db.types.get(base_type_name); resolved_type = self.db.types.get(base_type_name);
} else if let Some(t_or_arr) = &items.obj.type_ { } else if let Some(crate::database::object::SchemaTypeOrArray::Single(t)) = &items.obj.type_
match t_or_arr { {
crate::database::object::SchemaTypeOrArray::Single(t) => {
if !crate::database::object::is_primitive_type(t) { if !crate::database::object::is_primitive_type(t) {
resolved_type = self resolved_type = self
.db .db
@ -97,21 +95,6 @@ impl<'a> Compiler<'a> {
.get(&t.split('.').next_back().unwrap_or(t).to_string()); .get(&t.split('.').next_back().unwrap_or(t).to_string());
} }
} }
crate::database::object::SchemaTypeOrArray::Multiple(types) => {
for t in types {
if !crate::database::object::is_primitive_type(t) {
resolved_type = self
.db
.types
.get(&t.split('.').next_back().unwrap_or(t).to_string());
if resolved_type.is_some() {
break;
}
}
}
}
}
}
} }
if let Some(type_def) = resolved_type { if let Some(type_def) = resolved_type {
@ -152,27 +135,14 @@ impl<'a> Compiler<'a> {
.next_back() .next_back()
.unwrap_or(family_target); .unwrap_or(family_target);
resolved_type = self.db.types.get(base_type_name); resolved_type = self.db.types.get(base_type_name);
} else if let Some(t_or_arr) = &node.schema.obj.type_ { } else if let Some(crate::database::object::SchemaTypeOrArray::Single(t)) =
match t_or_arr { &node.schema.obj.type_
crate::database::object::SchemaTypeOrArray::Single(t) => { {
if !crate::database::object::is_primitive_type(t) { if !crate::database::object::is_primitive_type(t) {
let base_type_name = t.split('.').next_back().unwrap_or(t).to_string(); let base_type_name = t.split('.').next_back().unwrap_or(t).to_string();
resolved_type = self.db.types.get(&base_type_name); resolved_type = self.db.types.get(&base_type_name);
} }
} }
crate::database::object::SchemaTypeOrArray::Multiple(types) => {
for t in types {
if !crate::database::object::is_primitive_type(t) {
let base_type_name = t.split('.').next_back().unwrap_or(t).to_string();
resolved_type = self.db.types.get(&base_type_name);
if resolved_type.is_some() {
break;
}
}
}
}
}
}
} }
if let Some(type_def) = resolved_type { if let Some(type_def) = resolved_type {
@ -264,7 +234,17 @@ impl<'a> Compiler<'a> {
let where_clauses = self.compile_where_clause(r#type, &table_aliases, node)?; let where_clauses = self.compile_where_clause(r#type, &table_aliases, node)?;
let selection = if is_array { 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 { } else {
jsonb_obj_sql jsonb_obj_sql
}; };
@ -469,9 +449,6 @@ impl<'a> Compiler<'a> {
Some(crate::database::object::SchemaTypeOrArray::Single(s)) => { Some(crate::database::object::SchemaTypeOrArray::Single(s)) => {
!crate::database::object::is_primitive_type(s) !crate::database::object::is_primitive_type(s)
} }
Some(crate::database::object::SchemaTypeOrArray::Multiple(v)) => {
v.iter().any(|s| !crate::database::object::is_primitive_type(s))
}
_ => false, _ => false,
}; };
@ -582,9 +559,8 @@ impl<'a> Compiler<'a> {
where_clauses.push(format!("NOT {}.archived", entity_alias)); 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); self.compile_polymorphic_bounds(r#type, type_aliases, &node, &mut where_clauses);
self.compile_static_property_conditions(r#type, type_aliases, &node, &base_alias, &mut where_clauses);
let start_len = where_clauses.len(); let start_len = where_clauses.len();
self.compile_relation_conditions( self.compile_relation_conditions(
@ -723,6 +699,11 @@ impl<'a> Compiler<'a> {
|| pg_type.contains("int") || pg_type.contains("int")
|| pg_type == "real" || pg_type == "real"
|| pg_type == "double precision" || 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"; cast = "::numeric";
} else if pg_type == "text" || pg_type.contains("char") { } else if pg_type == "text" || pg_type.contains("char") {
@ -749,7 +730,7 @@ impl<'a> Compiler<'a> {
node: &Node, node: &Node,
base_alias: &str, base_alias: &str,
where_clauses: &mut Vec<String>, where_clauses: &mut Vec<String>,
) { ) -> Result<(), String> {
for (i, filter_key) in self.filter_keys.iter().enumerate() { for (i, filter_key) in self.filter_keys.iter().enumerate() {
let mut parts = filter_key.split(':'); let mut parts = filter_key.split(':');
let full_field_path = parts.next().unwrap_or(filter_key); let full_field_path = parts.next().unwrap_or(filter_key);
@ -779,6 +760,95 @@ impl<'a> Compiler<'a> {
let param_index = i + 1; let param_index = i + 1;
let p_val = format!("${}#>>'{{}}'", param_index); 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" { if op == "$of" || op == "$nof" {
let sql_op = if op == "$of" { "IN" } else { "NOT IN" }; let sql_op = if op == "$of" { "IN" } else { "NOT IN" };
let subquery = format!( let subquery = format!(
@ -830,6 +900,7 @@ impl<'a> Compiler<'a> {
)); ));
} }
} }
Ok(())
} }
fn compile_relation_conditions( fn compile_relation_conditions(
@ -899,69 +970,4 @@ impl<'a> Compiler<'a> {
} }
Ok(()) Ok(())
} }
fn compile_static_property_conditions(
&self,
r#type: &crate::database::r#type::Type,
type_aliases: &std::collections::HashMap<String, String>,
node: &Node,
base_alias: &str,
where_clauses: &mut Vec<String>,
) {
if let Some(props) = node.schema.obj.properties.as_ref() {
for (prop_name, prop_schema) in props {
let filter_alias = Self::resolve_filter_alias(r#type, type_aliases, base_alias, prop_name);
if let Some(const_val) = prop_schema.obj.const_.as_ref() {
let sql_val = Self::quote_literal(const_val);
where_clauses.push(format!("{}.{} = {}", filter_alias, prop_name, sql_val));
}
if let Some(enum_vals) = prop_schema.obj.enum_.as_ref() {
if !enum_vals.is_empty() {
let sql_vals: Vec<String> = enum_vals
.iter()
.map(|v| Self::quote_literal(v))
.collect();
where_clauses.push(format!(
"{}.{} IN ({})",
filter_alias,
prop_name,
sql_vals.join(", ")
));
}
}
}
}
}
fn quote_literal(val: &Value) -> String {
match val {
Value::Null => "NULL".to_string(),
Value::Bool(b) => {
if *b {
"true".to_string()
} else {
"false".to_string()
}
}
Value::Number(n) => {
if let Some(f) = n.as_f64() {
if f.fract() == 0.0 {
return f.trunc().to_string();
}
}
n.to_string()
}
Value::String(s) => {
if s.is_empty() {
"NULL".to_string()
} else {
format!("'{}'", s.replace('\'', "''"))
}
}
_ => format!(
"'{}'",
serde_json::to_string(val).unwrap().replace('\'', "''")
),
}
}
} }

View File

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

View File

@ -268,8 +268,7 @@ impl SqlFormatter {
match &join.join_operator { match &join.join_operator {
JoinOperator::Inner(JoinConstraint::On(expr)) JoinOperator::Inner(JoinConstraint::On(expr))
| JoinOperator::Left(JoinConstraint::On(expr)) | JoinOperator::Left(JoinConstraint::On(expr))
| JoinOperator::Right(JoinConstraint::On(expr)) | JoinOperator::Right(JoinConstraint::On(expr)) => {
| JoinOperator::Join(JoinConstraint::On(expr)) => {
self.push_str(" ON "); self.push_str(" ON ");
self.format_expr(expr); self.format_expr(expr);
} }
@ -390,6 +389,7 @@ impl SqlFormatter {
i += 2; i += 2;
} }
self.indent -= 2; self.indent -= 2;
self.format_function_clauses(list);
self.push_line(")"); self.push_line(")");
} else { } else {
for (i, arg) in list.args.iter().enumerate() { for (i, arg) in list.args.iter().enumerate() {
@ -397,6 +397,7 @@ impl SqlFormatter {
self.format_function_arg(arg); self.format_function_arg(arg);
self.push_str(comma); self.push_str(comma);
} }
self.format_function_clauses(list);
self.push_str(")"); self.push_str(")");
} }
} else { } else {
@ -404,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) { fn format_function_arg(&mut self, arg: &FunctionArg) {
match arg { match arg {
FunctionArg::Unnamed(sqlparser::ast::FunctionArgExpr::Expr(expr)) => self.format_expr(expr), FunctionArg::Unnamed(sqlparser::ast::FunctionArgExpr::Expr(expr)) => self.format_expr(expr),

View File

@ -157,10 +157,7 @@ fn test_library_api() {
} }
}, },
"name": { "type": ["string.condition", "null"] }, "name": { "type": ["string.condition", "null"] },
"target": { "target": { "type": ["target_schema.filter", "null"] },
"type": ["target_schema.filter", "null"],
"compiledPropertyNames": ["value", "$and", "$or"]
},
"type": { "type": ["string.condition", "null"] } "type": { "type": ["string.condition", "null"] }
}, },
"type": "filter" "type": "filter"

View File

@ -259,25 +259,3 @@ pub fn update_sql_fixture(path: &str, suite_idx: usize, case_idx: usize, queries
let formatted_json = serde_json::to_string_pretty(&file_data).unwrap(); let formatted_json = serde_json::to_string_pretty(&file_data).unwrap();
fs::write(path, formatted_json).unwrap(); fs::write(path, formatted_json).unwrap();
} }
pub fn update_schemas_fixture(path: &str, suite_idx: usize, case_idx: usize, db: &crate::database::Database) {
let content = fs::read_to_string(path).unwrap();
let mut file_data: Value = serde_json::from_str(&content).unwrap();
if let Some(expect) = file_data[suite_idx]["tests"][case_idx].get_mut("expect") {
if let Some(schemas_map) = expect.get_mut("schemas").and_then(|v| v.as_object_mut()) {
for (key, expected_val) in schemas_map {
if expected_val.is_object() && expected_val.as_object().unwrap().is_empty() {
continue;
}
if let Some(actual_ast) = db.schemas.get(key) {
let actual_val = serde_json::to_value(actual_ast).unwrap();
*expected_val = actual_val;
}
}
}
}
let formatted_json = serde_json::to_string_pretty(&file_data).unwrap();
fs::write(path, formatted_json).unwrap();
}

View File

@ -57,9 +57,6 @@ impl Case {
if env::var("UPDATE_EXPECT").is_ok() { if env::var("UPDATE_EXPECT").is_ok() {
update_validation_fixture(path, suite_idx, case_idx, &result.errors); update_validation_fixture(path, suite_idx, case_idx, &result.errors);
if let Ok(db) = db_res {
crate::tests::runner::update_schemas_fixture(path, suite_idx, case_idx, db);
}
} }
expect.assert_drop(&result)?; expect.assert_drop(&result)?;

View File

@ -1 +1 @@
1.0.178 1.0.176