From 6e88dd84d8a50341c22faab5bb8e941fc8aa8093 Mon Sep 17 00:00:00 2001 From: Satya Date: Wed, 2 Sep 2026 11:41:34 -0400 Subject: [PATCH] Edge resolution: a self-referential base edge is always read in reverse 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. Includes always start from the parent and list what hangs under it; nothing reads the edge from a child up to its parent object. So the edge is reverse, full stop. 1286 library tests pass. Pinned by api's accounting suites on branch activity-task-assignment-lines. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AvkGU4kQ4wsqxHFpXtZKZM --- fixtures/lineage.json | 287 ++++++++++++++++++++++++++++++++++++++++++ src/database/mod.rs | 7 ++ 2 files changed, 294 insertions(+) create mode 100644 fixtures/lineage.json diff --git a/fixtures/lineage.json b/fixtures/lineage.json new file mode 100644 index 0000000..76a6d8a --- /dev/null +++ b/fixtures/lineage.json @@ -0,0 +1,287 @@ +[ + { + "description": "Lineage: the self-referential base edge (entity.parent_id -> entity) is always read from the parent down", + "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" + } + } + } + }, + "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" + } + } + } + }, + "lookup_fields": [], + "historical": true, + "relationship": false, + "variations": [ + "invoice_line" + ] + } + ] + }, + "tests": [ + { + "description": "An array of children joins child.parent_id = parent.id, never the other way", + "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", + " )), '[]'::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_6.id,", + " 'type', invoice_line_6.type,", + " 'archived', entity_5.archived,", + " 'created_at', entity_5.created_at,", + " 'parent_id', entity_5.parent_id,", + " 'ancestors', entity_5.ancestors,", + " 'price', invoice_line_6.price", + " )", + " FROM agreego.entity entity_5", + " JOIN agreego.invoice_line invoice_line_6 ON invoice_line_6.id = entity_5.id", + " WHERE", + " NOT entity_5.archived", + " AND entity_5.parent_id = entity_1.id", + " LIMIT 1", + " )", + " )", + " FROM agreego.entity entity_1", + " JOIN agreego.invoice invoice_2 ON invoice_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..9dc3cf6 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -500,6 +500,13 @@ 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. Includes always start from the parent and list what hangs + // under it, so the edge is read in reverse — never from a child up to its parent object. + if is_forward && is_reverse { + is_forward = false; + } + // 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.