From 4379da4986f034fbb6665964823d118d2b469c22 Mon Sep 17 00:00:00 2001 From: Satya Date: Wed, 2 Sep 2026 11:41:34 -0400 Subject: [PATCH] Edge resolution: on a self-referential base edge, cardinality decides direction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit entity.parent_id -> entity matches every type pair in both directions, and the discovery loop kept forward whenever reverse also matched — so an array include compiled as `parent.parent_id = child.id` and every nested list read back empty. Now cardinality decides: an array lists the children (reverse), a scalar reaches the parent (forward) — a line's UI can include its invoice. A single child through this edge is declared as a one-element array, never a scalar. fixtures/composition.json pins both joins; 1288 library tests pass. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AvkGU4kQ4wsqxHFpXtZKZM --- fixtures/composition.json | 372 ++++++++++++++++++++++++++++++++++++++ src/database/mod.rs | 8 + src/tests/fixtures.rs | 12 ++ 3 files changed, 392 insertions(+) create mode 100644 fixtures/composition.json diff --git a/fixtures/composition.json b/fixtures/composition.json new file mode 100644 index 0000000..a71aa82 --- /dev/null +++ b/fixtures/composition.json @@ -0,0 +1,372 @@ +[ + { + "description": "Composition: the self-referential base edge (entity.parent_id -> entity) \u2014 an array lists the children, a scalar reaches the parent", + "database": { + "puncs": [], + "enums": [], + "relations": [ + { + "id": "44444444-4444-4444-4444-444444444441", + "type": "relation", + "constraint": "fk_entity_parent", + "source_type": "entity", + "source_columns": [ + "parent_id" + ], + "destination_type": "entity", + "destination_columns": [ + "id" + ] + } + ], + "types": [ + { + "name": "entity", + "hierarchy": [ + "entity" + ], + "fields": [ + "id", + "type", + "archived", + "created_at", + "parent_id", + "ancestors" + ], + "grouped_fields": { + "entity": [ + "id", + "type", + "archived", + "created_at", + "parent_id", + "ancestors" + ] + }, + "field_types": { + "id": "uuid", + "type": "text", + "archived": "boolean", + "created_at": "timestamptz", + "parent_id": "uuid", + "ancestors": "uuid[]" + }, + "schemas": { + "entity": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string" + }, + "archived": { + "type": "boolean" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "created": { + "type": "boolean" + }, + "parent_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "ancestors": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + } + }, + "lookup_fields": [], + "historical": true, + "relationship": false, + "variations": [ + "entity", + "invoice", + "invoice_line" + ] + }, + { + "name": "invoice", + "hierarchy": [ + "entity", + "invoice" + ], + "fields": [ + "id", + "type", + "total", + "archived", + "created_at", + "parent_id", + "ancestors" + ], + "grouped_fields": { + "invoice": [ + "id", + "type", + "total" + ], + "entity": [ + "id", + "type", + "archived", + "created_at", + "parent_id", + "ancestors" + ] + }, + "field_types": { + "id": "uuid", + "type": "text", + "archived": "boolean", + "created_at": "timestamptz", + "parent_id": "uuid", + "ancestors": "uuid[]", + "total": "numeric" + }, + "schemas": { + "invoice": { + "type": "entity", + "properties": { + "total": { + "type": "number" + }, + "lines": { + "type": "array", + "items": { + "type": "invoice_line" + } + }, + "first_line": { + "type": "invoice_line" + } + } + }, + "lite.invoice": { + "type": "entity", + "properties": { + "total": { + "type": "number" + } + } + } + }, + "lookup_fields": [], + "historical": true, + "relationship": false, + "variations": [ + "invoice" + ] + }, + { + "name": "invoice_line", + "hierarchy": [ + "entity", + "invoice_line" + ], + "fields": [ + "id", + "type", + "price", + "archived", + "created_at", + "parent_id", + "ancestors" + ], + "grouped_fields": { + "invoice_line": [ + "id", + "type", + "price" + ], + "entity": [ + "id", + "type", + "archived", + "created_at", + "parent_id", + "ancestors" + ] + }, + "field_types": { + "id": "uuid", + "type": "text", + "archived": "boolean", + "created_at": "timestamptz", + "parent_id": "uuid", + "ancestors": "uuid[]", + "price": "numeric" + }, + "schemas": { + "invoice_line": { + "type": "entity", + "properties": { + "price": { + "type": "number" + }, + "invoice": { + "type": "lite.invoice" + } + } + } + }, + "lookup_fields": [], + "historical": true, + "relationship": false, + "variations": [ + "invoice_line" + ] + } + ] + }, + "tests": [ + { + "description": "An array include lists the children: line.parent_id = invoice.id", + "action": "query", + "schema_id": "invoice", + "expect": { + "success": true, + "sql": [ + [ + "((SELECT jsonb_strip_nulls((", + " SELECT jsonb_build_object(", + " 'id', invoice_2.id,", + " 'type', invoice_2.type,", + " 'archived', entity_1.archived,", + " 'created_at', entity_1.created_at,", + " 'parent_id', entity_1.parent_id,", + " 'ancestors', entity_1.ancestors,", + " 'total', invoice_2.total,", + " 'lines', (", + " SELECT COALESCE(jsonb_agg(jsonb_build_object(", + " 'id', invoice_line_4.id,", + " 'type', invoice_line_4.type,", + " 'archived', entity_3.archived,", + " 'created_at', entity_3.created_at,", + " 'parent_id', entity_3.parent_id,", + " 'ancestors', entity_3.ancestors,", + " 'price', invoice_line_4.price,", + " 'invoice', (", + " SELECT jsonb_build_object(", + " 'id', invoice_6.id,", + " 'type', invoice_6.type,", + " 'archived', entity_5.archived,", + " 'created_at', entity_5.created_at,", + " 'parent_id', entity_5.parent_id,", + " 'ancestors', entity_5.ancestors,", + " 'total', invoice_6.total", + " )", + " FROM agreego.entity entity_5", + " JOIN agreego.invoice invoice_6 ON invoice_6.id = entity_5.id", + " WHERE", + " NOT entity_5.archived", + " AND entity_3.parent_id = entity_5.id", + " )", + " )), '[]'::jsonb)", + " FROM agreego.entity entity_3", + " JOIN agreego.invoice_line invoice_line_4 ON invoice_line_4.id = entity_3.id", + " WHERE", + " NOT entity_3.archived", + " AND entity_3.parent_id = entity_1.id", + " ),", + " 'first_line', (", + " SELECT jsonb_build_object(", + " 'id', invoice_line_8.id,", + " 'type', invoice_line_8.type,", + " 'archived', entity_7.archived,", + " 'created_at', entity_7.created_at,", + " 'parent_id', entity_7.parent_id,", + " 'ancestors', entity_7.ancestors,", + " 'price', invoice_line_8.price,", + " 'invoice', (", + " SELECT jsonb_build_object(", + " 'id', invoice_10.id,", + " 'type', invoice_10.type,", + " 'archived', entity_9.archived,", + " 'created_at', entity_9.created_at,", + " 'parent_id', entity_9.parent_id,", + " 'ancestors', entity_9.ancestors,", + " 'total', invoice_10.total", + " )", + " FROM agreego.entity entity_9", + " JOIN agreego.invoice invoice_10 ON invoice_10.id = entity_9.id", + " WHERE", + " NOT entity_9.archived", + " AND entity_7.parent_id = entity_9.id", + " )", + " )", + " FROM agreego.entity entity_7", + " JOIN agreego.invoice_line invoice_line_8 ON invoice_line_8.id = entity_7.id", + " WHERE", + " NOT entity_7.archived", + " AND entity_1.parent_id = entity_7.id", + " )", + " )", + " FROM agreego.entity entity_1", + " JOIN agreego.invoice invoice_2 ON invoice_2.id = entity_1.id", + " WHERE", + " NOT entity_1.archived", + "))))" + ] + ] + } + }, + { + "description": "A scalar include reaches the parent: line.parent_id = invoice.id, read from the line", + "action": "query", + "schema_id": "invoice_line", + "expect": { + "success": true, + "sql": [ + [ + "((SELECT jsonb_strip_nulls((", + " SELECT jsonb_build_object(", + " 'id', invoice_line_2.id,", + " 'type', invoice_line_2.type,", + " 'archived', entity_1.archived,", + " 'created_at', entity_1.created_at,", + " 'parent_id', entity_1.parent_id,", + " 'ancestors', entity_1.ancestors,", + " 'price', invoice_line_2.price,", + " 'invoice', (", + " SELECT jsonb_build_object(", + " 'id', invoice_4.id,", + " 'type', invoice_4.type,", + " 'archived', entity_3.archived,", + " 'created_at', entity_3.created_at,", + " 'parent_id', entity_3.parent_id,", + " 'ancestors', entity_3.ancestors,", + " 'total', invoice_4.total", + " )", + " FROM agreego.entity entity_3", + " JOIN agreego.invoice invoice_4 ON invoice_4.id = entity_3.id", + " WHERE", + " NOT entity_3.archived", + " AND entity_1.parent_id = entity_3.id", + " )", + " )", + " FROM agreego.entity entity_1", + " JOIN agreego.invoice_line invoice_line_2 ON invoice_line_2.id = entity_1.id", + " WHERE", + " NOT entity_1.archived", + "))))" + ] + ] + } + } + ] + } +] \ No newline at end of file diff --git a/src/database/mod.rs b/src/database/mod.rs index afa6eb8..c0b6ff8 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -500,6 +500,14 @@ impl Database { let is_reverse = p_def.hierarchy.contains(&rel.destination_type) && c_def.hierarchy.contains(&rel.source_type); + // A self-referential edge on a shared ancestor table (entity.parent_id -> entity) matches + // both ways for every type pair, so cardinality decides: an array lists the children + // (reverse), a scalar reaches the parent (forward). A single child through this edge is + // declared as a one-element array, never as a scalar. + if is_forward && is_reverse { + is_forward = !is_array; + } + // Structural Cardinality Filtration: // If the schema requires a collection (Array), it is mathematically impossible for a pure // Forward scalar edge (where the parent holds exactly one UUID pointer) to fulfill a One-to-Many request. diff --git a/src/tests/fixtures.rs b/src/tests/fixtures.rs index a423ede..9214f63 100644 --- a/src/tests/fixtures.rs +++ b/src/tests/fixtures.rs @@ -3803,6 +3803,18 @@ fn test_required_5_0() { crate::tests::runner::run_test_case(&path, 5, 0).unwrap(); } +#[test] +fn test_composition_0_0() { + let path = format!("{}/fixtures/composition.json", env!("CARGO_MANIFEST_DIR")); + crate::tests::runner::run_test_case(&path, 0, 0).unwrap(); +} + +#[test] +fn test_composition_0_1() { + let path = format!("{}/fixtures/composition.json", env!("CARGO_MANIFEST_DIR")); + crate::tests::runner::run_test_case(&path, 0, 1).unwrap(); +} + #[test] fn test_multiple_of_0_0() { let path = format!("{}/fixtures/multipleOf.json", env!("CARGO_MANIFEST_DIR"));