From a43444448b92574ca2529bd57281c03f790a805f Mon Sep 17 00:00:00 2001 From: Philip Z Date: Fri, 2 Oct 2026 07:18:22 +0800 Subject: [PATCH 01/40] feat: require owned request intent before Python execution (#43) Signed-off-by: Philip Z --- examples/conformance/Q.py | 1 + examples/conformance/app/masking_lifecycle.py | 10 +- examples/conformance/models/platform.py | 62 ++++- examples/conformance/models/work_item.py | 65 ++++- examples/conformance/pyproject.toml | 2 +- .../conformance/requests/platform_request.py | 29 +- .../conformance/requests/work_item_request.py | 29 +- examples/mutation-policy/main.py | 4 +- .../order-management/python-lib-core/Q.py | 1 + .../models/commerce_platform.py | 101 +++++-- .../python-lib-core/models/customer.py | 76 ++++- .../python-lib-core/models/customer_order.py | 94 +++++-- .../python-lib-core/models/order_line.py | 85 +++++- .../models/order_search_preset.py | 73 ++++- .../python-lib-core/models/order_status.py | 76 ++++- .../python-lib-core/models/product.py | 78 +++++- .../python-lib-core/pyproject.toml | 2 +- .../requests/commerce_platform_request.py | 29 +- .../requests/customer_order_request.py | 29 +- .../requests/customer_request.py | 29 +- .../requests/order_line_request.py | 29 +- .../requests/order_search_preset_request.py | 29 +- .../requests/order_status_request.py | 29 +- .../requests/product_request.py | 29 +- examples/query-policy/main.py | 4 +- examples/school-management/Q.py | 1 + examples/school-management/app/main.py | 51 ++++ examples/school-management/models/platform.py | 75 +++-- examples/school-management/models/school.py | 83 +++++- .../school-management/models/school_type.py | 74 ++++- examples/school-management/pyproject.toml | 2 +- .../requests/platform_request.py | 29 +- .../requests/school_request.py | 29 +- .../requests/school_type_request.py | 29 +- examples/task_board/generated/Q.py | 2 + .../task_board/generated/models/platform.py | 73 +++-- examples/task_board/generated/models/task.py | 78 ++++-- .../generated/models/task_execution_log.py | 65 ++++- .../generated/models/task_status.py | 78 +++++- examples/task_board/generated/pyproject.toml | 2 +- .../generated/requests/platform_request.py | 29 +- .../requests/task_execution_log_request.py | 29 +- .../generated/requests/task_request.py | 29 +- .../generated/requests/task_status_request.py | 29 +- src/teaql/core/__init__.py | 2 + src/teaql/core/mutation.py | 45 +-- src/teaql/core/request_intent.py | 54 ++++ src/teaql/data_service/__init__.py | 75 +++-- src/teaql/provider/tfp_client/__init__.py | 22 +- src/teaql/runtime/context.py | 20 +- src/teaql/runtime/mutation_policy.py | 12 +- src/teaql/sql/executor.py | 34 ++- tests/core/test_mutation_coverage.py | 14 +- tests/core/test_request_intent.py | 144 ++++++++++ tests/data_service/test_service.py | 36 +-- tests/fixtures/request-intent-v1.json | 259 ++++++++++++++++++ tests/provider/sqlite/test_sqlite.py | 121 +++++--- .../test_sql_masking_real_databases.py | 4 +- tests/provider/test_tfp_client.py | 6 +- tests/runtime/test_mutation_policy.py | 30 +- tests/runtime/test_query_policy.py | 8 +- tests/runtime/test_relation_masking.py | 4 +- tests/runtime/test_runtime.py | 10 +- tests/runtime/test_sql_mask_lifecycle.py | 22 +- tests/runtime/test_sql_masking_policy.py | 4 +- 65 files changed, 2040 insertions(+), 668 deletions(-) create mode 100644 src/teaql/core/request_intent.py create mode 100644 tests/core/test_request_intent.py create mode 100644 tests/fixtures/request-intent-v1.json diff --git a/examples/conformance/Q.py b/examples/conformance/Q.py index 0071098..8eecdba 100644 --- a/examples/conformance/Q.py +++ b/examples/conformance/Q.py @@ -19,3 +19,4 @@ def work_items() -> WorkItemRequest: def work_items_minimal() -> WorkItemRequest: return WorkItemRequest(minimal=True) + diff --git a/examples/conformance/app/masking_lifecycle.py b/examples/conformance/app/masking_lifecycle.py index 1b15783..4969747 100644 --- a/examples/conformance/app/masking_lifecycle.py +++ b/examples/conformance/app/masking_lifecycle.py @@ -50,7 +50,7 @@ async def insert(entity_id, target=None): command = (InsertCommand('MaskCustomer').value('id', entity_id).value('version', 1) .value('display_name', 'Riverside')) command.trace_chain = [TraceNode(comment='what: seed Riverside lifecycle fixture')] - return await (target or service).mutate(context, MutationRequest(command)) + return await (target or service).mutate(context, MutationRequest(command, comment='what: seed Riverside lifecycle fixture')) try: for entity_id in [1, 2, 3]: await insert(entity_id) @@ -58,7 +58,7 @@ async def insert(entity_id, target=None): output.clear() request = QueryRequest(SelectQuery('MaskCustomer') .filter(Expr.eq('display_name', 'Riverside')).limit(3) - ).comment('what: inspect customers').purpose('why: verify stream lifecycle') + , _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior').comment('what: inspect customers').purpose('why: verify stream lifecycle') # async-for break alone does not promise immediate generator close # in Python. aclosing makes ownership explicit and deterministic. async with aclosing(service.query_stream(context, request, 1)) as stream: @@ -106,7 +106,7 @@ async def partial_graph(): await insert(777, target) await insert(31, target) try: - await context.execute_graph_save(partial_graph) + await context.execute_graph_save(partial_graph, comment='what: runtime regression fixture') raise AssertionError('partial graph unexpectedly committed') except TransportError: pass @@ -120,11 +120,11 @@ async def partial_graph(): command = (InsertCommand('MaskChild').value('id', 1).value('version', 1).value('parent_id', 1)) command.trace_chain = [TraceNode(comment='what: seed child for relation verification')] - await service.mutate(context, MutationRequest(command)) + await service.mutate(context, MutationRequest(command, comment='what: runtime regression fixture')) graph_request = QueryRequest(SelectQuery('MaskCustomer').project('id') .filter(Expr.eq('id', 1)).and_filter(Expr.eq('display_name', 'Riverside')) .relation_query('children', SelectQuery('MaskChild').project('id').limit(2)).limit(1) - ).comment('what: load Riverside graph').purpose('why: verify inherited relation masking') + , _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior').comment('what: load Riverside graph').purpose('why: verify inherited relation masking') entries.clear() await service.transport.execute_sql(CompiledQuery( 'ALTER TABLE mask_child_data RENAME TO mask_child_unavailable', [])) diff --git a/examples/conformance/models/platform.py b/examples/conformance/models/platform.py index 24f3e1b..098578c 100644 --- a/examples/conformance/models/platform.py +++ b/examples/conformance/models/platform.py @@ -1,8 +1,10 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation import itertools + class Platform: _teaql_temporary_ids = itertools.count(1) @classmethod @@ -31,8 +33,11 @@ def __init__(self, **kwargs): self._comment = None self._loaded_fields = set(kwargs.keys()) self.id = kwargs.get("id") + self.name = kwargs.get("name") + self.version = kwargs.get("version") + self._work_item_list = kwargs.get("work_item_list", []) if "work_item_list" in kwargs or kwargs.get("id") is None: self._loaded_fields.add("work_item_list") @@ -67,13 +72,45 @@ def mark_for_deletion(self): return self def audit_as(self, comment: str): - if not isinstance(comment, str) or not comment.strip(): - raise ValueError("Security audit failure: audit_as() requires a non-empty reason") + MutationIntent(comment) self._comment = comment return self async def save(self, context): - return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context)) + intent = MutationIntent(self._comment) + # Reserve stable internal IDs before the transaction and before policy + # review. A relation to a new parent must have the same identity in the + # reviewed graph plan and in the commands eventually sent to SQL. + await self._teaql_reserve_graph_ids(context, set()) + await self._teaql_ensure_business_ids(context) + return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context), comment=intent.comment) + + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return + + async def _teaql_reserve_graph_ids(self, context, visited): + object_identity = id(self) + if object_identity in visited: + return + visited.add(object_identity) + if self._action == "Create" and getattr(self, "id", None) is None: + service = context.require_resource("dataService") + allocator = getattr(service, "next_id", None) + if not callable(allocator): + raise RuntimeError( + "Configured dataService does not support stable ID reservation" + ) + old_key = self._teaql_entity_key() + self.id = int(await allocator("Platform")) + self._loaded_fields.add("id") + self._ledger_id = self.id + new_key = self._teaql_entity_key() + self._entity_root.rekey(old_key, new_key) + self._entity_root.set(new_key, "id", Value.I64(self.id)) + for child in self._work_item_list: + child._teaql_attach_root(self._entity_root) + await child._teaql_reserve_graph_ids(context, visited) async def _teaql_preflight_and_save(self, context): self._teaql_preflight_graph(context) @@ -83,10 +120,13 @@ def _teaql_build_command(self): payload = {} if "id" in self._loaded_fields: payload["id"] = Value.I64(self.id) + if "name" in self._loaded_fields: payload["name"] = Value.Text(self.name) + if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -102,8 +142,7 @@ def _teaql_build_command(self): return action, cmd def _teaql_preflight_graph(self, context): - if not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: audit_as() must be called before save()") + MutationIntent(self._comment) if self._action == "Update": if "id" not in self._loaded_fields: raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) @@ -113,16 +152,14 @@ def _teaql_preflight_graph(self, context): raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) _action, cmd = self._teaql_build_command() try: - context.check_and_fix_mutation(cmd) + context.preflight_mutation(cmd) finally: for field, value in getattr(cmd, "values", {}).items(): if field not in ("id", "version"): self._entity_root.set(self._teaql_entity_key(), field, value) for index, child in enumerate(self._work_item_list): child._teaql_attach_root(self._entity_root) - setattr(child, "platform", self) - child._loaded_fields.add("platform") - child._entity_root.set(child._teaql_entity_key(), "platform", Value.Object(self)) + child.update_platform(self) child.audit_as(self._comment) try: child._teaql_preflight_graph(context) @@ -134,16 +171,13 @@ def _teaql_preflight_graph(self, context): ]) from error async def _teaql_save_within_graph(self, context): - if not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: audit_as() must be called before save()") + intent = MutationIntent(self._comment) self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() - req = MutationRequest(cmd) - if self._comment: - req.comment = self._comment + req = MutationRequest(cmd, comment=intent.comment) try: context.check_and_fix_mutation(cmd) diff --git a/examples/conformance/models/work_item.py b/examples/conformance/models/work_item.py index 2ffd6b3..76b7f25 100644 --- a/examples/conformance/models/work_item.py +++ b/examples/conformance/models/work_item.py @@ -1,9 +1,11 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation import itertools from models.platform import Platform + class WorkItem: _teaql_temporary_ids = itertools.count(1) @classmethod @@ -36,10 +38,15 @@ def __init__(self, **kwargs): self._comment = None self._loaded_fields = set(kwargs.keys()) self.id = kwargs.get("id") + self.title = kwargs.get("title") + self.description = kwargs.get("description") + self.platform = kwargs.get("platform") + self.version = kwargs.get("version") + if isinstance(self.platform, dict): self.platform = Platform(**self.platform) self._ledger_id = getattr(self, "id", None) @@ -65,13 +72,42 @@ def mark_for_deletion(self): return self def audit_as(self, comment: str): - if not isinstance(comment, str) or not comment.strip(): - raise ValueError("Security audit failure: audit_as() requires a non-empty reason") + MutationIntent(comment) self._comment = comment return self async def save(self, context): - return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context)) + intent = MutationIntent(self._comment) + # Reserve stable internal IDs before the transaction and before policy + # review. A relation to a new parent must have the same identity in the + # reviewed graph plan and in the commands eventually sent to SQL. + await self._teaql_reserve_graph_ids(context, set()) + await self._teaql_ensure_business_ids(context) + return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context), comment=intent.comment) + + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return + + async def _teaql_reserve_graph_ids(self, context, visited): + object_identity = id(self) + if object_identity in visited: + return + visited.add(object_identity) + if self._action == "Create" and getattr(self, "id", None) is None: + service = context.require_resource("dataService") + allocator = getattr(service, "next_id", None) + if not callable(allocator): + raise RuntimeError( + "Configured dataService does not support stable ID reservation" + ) + old_key = self._teaql_entity_key() + self.id = int(await allocator("WorkItem")) + self._loaded_fields.add("id") + self._ledger_id = self.id + new_key = self._teaql_entity_key() + self._entity_root.rekey(old_key, new_key) + self._entity_root.set(new_key, "id", Value.I64(self.id)) async def _teaql_preflight_and_save(self, context): self._teaql_preflight_graph(context) @@ -81,14 +117,23 @@ def _teaql_build_command(self): payload = {} if "id" in self._loaded_fields: payload["id"] = Value.I64(self.id) + if "title" in self._loaded_fields: payload["title"] = Value.Text(self.title) + if "description" in self._loaded_fields: payload["description"] = Value.Text(self.description) + if "platform" in self._loaded_fields: - payload["platform"] = Value.Object(self.platform) + reference = self.platform + reference_id = getattr(reference, "id", reference) + payload["platform"] = (Value.Object(reference) + if reference is not None and hasattr(reference, "id") and reference_id is None + else Value.I64(reference_id)) + if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -104,8 +149,7 @@ def _teaql_build_command(self): return action, cmd def _teaql_preflight_graph(self, context): - if not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: audit_as() must be called before save()") + MutationIntent(self._comment) if self._action == "Update": if "id" not in self._loaded_fields: raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) @@ -119,23 +163,20 @@ def _teaql_preflight_graph(self, context): raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) _action, cmd = self._teaql_build_command() try: - context.check_and_fix_mutation(cmd) + context.preflight_mutation(cmd) finally: for field, value in getattr(cmd, "values", {}).items(): if field not in ("id", "version"): self._entity_root.set(self._teaql_entity_key(), field, value) async def _teaql_save_within_graph(self, context): - if not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: audit_as() must be called before save()") + intent = MutationIntent(self._comment) self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() - req = MutationRequest(cmd) - if self._comment: - req.comment = self._comment + req = MutationRequest(cmd, comment=intent.comment) try: context.check_and_fix_mutation(cmd) diff --git a/examples/conformance/pyproject.toml b/examples/conformance/pyproject.toml index 565edd8..d8e7349 100644 --- a/examples/conformance/pyproject.toml +++ b/examples/conformance/pyproject.toml @@ -2,7 +2,7 @@ name = "runtime-example-conformance-service-lib" version = "1.0.0" description = "Generated python library" -dependencies = ["teaql==0.2.7", "aiosqlite>=0.22.1"] +dependencies = ["teaql==0.2.9", "aiosqlite>=0.22.1"] [tool.setuptools] py-modules = ["Q", "E"] diff --git a/examples/conformance/requests/platform_request.py b/examples/conformance/requests/platform_request.py index 2609c6e..4fa77f9 100644 --- a/examples/conformance/requests/platform_request.py +++ b/examples/conformance/requests/platform_request.py @@ -2,6 +2,8 @@ from teaql.core.list import SmartList, TeaQLPage from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest +from teaql.core import QueryIntent +from copy import deepcopy from teaql.core.expr import ( begin_with, between, column, contain, end_with, eq, gt, gte, in_list, in_subquery, is_not_null, is_null, lt, lte, ne, not_begin_with, @@ -352,8 +354,7 @@ def comment(self, c: str): def new_entity(self, context) -> Platform: request = self._request - if not request._comment or not request._comment.strip() or not request._purpose or not request._purpose.strip(): - raise ValueError("Security audit failure: non-empty comment() and purpose() are required before new_entity()") + QueryIntent(request._comment, request._purpose) entity = context.initialize_entity("Platform", Platform()) if not isinstance(entity, Platform): raise TypeError("entity initializer returned an incompatible Platform") @@ -361,10 +362,9 @@ def new_entity(self, context) -> Platform: async def execute_for_result(self, context): self = self._request - if not self._purpose or not self._purpose.strip() or not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: comment() and purpose() must be called before execute_for_rows()") + req = QueryRequest(self.query, _comment=self._comment, _purpose=self._purpose) + req = context.prepare_query_request(req) service = context.require_resource("dataService") - req = QueryRequest(context.prepare_query(self.query), _comment=self._comment, _purpose=self._purpose) return await service.query(context, req) async def execute_for_rows(self, context): @@ -379,10 +379,10 @@ async def execute_for_list(self, context) -> SmartList[Platform]: async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[Platform]: request = self._request - if not request._purpose or not request._purpose.strip() or not request._comment or not request._comment.strip(): - raise ValueError("Security audit failure: comment() and purpose() must be called before execute_for_page()") - request.query.offset(offset).limit(limit) - authorized = context.prepare_query(request.query) + intent = QueryIntent(request._comment, request._purpose) + query = deepcopy(request.query) + query.offset(offset).limit(limit) + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query service = context.require_resource("dataService") alias = "__teaql_total" if authorized.id_set_pagination is not None: @@ -406,19 +406,20 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): - self._request.limit(1) - entities = await self.execute_for_list(context) + request = deepcopy(self._request) + request.limit(1) + entities = await ExecutablePlatformRequest(request).execute_for_list(context) return entities[0] if entities else None async def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request - if not request._purpose or not request._purpose.strip() or not request._comment or not request._comment.strip(): - raise Exception("Security audit failure: comment() and purpose() must be called before execute_for_stream()") + req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) + req = context.prepare_query_request(req) service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") query_root = EntityRoot() - async for chunk in service.query_stream(context, QueryRequest(context.prepare_query(request.query), _comment=request._comment, _purpose=request._purpose), chunk_size): + async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: yield Platform(_entity_root=query_root, **row) diff --git a/examples/conformance/requests/work_item_request.py b/examples/conformance/requests/work_item_request.py index d3ed1ad..fd1f5e1 100644 --- a/examples/conformance/requests/work_item_request.py +++ b/examples/conformance/requests/work_item_request.py @@ -2,6 +2,8 @@ from teaql.core.list import SmartList, TeaQLPage from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest +from teaql.core import QueryIntent +from copy import deepcopy from teaql.core.expr import ( begin_with, between, column, contain, end_with, eq, gt, gte, in_list, in_subquery, is_not_null, is_null, lt, lte, ne, not_begin_with, @@ -442,8 +444,7 @@ def comment(self, c: str): def new_entity(self, context) -> WorkItem: request = self._request - if not request._comment or not request._comment.strip() or not request._purpose or not request._purpose.strip(): - raise ValueError("Security audit failure: non-empty comment() and purpose() are required before new_entity()") + QueryIntent(request._comment, request._purpose) entity = context.initialize_entity("WorkItem", WorkItem()) if not isinstance(entity, WorkItem): raise TypeError("entity initializer returned an incompatible WorkItem") @@ -451,10 +452,9 @@ def new_entity(self, context) -> WorkItem: async def execute_for_result(self, context): self = self._request - if not self._purpose or not self._purpose.strip() or not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: comment() and purpose() must be called before execute_for_rows()") + req = QueryRequest(self.query, _comment=self._comment, _purpose=self._purpose) + req = context.prepare_query_request(req) service = context.require_resource("dataService") - req = QueryRequest(context.prepare_query(self.query), _comment=self._comment, _purpose=self._purpose) return await service.query(context, req) async def execute_for_rows(self, context): @@ -469,10 +469,10 @@ async def execute_for_list(self, context) -> SmartList[WorkItem]: async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[WorkItem]: request = self._request - if not request._purpose or not request._purpose.strip() or not request._comment or not request._comment.strip(): - raise ValueError("Security audit failure: comment() and purpose() must be called before execute_for_page()") - request.query.offset(offset).limit(limit) - authorized = context.prepare_query(request.query) + intent = QueryIntent(request._comment, request._purpose) + query = deepcopy(request.query) + query.offset(offset).limit(limit) + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query service = context.require_resource("dataService") alias = "__teaql_total" if authorized.id_set_pagination is not None: @@ -496,19 +496,20 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): - self._request.limit(1) - entities = await self.execute_for_list(context) + request = deepcopy(self._request) + request.limit(1) + entities = await ExecutableWorkItemRequest(request).execute_for_list(context) return entities[0] if entities else None async def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request - if not request._purpose or not request._purpose.strip() or not request._comment or not request._comment.strip(): - raise Exception("Security audit failure: comment() and purpose() must be called before execute_for_stream()") + req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) + req = context.prepare_query_request(req) service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") query_root = EntityRoot() - async for chunk in service.query_stream(context, QueryRequest(context.prepare_query(request.query), _comment=request._comment, _purpose=request._purpose), chunk_size): + async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: yield WorkItem(_entity_root=query_root, **row) diff --git a/examples/mutation-policy/main.py b/examples/mutation-policy/main.py index 008ed06..d820776 100644 --- a/examples/mutation-policy/main.py +++ b/examples/mutation-policy/main.py @@ -132,9 +132,9 @@ async def graph(): for command in commands: context.preflight_mutation(command) for command in commands: - await transaction.mutate(context, MutationRequest(command)) + await transaction.mutate(context, MutationRequest(command, comment='what: runtime regression fixture')) - await context.execute_graph_save(graph) + await context.execute_graph_save(graph, comment='what: runtime regression fixture') async def main(): diff --git a/examples/order-management/python-lib-core/Q.py b/examples/order-management/python-lib-core/Q.py index ad178bf..22a4514 100644 --- a/examples/order-management/python-lib-core/Q.py +++ b/examples/order-management/python-lib-core/Q.py @@ -64,3 +64,4 @@ def order_search_presets() -> OrderSearchPresetRequest: def order_search_presets_minimal() -> OrderSearchPresetRequest: return OrderSearchPresetRequest(minimal=True) + diff --git a/examples/order-management/python-lib-core/models/commerce_platform.py b/examples/order-management/python-lib-core/models/commerce_platform.py index a0b2a83..04e1a4e 100644 --- a/examples/order-management/python-lib-core/models/commerce_platform.py +++ b/examples/order-management/python-lib-core/models/commerce_platform.py @@ -1,8 +1,10 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation import itertools + class CommercePlatform: _teaql_temporary_ids = itertools.count(1) @classmethod @@ -35,10 +37,15 @@ def __init__(self, **kwargs): self._comment = None self._loaded_fields = set(kwargs.keys()) self.id = kwargs.get("id") + self.name = kwargs.get("name") + self.createTime = kwargs.get("createTime") + self.updateTime = kwargs.get("updateTime") + self.version = kwargs.get("version") + self._customer_list = kwargs.get("customer_list", []) if "customer_list" in kwargs or kwargs.get("id") is None: self._loaded_fields.add("customer_list") @@ -128,13 +135,60 @@ def mark_for_deletion(self): return self def audit_as(self, comment: str): - if not isinstance(comment, str) or not comment.strip(): - raise ValueError("Security audit failure: audit_as() requires a non-empty reason") + MutationIntent(comment) self._comment = comment return self async def save(self, context): - return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context)) + intent = MutationIntent(self._comment) + # Reserve stable internal IDs before the transaction and before policy + # review. A relation to a new parent must have the same identity in the + # reviewed graph plan and in the commands eventually sent to SQL. + await self._teaql_reserve_graph_ids(context, set()) + await self._teaql_ensure_business_ids(context) + return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context), comment=intent.comment) + + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return + + async def _teaql_reserve_graph_ids(self, context, visited): + object_identity = id(self) + if object_identity in visited: + return + visited.add(object_identity) + if self._action == "Create" and getattr(self, "id", None) is None: + service = context.require_resource("dataService") + allocator = getattr(service, "next_id", None) + if not callable(allocator): + raise RuntimeError( + "Configured dataService does not support stable ID reservation" + ) + old_key = self._teaql_entity_key() + self.id = int(await allocator("CommercePlatform")) + self._loaded_fields.add("id") + self._ledger_id = self.id + new_key = self._teaql_entity_key() + self._entity_root.rekey(old_key, new_key) + self._entity_root.set(new_key, "id", Value.I64(self.id)) + for child in self._customer_list: + child._teaql_attach_root(self._entity_root) + await child._teaql_reserve_graph_ids(context, visited) + for child in self._order_status_list: + child._teaql_attach_root(self._entity_root) + await child._teaql_reserve_graph_ids(context, visited) + for child in self._customer_order_list: + child._teaql_attach_root(self._entity_root) + await child._teaql_reserve_graph_ids(context, visited) + for child in self._product_list: + child._teaql_attach_root(self._entity_root) + await child._teaql_reserve_graph_ids(context, visited) + for child in self._order_line_list: + child._teaql_attach_root(self._entity_root) + await child._teaql_reserve_graph_ids(context, visited) + for child in self._order_search_preset_list: + child._teaql_attach_root(self._entity_root) + await child._teaql_reserve_graph_ids(context, visited) async def _teaql_preflight_and_save(self, context): self._teaql_preflight_graph(context) @@ -144,14 +198,19 @@ def _teaql_build_command(self): payload = {} if "id" in self._loaded_fields: payload["id"] = Value.I64(self.id) + if "name" in self._loaded_fields: payload["name"] = Value.Text(self.name) + if "createTime" in self._loaded_fields: payload["create_time"] = Value.DateTime(self.createTime) + if "updateTime" in self._loaded_fields: payload["update_time"] = Value.DateTime(self.updateTime) + if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -167,8 +226,7 @@ def _teaql_build_command(self): return action, cmd def _teaql_preflight_graph(self, context): - if not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: audit_as() must be called before save()") + MutationIntent(self._comment) if self._action == "Update": if "id" not in self._loaded_fields: raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) @@ -182,16 +240,14 @@ def _teaql_preflight_graph(self, context): raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) _action, cmd = self._teaql_build_command() try: - context.check_and_fix_mutation(cmd) + context.preflight_mutation(cmd) finally: for field, value in getattr(cmd, "values", {}).items(): if field not in ("id", "version"): self._entity_root.set(self._teaql_entity_key(), field, value) for index, child in enumerate(self._customer_list): child._teaql_attach_root(self._entity_root) - setattr(child, "commercePlatform", self) - child._loaded_fields.add("commercePlatform") - child._entity_root.set(child._teaql_entity_key(), "commerce_platform", Value.Object(self)) + child.update_commerce_platform(self) child.audit_as(self._comment) try: child._teaql_preflight_graph(context) @@ -203,9 +259,7 @@ def _teaql_preflight_graph(self, context): ]) from error for index, child in enumerate(self._order_status_list): child._teaql_attach_root(self._entity_root) - setattr(child, "commercePlatform", self) - child._loaded_fields.add("commercePlatform") - child._entity_root.set(child._teaql_entity_key(), "commerce_platform", Value.Object(self)) + child.update_commerce_platform(self) child.audit_as(self._comment) try: child._teaql_preflight_graph(context) @@ -217,9 +271,7 @@ def _teaql_preflight_graph(self, context): ]) from error for index, child in enumerate(self._customer_order_list): child._teaql_attach_root(self._entity_root) - setattr(child, "commercePlatform", self) - child._loaded_fields.add("commercePlatform") - child._entity_root.set(child._teaql_entity_key(), "commerce_platform", Value.Object(self)) + child.update_commerce_platform(self) child.audit_as(self._comment) try: child._teaql_preflight_graph(context) @@ -231,9 +283,7 @@ def _teaql_preflight_graph(self, context): ]) from error for index, child in enumerate(self._product_list): child._teaql_attach_root(self._entity_root) - setattr(child, "commercePlatform", self) - child._loaded_fields.add("commercePlatform") - child._entity_root.set(child._teaql_entity_key(), "commerce_platform", Value.Object(self)) + child.update_commerce_platform(self) child.audit_as(self._comment) try: child._teaql_preflight_graph(context) @@ -245,9 +295,7 @@ def _teaql_preflight_graph(self, context): ]) from error for index, child in enumerate(self._order_line_list): child._teaql_attach_root(self._entity_root) - setattr(child, "commercePlatform", self) - child._loaded_fields.add("commercePlatform") - child._entity_root.set(child._teaql_entity_key(), "commerce_platform", Value.Object(self)) + child.update_commerce_platform(self) child.audit_as(self._comment) try: child._teaql_preflight_graph(context) @@ -259,9 +307,7 @@ def _teaql_preflight_graph(self, context): ]) from error for index, child in enumerate(self._order_search_preset_list): child._teaql_attach_root(self._entity_root) - setattr(child, "commercePlatform", self) - child._loaded_fields.add("commercePlatform") - child._entity_root.set(child._teaql_entity_key(), "commerce_platform", Value.Object(self)) + child.update_commerce_platform(self) child.audit_as(self._comment) try: child._teaql_preflight_graph(context) @@ -273,16 +319,13 @@ def _teaql_preflight_graph(self, context): ]) from error async def _teaql_save_within_graph(self, context): - if not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: audit_as() must be called before save()") + intent = MutationIntent(self._comment) self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() - req = MutationRequest(cmd) - if self._comment: - req.comment = self._comment + req = MutationRequest(cmd, comment=intent.comment) try: context.check_and_fix_mutation(cmd) diff --git a/examples/order-management/python-lib-core/models/customer.py b/examples/order-management/python-lib-core/models/customer.py index c8cd7ab..01382e0 100644 --- a/examples/order-management/python-lib-core/models/customer.py +++ b/examples/order-management/python-lib-core/models/customer.py @@ -1,9 +1,11 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation import itertools from models.commerce_platform import CommercePlatform + class Customer: _teaql_temporary_ids = itertools.count(1) @classmethod @@ -40,12 +42,19 @@ def __init__(self, **kwargs): self._comment = None self._loaded_fields = set(kwargs.keys()) self.id = kwargs.get("id") + self.name = kwargs.get("name") + self.email = kwargs.get("email") + self.commercePlatform = kwargs.get("commercePlatform") + self.createTime = kwargs.get("createTime") + self.updateTime = kwargs.get("updateTime") + self.version = kwargs.get("version") + if isinstance(self.commercePlatform, dict): self.commercePlatform = CommercePlatform(**self.commercePlatform) self._customer_order_list = kwargs.get("customer_order_list", []) @@ -82,13 +91,45 @@ def mark_for_deletion(self): return self def audit_as(self, comment: str): - if not isinstance(comment, str) or not comment.strip(): - raise ValueError("Security audit failure: audit_as() requires a non-empty reason") + MutationIntent(comment) self._comment = comment return self async def save(self, context): - return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context)) + intent = MutationIntent(self._comment) + # Reserve stable internal IDs before the transaction and before policy + # review. A relation to a new parent must have the same identity in the + # reviewed graph plan and in the commands eventually sent to SQL. + await self._teaql_reserve_graph_ids(context, set()) + await self._teaql_ensure_business_ids(context) + return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context), comment=intent.comment) + + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return + + async def _teaql_reserve_graph_ids(self, context, visited): + object_identity = id(self) + if object_identity in visited: + return + visited.add(object_identity) + if self._action == "Create" and getattr(self, "id", None) is None: + service = context.require_resource("dataService") + allocator = getattr(service, "next_id", None) + if not callable(allocator): + raise RuntimeError( + "Configured dataService does not support stable ID reservation" + ) + old_key = self._teaql_entity_key() + self.id = int(await allocator("Customer")) + self._loaded_fields.add("id") + self._ledger_id = self.id + new_key = self._teaql_entity_key() + self._entity_root.rekey(old_key, new_key) + self._entity_root.set(new_key, "id", Value.I64(self.id)) + for child in self._customer_order_list: + child._teaql_attach_root(self._entity_root) + await child._teaql_reserve_graph_ids(context, visited) async def _teaql_preflight_and_save(self, context): self._teaql_preflight_graph(context) @@ -98,18 +139,29 @@ def _teaql_build_command(self): payload = {} if "id" in self._loaded_fields: payload["id"] = Value.I64(self.id) + if "name" in self._loaded_fields: payload["name"] = Value.Text(self.name) + if "email" in self._loaded_fields: payload["email"] = Value.Text(self.email) + if "commercePlatform" in self._loaded_fields: - payload["commerce_platform"] = Value.Object(self.commercePlatform) + reference = self.commercePlatform + reference_id = getattr(reference, "id", reference) + payload["commerce_platform"] = (Value.Object(reference) + if reference is not None and hasattr(reference, "id") and reference_id is None + else Value.I64(reference_id)) + if "createTime" in self._loaded_fields: payload["create_time"] = Value.DateTime(self.createTime) + if "updateTime" in self._loaded_fields: payload["update_time"] = Value.DateTime(self.updateTime) + if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -125,8 +177,7 @@ def _teaql_build_command(self): return action, cmd def _teaql_preflight_graph(self, context): - if not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: audit_as() must be called before save()") + MutationIntent(self._comment) if self._action == "Update": if "id" not in self._loaded_fields: raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) @@ -144,16 +195,14 @@ def _teaql_preflight_graph(self, context): raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) _action, cmd = self._teaql_build_command() try: - context.check_and_fix_mutation(cmd) + context.preflight_mutation(cmd) finally: for field, value in getattr(cmd, "values", {}).items(): if field not in ("id", "version"): self._entity_root.set(self._teaql_entity_key(), field, value) for index, child in enumerate(self._customer_order_list): child._teaql_attach_root(self._entity_root) - setattr(child, "customer", self) - child._loaded_fields.add("customer") - child._entity_root.set(child._teaql_entity_key(), "customer", Value.Object(self)) + child.update_customer(self) child.audit_as(self._comment) try: child._teaql_preflight_graph(context) @@ -165,16 +214,13 @@ def _teaql_preflight_graph(self, context): ]) from error async def _teaql_save_within_graph(self, context): - if not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: audit_as() must be called before save()") + intent = MutationIntent(self._comment) self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() - req = MutationRequest(cmd) - if self._comment: - req.comment = self._comment + req = MutationRequest(cmd, comment=intent.comment) try: context.check_and_fix_mutation(cmd) diff --git a/examples/order-management/python-lib-core/models/customer_order.py b/examples/order-management/python-lib-core/models/customer_order.py index 7eb8c5c..5754f29 100644 --- a/examples/order-management/python-lib-core/models/customer_order.py +++ b/examples/order-management/python-lib-core/models/customer_order.py @@ -1,4 +1,5 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation import itertools @@ -6,6 +7,7 @@ from models.customer import Customer from models.commerce_platform import CommercePlatform + class CustomerOrder: _teaql_temporary_ids = itertools.count(1) @classmethod @@ -48,15 +50,25 @@ def __init__(self, **kwargs): self._comment = None self._loaded_fields = set(kwargs.keys()) self.id = kwargs.get("id") + self.orderNumber = kwargs.get("orderNumber") + self.orderDate = kwargs.get("orderDate") + self.totalAmount = kwargs.get("totalAmount") + self.status = kwargs.get("status") + self.customer = kwargs.get("customer") + self.commercePlatform = kwargs.get("commercePlatform") + self.createTime = kwargs.get("createTime") + self.updateTime = kwargs.get("updateTime") + self.version = kwargs.get("version") + if isinstance(self.status, dict): self.status = OrderStatus(**self.status) if isinstance(self.customer, dict): @@ -97,13 +109,45 @@ def mark_for_deletion(self): return self def audit_as(self, comment: str): - if not isinstance(comment, str) or not comment.strip(): - raise ValueError("Security audit failure: audit_as() requires a non-empty reason") + MutationIntent(comment) self._comment = comment return self async def save(self, context): - return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context)) + intent = MutationIntent(self._comment) + # Reserve stable internal IDs before the transaction and before policy + # review. A relation to a new parent must have the same identity in the + # reviewed graph plan and in the commands eventually sent to SQL. + await self._teaql_reserve_graph_ids(context, set()) + await self._teaql_ensure_business_ids(context) + return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context), comment=intent.comment) + + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return + + async def _teaql_reserve_graph_ids(self, context, visited): + object_identity = id(self) + if object_identity in visited: + return + visited.add(object_identity) + if self._action == "Create" and getattr(self, "id", None) is None: + service = context.require_resource("dataService") + allocator = getattr(service, "next_id", None) + if not callable(allocator): + raise RuntimeError( + "Configured dataService does not support stable ID reservation" + ) + old_key = self._teaql_entity_key() + self.id = int(await allocator("CustomerOrder")) + self._loaded_fields.add("id") + self._ledger_id = self.id + new_key = self._teaql_entity_key() + self._entity_root.rekey(old_key, new_key) + self._entity_root.set(new_key, "id", Value.I64(self.id)) + for child in self._order_line_list: + child._teaql_attach_root(self._entity_root) + await child._teaql_reserve_graph_ids(context, visited) async def _teaql_preflight_and_save(self, context): self._teaql_preflight_graph(context) @@ -113,24 +157,46 @@ def _teaql_build_command(self): payload = {} if "id" in self._loaded_fields: payload["id"] = Value.I64(self.id) + if "orderNumber" in self._loaded_fields: payload["order_number"] = Value.Text(self.orderNumber) + if "orderDate" in self._loaded_fields: payload["order_date"] = Value.Date(self.orderDate) + if "totalAmount" in self._loaded_fields: payload["total_amount"] = Value.Decimal(self.totalAmount) + if "status" in self._loaded_fields: - payload["status"] = Value.Object(self.status) + reference = self.status + reference_id = getattr(reference, "id", reference) + payload["status"] = (Value.Object(reference) + if reference is not None and hasattr(reference, "id") and reference_id is None + else Value.I64(reference_id)) + if "customer" in self._loaded_fields: - payload["customer"] = Value.Object(self.customer) + reference = self.customer + reference_id = getattr(reference, "id", reference) + payload["customer"] = (Value.Object(reference) + if reference is not None and hasattr(reference, "id") and reference_id is None + else Value.I64(reference_id)) + if "commercePlatform" in self._loaded_fields: - payload["commerce_platform"] = Value.Object(self.commercePlatform) + reference = self.commercePlatform + reference_id = getattr(reference, "id", reference) + payload["commerce_platform"] = (Value.Object(reference) + if reference is not None and hasattr(reference, "id") and reference_id is None + else Value.I64(reference_id)) + if "createTime" in self._loaded_fields: payload["create_time"] = Value.DateTime(self.createTime) + if "updateTime" in self._loaded_fields: payload["update_time"] = Value.DateTime(self.updateTime) + if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -146,8 +212,7 @@ def _teaql_build_command(self): return action, cmd def _teaql_preflight_graph(self, context): - if not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: audit_as() must be called before save()") + MutationIntent(self._comment) if self._action == "Update": if "id" not in self._loaded_fields: raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) @@ -171,16 +236,14 @@ def _teaql_preflight_graph(self, context): raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) _action, cmd = self._teaql_build_command() try: - context.check_and_fix_mutation(cmd) + context.preflight_mutation(cmd) finally: for field, value in getattr(cmd, "values", {}).items(): if field not in ("id", "version"): self._entity_root.set(self._teaql_entity_key(), field, value) for index, child in enumerate(self._order_line_list): child._teaql_attach_root(self._entity_root) - setattr(child, "customerOrder", self) - child._loaded_fields.add("customerOrder") - child._entity_root.set(child._teaql_entity_key(), "customer_order", Value.Object(self)) + child.update_customer_order(self) child.audit_as(self._comment) try: child._teaql_preflight_graph(context) @@ -192,16 +255,13 @@ def _teaql_preflight_graph(self, context): ]) from error async def _teaql_save_within_graph(self, context): - if not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: audit_as() must be called before save()") + intent = MutationIntent(self._comment) self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() - req = MutationRequest(cmd) - if self._comment: - req.comment = self._comment + req = MutationRequest(cmd, comment=intent.comment) try: context.check_and_fix_mutation(cmd) diff --git a/examples/order-management/python-lib-core/models/order_line.py b/examples/order-management/python-lib-core/models/order_line.py index d754e9a..f91d252 100644 --- a/examples/order-management/python-lib-core/models/order_line.py +++ b/examples/order-management/python-lib-core/models/order_line.py @@ -1,4 +1,5 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation import itertools @@ -6,6 +7,7 @@ from models.product import Product from models.commerce_platform import CommercePlatform + class OrderLine: _teaql_temporary_ids = itertools.count(1) @classmethod @@ -46,14 +48,23 @@ def __init__(self, **kwargs): self._comment = None self._loaded_fields = set(kwargs.keys()) self.id = kwargs.get("id") + self.customerOrder = kwargs.get("customerOrder") + self.product = kwargs.get("product") + self.productName = kwargs.get("productName") + self.sku = kwargs.get("sku") + self.quantity = kwargs.get("quantity") + self.commercePlatform = kwargs.get("commercePlatform") + self.createTime = kwargs.get("createTime") + self.version = kwargs.get("version") + if isinstance(self.customerOrder, dict): self.customerOrder = CustomerOrder(**self.customerOrder) if isinstance(self.product, dict): @@ -83,13 +94,42 @@ def mark_for_deletion(self): return self def audit_as(self, comment: str): - if not isinstance(comment, str) or not comment.strip(): - raise ValueError("Security audit failure: audit_as() requires a non-empty reason") + MutationIntent(comment) self._comment = comment return self async def save(self, context): - return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context)) + intent = MutationIntent(self._comment) + # Reserve stable internal IDs before the transaction and before policy + # review. A relation to a new parent must have the same identity in the + # reviewed graph plan and in the commands eventually sent to SQL. + await self._teaql_reserve_graph_ids(context, set()) + await self._teaql_ensure_business_ids(context) + return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context), comment=intent.comment) + + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return + + async def _teaql_reserve_graph_ids(self, context, visited): + object_identity = id(self) + if object_identity in visited: + return + visited.add(object_identity) + if self._action == "Create" and getattr(self, "id", None) is None: + service = context.require_resource("dataService") + allocator = getattr(service, "next_id", None) + if not callable(allocator): + raise RuntimeError( + "Configured dataService does not support stable ID reservation" + ) + old_key = self._teaql_entity_key() + self.id = int(await allocator("OrderLine")) + self._loaded_fields.add("id") + self._ledger_id = self.id + new_key = self._teaql_entity_key() + self._entity_root.rekey(old_key, new_key) + self._entity_root.set(new_key, "id", Value.I64(self.id)) async def _teaql_preflight_and_save(self, context): self._teaql_preflight_graph(context) @@ -99,22 +139,43 @@ def _teaql_build_command(self): payload = {} if "id" in self._loaded_fields: payload["id"] = Value.I64(self.id) + if "customerOrder" in self._loaded_fields: - payload["customer_order"] = Value.Object(self.customerOrder) + reference = self.customerOrder + reference_id = getattr(reference, "id", reference) + payload["customer_order"] = (Value.Object(reference) + if reference is not None and hasattr(reference, "id") and reference_id is None + else Value.I64(reference_id)) + if "product" in self._loaded_fields: - payload["product"] = Value.Object(self.product) + reference = self.product + reference_id = getattr(reference, "id", reference) + payload["product"] = (Value.Object(reference) + if reference is not None and hasattr(reference, "id") and reference_id is None + else Value.I64(reference_id)) + if "productName" in self._loaded_fields: payload["product_name"] = Value.Text(self.productName) + if "sku" in self._loaded_fields: payload["sku"] = Value.Text(self.sku) + if "quantity" in self._loaded_fields: payload["quantity"] = Value.I64(self.quantity) + if "commercePlatform" in self._loaded_fields: - payload["commerce_platform"] = Value.Object(self.commercePlatform) + reference = self.commercePlatform + reference_id = getattr(reference, "id", reference) + payload["commerce_platform"] = (Value.Object(reference) + if reference is not None and hasattr(reference, "id") and reference_id is None + else Value.I64(reference_id)) + if "createTime" in self._loaded_fields: payload["create_time"] = Value.DateTime(self.createTime) + if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -130,8 +191,7 @@ def _teaql_build_command(self): return action, cmd def _teaql_preflight_graph(self, context): - if not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: audit_as() must be called before save()") + MutationIntent(self._comment) if self._action == "Update": if "id" not in self._loaded_fields: raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) @@ -153,23 +213,20 @@ def _teaql_preflight_graph(self, context): raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) _action, cmd = self._teaql_build_command() try: - context.check_and_fix_mutation(cmd) + context.preflight_mutation(cmd) finally: for field, value in getattr(cmd, "values", {}).items(): if field not in ("id", "version"): self._entity_root.set(self._teaql_entity_key(), field, value) async def _teaql_save_within_graph(self, context): - if not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: audit_as() must be called before save()") + intent = MutationIntent(self._comment) self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() - req = MutationRequest(cmd) - if self._comment: - req.comment = self._comment + req = MutationRequest(cmd, comment=intent.comment) try: context.check_and_fix_mutation(cmd) diff --git a/examples/order-management/python-lib-core/models/order_search_preset.py b/examples/order-management/python-lib-core/models/order_search_preset.py index 7b774cc..bf817d7 100644 --- a/examples/order-management/python-lib-core/models/order_search_preset.py +++ b/examples/order-management/python-lib-core/models/order_search_preset.py @@ -1,9 +1,11 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation import itertools from models.commerce_platform import CommercePlatform + class OrderSearchPreset: _teaql_temporary_ids = itertools.count(1) @classmethod @@ -44,14 +46,23 @@ def __init__(self, **kwargs): self._comment = None self._loaded_fields = set(kwargs.keys()) self.id = kwargs.get("id") + self.name = kwargs.get("name") + self.filterJson = kwargs.get("filterJson") + self.requestId = kwargs.get("requestId") + self.ownerUserId = kwargs.get("ownerUserId") + self.commercePlatform = kwargs.get("commercePlatform") + self.createTime = kwargs.get("createTime") + self.updateTime = kwargs.get("updateTime") + self.version = kwargs.get("version") + if isinstance(self.commercePlatform, dict): self.commercePlatform = CommercePlatform(**self.commercePlatform) self._ledger_id = getattr(self, "id", None) @@ -77,13 +88,42 @@ def mark_for_deletion(self): return self def audit_as(self, comment: str): - if not isinstance(comment, str) or not comment.strip(): - raise ValueError("Security audit failure: audit_as() requires a non-empty reason") + MutationIntent(comment) self._comment = comment return self async def save(self, context): - return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context)) + intent = MutationIntent(self._comment) + # Reserve stable internal IDs before the transaction and before policy + # review. A relation to a new parent must have the same identity in the + # reviewed graph plan and in the commands eventually sent to SQL. + await self._teaql_reserve_graph_ids(context, set()) + await self._teaql_ensure_business_ids(context) + return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context), comment=intent.comment) + + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return + + async def _teaql_reserve_graph_ids(self, context, visited): + object_identity = id(self) + if object_identity in visited: + return + visited.add(object_identity) + if self._action == "Create" and getattr(self, "id", None) is None: + service = context.require_resource("dataService") + allocator = getattr(service, "next_id", None) + if not callable(allocator): + raise RuntimeError( + "Configured dataService does not support stable ID reservation" + ) + old_key = self._teaql_entity_key() + self.id = int(await allocator("OrderSearchPreset")) + self._loaded_fields.add("id") + self._ledger_id = self.id + new_key = self._teaql_entity_key() + self._entity_root.rekey(old_key, new_key) + self._entity_root.set(new_key, "id", Value.I64(self.id)) async def _teaql_preflight_and_save(self, context): self._teaql_preflight_graph(context) @@ -93,22 +133,35 @@ def _teaql_build_command(self): payload = {} if "id" in self._loaded_fields: payload["id"] = Value.I64(self.id) + if "name" in self._loaded_fields: payload["name"] = Value.Text(self.name) + if "filterJson" in self._loaded_fields: payload["filter_json"] = Value.Text(self.filterJson) + if "requestId" in self._loaded_fields: payload["request_id"] = Value.Text(self.requestId) + if "ownerUserId" in self._loaded_fields: payload["owner_user_id"] = Value.Text(self.ownerUserId) + if "commercePlatform" in self._loaded_fields: - payload["commerce_platform"] = Value.Object(self.commercePlatform) + reference = self.commercePlatform + reference_id = getattr(reference, "id", reference) + payload["commerce_platform"] = (Value.Object(reference) + if reference is not None and hasattr(reference, "id") and reference_id is None + else Value.I64(reference_id)) + if "createTime" in self._loaded_fields: payload["create_time"] = Value.DateTime(self.createTime) + if "updateTime" in self._loaded_fields: payload["update_time"] = Value.DateTime(self.updateTime) + if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -124,8 +177,7 @@ def _teaql_build_command(self): return action, cmd def _teaql_preflight_graph(self, context): - if not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: audit_as() must be called before save()") + MutationIntent(self._comment) if self._action == "Update": if "id" not in self._loaded_fields: raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) @@ -147,23 +199,20 @@ def _teaql_preflight_graph(self, context): raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) _action, cmd = self._teaql_build_command() try: - context.check_and_fix_mutation(cmd) + context.preflight_mutation(cmd) finally: for field, value in getattr(cmd, "values", {}).items(): if field not in ("id", "version"): self._entity_root.set(self._teaql_entity_key(), field, value) async def _teaql_save_within_graph(self, context): - if not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: audit_as() must be called before save()") + intent = MutationIntent(self._comment) self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() - req = MutationRequest(cmd) - if self._comment: - req.comment = self._comment + req = MutationRequest(cmd, comment=intent.comment) try: context.check_and_fix_mutation(cmd) diff --git a/examples/order-management/python-lib-core/models/order_status.py b/examples/order-management/python-lib-core/models/order_status.py index d0eb8df..ea7e4da 100644 --- a/examples/order-management/python-lib-core/models/order_status.py +++ b/examples/order-management/python-lib-core/models/order_status.py @@ -1,9 +1,11 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation import itertools from models.commerce_platform import CommercePlatform + class OrderStatus: _teaql_temporary_ids = itertools.count(1) @classmethod @@ -40,12 +42,19 @@ def __init__(self, **kwargs): self._comment = None self._loaded_fields = set(kwargs.keys()) self.id = kwargs.get("id") + self.name = kwargs.get("name") + self.code = kwargs.get("code") + self.color = kwargs.get("color") + self.displayOrder = kwargs.get("displayOrder") + self.commercePlatform = kwargs.get("commercePlatform") + self.version = kwargs.get("version") + if isinstance(self.commercePlatform, dict): self.commercePlatform = CommercePlatform(**self.commercePlatform) self._customer_order_list = kwargs.get("customer_order_list", []) @@ -82,13 +91,45 @@ def mark_for_deletion(self): return self def audit_as(self, comment: str): - if not isinstance(comment, str) or not comment.strip(): - raise ValueError("Security audit failure: audit_as() requires a non-empty reason") + MutationIntent(comment) self._comment = comment return self async def save(self, context): - return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context)) + intent = MutationIntent(self._comment) + # Reserve stable internal IDs before the transaction and before policy + # review. A relation to a new parent must have the same identity in the + # reviewed graph plan and in the commands eventually sent to SQL. + await self._teaql_reserve_graph_ids(context, set()) + await self._teaql_ensure_business_ids(context) + return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context), comment=intent.comment) + + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return + + async def _teaql_reserve_graph_ids(self, context, visited): + object_identity = id(self) + if object_identity in visited: + return + visited.add(object_identity) + if self._action == "Create" and getattr(self, "id", None) is None: + service = context.require_resource("dataService") + allocator = getattr(service, "next_id", None) + if not callable(allocator): + raise RuntimeError( + "Configured dataService does not support stable ID reservation" + ) + old_key = self._teaql_entity_key() + self.id = int(await allocator("OrderStatus")) + self._loaded_fields.add("id") + self._ledger_id = self.id + new_key = self._teaql_entity_key() + self._entity_root.rekey(old_key, new_key) + self._entity_root.set(new_key, "id", Value.I64(self.id)) + for child in self._customer_order_list: + child._teaql_attach_root(self._entity_root) + await child._teaql_reserve_graph_ids(context, visited) async def _teaql_preflight_and_save(self, context): self._teaql_preflight_graph(context) @@ -98,18 +139,29 @@ def _teaql_build_command(self): payload = {} if "id" in self._loaded_fields: payload["id"] = Value.I64(self.id) + if "name" in self._loaded_fields: payload["name"] = Value.Text(self.name) + if "code" in self._loaded_fields: payload["code"] = Value.Text(self.code) + if "color" in self._loaded_fields: payload["color"] = Value.Text(self.color) + if "displayOrder" in self._loaded_fields: payload["display_order"] = Value.Decimal(self.displayOrder) + if "commercePlatform" in self._loaded_fields: - payload["commerce_platform"] = Value.Object(self.commercePlatform) + reference = self.commercePlatform + reference_id = getattr(reference, "id", reference) + payload["commerce_platform"] = (Value.Object(reference) + if reference is not None and hasattr(reference, "id") and reference_id is None + else Value.I64(reference_id)) + if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -125,8 +177,7 @@ def _teaql_build_command(self): return action, cmd def _teaql_preflight_graph(self, context): - if not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: audit_as() must be called before save()") + MutationIntent(self._comment) if self._action == "Update": if "id" not in self._loaded_fields: raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) @@ -144,16 +195,14 @@ def _teaql_preflight_graph(self, context): raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) _action, cmd = self._teaql_build_command() try: - context.check_and_fix_mutation(cmd) + context.preflight_mutation(cmd) finally: for field, value in getattr(cmd, "values", {}).items(): if field not in ("id", "version"): self._entity_root.set(self._teaql_entity_key(), field, value) for index, child in enumerate(self._customer_order_list): child._teaql_attach_root(self._entity_root) - setattr(child, "status", self) - child._loaded_fields.add("status") - child._entity_root.set(child._teaql_entity_key(), "status", Value.Object(self)) + child.update_status(self) child.audit_as(self._comment) try: child._teaql_preflight_graph(context) @@ -165,16 +214,13 @@ def _teaql_preflight_graph(self, context): ]) from error async def _teaql_save_within_graph(self, context): - if not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: audit_as() must be called before save()") + intent = MutationIntent(self._comment) self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() - req = MutationRequest(cmd) - if self._comment: - req.comment = self._comment + req = MutationRequest(cmd, comment=intent.comment) try: context.check_and_fix_mutation(cmd) diff --git a/examples/order-management/python-lib-core/models/product.py b/examples/order-management/python-lib-core/models/product.py index 6718e79..ab45aa0 100644 --- a/examples/order-management/python-lib-core/models/product.py +++ b/examples/order-management/python-lib-core/models/product.py @@ -1,9 +1,11 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation import itertools from models.commerce_platform import CommercePlatform + class Product: _teaql_temporary_ids = itertools.count(1) @classmethod @@ -42,13 +44,21 @@ def __init__(self, **kwargs): self._comment = None self._loaded_fields = set(kwargs.keys()) self.id = kwargs.get("id") + self.name = kwargs.get("name") + self.sku = kwargs.get("sku") + self.imageUrl = kwargs.get("imageUrl") + self.commercePlatform = kwargs.get("commercePlatform") + self.createTime = kwargs.get("createTime") + self.updateTime = kwargs.get("updateTime") + self.version = kwargs.get("version") + if isinstance(self.commercePlatform, dict): self.commercePlatform = CommercePlatform(**self.commercePlatform) self._order_line_list = kwargs.get("order_line_list", []) @@ -85,13 +95,45 @@ def mark_for_deletion(self): return self def audit_as(self, comment: str): - if not isinstance(comment, str) or not comment.strip(): - raise ValueError("Security audit failure: audit_as() requires a non-empty reason") + MutationIntent(comment) self._comment = comment return self async def save(self, context): - return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context)) + intent = MutationIntent(self._comment) + # Reserve stable internal IDs before the transaction and before policy + # review. A relation to a new parent must have the same identity in the + # reviewed graph plan and in the commands eventually sent to SQL. + await self._teaql_reserve_graph_ids(context, set()) + await self._teaql_ensure_business_ids(context) + return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context), comment=intent.comment) + + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return + + async def _teaql_reserve_graph_ids(self, context, visited): + object_identity = id(self) + if object_identity in visited: + return + visited.add(object_identity) + if self._action == "Create" and getattr(self, "id", None) is None: + service = context.require_resource("dataService") + allocator = getattr(service, "next_id", None) + if not callable(allocator): + raise RuntimeError( + "Configured dataService does not support stable ID reservation" + ) + old_key = self._teaql_entity_key() + self.id = int(await allocator("Product")) + self._loaded_fields.add("id") + self._ledger_id = self.id + new_key = self._teaql_entity_key() + self._entity_root.rekey(old_key, new_key) + self._entity_root.set(new_key, "id", Value.I64(self.id)) + for child in self._order_line_list: + child._teaql_attach_root(self._entity_root) + await child._teaql_reserve_graph_ids(context, visited) async def _teaql_preflight_and_save(self, context): self._teaql_preflight_graph(context) @@ -101,20 +143,32 @@ def _teaql_build_command(self): payload = {} if "id" in self._loaded_fields: payload["id"] = Value.I64(self.id) + if "name" in self._loaded_fields: payload["name"] = Value.Text(self.name) + if "sku" in self._loaded_fields: payload["sku"] = Value.Text(self.sku) + if "imageUrl" in self._loaded_fields: payload["image_url"] = Value.Text(self.imageUrl) + if "commercePlatform" in self._loaded_fields: - payload["commerce_platform"] = Value.Object(self.commercePlatform) + reference = self.commercePlatform + reference_id = getattr(reference, "id", reference) + payload["commerce_platform"] = (Value.Object(reference) + if reference is not None and hasattr(reference, "id") and reference_id is None + else Value.I64(reference_id)) + if "createTime" in self._loaded_fields: payload["create_time"] = Value.DateTime(self.createTime) + if "updateTime" in self._loaded_fields: payload["update_time"] = Value.DateTime(self.updateTime) + if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -130,8 +184,7 @@ def _teaql_build_command(self): return action, cmd def _teaql_preflight_graph(self, context): - if not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: audit_as() must be called before save()") + MutationIntent(self._comment) if self._action == "Update": if "id" not in self._loaded_fields: raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) @@ -151,16 +204,14 @@ def _teaql_preflight_graph(self, context): raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) _action, cmd = self._teaql_build_command() try: - context.check_and_fix_mutation(cmd) + context.preflight_mutation(cmd) finally: for field, value in getattr(cmd, "values", {}).items(): if field not in ("id", "version"): self._entity_root.set(self._teaql_entity_key(), field, value) for index, child in enumerate(self._order_line_list): child._teaql_attach_root(self._entity_root) - setattr(child, "product", self) - child._loaded_fields.add("product") - child._entity_root.set(child._teaql_entity_key(), "product", Value.Object(self)) + child.update_product(self) child.audit_as(self._comment) try: child._teaql_preflight_graph(context) @@ -172,16 +223,13 @@ def _teaql_preflight_graph(self, context): ]) from error async def _teaql_save_within_graph(self, context): - if not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: audit_as() must be called before save()") + intent = MutationIntent(self._comment) self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() - req = MutationRequest(cmd) - if self._comment: - req.comment = self._comment + req = MutationRequest(cmd, comment=intent.comment) try: context.check_and_fix_mutation(cmd) diff --git a/examples/order-management/python-lib-core/pyproject.toml b/examples/order-management/python-lib-core/pyproject.toml index d25c271..6e49444 100644 --- a/examples/order-management/python-lib-core/pyproject.toml +++ b/examples/order-management/python-lib-core/pyproject.toml @@ -2,7 +2,7 @@ name = "order-management-service-lib" version = "1.0.0" description = "Generated python library" -dependencies = ["teaql==0.2.7", "aiosqlite>=0.22.1"] +dependencies = ["teaql==0.2.9", "aiosqlite>=0.22.1"] [tool.setuptools] py-modules = ["Q", "E"] diff --git a/examples/order-management/python-lib-core/requests/commerce_platform_request.py b/examples/order-management/python-lib-core/requests/commerce_platform_request.py index 12fd607..aa70b5d 100644 --- a/examples/order-management/python-lib-core/requests/commerce_platform_request.py +++ b/examples/order-management/python-lib-core/requests/commerce_platform_request.py @@ -2,6 +2,8 @@ from teaql.core.list import SmartList, TeaQLPage from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest +from teaql.core import QueryIntent +from copy import deepcopy from teaql.core.expr import ( begin_with, between, column, contain, end_with, eq, gt, gte, in_list, in_subquery, is_not_null, is_null, lt, lte, ne, not_begin_with, @@ -934,8 +936,7 @@ def comment(self, c: str): def new_entity(self, context) -> CommercePlatform: request = self._request - if not request._comment or not request._comment.strip() or not request._purpose or not request._purpose.strip(): - raise ValueError("Security audit failure: non-empty comment() and purpose() are required before new_entity()") + QueryIntent(request._comment, request._purpose) entity = context.initialize_entity("CommercePlatform", CommercePlatform()) if not isinstance(entity, CommercePlatform): raise TypeError("entity initializer returned an incompatible CommercePlatform") @@ -943,10 +944,9 @@ def new_entity(self, context) -> CommercePlatform: async def execute_for_result(self, context): self = self._request - if not self._purpose or not self._purpose.strip() or not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: comment() and purpose() must be called before execute_for_rows()") + req = QueryRequest(self.query, _comment=self._comment, _purpose=self._purpose) + req = context.prepare_query_request(req) service = context.require_resource("dataService") - req = QueryRequest(context.prepare_query(self.query), _comment=self._comment, _purpose=self._purpose) return await service.query(context, req) async def execute_for_rows(self, context): @@ -961,10 +961,10 @@ async def execute_for_list(self, context) -> SmartList[CommercePlatform]: async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[CommercePlatform]: request = self._request - if not request._purpose or not request._purpose.strip() or not request._comment or not request._comment.strip(): - raise ValueError("Security audit failure: comment() and purpose() must be called before execute_for_page()") - request.query.offset(offset).limit(limit) - authorized = context.prepare_query(request.query) + intent = QueryIntent(request._comment, request._purpose) + query = deepcopy(request.query) + query.offset(offset).limit(limit) + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query service = context.require_resource("dataService") alias = "__teaql_total" if authorized.id_set_pagination is not None: @@ -988,19 +988,20 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): - self._request.limit(1) - entities = await self.execute_for_list(context) + request = deepcopy(self._request) + request.limit(1) + entities = await ExecutableCommercePlatformRequest(request).execute_for_list(context) return entities[0] if entities else None async def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request - if not request._purpose or not request._purpose.strip() or not request._comment or not request._comment.strip(): - raise Exception("Security audit failure: comment() and purpose() must be called before execute_for_stream()") + req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) + req = context.prepare_query_request(req) service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") query_root = EntityRoot() - async for chunk in service.query_stream(context, QueryRequest(context.prepare_query(request.query), _comment=request._comment, _purpose=request._purpose), chunk_size): + async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: yield CommercePlatform(_entity_root=query_root, **row) diff --git a/examples/order-management/python-lib-core/requests/customer_order_request.py b/examples/order-management/python-lib-core/requests/customer_order_request.py index 8dcc698..9b86b82 100644 --- a/examples/order-management/python-lib-core/requests/customer_order_request.py +++ b/examples/order-management/python-lib-core/requests/customer_order_request.py @@ -2,6 +2,8 @@ from teaql.core.list import SmartList, TeaQLPage from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest +from teaql.core import QueryIntent +from copy import deepcopy from teaql.core.expr import ( begin_with, between, column, contain, end_with, eq, gt, gte, in_list, in_subquery, is_not_null, is_null, lt, lte, ne, not_begin_with, @@ -862,8 +864,7 @@ def comment(self, c: str): def new_entity(self, context) -> CustomerOrder: request = self._request - if not request._comment or not request._comment.strip() or not request._purpose or not request._purpose.strip(): - raise ValueError("Security audit failure: non-empty comment() and purpose() are required before new_entity()") + QueryIntent(request._comment, request._purpose) entity = context.initialize_entity("CustomerOrder", CustomerOrder()) if not isinstance(entity, CustomerOrder): raise TypeError("entity initializer returned an incompatible CustomerOrder") @@ -871,10 +872,9 @@ def new_entity(self, context) -> CustomerOrder: async def execute_for_result(self, context): self = self._request - if not self._purpose or not self._purpose.strip() or not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: comment() and purpose() must be called before execute_for_rows()") + req = QueryRequest(self.query, _comment=self._comment, _purpose=self._purpose) + req = context.prepare_query_request(req) service = context.require_resource("dataService") - req = QueryRequest(context.prepare_query(self.query), _comment=self._comment, _purpose=self._purpose) return await service.query(context, req) async def execute_for_rows(self, context): @@ -889,10 +889,10 @@ async def execute_for_list(self, context) -> SmartList[CustomerOrder]: async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[CustomerOrder]: request = self._request - if not request._purpose or not request._purpose.strip() or not request._comment or not request._comment.strip(): - raise ValueError("Security audit failure: comment() and purpose() must be called before execute_for_page()") - request.query.offset(offset).limit(limit) - authorized = context.prepare_query(request.query) + intent = QueryIntent(request._comment, request._purpose) + query = deepcopy(request.query) + query.offset(offset).limit(limit) + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query service = context.require_resource("dataService") alias = "__teaql_total" if authorized.id_set_pagination is not None: @@ -916,19 +916,20 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): - self._request.limit(1) - entities = await self.execute_for_list(context) + request = deepcopy(self._request) + request.limit(1) + entities = await ExecutableCustomerOrderRequest(request).execute_for_list(context) return entities[0] if entities else None async def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request - if not request._purpose or not request._purpose.strip() or not request._comment or not request._comment.strip(): - raise Exception("Security audit failure: comment() and purpose() must be called before execute_for_stream()") + req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) + req = context.prepare_query_request(req) service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") query_root = EntityRoot() - async for chunk in service.query_stream(context, QueryRequest(context.prepare_query(request.query), _comment=request._comment, _purpose=request._purpose), chunk_size): + async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: yield CustomerOrder(_entity_root=query_root, **row) diff --git a/examples/order-management/python-lib-core/requests/customer_request.py b/examples/order-management/python-lib-core/requests/customer_request.py index 868c902..cb78d46 100644 --- a/examples/order-management/python-lib-core/requests/customer_request.py +++ b/examples/order-management/python-lib-core/requests/customer_request.py @@ -2,6 +2,8 @@ from teaql.core.list import SmartList, TeaQLPage from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest +from teaql.core import QueryIntent +from copy import deepcopy from teaql.core.expr import ( begin_with, between, column, contain, end_with, eq, gt, gte, in_list, in_subquery, is_not_null, is_null, lt, lte, ne, not_begin_with, @@ -694,8 +696,7 @@ def comment(self, c: str): def new_entity(self, context) -> Customer: request = self._request - if not request._comment or not request._comment.strip() or not request._purpose or not request._purpose.strip(): - raise ValueError("Security audit failure: non-empty comment() and purpose() are required before new_entity()") + QueryIntent(request._comment, request._purpose) entity = context.initialize_entity("Customer", Customer()) if not isinstance(entity, Customer): raise TypeError("entity initializer returned an incompatible Customer") @@ -703,10 +704,9 @@ def new_entity(self, context) -> Customer: async def execute_for_result(self, context): self = self._request - if not self._purpose or not self._purpose.strip() or not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: comment() and purpose() must be called before execute_for_rows()") + req = QueryRequest(self.query, _comment=self._comment, _purpose=self._purpose) + req = context.prepare_query_request(req) service = context.require_resource("dataService") - req = QueryRequest(context.prepare_query(self.query), _comment=self._comment, _purpose=self._purpose) return await service.query(context, req) async def execute_for_rows(self, context): @@ -721,10 +721,10 @@ async def execute_for_list(self, context) -> SmartList[Customer]: async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[Customer]: request = self._request - if not request._purpose or not request._purpose.strip() or not request._comment or not request._comment.strip(): - raise ValueError("Security audit failure: comment() and purpose() must be called before execute_for_page()") - request.query.offset(offset).limit(limit) - authorized = context.prepare_query(request.query) + intent = QueryIntent(request._comment, request._purpose) + query = deepcopy(request.query) + query.offset(offset).limit(limit) + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query service = context.require_resource("dataService") alias = "__teaql_total" if authorized.id_set_pagination is not None: @@ -748,19 +748,20 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): - self._request.limit(1) - entities = await self.execute_for_list(context) + request = deepcopy(self._request) + request.limit(1) + entities = await ExecutableCustomerRequest(request).execute_for_list(context) return entities[0] if entities else None async def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request - if not request._purpose or not request._purpose.strip() or not request._comment or not request._comment.strip(): - raise Exception("Security audit failure: comment() and purpose() must be called before execute_for_stream()") + req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) + req = context.prepare_query_request(req) service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") query_root = EntityRoot() - async for chunk in service.query_stream(context, QueryRequest(context.prepare_query(request.query), _comment=request._comment, _purpose=request._purpose), chunk_size): + async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: yield Customer(_entity_root=query_root, **row) diff --git a/examples/order-management/python-lib-core/requests/order_line_request.py b/examples/order-management/python-lib-core/requests/order_line_request.py index 135cfdb..fde4639 100644 --- a/examples/order-management/python-lib-core/requests/order_line_request.py +++ b/examples/order-management/python-lib-core/requests/order_line_request.py @@ -2,6 +2,8 @@ from teaql.core.list import SmartList, TeaQLPage from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest +from teaql.core import QueryIntent +from copy import deepcopy from teaql.core.expr import ( begin_with, between, column, contain, end_with, eq, gt, gte, in_list, in_subquery, is_not_null, is_null, lt, lte, ne, not_begin_with, @@ -694,8 +696,7 @@ def comment(self, c: str): def new_entity(self, context) -> OrderLine: request = self._request - if not request._comment or not request._comment.strip() or not request._purpose or not request._purpose.strip(): - raise ValueError("Security audit failure: non-empty comment() and purpose() are required before new_entity()") + QueryIntent(request._comment, request._purpose) entity = context.initialize_entity("OrderLine", OrderLine()) if not isinstance(entity, OrderLine): raise TypeError("entity initializer returned an incompatible OrderLine") @@ -703,10 +704,9 @@ def new_entity(self, context) -> OrderLine: async def execute_for_result(self, context): self = self._request - if not self._purpose or not self._purpose.strip() or not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: comment() and purpose() must be called before execute_for_rows()") + req = QueryRequest(self.query, _comment=self._comment, _purpose=self._purpose) + req = context.prepare_query_request(req) service = context.require_resource("dataService") - req = QueryRequest(context.prepare_query(self.query), _comment=self._comment, _purpose=self._purpose) return await service.query(context, req) async def execute_for_rows(self, context): @@ -721,10 +721,10 @@ async def execute_for_list(self, context) -> SmartList[OrderLine]: async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[OrderLine]: request = self._request - if not request._purpose or not request._purpose.strip() or not request._comment or not request._comment.strip(): - raise ValueError("Security audit failure: comment() and purpose() must be called before execute_for_page()") - request.query.offset(offset).limit(limit) - authorized = context.prepare_query(request.query) + intent = QueryIntent(request._comment, request._purpose) + query = deepcopy(request.query) + query.offset(offset).limit(limit) + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query service = context.require_resource("dataService") alias = "__teaql_total" if authorized.id_set_pagination is not None: @@ -748,19 +748,20 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): - self._request.limit(1) - entities = await self.execute_for_list(context) + request = deepcopy(self._request) + request.limit(1) + entities = await ExecutableOrderLineRequest(request).execute_for_list(context) return entities[0] if entities else None async def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request - if not request._purpose or not request._purpose.strip() or not request._comment or not request._comment.strip(): - raise Exception("Security audit failure: comment() and purpose() must be called before execute_for_stream()") + req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) + req = context.prepare_query_request(req) service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") query_root = EntityRoot() - async for chunk in service.query_stream(context, QueryRequest(context.prepare_query(request.query), _comment=request._comment, _purpose=request._purpose), chunk_size): + async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: yield OrderLine(_entity_root=query_root, **row) diff --git a/examples/order-management/python-lib-core/requests/order_search_preset_request.py b/examples/order-management/python-lib-core/requests/order_search_preset_request.py index f41b3e6..aa62b1a 100644 --- a/examples/order-management/python-lib-core/requests/order_search_preset_request.py +++ b/examples/order-management/python-lib-core/requests/order_search_preset_request.py @@ -2,6 +2,8 @@ from teaql.core.list import SmartList, TeaQLPage from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest +from teaql.core import QueryIntent +from copy import deepcopy from teaql.core.expr import ( begin_with, between, column, contain, end_with, eq, gt, gte, in_list, in_subquery, is_not_null, is_null, lt, lte, ne, not_begin_with, @@ -748,8 +750,7 @@ def comment(self, c: str): def new_entity(self, context) -> OrderSearchPreset: request = self._request - if not request._comment or not request._comment.strip() or not request._purpose or not request._purpose.strip(): - raise ValueError("Security audit failure: non-empty comment() and purpose() are required before new_entity()") + QueryIntent(request._comment, request._purpose) entity = context.initialize_entity("OrderSearchPreset", OrderSearchPreset()) if not isinstance(entity, OrderSearchPreset): raise TypeError("entity initializer returned an incompatible OrderSearchPreset") @@ -757,10 +758,9 @@ def new_entity(self, context) -> OrderSearchPreset: async def execute_for_result(self, context): self = self._request - if not self._purpose or not self._purpose.strip() or not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: comment() and purpose() must be called before execute_for_rows()") + req = QueryRequest(self.query, _comment=self._comment, _purpose=self._purpose) + req = context.prepare_query_request(req) service = context.require_resource("dataService") - req = QueryRequest(context.prepare_query(self.query), _comment=self._comment, _purpose=self._purpose) return await service.query(context, req) async def execute_for_rows(self, context): @@ -775,10 +775,10 @@ async def execute_for_list(self, context) -> SmartList[OrderSearchPreset]: async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[OrderSearchPreset]: request = self._request - if not request._purpose or not request._purpose.strip() or not request._comment or not request._comment.strip(): - raise ValueError("Security audit failure: comment() and purpose() must be called before execute_for_page()") - request.query.offset(offset).limit(limit) - authorized = context.prepare_query(request.query) + intent = QueryIntent(request._comment, request._purpose) + query = deepcopy(request.query) + query.offset(offset).limit(limit) + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query service = context.require_resource("dataService") alias = "__teaql_total" if authorized.id_set_pagination is not None: @@ -802,19 +802,20 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): - self._request.limit(1) - entities = await self.execute_for_list(context) + request = deepcopy(self._request) + request.limit(1) + entities = await ExecutableOrderSearchPresetRequest(request).execute_for_list(context) return entities[0] if entities else None async def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request - if not request._purpose or not request._purpose.strip() or not request._comment or not request._comment.strip(): - raise Exception("Security audit failure: comment() and purpose() must be called before execute_for_stream()") + req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) + req = context.prepare_query_request(req) service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") query_root = EntityRoot() - async for chunk in service.query_stream(context, QueryRequest(context.prepare_query(request.query), _comment=request._comment, _purpose=request._purpose), chunk_size): + async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: yield OrderSearchPreset(_entity_root=query_root, **row) diff --git a/examples/order-management/python-lib-core/requests/order_status_request.py b/examples/order-management/python-lib-core/requests/order_status_request.py index 27a10d6..58d1e66 100644 --- a/examples/order-management/python-lib-core/requests/order_status_request.py +++ b/examples/order-management/python-lib-core/requests/order_status_request.py @@ -2,6 +2,8 @@ from teaql.core.list import SmartList, TeaQLPage from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest +from teaql.core import QueryIntent +from copy import deepcopy from teaql.core.expr import ( begin_with, between, column, contain, end_with, eq, gt, gte, in_list, in_subquery, is_not_null, is_null, lt, lte, ne, not_begin_with, @@ -769,8 +771,7 @@ def comment(self, c: str): def new_entity(self, context) -> OrderStatus: request = self._request - if not request._comment or not request._comment.strip() or not request._purpose or not request._purpose.strip(): - raise ValueError("Security audit failure: non-empty comment() and purpose() are required before new_entity()") + QueryIntent(request._comment, request._purpose) entity = context.initialize_entity("OrderStatus", OrderStatus()) if not isinstance(entity, OrderStatus): raise TypeError("entity initializer returned an incompatible OrderStatus") @@ -778,10 +779,9 @@ def new_entity(self, context) -> OrderStatus: async def execute_for_result(self, context): self = self._request - if not self._purpose or not self._purpose.strip() or not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: comment() and purpose() must be called before execute_for_rows()") + req = QueryRequest(self.query, _comment=self._comment, _purpose=self._purpose) + req = context.prepare_query_request(req) service = context.require_resource("dataService") - req = QueryRequest(context.prepare_query(self.query), _comment=self._comment, _purpose=self._purpose) return await service.query(context, req) async def execute_for_rows(self, context): @@ -796,10 +796,10 @@ async def execute_for_list(self, context) -> SmartList[OrderStatus]: async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[OrderStatus]: request = self._request - if not request._purpose or not request._purpose.strip() or not request._comment or not request._comment.strip(): - raise ValueError("Security audit failure: comment() and purpose() must be called before execute_for_page()") - request.query.offset(offset).limit(limit) - authorized = context.prepare_query(request.query) + intent = QueryIntent(request._comment, request._purpose) + query = deepcopy(request.query) + query.offset(offset).limit(limit) + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query service = context.require_resource("dataService") alias = "__teaql_total" if authorized.id_set_pagination is not None: @@ -823,19 +823,20 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): - self._request.limit(1) - entities = await self.execute_for_list(context) + request = deepcopy(self._request) + request.limit(1) + entities = await ExecutableOrderStatusRequest(request).execute_for_list(context) return entities[0] if entities else None async def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request - if not request._purpose or not request._purpose.strip() or not request._comment or not request._comment.strip(): - raise Exception("Security audit failure: comment() and purpose() must be called before execute_for_stream()") + req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) + req = context.prepare_query_request(req) service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") query_root = EntityRoot() - async for chunk in service.query_stream(context, QueryRequest(context.prepare_query(request.query), _comment=request._comment, _purpose=request._purpose), chunk_size): + async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: yield OrderStatus(_entity_root=query_root, **row) diff --git a/examples/order-management/python-lib-core/requests/product_request.py b/examples/order-management/python-lib-core/requests/product_request.py index 5bbbad0..0113db3 100644 --- a/examples/order-management/python-lib-core/requests/product_request.py +++ b/examples/order-management/python-lib-core/requests/product_request.py @@ -2,6 +2,8 @@ from teaql.core.list import SmartList, TeaQLPage from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest +from teaql.core import QueryIntent +from copy import deepcopy from teaql.core.expr import ( begin_with, between, column, contain, end_with, eq, gt, gte, in_list, in_subquery, is_not_null, is_null, lt, lte, ne, not_begin_with, @@ -784,8 +786,7 @@ def comment(self, c: str): def new_entity(self, context) -> Product: request = self._request - if not request._comment or not request._comment.strip() or not request._purpose or not request._purpose.strip(): - raise ValueError("Security audit failure: non-empty comment() and purpose() are required before new_entity()") + QueryIntent(request._comment, request._purpose) entity = context.initialize_entity("Product", Product()) if not isinstance(entity, Product): raise TypeError("entity initializer returned an incompatible Product") @@ -793,10 +794,9 @@ def new_entity(self, context) -> Product: async def execute_for_result(self, context): self = self._request - if not self._purpose or not self._purpose.strip() or not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: comment() and purpose() must be called before execute_for_rows()") + req = QueryRequest(self.query, _comment=self._comment, _purpose=self._purpose) + req = context.prepare_query_request(req) service = context.require_resource("dataService") - req = QueryRequest(context.prepare_query(self.query), _comment=self._comment, _purpose=self._purpose) return await service.query(context, req) async def execute_for_rows(self, context): @@ -811,10 +811,10 @@ async def execute_for_list(self, context) -> SmartList[Product]: async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[Product]: request = self._request - if not request._purpose or not request._purpose.strip() or not request._comment or not request._comment.strip(): - raise ValueError("Security audit failure: comment() and purpose() must be called before execute_for_page()") - request.query.offset(offset).limit(limit) - authorized = context.prepare_query(request.query) + intent = QueryIntent(request._comment, request._purpose) + query = deepcopy(request.query) + query.offset(offset).limit(limit) + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query service = context.require_resource("dataService") alias = "__teaql_total" if authorized.id_set_pagination is not None: @@ -838,19 +838,20 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): - self._request.limit(1) - entities = await self.execute_for_list(context) + request = deepcopy(self._request) + request.limit(1) + entities = await ExecutableProductRequest(request).execute_for_list(context) return entities[0] if entities else None async def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request - if not request._purpose or not request._purpose.strip() or not request._comment or not request._comment.strip(): - raise Exception("Security audit failure: comment() and purpose() must be called before execute_for_stream()") + req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) + req = context.prepare_query_request(req) service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") query_root = EntityRoot() - async for chunk in service.query_stream(context, QueryRequest(context.prepare_query(request.query), _comment=request._comment, _purpose=request._purpose), chunk_size): + async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: yield Product(_entity_root=query_root, **row) diff --git a/examples/query-policy/main.py b/examples/query-policy/main.py index dcc8569..e047395 100644 --- a/examples/query-policy/main.py +++ b/examples/query-policy/main.py @@ -3,7 +3,7 @@ shared = SelectQuery("SchoolType").project("id") -original = SelectQuery("School").project("id") +original = SelectQuery("School").project("id").comment('govern school graph').purpose('verify query policy') original.relations.append(RelationLoad("schoolType", shared)) original.facets.append(FacetRequest("types", "schoolType", shared)) calls = [] @@ -27,7 +27,7 @@ def tenant_policy(query): assert "tenant_id" not in shared.projection try: - context.prepare_query(SelectQuery("ForbiddenReport")) + context.prepare_query(SelectQuery("ForbiddenReport").comment('attempt forbidden report').purpose('verify denial')) except PermissionError as error: assert str(error) == "query policy denied ForbiddenReport" else: diff --git a/examples/school-management/Q.py b/examples/school-management/Q.py index 02e5cba..fa189cd 100644 --- a/examples/school-management/Q.py +++ b/examples/school-management/Q.py @@ -28,3 +28,4 @@ def schools() -> SchoolRequest: def schools_minimal() -> SchoolRequest: return SchoolRequest(minimal=True) + diff --git a/examples/school-management/app/main.py b/examples/school-management/app/main.py index 33bd0bb..10cdedb 100644 --- a/examples/school-management/app/main.py +++ b/examples/school-management/app/main.py @@ -8,12 +8,60 @@ sys.path.insert(0, str(ROOT)) from Q import Q +from E import E from models.platform import Platform from models.school_type import SchoolType from runtime_module import GENERATED_RUNTIME_MODULE from teaql.data_service import SQLiteTeaQLClient from teaql.runtime import UserContext from teaql.core.dynamic_search import normalize_dynamic_search +from teaql.core import RequestIntentError +from teaql.runtime.context import SqlLogOptions + + +async def verify_request_intent(context, client): + calls = [] + context.with_sql_log_options(SqlLogOptions.disabled()) + context.with_request_policy(lambda _: calls.append('policy')) + original_allocator = client.next_id + async def counted_allocator(entity): + calls.append('id allocator') + return await original_allocator(entity) + client.next_id = counted_allocator + try: + executions = [ + (Q.schools().purpose('render schools').execute_for_list(context), 'REQUEST_COMMENT_REQUIRED'), + (Q.schools().comment('load schools').purpose('\u0085').execute_for_rows(context), 'QUERY_PURPOSE_REQUIRED'), + (Q.schools().purpose('render page').execute_for_page(context, 0, 10), 'REQUEST_COMMENT_REQUIRED'), + ] + for execution, code in executions: + try: + await execution + raise AssertionError('missing generated request gate') + except RequestIntentError as error: + assert error.code == code + try: + async for _ in Q.schools().purpose('stream schools').execute_for_stream(context): + raise AssertionError('stream must not open') + except RequestIntentError as error: + assert error.code == 'REQUEST_COMMENT_REQUIRED' + entity = Q.schools().comment('prepare unsaved school').purpose('verify save gate').new_entity(context) + for reason in (None, '\u0085'): + try: + if reason is not None: + entity.audit_as(reason) + await entity.save(context) + raise AssertionError('missing generated mutation gate') + except RequestIntentError as error: + assert error.code == 'REQUEST_COMMENT_REQUIRED' + assert calls == [], calls + assert entity.id is None + finally: + client.next_id = original_allocator + context.clear_request_policy() + context.with_sql_log_options(SqlLogOptions.all()) + assert len(await Q.schools().comment('verify rejected writes').purpose('check empty table').execute_for_list(context)) == 0 + print('PASS Python generated request intent: list/rows/page/stream/save reject before policy and ID allocation with logs off') async def verify_dynamic_search(context): @@ -59,6 +107,7 @@ async def main() -> None: .insert_resource("dataService", client)) await context.ensure_schema() await context.ensure_schema() + await verify_request_intent(context, client) platform = await (Q.platforms().with_id_is(1) .comment("Load the generated domain root") .purpose("Verify idempotent schema bootstrap").execute_for_one(context)) @@ -121,6 +170,8 @@ async def main() -> None: assert loaded.platform.baseUrl == "https://campus.example.com" assert loaded.schoolType.code == "PRIMARY" assert loaded.schoolType.displayOrder == 1 + assert E.school(loaded).school_type().code().eval() == 'PRIMARY' + assert E.school(loaded).platform().name().eval() == 'Deployment Campus' query_cases = [ ("string equality", Q.schools().with_name_is("Riverside Primary School"), 1), diff --git a/examples/school-management/models/platform.py b/examples/school-management/models/platform.py index a2878ec..3579720 100644 --- a/examples/school-management/models/platform.py +++ b/examples/school-management/models/platform.py @@ -1,8 +1,10 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation import itertools + class Platform: _teaql_temporary_ids = itertools.count(1) @classmethod @@ -37,11 +39,17 @@ def __init__(self, **kwargs): self._comment = None self._loaded_fields = set(kwargs.keys()) self.id = kwargs.get("id") + self.name = kwargs.get("name") + self.baseUrl = kwargs.get("baseUrl") + self.createTime = kwargs.get("createTime") + self.updateTime = kwargs.get("updateTime") + self.version = kwargs.get("version") + self._school_type_list = kwargs.get("school_type_list", []) if "school_type_list" in kwargs or kwargs.get("id") is None: self._loaded_fields.add("school_type_list") @@ -87,13 +95,48 @@ def mark_for_deletion(self): return self def audit_as(self, comment: str): - if not isinstance(comment, str) or not comment.strip(): - raise ValueError("Security audit failure: audit_as() requires a non-empty reason") + MutationIntent(comment) self._comment = comment return self async def save(self, context): - return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context)) + intent = MutationIntent(self._comment) + # Reserve stable internal IDs before the transaction and before policy + # review. A relation to a new parent must have the same identity in the + # reviewed graph plan and in the commands eventually sent to SQL. + await self._teaql_reserve_graph_ids(context, set()) + await self._teaql_ensure_business_ids(context) + return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context), comment=intent.comment) + + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return + + async def _teaql_reserve_graph_ids(self, context, visited): + object_identity = id(self) + if object_identity in visited: + return + visited.add(object_identity) + if self._action == "Create" and getattr(self, "id", None) is None: + service = context.require_resource("dataService") + allocator = getattr(service, "next_id", None) + if not callable(allocator): + raise RuntimeError( + "Configured dataService does not support stable ID reservation" + ) + old_key = self._teaql_entity_key() + self.id = int(await allocator("Platform")) + self._loaded_fields.add("id") + self._ledger_id = self.id + new_key = self._teaql_entity_key() + self._entity_root.rekey(old_key, new_key) + self._entity_root.set(new_key, "id", Value.I64(self.id)) + for child in self._school_type_list: + child._teaql_attach_root(self._entity_root) + await child._teaql_reserve_graph_ids(context, visited) + for child in self._school_list: + child._teaql_attach_root(self._entity_root) + await child._teaql_reserve_graph_ids(context, visited) async def _teaql_preflight_and_save(self, context): self._teaql_preflight_graph(context) @@ -103,16 +146,22 @@ def _teaql_build_command(self): payload = {} if "id" in self._loaded_fields: payload["id"] = Value.I64(self.id) + if "name" in self._loaded_fields: payload["name"] = Value.Text(self.name) + if "baseUrl" in self._loaded_fields: payload["base_url"] = Value.Text(self.baseUrl) + if "createTime" in self._loaded_fields: payload["create_time"] = Value.DateTime(self.createTime) + if "updateTime" in self._loaded_fields: payload["update_time"] = Value.DateTime(self.updateTime) + if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -128,8 +177,7 @@ def _teaql_build_command(self): return action, cmd def _teaql_preflight_graph(self, context): - if not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: audit_as() must be called before save()") + MutationIntent(self._comment) if self._action == "Update": if "id" not in self._loaded_fields: raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) @@ -145,16 +193,14 @@ def _teaql_preflight_graph(self, context): raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) _action, cmd = self._teaql_build_command() try: - context.check_and_fix_mutation(cmd) + context.preflight_mutation(cmd) finally: for field, value in getattr(cmd, "values", {}).items(): if field not in ("id", "version"): self._entity_root.set(self._teaql_entity_key(), field, value) for index, child in enumerate(self._school_type_list): child._teaql_attach_root(self._entity_root) - setattr(child, "platform", self) - child._loaded_fields.add("platform") - child._entity_root.set(child._teaql_entity_key(), "platform", Value.Object(self)) + child.update_platform(self) child.audit_as(self._comment) try: child._teaql_preflight_graph(context) @@ -166,9 +212,7 @@ def _teaql_preflight_graph(self, context): ]) from error for index, child in enumerate(self._school_list): child._teaql_attach_root(self._entity_root) - setattr(child, "platform", self) - child._loaded_fields.add("platform") - child._entity_root.set(child._teaql_entity_key(), "platform", Value.Object(self)) + child.update_platform(self) child.audit_as(self._comment) try: child._teaql_preflight_graph(context) @@ -180,16 +224,13 @@ def _teaql_preflight_graph(self, context): ]) from error async def _teaql_save_within_graph(self, context): - if not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: audit_as() must be called before save()") + intent = MutationIntent(self._comment) self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() - req = MutationRequest(cmd) - if self._comment: - req.comment = self._comment + req = MutationRequest(cmd, comment=intent.comment) try: context.check_and_fix_mutation(cmd) diff --git a/examples/school-management/models/school.py b/examples/school-management/models/school.py index 43d2345..7742ee6 100644 --- a/examples/school-management/models/school.py +++ b/examples/school-management/models/school.py @@ -1,10 +1,12 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation import itertools from models.platform import Platform from models.school_type import SchoolType + class School: _teaql_temporary_ids = itertools.count(1) @classmethod @@ -49,16 +51,27 @@ def __init__(self, **kwargs): self._comment = None self._loaded_fields = set(kwargs.keys()) self.id = kwargs.get("id") + self.platform = kwargs.get("platform") + self.schoolType = kwargs.get("schoolType") + self.name = kwargs.get("name") + self.address = kwargs.get("address") + self.establishedDate = kwargs.get("establishedDate") + self.studentCapacity = kwargs.get("studentCapacity") + self.active = kwargs.get("active") + self.createTime = kwargs.get("createTime") + self.updateTime = kwargs.get("updateTime") + self.version = kwargs.get("version") + if isinstance(self.platform, dict): self.platform = Platform(**self.platform) if isinstance(self.schoolType, dict): @@ -86,13 +99,42 @@ def mark_for_deletion(self): return self def audit_as(self, comment: str): - if not isinstance(comment, str) or not comment.strip(): - raise ValueError("Security audit failure: audit_as() requires a non-empty reason") + MutationIntent(comment) self._comment = comment return self async def save(self, context): - return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context)) + intent = MutationIntent(self._comment) + # Reserve stable internal IDs before the transaction and before policy + # review. A relation to a new parent must have the same identity in the + # reviewed graph plan and in the commands eventually sent to SQL. + await self._teaql_reserve_graph_ids(context, set()) + await self._teaql_ensure_business_ids(context) + return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context), comment=intent.comment) + + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return + + async def _teaql_reserve_graph_ids(self, context, visited): + object_identity = id(self) + if object_identity in visited: + return + visited.add(object_identity) + if self._action == "Create" and getattr(self, "id", None) is None: + service = context.require_resource("dataService") + allocator = getattr(service, "next_id", None) + if not callable(allocator): + raise RuntimeError( + "Configured dataService does not support stable ID reservation" + ) + old_key = self._teaql_entity_key() + self.id = int(await allocator("School")) + self._loaded_fields.add("id") + self._ledger_id = self.id + new_key = self._teaql_entity_key() + self._entity_root.rekey(old_key, new_key) + self._entity_root.set(new_key, "id", Value.I64(self.id)) async def _teaql_preflight_and_save(self, context): self._teaql_preflight_graph(context) @@ -102,26 +144,45 @@ def _teaql_build_command(self): payload = {} if "id" in self._loaded_fields: payload["id"] = Value.I64(self.id) + if "platform" in self._loaded_fields: - payload["platform"] = Value.Object(self.platform) + reference = self.platform + reference_id = getattr(reference, "id", reference) + payload["platform"] = (Value.Object(reference) + if reference is not None and hasattr(reference, "id") and reference_id is None + else Value.I64(reference_id)) + if "schoolType" in self._loaded_fields: - payload["school_type"] = Value.Object(self.schoolType) + reference = self.schoolType + reference_id = getattr(reference, "id", reference) + payload["school_type"] = (Value.Object(reference) + if reference is not None and hasattr(reference, "id") and reference_id is None + else Value.I64(reference_id)) + if "name" in self._loaded_fields: payload["name"] = Value.Text(self.name) + if "address" in self._loaded_fields: payload["address"] = Value.Text(self.address) + if "establishedDate" in self._loaded_fields: payload["established_date"] = Value.Date(self.establishedDate) + if "studentCapacity" in self._loaded_fields: payload["student_capacity"] = Value.I64(self.studentCapacity) + if "active" in self._loaded_fields: payload["active"] = Value.Bool(self.active) + if "createTime" in self._loaded_fields: payload["create_time"] = Value.DateTime(self.createTime) + if "updateTime" in self._loaded_fields: payload["update_time"] = Value.DateTime(self.updateTime) + if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -137,8 +198,7 @@ def _teaql_build_command(self): return action, cmd def _teaql_preflight_graph(self, context): - if not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: audit_as() must be called before save()") + MutationIntent(self._comment) if self._action == "Update": if "id" not in self._loaded_fields: raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) @@ -164,23 +224,20 @@ def _teaql_preflight_graph(self, context): raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) _action, cmd = self._teaql_build_command() try: - context.check_and_fix_mutation(cmd) + context.preflight_mutation(cmd) finally: for field, value in getattr(cmd, "values", {}).items(): if field not in ("id", "version"): self._entity_root.set(self._teaql_entity_key(), field, value) async def _teaql_save_within_graph(self, context): - if not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: audit_as() must be called before save()") + intent = MutationIntent(self._comment) self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() - req = MutationRequest(cmd) - if self._comment: - req.comment = self._comment + req = MutationRequest(cmd, comment=intent.comment) try: context.check_and_fix_mutation(cmd) diff --git a/examples/school-management/models/school_type.py b/examples/school-management/models/school_type.py index 418f288..a70be0a 100644 --- a/examples/school-management/models/school_type.py +++ b/examples/school-management/models/school_type.py @@ -1,9 +1,11 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation import itertools from models.platform import Platform + class SchoolType: _teaql_temporary_ids = itertools.count(1) @classmethod @@ -38,11 +40,17 @@ def __init__(self, **kwargs): self._comment = None self._loaded_fields = set(kwargs.keys()) self.platform = kwargs.get("platform") + self.id = kwargs.get("id") + self.name = kwargs.get("name") + self.code = kwargs.get("code") + self.displayOrder = kwargs.get("displayOrder") + self.version = kwargs.get("version") + if isinstance(self.platform, dict): self.platform = Platform(**self.platform) self._school_list = kwargs.get("school_list", []) @@ -79,13 +87,45 @@ def mark_for_deletion(self): return self def audit_as(self, comment: str): - if not isinstance(comment, str) or not comment.strip(): - raise ValueError("Security audit failure: audit_as() requires a non-empty reason") + MutationIntent(comment) self._comment = comment return self async def save(self, context): - return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context)) + intent = MutationIntent(self._comment) + # Reserve stable internal IDs before the transaction and before policy + # review. A relation to a new parent must have the same identity in the + # reviewed graph plan and in the commands eventually sent to SQL. + await self._teaql_reserve_graph_ids(context, set()) + await self._teaql_ensure_business_ids(context) + return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context), comment=intent.comment) + + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return + + async def _teaql_reserve_graph_ids(self, context, visited): + object_identity = id(self) + if object_identity in visited: + return + visited.add(object_identity) + if self._action == "Create" and getattr(self, "id", None) is None: + service = context.require_resource("dataService") + allocator = getattr(service, "next_id", None) + if not callable(allocator): + raise RuntimeError( + "Configured dataService does not support stable ID reservation" + ) + old_key = self._teaql_entity_key() + self.id = int(await allocator("SchoolType")) + self._loaded_fields.add("id") + self._ledger_id = self.id + new_key = self._teaql_entity_key() + self._entity_root.rekey(old_key, new_key) + self._entity_root.set(new_key, "id", Value.I64(self.id)) + for child in self._school_list: + child._teaql_attach_root(self._entity_root) + await child._teaql_reserve_graph_ids(context, visited) async def _teaql_preflight_and_save(self, context): self._teaql_preflight_graph(context) @@ -94,17 +134,27 @@ async def _teaql_preflight_and_save(self, context): def _teaql_build_command(self): payload = {} if "platform" in self._loaded_fields: - payload["platform"] = Value.Object(self.platform) + reference = self.platform + reference_id = getattr(reference, "id", reference) + payload["platform"] = (Value.Object(reference) + if reference is not None and hasattr(reference, "id") and reference_id is None + else Value.I64(reference_id)) + if "id" in self._loaded_fields: payload["id"] = Value.I64(self.id) + if "name" in self._loaded_fields: payload["name"] = Value.Text(self.name) + if "code" in self._loaded_fields: payload["code"] = Value.Text(self.code) + if "displayOrder" in self._loaded_fields: payload["display_order"] = Value.Decimal(self.displayOrder) + if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -120,8 +170,7 @@ def _teaql_build_command(self): return action, cmd def _teaql_preflight_graph(self, context): - if not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: audit_as() must be called before save()") + MutationIntent(self._comment) if self._action == "Update": if "platform" not in self._loaded_fields: raise CheckException([CheckResult("invalid_type", ObjectLocation().property("platform"), message="Mutation requires a fully loaded entity")]) @@ -137,16 +186,14 @@ def _teaql_preflight_graph(self, context): raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) _action, cmd = self._teaql_build_command() try: - context.check_and_fix_mutation(cmd) + context.preflight_mutation(cmd) finally: for field, value in getattr(cmd, "values", {}).items(): if field not in ("id", "version"): self._entity_root.set(self._teaql_entity_key(), field, value) for index, child in enumerate(self._school_list): child._teaql_attach_root(self._entity_root) - setattr(child, "schoolType", self) - child._loaded_fields.add("schoolType") - child._entity_root.set(child._teaql_entity_key(), "school_type", Value.Object(self)) + child.update_school_type(self) child.audit_as(self._comment) try: child._teaql_preflight_graph(context) @@ -158,16 +205,13 @@ def _teaql_preflight_graph(self, context): ]) from error async def _teaql_save_within_graph(self, context): - if not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: audit_as() must be called before save()") + intent = MutationIntent(self._comment) self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() - req = MutationRequest(cmd) - if self._comment: - req.comment = self._comment + req = MutationRequest(cmd, comment=intent.comment) try: context.check_and_fix_mutation(cmd) diff --git a/examples/school-management/pyproject.toml b/examples/school-management/pyproject.toml index 7984d34..062f286 100644 --- a/examples/school-management/pyproject.toml +++ b/examples/school-management/pyproject.toml @@ -2,7 +2,7 @@ name = "school-management-service-lib" version = "1.0.0" description = "Generated python library" -dependencies = ["teaql==0.2.7", "aiosqlite>=0.22.1"] +dependencies = ["teaql==0.2.9", "aiosqlite>=0.22.1"] [tool.setuptools] py-modules = ["Q", "E"] diff --git a/examples/school-management/requests/platform_request.py b/examples/school-management/requests/platform_request.py index 503e9ec..8857005 100644 --- a/examples/school-management/requests/platform_request.py +++ b/examples/school-management/requests/platform_request.py @@ -2,6 +2,8 @@ from teaql.core.list import SmartList, TeaQLPage from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest +from teaql.core import QueryIntent +from copy import deepcopy from teaql.core.expr import ( begin_with, between, column, contain, end_with, eq, gt, gte, in_list, in_subquery, is_not_null, is_null, lt, lte, ne, not_begin_with, @@ -781,8 +783,7 @@ def comment(self, c: str): def new_entity(self, context) -> Platform: request = self._request - if not request._comment or not request._comment.strip() or not request._purpose or not request._purpose.strip(): - raise ValueError("Security audit failure: non-empty comment() and purpose() are required before new_entity()") + QueryIntent(request._comment, request._purpose) entity = context.initialize_entity("Platform", Platform()) if not isinstance(entity, Platform): raise TypeError("entity initializer returned an incompatible Platform") @@ -790,10 +791,9 @@ def new_entity(self, context) -> Platform: async def execute_for_result(self, context): self = self._request - if not self._purpose or not self._purpose.strip() or not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: comment() and purpose() must be called before execute_for_rows()") + req = QueryRequest(self.query, _comment=self._comment, _purpose=self._purpose) + req = context.prepare_query_request(req) service = context.require_resource("dataService") - req = QueryRequest(context.prepare_query(self.query), _comment=self._comment, _purpose=self._purpose) return await service.query(context, req) async def execute_for_rows(self, context): @@ -808,10 +808,10 @@ async def execute_for_list(self, context) -> SmartList[Platform]: async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[Platform]: request = self._request - if not request._purpose or not request._purpose.strip() or not request._comment or not request._comment.strip(): - raise ValueError("Security audit failure: comment() and purpose() must be called before execute_for_page()") - request.query.offset(offset).limit(limit) - authorized = context.prepare_query(request.query) + intent = QueryIntent(request._comment, request._purpose) + query = deepcopy(request.query) + query.offset(offset).limit(limit) + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query service = context.require_resource("dataService") alias = "__teaql_total" if authorized.id_set_pagination is not None: @@ -835,19 +835,20 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): - self._request.limit(1) - entities = await self.execute_for_list(context) + request = deepcopy(self._request) + request.limit(1) + entities = await ExecutablePlatformRequest(request).execute_for_list(context) return entities[0] if entities else None async def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request - if not request._purpose or not request._purpose.strip() or not request._comment or not request._comment.strip(): - raise Exception("Security audit failure: comment() and purpose() must be called before execute_for_stream()") + req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) + req = context.prepare_query_request(req) service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") query_root = EntityRoot() - async for chunk in service.query_stream(context, QueryRequest(context.prepare_query(request.query), _comment=request._comment, _purpose=request._purpose), chunk_size): + async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: yield Platform(_entity_root=query_root, **row) diff --git a/examples/school-management/requests/school_request.py b/examples/school-management/requests/school_request.py index 9d33284..14e9f59 100644 --- a/examples/school-management/requests/school_request.py +++ b/examples/school-management/requests/school_request.py @@ -2,6 +2,8 @@ from teaql.core.list import SmartList, TeaQLPage from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest +from teaql.core import QueryIntent +from copy import deepcopy from teaql.core.expr import ( begin_with, between, column, contain, end_with, eq, gt, gte, in_list, in_subquery, is_not_null, is_null, lt, lte, ne, not_begin_with, @@ -850,8 +852,7 @@ def comment(self, c: str): def new_entity(self, context) -> School: request = self._request - if not request._comment or not request._comment.strip() or not request._purpose or not request._purpose.strip(): - raise ValueError("Security audit failure: non-empty comment() and purpose() are required before new_entity()") + QueryIntent(request._comment, request._purpose) entity = context.initialize_entity("School", School()) if not isinstance(entity, School): raise TypeError("entity initializer returned an incompatible School") @@ -859,10 +860,9 @@ def new_entity(self, context) -> School: async def execute_for_result(self, context): self = self._request - if not self._purpose or not self._purpose.strip() or not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: comment() and purpose() must be called before execute_for_rows()") + req = QueryRequest(self.query, _comment=self._comment, _purpose=self._purpose) + req = context.prepare_query_request(req) service = context.require_resource("dataService") - req = QueryRequest(context.prepare_query(self.query), _comment=self._comment, _purpose=self._purpose) return await service.query(context, req) async def execute_for_rows(self, context): @@ -877,10 +877,10 @@ async def execute_for_list(self, context) -> SmartList[School]: async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[School]: request = self._request - if not request._purpose or not request._purpose.strip() or not request._comment or not request._comment.strip(): - raise ValueError("Security audit failure: comment() and purpose() must be called before execute_for_page()") - request.query.offset(offset).limit(limit) - authorized = context.prepare_query(request.query) + intent = QueryIntent(request._comment, request._purpose) + query = deepcopy(request.query) + query.offset(offset).limit(limit) + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query service = context.require_resource("dataService") alias = "__teaql_total" if authorized.id_set_pagination is not None: @@ -904,19 +904,20 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): - self._request.limit(1) - entities = await self.execute_for_list(context) + request = deepcopy(self._request) + request.limit(1) + entities = await ExecutableSchoolRequest(request).execute_for_list(context) return entities[0] if entities else None async def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request - if not request._purpose or not request._purpose.strip() or not request._comment or not request._comment.strip(): - raise Exception("Security audit failure: comment() and purpose() must be called before execute_for_stream()") + req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) + req = context.prepare_query_request(req) service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") query_root = EntityRoot() - async for chunk in service.query_stream(context, QueryRequest(context.prepare_query(request.query), _comment=request._comment, _purpose=request._purpose), chunk_size): + async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: yield School(_entity_root=query_root, **row) diff --git a/examples/school-management/requests/school_type_request.py b/examples/school-management/requests/school_type_request.py index 76a097a..5d374aa 100644 --- a/examples/school-management/requests/school_type_request.py +++ b/examples/school-management/requests/school_type_request.py @@ -2,6 +2,8 @@ from teaql.core.list import SmartList, TeaQLPage from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest +from teaql.core import QueryIntent +from copy import deepcopy from teaql.core.expr import ( begin_with, between, column, contain, end_with, eq, gt, gte, in_list, in_subquery, is_not_null, is_null, lt, lte, ne, not_begin_with, @@ -677,8 +679,7 @@ def comment(self, c: str): def new_entity(self, context) -> SchoolType: request = self._request - if not request._comment or not request._comment.strip() or not request._purpose or not request._purpose.strip(): - raise ValueError("Security audit failure: non-empty comment() and purpose() are required before new_entity()") + QueryIntent(request._comment, request._purpose) entity = context.initialize_entity("SchoolType", SchoolType()) if not isinstance(entity, SchoolType): raise TypeError("entity initializer returned an incompatible SchoolType") @@ -686,10 +687,9 @@ def new_entity(self, context) -> SchoolType: async def execute_for_result(self, context): self = self._request - if not self._purpose or not self._purpose.strip() or not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: comment() and purpose() must be called before execute_for_rows()") + req = QueryRequest(self.query, _comment=self._comment, _purpose=self._purpose) + req = context.prepare_query_request(req) service = context.require_resource("dataService") - req = QueryRequest(context.prepare_query(self.query), _comment=self._comment, _purpose=self._purpose) return await service.query(context, req) async def execute_for_rows(self, context): @@ -704,10 +704,10 @@ async def execute_for_list(self, context) -> SmartList[SchoolType]: async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[SchoolType]: request = self._request - if not request._purpose or not request._purpose.strip() or not request._comment or not request._comment.strip(): - raise ValueError("Security audit failure: comment() and purpose() must be called before execute_for_page()") - request.query.offset(offset).limit(limit) - authorized = context.prepare_query(request.query) + intent = QueryIntent(request._comment, request._purpose) + query = deepcopy(request.query) + query.offset(offset).limit(limit) + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query service = context.require_resource("dataService") alias = "__teaql_total" if authorized.id_set_pagination is not None: @@ -731,19 +731,20 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): - self._request.limit(1) - entities = await self.execute_for_list(context) + request = deepcopy(self._request) + request.limit(1) + entities = await ExecutableSchoolTypeRequest(request).execute_for_list(context) return entities[0] if entities else None async def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request - if not request._purpose or not request._purpose.strip() or not request._comment or not request._comment.strip(): - raise Exception("Security audit failure: comment() and purpose() must be called before execute_for_stream()") + req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) + req = context.prepare_query_request(req) service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") query_root = EntityRoot() - async for chunk in service.query_stream(context, QueryRequest(context.prepare_query(request.query), _comment=request._comment, _purpose=request._purpose), chunk_size): + async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: yield SchoolType(_entity_root=query_root, **row) diff --git a/examples/task_board/generated/Q.py b/examples/task_board/generated/Q.py index 7cb255d..660153c 100644 --- a/examples/task_board/generated/Q.py +++ b/examples/task_board/generated/Q.py @@ -36,3 +36,5 @@ def task_execution_logs() -> TaskExecutionLogRequest: @staticmethod def task_execution_logs_minimal() -> TaskExecutionLogRequest: return TaskExecutionLogRequest(minimal=True) + + diff --git a/examples/task_board/generated/models/platform.py b/examples/task_board/generated/models/platform.py index fc1ab44..2db2f33 100644 --- a/examples/task_board/generated/models/platform.py +++ b/examples/task_board/generated/models/platform.py @@ -1,8 +1,10 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation import itertools + class Platform: _teaql_temporary_ids = itertools.count(1) @classmethod @@ -35,10 +37,15 @@ def __init__(self, **kwargs): self._comment = None self._loaded_fields = set(kwargs.keys()) self.id = kwargs.get("id") + self.name = kwargs.get("name") + self.founded = kwargs.get("founded") + self.userEmail = kwargs.get("userEmail") + self.version = kwargs.get("version") + self._task_status_list = kwargs.get("task_status_list", []) if "task_status_list" in kwargs or kwargs.get("id") is None: self._loaded_fields.add("task_status_list") @@ -84,13 +91,48 @@ def mark_for_deletion(self): return self def audit_as(self, comment: str): - if not isinstance(comment, str) or not comment.strip(): - raise ValueError("Security audit failure: audit_as() requires a non-empty reason") + MutationIntent(comment) self._comment = comment return self async def save(self, context): - return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context)) + intent = MutationIntent(self._comment) + # Reserve stable internal IDs before the transaction and before policy + # review. A relation to a new parent must have the same identity in the + # reviewed graph plan and in the commands eventually sent to SQL. + await self._teaql_reserve_graph_ids(context, set()) + await self._teaql_ensure_business_ids(context) + return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context), comment=intent.comment) + + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return + + async def _teaql_reserve_graph_ids(self, context, visited): + object_identity = id(self) + if object_identity in visited: + return + visited.add(object_identity) + if self._action == "Create" and getattr(self, "id", None) is None: + service = context.require_resource("dataService") + allocator = getattr(service, "next_id", None) + if not callable(allocator): + raise RuntimeError( + "Configured dataService does not support stable ID reservation" + ) + old_key = self._teaql_entity_key() + self.id = int(await allocator("Platform")) + self._loaded_fields.add("id") + self._ledger_id = self.id + new_key = self._teaql_entity_key() + self._entity_root.rekey(old_key, new_key) + self._entity_root.set(new_key, "id", Value.I64(self.id)) + for child in self._task_status_list: + child._teaql_attach_root(self._entity_root) + await child._teaql_reserve_graph_ids(context, visited) + for child in self._task_list: + child._teaql_attach_root(self._entity_root) + await child._teaql_reserve_graph_ids(context, visited) async def _teaql_preflight_and_save(self, context): self._teaql_preflight_graph(context) @@ -100,14 +142,19 @@ def _teaql_build_command(self): payload = {} if "id" in self._loaded_fields: payload["id"] = Value.I64(self.id) + if "name" in self._loaded_fields: payload["name"] = Value.Text(self.name) + if "founded" in self._loaded_fields: payload["founded"] = Value.DateTime(self.founded) + if "userEmail" in self._loaded_fields: payload["user_email"] = Value.Text(self.userEmail) + if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -123,8 +170,7 @@ def _teaql_build_command(self): return action, cmd def _teaql_preflight_graph(self, context): - if not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: audit_as() must be called before save()") + MutationIntent(self._comment) if self._action == "Update": if "id" not in self._loaded_fields: raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) @@ -138,16 +184,14 @@ def _teaql_preflight_graph(self, context): raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) _action, cmd = self._teaql_build_command() try: - context.check_and_fix_mutation(cmd) + context.preflight_mutation(cmd) finally: for field, value in getattr(cmd, "values", {}).items(): if field not in ("id", "version"): self._entity_root.set(self._teaql_entity_key(), field, value) for index, child in enumerate(self._task_status_list): child._teaql_attach_root(self._entity_root) - setattr(child, "platform", self) - child._loaded_fields.add("platform") - child._entity_root.set(child._teaql_entity_key(), "platform", Value.Object(self)) + child.update_platform(self) child.audit_as(self._comment) try: child._teaql_preflight_graph(context) @@ -159,9 +203,7 @@ def _teaql_preflight_graph(self, context): ]) from error for index, child in enumerate(self._task_list): child._teaql_attach_root(self._entity_root) - setattr(child, "platform", self) - child._loaded_fields.add("platform") - child._entity_root.set(child._teaql_entity_key(), "platform", Value.Object(self)) + child.update_platform(self) child.audit_as(self._comment) try: child._teaql_preflight_graph(context) @@ -173,16 +215,13 @@ def _teaql_preflight_graph(self, context): ]) from error async def _teaql_save_within_graph(self, context): - if not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: audit_as() must be called before save()") + intent = MutationIntent(self._comment) self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() - req = MutationRequest(cmd) - if self._comment: - req.comment = self._comment + req = MutationRequest(cmd, comment=intent.comment) try: context.check_and_fix_mutation(cmd) diff --git a/examples/task_board/generated/models/task.py b/examples/task_board/generated/models/task.py index c938894..2a1cb78 100644 --- a/examples/task_board/generated/models/task.py +++ b/examples/task_board/generated/models/task.py @@ -1,10 +1,12 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation import itertools from models.task_status import TaskStatus from models.platform import Platform + class Task: _teaql_temporary_ids = itertools.count(1) @classmethod @@ -37,10 +39,15 @@ def __init__(self, **kwargs): self._comment = None self._loaded_fields = set(kwargs.keys()) self.id = kwargs.get("id") + self.name = kwargs.get("name") + self.status = kwargs.get("status") + self.platform = kwargs.get("platform") + self.version = kwargs.get("version") + if isinstance(self.status, dict): self.status = TaskStatus(**self.status) if isinstance(self.platform, dict): @@ -79,13 +86,45 @@ def mark_for_deletion(self): return self def audit_as(self, comment: str): - if not isinstance(comment, str) or not comment.strip(): - raise ValueError("Security audit failure: audit_as() requires a non-empty reason") + MutationIntent(comment) self._comment = comment return self async def save(self, context): - return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context)) + intent = MutationIntent(self._comment) + # Reserve stable internal IDs before the transaction and before policy + # review. A relation to a new parent must have the same identity in the + # reviewed graph plan and in the commands eventually sent to SQL. + await self._teaql_reserve_graph_ids(context, set()) + await self._teaql_ensure_business_ids(context) + return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context), comment=intent.comment) + + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return + + async def _teaql_reserve_graph_ids(self, context, visited): + object_identity = id(self) + if object_identity in visited: + return + visited.add(object_identity) + if self._action == "Create" and getattr(self, "id", None) is None: + service = context.require_resource("dataService") + allocator = getattr(service, "next_id", None) + if not callable(allocator): + raise RuntimeError( + "Configured dataService does not support stable ID reservation" + ) + old_key = self._teaql_entity_key() + self.id = int(await allocator("Task")) + self._loaded_fields.add("id") + self._ledger_id = self.id + new_key = self._teaql_entity_key() + self._entity_root.rekey(old_key, new_key) + self._entity_root.set(new_key, "id", Value.I64(self.id)) + for child in self._task_execution_log_list: + child._teaql_attach_root(self._entity_root) + await child._teaql_reserve_graph_ids(context, visited) async def _teaql_preflight_and_save(self, context): self._teaql_preflight_graph(context) @@ -95,14 +134,27 @@ def _teaql_build_command(self): payload = {} if "id" in self._loaded_fields: payload["id"] = Value.I64(self.id) + if "name" in self._loaded_fields: payload["name"] = Value.Text(self.name) + if "status" in self._loaded_fields: - payload["status"] = Value.Object(self.status) + reference = self.status + reference_id = getattr(reference, "id", reference) + payload["status"] = (Value.Object(reference) + if reference is not None and hasattr(reference, "id") and reference_id is None + else Value.I64(reference_id)) + if "platform" in self._loaded_fields: - payload["platform"] = Value.Object(self.platform) + reference = self.platform + reference_id = getattr(reference, "id", reference) + payload["platform"] = (Value.Object(reference) + if reference is not None and hasattr(reference, "id") and reference_id is None + else Value.I64(reference_id)) + if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -118,8 +170,7 @@ def _teaql_build_command(self): return action, cmd def _teaql_preflight_graph(self, context): - if not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: audit_as() must be called before save()") + MutationIntent(self._comment) if self._action == "Update": if "id" not in self._loaded_fields: raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) @@ -133,16 +184,14 @@ def _teaql_preflight_graph(self, context): raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) _action, cmd = self._teaql_build_command() try: - context.check_and_fix_mutation(cmd) + context.preflight_mutation(cmd) finally: for field, value in getattr(cmd, "values", {}).items(): if field not in ("id", "version"): self._entity_root.set(self._teaql_entity_key(), field, value) for index, child in enumerate(self._task_execution_log_list): child._teaql_attach_root(self._entity_root) - setattr(child, "task", self) - child._loaded_fields.add("task") - child._entity_root.set(child._teaql_entity_key(), "task", Value.Object(self)) + child.update_task(self) child.audit_as(self._comment) try: child._teaql_preflight_graph(context) @@ -154,16 +203,13 @@ def _teaql_preflight_graph(self, context): ]) from error async def _teaql_save_within_graph(self, context): - if not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: audit_as() must be called before save()") + intent = MutationIntent(self._comment) self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() - req = MutationRequest(cmd) - if self._comment: - req.comment = self._comment + req = MutationRequest(cmd, comment=intent.comment) try: context.check_and_fix_mutation(cmd) diff --git a/examples/task_board/generated/models/task_execution_log.py b/examples/task_board/generated/models/task_execution_log.py index 15294c5..1c6923c 100644 --- a/examples/task_board/generated/models/task_execution_log.py +++ b/examples/task_board/generated/models/task_execution_log.py @@ -1,9 +1,11 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation import itertools from models.task import Task + class TaskExecutionLog: _teaql_temporary_ids = itertools.count(1) @classmethod @@ -36,10 +38,15 @@ def __init__(self, **kwargs): self._comment = None self._loaded_fields = set(kwargs.keys()) self.id = kwargs.get("id") + self.task = kwargs.get("task") + self.action = kwargs.get("action") + self.detail = kwargs.get("detail") + self.version = kwargs.get("version") + if isinstance(self.task, dict): self.task = Task(**self.task) self._ledger_id = getattr(self, "id", None) @@ -65,13 +72,42 @@ def mark_for_deletion(self): return self def audit_as(self, comment: str): - if not isinstance(comment, str) or not comment.strip(): - raise ValueError("Security audit failure: audit_as() requires a non-empty reason") + MutationIntent(comment) self._comment = comment return self async def save(self, context): - return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context)) + intent = MutationIntent(self._comment) + # Reserve stable internal IDs before the transaction and before policy + # review. A relation to a new parent must have the same identity in the + # reviewed graph plan and in the commands eventually sent to SQL. + await self._teaql_reserve_graph_ids(context, set()) + await self._teaql_ensure_business_ids(context) + return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context), comment=intent.comment) + + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return + + async def _teaql_reserve_graph_ids(self, context, visited): + object_identity = id(self) + if object_identity in visited: + return + visited.add(object_identity) + if self._action == "Create" and getattr(self, "id", None) is None: + service = context.require_resource("dataService") + allocator = getattr(service, "next_id", None) + if not callable(allocator): + raise RuntimeError( + "Configured dataService does not support stable ID reservation" + ) + old_key = self._teaql_entity_key() + self.id = int(await allocator("TaskExecutionLog")) + self._loaded_fields.add("id") + self._ledger_id = self.id + new_key = self._teaql_entity_key() + self._entity_root.rekey(old_key, new_key) + self._entity_root.set(new_key, "id", Value.I64(self.id)) async def _teaql_preflight_and_save(self, context): self._teaql_preflight_graph(context) @@ -81,14 +117,23 @@ def _teaql_build_command(self): payload = {} if "id" in self._loaded_fields: payload["id"] = Value.I64(self.id) + if "task" in self._loaded_fields: - payload["task"] = Value.Object(self.task) + reference = self.task + reference_id = getattr(reference, "id", reference) + payload["task"] = (Value.Object(reference) + if reference is not None and hasattr(reference, "id") and reference_id is None + else Value.I64(reference_id)) + if "action" in self._loaded_fields: payload["action"] = Value.Text(self.action) + if "detail" in self._loaded_fields: payload["detail"] = Value.Text(self.detail) + if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -104,8 +149,7 @@ def _teaql_build_command(self): return action, cmd def _teaql_preflight_graph(self, context): - if not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: audit_as() must be called before save()") + MutationIntent(self._comment) if self._action == "Update": if "id" not in self._loaded_fields: raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) @@ -119,23 +163,20 @@ def _teaql_preflight_graph(self, context): raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) _action, cmd = self._teaql_build_command() try: - context.check_and_fix_mutation(cmd) + context.preflight_mutation(cmd) finally: for field, value in getattr(cmd, "values", {}).items(): if field not in ("id", "version"): self._entity_root.set(self._teaql_entity_key(), field, value) async def _teaql_save_within_graph(self, context): - if not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: audit_as() must be called before save()") + intent = MutationIntent(self._comment) self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() - req = MutationRequest(cmd) - if self._comment: - req.comment = self._comment + req = MutationRequest(cmd, comment=intent.comment) try: context.check_and_fix_mutation(cmd) diff --git a/examples/task_board/generated/models/task_status.py b/examples/task_board/generated/models/task_status.py index 047cad1..06f1ed8 100644 --- a/examples/task_board/generated/models/task_status.py +++ b/examples/task_board/generated/models/task_status.py @@ -1,9 +1,11 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation import itertools from models.platform import Platform + class TaskStatus: _teaql_temporary_ids = itertools.count(1) @classmethod @@ -42,13 +44,21 @@ def __init__(self, **kwargs): self._comment = None self._loaded_fields = set(kwargs.keys()) self.id = kwargs.get("id") + self.name = kwargs.get("name") + self.code = kwargs.get("code") + self.color = kwargs.get("color") + self.displayOrder = kwargs.get("displayOrder") + self.progress = kwargs.get("progress") + self.platform = kwargs.get("platform") + self.version = kwargs.get("version") + if isinstance(self.platform, dict): self.platform = Platform(**self.platform) self._task_list = kwargs.get("task_list", []) @@ -85,13 +95,45 @@ def mark_for_deletion(self): return self def audit_as(self, comment: str): - if not isinstance(comment, str) or not comment.strip(): - raise ValueError("Security audit failure: audit_as() requires a non-empty reason") + MutationIntent(comment) self._comment = comment return self async def save(self, context): - return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context)) + intent = MutationIntent(self._comment) + # Reserve stable internal IDs before the transaction and before policy + # review. A relation to a new parent must have the same identity in the + # reviewed graph plan and in the commands eventually sent to SQL. + await self._teaql_reserve_graph_ids(context, set()) + await self._teaql_ensure_business_ids(context) + return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context), comment=intent.comment) + + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return + + async def _teaql_reserve_graph_ids(self, context, visited): + object_identity = id(self) + if object_identity in visited: + return + visited.add(object_identity) + if self._action == "Create" and getattr(self, "id", None) is None: + service = context.require_resource("dataService") + allocator = getattr(service, "next_id", None) + if not callable(allocator): + raise RuntimeError( + "Configured dataService does not support stable ID reservation" + ) + old_key = self._teaql_entity_key() + self.id = int(await allocator("TaskStatus")) + self._loaded_fields.add("id") + self._ledger_id = self.id + new_key = self._teaql_entity_key() + self._entity_root.rekey(old_key, new_key) + self._entity_root.set(new_key, "id", Value.I64(self.id)) + for child in self._task_list: + child._teaql_attach_root(self._entity_root) + await child._teaql_reserve_graph_ids(context, visited) async def _teaql_preflight_and_save(self, context): self._teaql_preflight_graph(context) @@ -101,20 +143,32 @@ def _teaql_build_command(self): payload = {} if "id" in self._loaded_fields: payload["id"] = Value.I64(self.id) + if "name" in self._loaded_fields: payload["name"] = Value.Text(self.name) + if "code" in self._loaded_fields: payload["code"] = Value.Text(self.code) + if "color" in self._loaded_fields: payload["color"] = Value.Text(self.color) + if "displayOrder" in self._loaded_fields: payload["display_order"] = Value.Decimal(self.displayOrder) + if "progress" in self._loaded_fields: payload["progress"] = Value.Decimal(self.progress) + if "platform" in self._loaded_fields: - payload["platform"] = Value.Object(self.platform) + reference = self.platform + reference_id = getattr(reference, "id", reference) + payload["platform"] = (Value.Object(reference) + if reference is not None and hasattr(reference, "id") and reference_id is None + else Value.I64(reference_id)) + if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -130,8 +184,7 @@ def _teaql_build_command(self): return action, cmd def _teaql_preflight_graph(self, context): - if not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: audit_as() must be called before save()") + MutationIntent(self._comment) if self._action == "Update": if "id" not in self._loaded_fields: raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) @@ -151,16 +204,14 @@ def _teaql_preflight_graph(self, context): raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) _action, cmd = self._teaql_build_command() try: - context.check_and_fix_mutation(cmd) + context.preflight_mutation(cmd) finally: for field, value in getattr(cmd, "values", {}).items(): if field not in ("id", "version"): self._entity_root.set(self._teaql_entity_key(), field, value) for index, child in enumerate(self._task_list): child._teaql_attach_root(self._entity_root) - setattr(child, "status", self) - child._loaded_fields.add("status") - child._entity_root.set(child._teaql_entity_key(), "status", Value.Object(self)) + child.update_status(self) child.audit_as(self._comment) try: child._teaql_preflight_graph(context) @@ -172,16 +223,13 @@ def _teaql_preflight_graph(self, context): ]) from error async def _teaql_save_within_graph(self, context): - if not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: audit_as() must be called before save()") + intent = MutationIntent(self._comment) self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() - req = MutationRequest(cmd) - if self._comment: - req.comment = self._comment + req = MutationRequest(cmd, comment=intent.comment) try: context.check_and_fix_mutation(cmd) diff --git a/examples/task_board/generated/pyproject.toml b/examples/task_board/generated/pyproject.toml index bee20bc..684b260 100644 --- a/examples/task_board/generated/pyproject.toml +++ b/examples/task_board/generated/pyproject.toml @@ -2,7 +2,7 @@ name = "robot-kanban-service-lib" version = "1.0.0" description = "Generated python library" -dependencies = ["teaql==0.2.7", "aiosqlite>=0.22.1"] +dependencies = ["teaql==0.2.9", "aiosqlite>=0.22.1"] [tool.setuptools] py-modules = ["Q", "E"] diff --git a/examples/task_board/generated/requests/platform_request.py b/examples/task_board/generated/requests/platform_request.py index c47f851..f7fc641 100644 --- a/examples/task_board/generated/requests/platform_request.py +++ b/examples/task_board/generated/requests/platform_request.py @@ -2,6 +2,8 @@ from teaql.core.list import SmartList, TeaQLPage from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest +from teaql.core import QueryIntent +from copy import deepcopy from teaql.core.expr import ( begin_with, between, column, contain, end_with, eq, gt, gte, in_list, in_subquery, is_not_null, is_null, lt, lte, ne, not_begin_with, @@ -719,8 +721,7 @@ def comment(self, c: str): def new_entity(self, context) -> Platform: request = self._request - if not request._comment or not request._comment.strip() or not request._purpose or not request._purpose.strip(): - raise ValueError("Security audit failure: non-empty comment() and purpose() are required before new_entity()") + QueryIntent(request._comment, request._purpose) entity = context.initialize_entity("Platform", Platform()) if not isinstance(entity, Platform): raise TypeError("entity initializer returned an incompatible Platform") @@ -728,10 +729,9 @@ def new_entity(self, context) -> Platform: async def execute_for_result(self, context): self = self._request - if not self._purpose or not self._purpose.strip() or not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: comment() and purpose() must be called before execute_for_rows()") + req = QueryRequest(self.query, _comment=self._comment, _purpose=self._purpose) + req = context.prepare_query_request(req) service = context.require_resource("dataService") - req = QueryRequest(context.prepare_query(self.query), _comment=self._comment, _purpose=self._purpose) return await service.query(context, req) async def execute_for_rows(self, context): @@ -746,10 +746,10 @@ async def execute_for_list(self, context) -> SmartList[Platform]: async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[Platform]: request = self._request - if not request._purpose or not request._purpose.strip() or not request._comment or not request._comment.strip(): - raise ValueError("Security audit failure: comment() and purpose() must be called before execute_for_page()") - request.query.offset(offset).limit(limit) - authorized = context.prepare_query(request.query) + intent = QueryIntent(request._comment, request._purpose) + query = deepcopy(request.query) + query.offset(offset).limit(limit) + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query service = context.require_resource("dataService") alias = "__teaql_total" if authorized.id_set_pagination is not None: @@ -773,19 +773,20 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): - self._request.limit(1) - entities = await self.execute_for_list(context) + request = deepcopy(self._request) + request.limit(1) + entities = await ExecutablePlatformRequest(request).execute_for_list(context) return entities[0] if entities else None async def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request - if not request._purpose or not request._purpose.strip() or not request._comment or not request._comment.strip(): - raise Exception("Security audit failure: comment() and purpose() must be called before execute_for_stream()") + req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) + req = context.prepare_query_request(req) service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") query_root = EntityRoot() - async for chunk in service.query_stream(context, QueryRequest(context.prepare_query(request.query), _comment=request._comment, _purpose=request._purpose), chunk_size): + async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: yield Platform(_entity_root=query_root, **row) diff --git a/examples/task_board/generated/requests/task_execution_log_request.py b/examples/task_board/generated/requests/task_execution_log_request.py index 1cbb08a..07ab42b 100644 --- a/examples/task_board/generated/requests/task_execution_log_request.py +++ b/examples/task_board/generated/requests/task_execution_log_request.py @@ -2,6 +2,8 @@ from teaql.core.list import SmartList, TeaQLPage from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest +from teaql.core import QueryIntent +from copy import deepcopy from teaql.core.expr import ( begin_with, between, column, contain, end_with, eq, gt, gte, in_list, in_subquery, is_not_null, is_null, lt, lte, ne, not_begin_with, @@ -442,8 +444,7 @@ def comment(self, c: str): def new_entity(self, context) -> TaskExecutionLog: request = self._request - if not request._comment or not request._comment.strip() or not request._purpose or not request._purpose.strip(): - raise ValueError("Security audit failure: non-empty comment() and purpose() are required before new_entity()") + QueryIntent(request._comment, request._purpose) entity = context.initialize_entity("TaskExecutionLog", TaskExecutionLog()) if not isinstance(entity, TaskExecutionLog): raise TypeError("entity initializer returned an incompatible TaskExecutionLog") @@ -451,10 +452,9 @@ def new_entity(self, context) -> TaskExecutionLog: async def execute_for_result(self, context): self = self._request - if not self._purpose or not self._purpose.strip() or not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: comment() and purpose() must be called before execute_for_rows()") + req = QueryRequest(self.query, _comment=self._comment, _purpose=self._purpose) + req = context.prepare_query_request(req) service = context.require_resource("dataService") - req = QueryRequest(context.prepare_query(self.query), _comment=self._comment, _purpose=self._purpose) return await service.query(context, req) async def execute_for_rows(self, context): @@ -469,10 +469,10 @@ async def execute_for_list(self, context) -> SmartList[TaskExecutionLog]: async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[TaskExecutionLog]: request = self._request - if not request._purpose or not request._purpose.strip() or not request._comment or not request._comment.strip(): - raise ValueError("Security audit failure: comment() and purpose() must be called before execute_for_page()") - request.query.offset(offset).limit(limit) - authorized = context.prepare_query(request.query) + intent = QueryIntent(request._comment, request._purpose) + query = deepcopy(request.query) + query.offset(offset).limit(limit) + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query service = context.require_resource("dataService") alias = "__teaql_total" if authorized.id_set_pagination is not None: @@ -496,19 +496,20 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): - self._request.limit(1) - entities = await self.execute_for_list(context) + request = deepcopy(self._request) + request.limit(1) + entities = await ExecutableTaskExecutionLogRequest(request).execute_for_list(context) return entities[0] if entities else None async def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request - if not request._purpose or not request._purpose.strip() or not request._comment or not request._comment.strip(): - raise Exception("Security audit failure: comment() and purpose() must be called before execute_for_stream()") + req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) + req = context.prepare_query_request(req) service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") query_root = EntityRoot() - async for chunk in service.query_stream(context, QueryRequest(context.prepare_query(request.query), _comment=request._comment, _purpose=request._purpose), chunk_size): + async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: yield TaskExecutionLog(_entity_root=query_root, **row) diff --git a/examples/task_board/generated/requests/task_request.py b/examples/task_board/generated/requests/task_request.py index eaa6d57..fd651d4 100644 --- a/examples/task_board/generated/requests/task_request.py +++ b/examples/task_board/generated/requests/task_request.py @@ -2,6 +2,8 @@ from teaql.core.list import SmartList, TeaQLPage from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest +from teaql.core import QueryIntent +from copy import deepcopy from teaql.core.expr import ( begin_with, between, column, contain, end_with, eq, gt, gte, in_list, in_subquery, is_not_null, is_null, lt, lte, ne, not_begin_with, @@ -442,8 +444,7 @@ def comment(self, c: str): def new_entity(self, context) -> Task: request = self._request - if not request._comment or not request._comment.strip() or not request._purpose or not request._purpose.strip(): - raise ValueError("Security audit failure: non-empty comment() and purpose() are required before new_entity()") + QueryIntent(request._comment, request._purpose) entity = context.initialize_entity("Task", Task()) if not isinstance(entity, Task): raise TypeError("entity initializer returned an incompatible Task") @@ -451,10 +452,9 @@ def new_entity(self, context) -> Task: async def execute_for_result(self, context): self = self._request - if not self._purpose or not self._purpose.strip() or not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: comment() and purpose() must be called before execute_for_rows()") + req = QueryRequest(self.query, _comment=self._comment, _purpose=self._purpose) + req = context.prepare_query_request(req) service = context.require_resource("dataService") - req = QueryRequest(context.prepare_query(self.query), _comment=self._comment, _purpose=self._purpose) return await service.query(context, req) async def execute_for_rows(self, context): @@ -469,10 +469,10 @@ async def execute_for_list(self, context) -> SmartList[Task]: async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[Task]: request = self._request - if not request._purpose or not request._purpose.strip() or not request._comment or not request._comment.strip(): - raise ValueError("Security audit failure: comment() and purpose() must be called before execute_for_page()") - request.query.offset(offset).limit(limit) - authorized = context.prepare_query(request.query) + intent = QueryIntent(request._comment, request._purpose) + query = deepcopy(request.query) + query.offset(offset).limit(limit) + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query service = context.require_resource("dataService") alias = "__teaql_total" if authorized.id_set_pagination is not None: @@ -496,19 +496,20 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): - self._request.limit(1) - entities = await self.execute_for_list(context) + request = deepcopy(self._request) + request.limit(1) + entities = await ExecutableTaskRequest(request).execute_for_list(context) return entities[0] if entities else None async def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request - if not request._purpose or not request._purpose.strip() or not request._comment or not request._comment.strip(): - raise Exception("Security audit failure: comment() and purpose() must be called before execute_for_stream()") + req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) + req = context.prepare_query_request(req) service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") query_root = EntityRoot() - async for chunk in service.query_stream(context, QueryRequest(context.prepare_query(request.query), _comment=request._comment, _purpose=request._purpose), chunk_size): + async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: yield Task(_entity_root=query_root, **row) diff --git a/examples/task_board/generated/requests/task_status_request.py b/examples/task_board/generated/requests/task_status_request.py index 737eb50..f73ebcd 100644 --- a/examples/task_board/generated/requests/task_status_request.py +++ b/examples/task_board/generated/requests/task_status_request.py @@ -2,6 +2,8 @@ from teaql.core.list import SmartList, TeaQLPage from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest +from teaql.core import QueryIntent +from copy import deepcopy from teaql.core.expr import ( begin_with, between, column, contain, end_with, eq, gt, gte, in_list, in_subquery, is_not_null, is_null, lt, lte, ne, not_begin_with, @@ -793,8 +795,7 @@ def comment(self, c: str): def new_entity(self, context) -> TaskStatus: request = self._request - if not request._comment or not request._comment.strip() or not request._purpose or not request._purpose.strip(): - raise ValueError("Security audit failure: non-empty comment() and purpose() are required before new_entity()") + QueryIntent(request._comment, request._purpose) entity = context.initialize_entity("TaskStatus", TaskStatus()) if not isinstance(entity, TaskStatus): raise TypeError("entity initializer returned an incompatible TaskStatus") @@ -802,10 +803,9 @@ def new_entity(self, context) -> TaskStatus: async def execute_for_result(self, context): self = self._request - if not self._purpose or not self._purpose.strip() or not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: comment() and purpose() must be called before execute_for_rows()") + req = QueryRequest(self.query, _comment=self._comment, _purpose=self._purpose) + req = context.prepare_query_request(req) service = context.require_resource("dataService") - req = QueryRequest(context.prepare_query(self.query), _comment=self._comment, _purpose=self._purpose) return await service.query(context, req) async def execute_for_rows(self, context): @@ -820,10 +820,10 @@ async def execute_for_list(self, context) -> SmartList[TaskStatus]: async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[TaskStatus]: request = self._request - if not request._purpose or not request._purpose.strip() or not request._comment or not request._comment.strip(): - raise ValueError("Security audit failure: comment() and purpose() must be called before execute_for_page()") - request.query.offset(offset).limit(limit) - authorized = context.prepare_query(request.query) + intent = QueryIntent(request._comment, request._purpose) + query = deepcopy(request.query) + query.offset(offset).limit(limit) + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query service = context.require_resource("dataService") alias = "__teaql_total" if authorized.id_set_pagination is not None: @@ -847,19 +847,20 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): - self._request.limit(1) - entities = await self.execute_for_list(context) + request = deepcopy(self._request) + request.limit(1) + entities = await ExecutableTaskStatusRequest(request).execute_for_list(context) return entities[0] if entities else None async def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request - if not request._purpose or not request._purpose.strip() or not request._comment or not request._comment.strip(): - raise Exception("Security audit failure: comment() and purpose() must be called before execute_for_stream()") + req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) + req = context.prepare_query_request(req) service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") query_root = EntityRoot() - async for chunk in service.query_stream(context, QueryRequest(context.prepare_query(request.query), _comment=request._comment, _purpose=request._purpose), chunk_size): + async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: yield TaskStatus(_entity_root=query_root, **row) diff --git a/src/teaql/core/__init__.py b/src/teaql/core/__init__.py index c82407c..44ca5b3 100644 --- a/src/teaql/core/__init__.py +++ b/src/teaql/core/__init__.py @@ -18,6 +18,7 @@ BatchInsertCommand, BatchUpdateCommand, MutationRequest ) from .graph import GraphNode +from .request_intent import QueryIntent, MutationIntent, RequestIntentError from .meta import EntityDescriptor, PropertyDescriptor from .list import SmartList, TeaQLPage @@ -43,6 +44,7 @@ ) __all__ = [ + "QueryIntent", "MutationIntent", "RequestIntentError", "Value", "DataType", "Timestamp", "BaseEntityData", "EntityKey", "EntityChangeSet", "EntityRoot", "Expr", "ExprBuilder", "BinaryOp", "ExprFunction", diff --git a/src/teaql/core/mutation.py b/src/teaql/core/mutation.py index 7e588a1..e7696ff 100644 --- a/src/teaql/core/mutation.py +++ b/src/teaql/core/mutation.py @@ -2,6 +2,7 @@ from typing import Dict, Any, List, Optional from dataclasses import dataclass, field from .value import Value +from .request_intent import MutationIntent @dataclass class TraceNode: @@ -113,38 +114,46 @@ class MutationKind(Enum): BATCH = auto() class MutationRequest: - def __init__(self, data: Any): + __slots__ = ('_data', '__intent') + + def __init__(self, data: Any, comment: Optional[str] = None): + self.__intent = MutationIntent(comment) self._data = data + @property + def intent(self) -> MutationIntent: + return self.__intent + + def validate(self) -> None: + MutationIntent(getattr(getattr(self, '_MutationRequest__intent', None), 'comment', None)) + + def with_root_intent(self, intent: MutationIntent) -> 'MutationRequest': + return MutationRequest(self._data, comment=intent.comment) + def trace_chain(self) -> List[TraceNode]: if isinstance(self._data, list): return [] return getattr(self._data, 'trace_chain', []) - def comment(self) -> Optional[str]: - if isinstance(self._data, list): - return None - traces = self.trace_chain() - if traces: - return traces[-1].comment - return None + def comment(self) -> str: + return self.intent.comment @classmethod - def Insert(cls, cmd: InsertCommand) -> 'MutationRequest': - return cls(cmd) + def Insert(cls, cmd: InsertCommand, comment: Optional[str] = None) -> 'MutationRequest': + return cls(cmd, comment) @classmethod - def Update(cls, cmd: UpdateCommand) -> 'MutationRequest': - return cls(cmd) + def Update(cls, cmd: UpdateCommand, comment: Optional[str] = None) -> 'MutationRequest': + return cls(cmd, comment) @classmethod - def Delete(cls, cmd: DeleteCommand) -> 'MutationRequest': - return cls(cmd) + def Delete(cls, cmd: DeleteCommand, comment: Optional[str] = None) -> 'MutationRequest': + return cls(cmd, comment) @classmethod - def Recover(cls, cmd: RecoverCommand) -> 'MutationRequest': - return cls(cmd) + def Recover(cls, cmd: RecoverCommand, comment: Optional[str] = None) -> 'MutationRequest': + return cls(cmd, comment) @classmethod - def Batch(cls, cmds: List['MutationRequest']) -> 'MutationRequest': - return cls(cmds) + def Batch(cls, cmds: List['MutationRequest'], comment: Optional[str] = None) -> 'MutationRequest': + return cls(cmds, comment) diff --git a/src/teaql/core/request_intent.py b/src/teaql/core/request_intent.py new file mode 100644 index 0000000..9c2a085 --- /dev/null +++ b/src/teaql/core/request_intent.py @@ -0,0 +1,54 @@ +"""Validated, request-owned intent shared by all execution adapters.""" +from dataclasses import dataclass, field +from typing import Optional + + +class RequestIntentError(ValueError): + def __init__(self, code: str, field_name: str, request_kind: str): + self.code = code + self.field = field_name + self.request_kind = request_kind + super().__init__( + f'{code}: {request_kind} request requires non-blank {field_name}; ' + f'supply {field_name} at the request entry point') + + +def _white_space(character: str) -> bool: + # Unicode White_Space, identical to Rust str::trim. Python str.isspace() + # additionally accepts U+001C..U+001F, which are NOT contract whitespace. + value = ord(character) + return (0x09 <= value <= 0x0D or value in ( + 0x20, 0x85, 0xA0, 0x1680, 0x2028, 0x2029, 0x202F, 0x205F, 0x3000) + or 0x2000 <= value <= 0x200A) + + +def require_intent(value: Optional[str], field_name: str, request_kind: str) -> str: + if not isinstance(value, str) or not value or all(_white_space(c) for c in value): + code = 'QUERY_PURPOSE_REQUIRED' if field_name == 'purpose' else 'REQUEST_COMMENT_REQUIRED' + raise RequestIntentError(code, field_name, request_kind) + return value # Preserve text verbatim; validation must not rewrite evidence. + + +@dataclass(frozen=True, slots=True) +class QueryIntent: + comment: str = field(repr=False) + purpose: str = field(repr=False) + + def __post_init__(self): + require_intent(self.comment, 'comment', 'query') + require_intent(self.purpose, 'purpose', 'query') + + @classmethod + def from_query(cls, query): + return cls(getattr(query, 'comment_text', None), getattr(query, 'purpose_text', None)) + + +@dataclass(frozen=True, slots=True) +class MutationIntent: + comment: str = field(repr=False) + + def __post_init__(self): + require_intent(self.comment, 'comment', 'mutation') + + def readback(self) -> QueryIntent: + return QueryIntent(self.comment, 'verify the persisted mutation result') diff --git a/src/teaql/data_service/__init__.py b/src/teaql/data_service/__init__.py index bb1c255..650d8dd 100644 --- a/src/teaql/data_service/__init__.py +++ b/src/teaql/data_service/__init__.py @@ -2,8 +2,10 @@ from datetime import datetime from enum import Enum, auto from typing import List, Dict, Any, Optional, Protocol, Union, AsyncIterator +from copy import deepcopy from teaql.core.query import SelectQuery +from teaql.core.request_intent import QueryIntent from teaql.core.mutation import ( MutationRequest, InsertCommand as CoreInsertCommand, @@ -25,33 +27,46 @@ class DataServiceCapabilities: returning: bool = False -@dataclass class QueryRequest: - query: SelectQuery - trace_chain: List[TraceNode] = field(default_factory=list) - _comment: Optional[str] = None - _purpose: Optional[str] = None - - def __post_init__(self) -> None: - # Older generated wrappers store intent on SelectQuery but construct - # QueryRequest(query) without forwarding it. Keep the explicit request - # values authoritative while preserving their diagnostic intent. - if self._comment is None: - self._comment = getattr(self.query, 'comment_text', None) - if self._purpose is None: - self._purpose = getattr(self.query, 'purpose_text', None) + __slots__ = ('query', 'trace_chain', '__intent', '_log_intent_source') + _UNSET = object() + + def __init__(self, query: SelectQuery, trace_chain=None, _comment=_UNSET, _purpose=_UNSET): + comment = getattr(query, 'comment_text', None) if _comment is self._UNSET else _comment + purpose = getattr(query, 'purpose_text', None) if _purpose is self._UNSET else _purpose + self.__intent = QueryIntent(comment, purpose) + self.query = deepcopy(query) + self.query.comment_text = self.intent.comment + self.query.purpose_text = self.intent.purpose + self.trace_chain = deepcopy(trace_chain) if trace_chain is not None else [] + + @property + def intent(self) -> QueryIntent: + return self.__intent + + @property + def _comment(self) -> str: + return self.intent.comment + + @property + def _purpose(self) -> str: + return self.intent.purpose + + def validate(self) -> None: + intent = getattr(self, '_QueryRequest__intent', None) + QueryIntent(getattr(intent, 'comment', None), getattr(intent, 'purpose', None)) + + def with_query(self, query: SelectQuery) -> 'QueryRequest': + result = QueryRequest(query, self.trace_chain, self._comment, self._purpose) + if hasattr(self, '_log_intent_source'): + result._log_intent_source = self._log_intent_source + return result def comment(self, text: str) -> 'QueryRequest': - if not text: - raise ValueError("comment cannot be empty") - self._comment = text - return self + return QueryRequest(self.query, self.trace_chain, text, self._purpose) def purpose(self, text: str) -> 'QueryRequest': - if not text: - raise ValueError("purpose cannot be empty") - self._purpose = text - return self + return QueryRequest(self.query, self.trace_chain, self._comment, text) class DataServiceOperation(Enum): @@ -93,17 +108,17 @@ class QueryResult: metadata: ExecutionMetadata facets: Dict[str, Any] = field(default_factory=dict) -def InsertCommand(cmd): - return MutationRequest(cmd) +def InsertCommand(cmd, comment=None): + return MutationRequest(cmd, comment) -def UpdateCommand(cmd): - return MutationRequest(cmd) +def UpdateCommand(cmd, comment=None): + return MutationRequest(cmd, comment) -def DeleteCommand(cmd): - return MutationRequest(cmd) +def DeleteCommand(cmd, comment=None): + return MutationRequest(cmd, comment) -def RecoverCommand(cmd): - return MutationRequest(cmd) +def RecoverCommand(cmd, comment=None): + return MutationRequest(cmd, comment) diff --git a/src/teaql/provider/tfp_client/__init__.py b/src/teaql/provider/tfp_client/__init__.py index 2926837..fc2e8ea 100644 --- a/src/teaql/provider/tfp_client/__init__.py +++ b/src/teaql/provider/tfp_client/__init__.py @@ -151,6 +151,7 @@ def capabilities(self) -> DataServiceCapabilities: return DataServiceCapabilities(query=True, mutation=True) async def query(self, context: Any, request: QueryRequest) -> QueryResult: + request.validate() started_at = datetime.now() federal = _query_request(request) rows = await self.federal_client.execute_query(federal) @@ -161,10 +162,12 @@ async def query(self, context: Any, request: QueryRequest) -> QueryResult: backend="teaql-federal", operation=DataServiceOperation.Query, started_at=started_at, ended_at=datetime.now(), result_count=len(rows), comment=federal.comment, + purpose=federal.purpose, ), ) async def mutate(self, context: Any, request: MutationRequest) -> MutationResult: + request.validate() if context is not None and not context.consume_mutation_checked(request._data): context.check_and_fix_mutation(request._data) scope = context.mutation_policy_execution(request) if context is not None else nullcontext() @@ -188,15 +191,14 @@ async def mutate(self, context: Any, request: MutationRequest) -> MutationResult backend="teaql-federal", operation=operation, started_at=started_at, ended_at=datetime.now(), affected_rows=affected, comment=federal.comment, + audit_reason=federal.comment, ), ) def _federal_query_payload(query: FederalQuery) -> Dict[str, Any]: - if not query.comment or not query.comment.strip(): - raise TfpError("TFP_INVALID_REQUEST", "commentText is required") - if not query.purpose or not query.purpose.strip(): - raise TfpError("TFP_POLICY_VIOLATION", "purposeText is required") + from teaql.core.request_intent import QueryIntent + intent = QueryIntent(query.comment, query.purpose) if query.limit is not None and query.limit < 1: raise TfpError("TFP_INVALID_REQUEST", "limitValue must be positive") if query.offset is not None and query.offset < 0: @@ -207,8 +209,8 @@ def _federal_query_payload(query: FederalQuery) -> Dict[str, Any]: "selectItems": query.select_items, "groupByItems": query.group_by_items, "aggregateItems": query.aggregate_items, - "commentText": query.comment.strip(), - "purposeText": query.purpose.strip(), + "commentText": intent.comment, + "purposeText": intent.purpose, } if query.filter_condition is not None: payload["filterCondition"] = query.filter_condition @@ -221,13 +223,13 @@ def _federal_query_payload(query: FederalQuery) -> Dict[str, Any]: def _federal_mutation_payload(mutation: FederalMutation) -> Dict[str, Any]: + from teaql.core.request_intent import MutationIntent + intent = MutationIntent(mutation.comment) if mutation.action not in {"Create", "Update", "Delete", "Recover"}: raise TfpError("TFP_INVALID_REQUEST", f"unsupported mutation action: {mutation.action}") - if not mutation.comment or not mutation.comment.strip(): - raise TfpError("TFP_AUDIT_REASON_REQUIRED", "mutation audit reason is required") payload: Dict[str, Any] = { "entity": mutation.entity, "action": mutation.action, - "payload": _json_value(mutation.payload), "comment": mutation.comment.strip(), + "payload": _json_value(mutation.payload), "comment": intent.comment, } if mutation.id is not None: payload["id"] = _json_value(mutation.id) @@ -256,7 +258,7 @@ def _query_request(request: QueryRequest) -> FederalQuery: select_items=list(query.projection), group_by_items=list(query.group_by_items), aggregate_items=[{"function": item.function.name, "field": item.field, "alias": item.alias} for item in query.aggregates], - comment=request._comment or query.comment_text, + comment=request.intent.comment, purpose=request._purpose, ) diff --git a/src/teaql/runtime/context.py b/src/teaql/runtime/context.py index a758daf..e5df73c 100644 --- a/src/teaql/runtime/context.py +++ b/src/teaql/runtime/context.py @@ -207,8 +207,10 @@ def decode_entity_reference( ) return claims - async def execute_graph_save(self, work): + async def execute_graph_save(self, work, *, comment=None): """Run one generated entity graph in one provider transaction.""" + from teaql.core.request_intent import MutationIntent + intent = MutationIntent(comment) if self._graph_save_owner.get() is not None: return await work() async with self._graph_save_lock: @@ -222,7 +224,7 @@ async def execute_graph_save(self, work): transaction = await begin() owner_token = self._graph_save_owner.set(object()) self._graph_save_active = True - self._mutation_policy.begin_graph() + self._mutation_policy.begin_graph(intent.comment) self._graph_commit_actions = [] self._graph_rollback_actions = [] self.insert_resource("fix_time", self.business_time()) @@ -385,6 +387,8 @@ def require_resource(self, resource_type: str) -> Any: def prepare_query(self, query: Any) -> Any: """Clone a query graph and apply trusted policy once to every query node.""" + from teaql.core.request_intent import QueryIntent + intent = QueryIntent.from_query(query) prepared_root = deepcopy(query) policy = self.get_resource("request_policy") if policy is None: @@ -420,7 +424,17 @@ def prepare_node(node: Any) -> Any: prepared.child_enhancements = [prepare_node(child) for child in children] return prepared - return prepare_node(prepared_root) + result = prepare_node(prepared_root) + result.comment_text = intent.comment + result.purpose_text = intent.purpose + return result + + def prepare_query_request(self, request): + request.validate() + query = deepcopy(request.query) + query.comment_text = request.intent.comment + query.purpose_text = request.intent.purpose + return request.with_query(self.prepare_query(query)) def set_user_identifier(self, identifier: str): self._user_identifier = identifier diff --git a/src/teaql/runtime/mutation_policy.py b/src/teaql/runtime/mutation_policy.py index 78c68f0..5d8d252 100644 --- a/src/teaql/runtime/mutation_policy.py +++ b/src/teaql/runtime/mutation_policy.py @@ -244,12 +244,14 @@ def __init__(self) -> None: def current(self) -> Optional[MutationGovernanceSnapshot]: return self._active.get() - def begin_graph(self) -> None: + def begin_graph(self, audit_reason: str) -> None: + from teaql.core.request_intent import MutationIntent + intent = MutationIntent(audit_reason) self._graph_active = True self._graph_reviewed = False self._preflight.clear() self._root_entity = None - self._audit_reason = None + self._audit_reason = intent.comment self._remaining.clear() self._active.set(None) @@ -273,10 +275,10 @@ def record_preflight(self, command: Any) -> None: if not operations: raise MutationPolicyError("mutation preflight must contain an operation") self._root_entity = self._root_entity or operations[0].entity - self._audit_reason = self._audit_reason or _comment_from_data(command) self._preflight.extend(operations) def enter_mutation(self, context: Any, request: MutationRequest): + request.validate() operations = _operations_from_data(request._data) if not operations: raise MutationPolicyError("mutation request must contain an operation") @@ -287,7 +289,7 @@ def enter_mutation(self, context: Any, request: MutationRequest): # remains fail-closed. if self._graph_active and self.registry is None and not self._preflight: plan = self._build_plan( - context, operations[0].entity, _request_comment(request), tuple(operations) + context, operations[0].entity, self._audit_reason, tuple(operations) ) token = self._active.set(self.review(context, plan)) return _ResetScope(self._active, token) @@ -300,7 +302,7 @@ def enter_mutation(self, context: Any, request: MutationRequest): ) planned = tuple(self._preflight or operations) root = self._root_entity or planned[0].entity - reason = self._audit_reason or _request_comment(request) + reason = self._audit_reason snapshot = self.review(context, self._build_plan(context, root, reason, planned)) self._active.set(snapshot) self._remaining = Counter(_operation_signature(item) for item in planned) diff --git a/src/teaql/sql/executor.py b/src/teaql/sql/executor.py index 2f4207e..99f2079 100644 --- a/src/teaql/sql/executor.py +++ b/src/teaql/sql/executor.py @@ -240,6 +240,7 @@ def _record_statement(self, context, request, compiled, started_at, operation, return metadata async def query_stream(self, context, request: QueryRequest, chunk_size: int): + request.validate() if chunk_size <= 0: raise ValueError("chunk_size must be positive") if (request.query.relations or request.query.child_enhancements @@ -291,6 +292,7 @@ async def query_stream(self, context, request: QueryRequest, chunk_size: int): DataServiceOperation.Query, outcome, result_count=delivered) async def query(self, context: 'UserContext', request: QueryRequest) -> QueryResult: + request.validate() telemetry = context.runtime_telemetry() if context is not None else None return await observe_runtime_operation( telemetry, @@ -302,6 +304,7 @@ async def query(self, context: 'UserContext', request: QueryRequest) -> QueryRes ) async def _query(self, context: 'UserContext', request: QueryRequest) -> QueryResult: + request.validate() self._sync_generated_schema(context) request.query.prepare_for_list() execution_query, retained_order, retained_empty = await self._prepare_id_set_page(context, request.query) @@ -384,7 +387,7 @@ async def _query(self, context: 'UserContext', request: QueryRequest) -> QueryRe membership_query.aggregates = [Aggregate( AggregateFunction.Count, "id", "__teaql_facet_count")] membership_query.group_by_items = [facet.relation_name] - membership_result = await self._query(context, QueryRequest(membership_query)) + membership_result = await self._query(context, request.with_query(membership_query)) counts = { str(row[facet.relation_name]): int(row["__teaql_facet_count"]) for row in membership_result.rows @@ -399,7 +402,7 @@ async def _query(self, context: 'UserContext', request: QueryRequest) -> QueryRe ] nested_query.aggregates = [] nested_query.group_by_items = [] - nested_result = await self._query(context, QueryRequest(nested_query)) + nested_result = await self._query(context, request.with_query(nested_query)) facet_rows = [] for row in nested_result.rows: count = counts.get(str(row.get("id")), 0) @@ -673,12 +676,20 @@ def _attach_empty_relation_aggregate(self, parents, aggregate, query): parent[aggregate.alias] = value async def mutate(self, context: 'UserContext', request: MutationRequest) -> MutationResult: + request.validate() self._sync_generated_schema(context) entity = getattr(request._data, "entity", "unknown") kind = type(request._data).__name__.replace("Command", "").lower() if context is not None: - if not context.consume_mutation_checked(request._data): - context.check_and_fix_mutation(request._data) + def check(data): + if isinstance(data, MutationRequest): + check(data._data) + elif isinstance(data, list): + for child in data: + check(child) + elif not context.consume_mutation_checked(data): + context.check_and_fix_mutation(data) + check(request._data) telemetry = context.runtime_telemetry() if context is not None else None scope = ( context.mutation_policy_execution(request) @@ -696,6 +707,7 @@ async def mutate(self, context: 'UserContext', request: MutationRequest) -> Muta ) async def _mutate(self, context: 'UserContext', request: MutationRequest) -> MutationResult: + request.validate() if isinstance(self.transport, SqlTransactionTransport): transaction = await self.transport.begin_sql() executor = SqlDataServiceExecutor(self.dialect, transaction, self.schema_provider) @@ -713,6 +725,19 @@ async def _mutate(self, context: 'UserContext', request: MutationRequest) -> Mut raise req_data = request._data + if isinstance(req_data, list): + start = datetime.now() + results = [] + for child in req_data: + child_request = (child.with_root_intent(request.intent) + if isinstance(child, MutationRequest) else + MutationRequest(child, comment=request.intent.comment)) + results.append(await self._mutate(context, child_request)) + affected = sum(result.affected_rows for result in results) + return MutationResult(affected, {}, ExecutionMetadata( + backend=str(self.dialect.kind()).lower(), operation=DataServiceOperation.Batch, + started_at=start, ended_at=datetime.now(), affected_rows=affected, + comment=request.intent.comment, audit_reason=request.intent.comment)) entity_desc = self.schema_provider.get_entity(req_data.entity) if not entity_desc and context: entities = context.get_resource("entities") @@ -886,6 +911,7 @@ def _record_readback(self, context, readback, source, write_metadata, started_at outcome = ('success' if rows is not None else 'cancelled' if isinstance(error, asyncio.CancelledError) else 'failure') metadata = replace(write_metadata, operation=DataServiceOperation.Query, + purpose='verify the persisted mutation result', started_at=started_at, ended_at=datetime.now(), execution_outcome=outcome, parameterized_sql=readback.sql, parameters=list(readback.params), parameter_log_policies=readback.parameter_log_policies, sql_origin=readback.sql_origin, diff --git a/tests/core/test_mutation_coverage.py b/tests/core/test_mutation_coverage.py index 1043808..4fd4dc5 100644 --- a/tests/core/test_mutation_coverage.py +++ b/tests/core/test_mutation_coverage.py @@ -35,15 +35,15 @@ def test_mutation_requests(): i = InsertCommand.new("User") i.trace_chain = [t] - req_i = MutationRequest.Insert(i) - req_u = MutationRequest.Update(UpdateCommand.new("U", 1)) - req_d = MutationRequest.Delete(DeleteCommand.new("U", 1)) - req_r = MutationRequest.Recover(RecoverCommand.new("U", 1, 1)) + req_i = MutationRequest.Insert(i, comment='what: runtime regression fixture') + req_u = MutationRequest.Update(UpdateCommand.new("U", 1), comment='what: runtime regression fixture') + req_d = MutationRequest.Delete(DeleteCommand.new("U", 1), comment='what: runtime regression fixture') + req_r = MutationRequest.Recover(RecoverCommand.new("U", 1, 1), comment='what: runtime regression fixture') - req_b = MutationRequest.Batch([req_i, req_u]) + req_b = MutationRequest.Batch([req_i, req_u], comment='what: runtime regression fixture') assert len(req_i.trace_chain()) == 1 - assert req_i.comment() == "test" + assert req_i.comment() == 'what: runtime regression fixture' assert req_b.trace_chain() == [] - assert req_b.comment() is None + assert req_b.comment() == 'what: runtime regression fixture' diff --git a/tests/core/test_request_intent.py b/tests/core/test_request_intent.py new file mode 100644 index 0000000..80da8bd --- /dev/null +++ b/tests/core/test_request_intent.py @@ -0,0 +1,144 @@ +import pytest +import json +from pathlib import Path +from dataclasses import FrozenInstanceError + +from teaql.core import InsertCommand, MutationRequest, SelectQuery, TraceNode +from teaql.data_service import QueryRequest +from teaql.core import QueryIntent, MutationIntent, RequestIntentError + +VECTORS = json.loads((Path(__file__).parents[1] / 'fixtures/request-intent-v1.json').read_text())['cases'] + + +@pytest.mark.parametrize('case', VECTORS, ids=lambda case: case['id']) +def test_shared_request_intent_contract(case): + data = case['input'] + traces = [TraceNode(kind=node['kind'], comment=node['detail']) for node in data.get('trace', [])] + + def build(): + if case['kind'] == 'query': + return QueryRequest(SelectQuery('Order'), traces, + _comment=data.get('comment'), _purpose=data.get('purpose')) + if 'children' in data: + children = [MutationRequest(InsertCommand('OrderItem'), comment=child['comment']) + for child in data['children']] + return MutationRequest.Batch(children, comment=data.get('comment')) + return MutationRequest(InsertCommand('Order', trace_chain=traces), comment=data.get('comment')) + + if 'error' in case: + with pytest.raises(RequestIntentError) as caught: + build() + assert caught.value.code == case['error']['code'] + assert caught.value.field == case['error']['field'] + assert caught.value.request_kind == case['kind'] + else: + request = build() + assert request.intent.comment == case['expected']['comment'] + if case['kind'] == 'query': + assert request.intent.purpose == case['expected']['purpose'] + + +@pytest.mark.parametrize('codepoint', [*range(9, 14), 0x20, 0x85, 0xA0, 0x1680, + *range(0x2000, 0x200B), 0x2028, 0x2029, 0x202F, 0x205F, 0x3000]) +def test_unicode_white_space_matches_rust(codepoint): + with pytest.raises(RequestIntentError, match='REQUEST_COMMENT_REQUIRED'): + MutationIntent(chr(codepoint)) + with pytest.raises(RequestIntentError, match='QUERY_PURPOSE_REQUIRED'): + QueryIntent('load order', chr(codepoint)) + + +@pytest.mark.parametrize('text', ['\u001c', '\u001d', '\u001e', '\u001f', '\u200b', '\ufeff']) +def test_non_white_space_is_not_silently_reclassified(text): + assert MutationIntent(text).comment == text + + +def test_owned_intent_survives_builder_and_route_changes_without_leaking_in_repr(): + query = SelectQuery('Order').comment(' load order ').purpose('render order') + request = QueryRequest(query) + query.comment('different request') + request.query.comment('policy must not rewrite intent') + assert request.intent.comment == ' load order ' + assert request.with_query(SelectQuery('OrderItem')).intent == request.intent + with pytest.raises(AttributeError): + request._comment = '' + with pytest.raises(FrozenInstanceError): + request.intent.comment = '' + command = InsertCommand('Order', trace_chain=[TraceNode(comment='child reason')]) + mutation = MutationRequest(command, comment='submit order') + command.trace_chain.append(TraceNode(kind='provider', comment='')) + assert mutation.comment() == 'submit order' + assert mutation.intent.readback().comment == 'submit order' + with pytest.raises(AttributeError): + mutation.comment = 'cannot shadow accessor' + assert 'submit order' not in repr(mutation.intent) + assert 'load order' not in repr(request.intent) + + +def test_query_policy_cannot_replace_root_intent(): + from teaql.runtime import UserContext + context = UserContext().with_request_policy(lambda _: SelectQuery('Order').comment('rewrite').purpose('rewrite')) + request = QueryRequest(SelectQuery('Order'), _comment='original comment', _purpose='original purpose') + prepared = context.prepare_query_request(request) + assert prepared.intent == request.intent + assert prepared.query.comment_text == 'original comment' + assert prepared.query.purpose_text == 'original purpose' + + +@pytest.mark.asyncio +async def test_graph_root_is_required_before_begin_even_with_a_valid_child(): + from teaql.runtime import UserContext + calls = [] + async def work(): + calls.append('work') + return MutationRequest(InsertCommand('OrderItem'), comment='create item') + class Provider: + async def begin(self, _): + calls.append('begin') + raise AssertionError('provider must not be called') + context = UserContext().insert_resource('dataService', Provider()) + with pytest.raises(RequestIntentError, match='REQUEST_COMMENT_REQUIRED'): + await context.execute_graph_save(work, comment='\u0085') + assert calls == [] + + +@pytest.mark.asyncio +async def test_direct_provider_gates_before_checker_schema_and_stream_with_logs_off(): + from teaql.runtime import UserContext + from teaql.runtime.context import SqlLogOptions + from teaql.sql.executor import SqlDataServiceExecutor + context = UserContext().with_sql_log_options(SqlLogOptions.disabled()) + calls = [] + context.check_and_fix_mutation = lambda _: calls.append('checker') + context.with_request_policy(lambda _: calls.append('policy')) + provider = object.__new__(SqlDataServiceExecutor) + provider._sync_generated_schema = lambda _: calls.append('schema') + query = object.__new__(QueryRequest) + query.query = SelectQuery('MissingTable') + mutation = object.__new__(MutationRequest) + mutation._data = InsertCommand('MissingTable').value('secret', 'PAYLOAD-CANARY') + for execute in [provider.query(context, query), provider.mutate(context, mutation)]: + with pytest.raises(RequestIntentError) as caught: + await execute + assert 'PAYLOAD-CANARY' not in str(caught.value) + with pytest.raises(RequestIntentError): + await context.prepare_query_request(query) + with pytest.raises(RequestIntentError): + async for _ in provider.query_stream(context, query, 10): + raise AssertionError('stream must not open') + assert calls == [] + + +@pytest.mark.parametrize('comment', [None, '', ' \t\r\n', '\u0085', '\u2003']) +def test_query_request_rejects_missing_or_blank_comment(comment): + with pytest.raises(ValueError, match='REQUEST_COMMENT_REQUIRED'): + QueryRequest(SelectQuery('Order'), _comment=comment, _purpose='render orders') + + +@pytest.mark.parametrize('comment', [None, '', ' \t\r\n', '\u0085', '\u2003']) +def test_mutation_request_does_not_infer_comment_from_trace(comment): + command = InsertCommand('Order', trace_chain=[TraceNode(comment='submit order')]) + with pytest.raises(ValueError, match='REQUEST_COMMENT_REQUIRED'): + if comment is None: + MutationRequest(command) + else: + MutationRequest(command, comment=comment) diff --git a/tests/data_service/test_service.py b/tests/data_service/test_service.py index f9773e4..21480fc 100644 --- a/tests/data_service/test_service.py +++ b/tests/data_service/test_service.py @@ -28,10 +28,10 @@ def test_mutation_request_trace_and_comment_accessors(): values={}, trace_chain=trace_chain.copy() ) - req_insert = MutationRequest.Insert(insert_cmd) + req_insert = MutationRequest.Insert(insert_cmd, comment='what: runtime regression fixture') assert len(req_insert.trace_chain()) == 2 assert req_insert.trace_chain()[1] == trace2 - assert req_insert.comment() == "Create Profile" + assert req_insert.comment() == 'what: runtime regression fixture' # Test Update update_cmd = CoreUpdateCommand( @@ -42,9 +42,9 @@ def test_mutation_request_trace_and_comment_accessors(): old_values=None, trace_chain=trace_chain.copy() ) - req_update = MutationRequest.Update(update_cmd) + req_update = MutationRequest.Update(update_cmd, comment='what: runtime regression fixture') assert len(req_update.trace_chain()) == 2 - assert req_update.comment() == "Create Profile" + assert req_update.comment() == 'what: runtime regression fixture' # Test Delete delete_cmd = CoreDeleteCommand( @@ -54,9 +54,9 @@ def test_mutation_request_trace_and_comment_accessors(): soft_delete=True, trace_chain=trace_chain.copy() ) - req_delete = MutationRequest.Delete(delete_cmd) + req_delete = MutationRequest.Delete(delete_cmd, comment='what: runtime regression fixture') assert len(req_delete.trace_chain()) == 2 - assert req_delete.comment() == "Create Profile" + assert req_delete.comment() == 'what: runtime regression fixture' # Test Recover recover_cmd = CoreRecoverCommand( @@ -65,14 +65,14 @@ def test_mutation_request_trace_and_comment_accessors(): expected_version_val=1, trace_chain=trace_chain.copy() ) - req_recover = MutationRequest.Recover(recover_cmd) + req_recover = MutationRequest.Recover(recover_cmd, comment='what: runtime regression fixture') assert len(req_recover.trace_chain()) == 2 - assert req_recover.comment() == "Create Profile" + assert req_recover.comment() == 'what: runtime regression fixture' # Test Batch - req_batch = MutationRequest.Batch([req_insert, req_update]) + req_batch = MutationRequest.Batch([req_insert, req_update], comment='what: runtime regression fixture') assert len(req_batch.trace_chain()) == 0 - assert req_batch.comment() is None + assert req_batch.comment() == 'what: runtime regression fixture' # Test empty trace chain insert_empty = CoreInsertCommand( @@ -80,8 +80,8 @@ def test_mutation_request_trace_and_comment_accessors(): values={}, trace_chain=[] ) - req_insert_empty = MutationRequest.Insert(insert_empty) - assert req_insert_empty.comment() is None + req_insert_empty = MutationRequest.Insert(insert_empty, comment='what: runtime regression fixture') + assert req_insert_empty.comment() == 'what: runtime regression fixture' def test_data_service_capabilities_default(): caps = DataServiceCapabilities() @@ -96,8 +96,8 @@ def test_data_service_capabilities_default(): def test_query_request_coverage(): from teaql.data_service import QueryRequest from teaql.core.query import SelectQuery - q = QueryRequest(SelectQuery.new("User")) - q.comment("c").purpose("p") + q = QueryRequest(SelectQuery.new("User"), _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior') + q = q.comment("c").purpose("p") assert q._comment == "c" assert q._purpose == "p" @@ -110,7 +110,7 @@ def test_query_request_coverage(): def test_aliases(): from teaql.data_service import InsertCommand, UpdateCommand, DeleteCommand, RecoverCommand from teaql.core.mutation import InsertCommand as CoreInsertCommand - assert InsertCommand(CoreInsertCommand.new("A")) is not None - assert UpdateCommand(None) is not None - assert DeleteCommand(None) is not None - assert RecoverCommand(None) is not None + assert InsertCommand(CoreInsertCommand.new("A"), comment='create alias fixture') is not None + assert UpdateCommand(None, comment='update alias fixture') is not None + assert DeleteCommand(None, comment='delete alias fixture') is not None + assert RecoverCommand(None, comment='recover alias fixture') is not None diff --git a/tests/fixtures/request-intent-v1.json b/tests/fixtures/request-intent-v1.json new file mode 100644 index 0000000..b348aba --- /dev/null +++ b/tests/fixtures/request-intent-v1.json @@ -0,0 +1,259 @@ +{ + "contract": "teaql.request-intent.v1", + "cases": [ + { + "id": "Q01-missing", + "kind": "query", + "input": { + "purpose": "render orders" + }, + "error": { + "code": "REQUEST_COMMENT_REQUIRED", + "field": "comment" + } + }, + { + "id": "Q02-null", + "kind": "query", + "input": { + "comment": null, + "purpose": "render orders" + }, + "error": { + "code": "REQUEST_COMMENT_REQUIRED", + "field": "comment" + } + }, + { + "id": "Q03-empty", + "kind": "query", + "input": { + "comment": "", + "purpose": "render orders" + }, + "error": { + "code": "REQUEST_COMMENT_REQUIRED", + "field": "comment" + } + }, + { + "id": "Q04-ascii-blank", + "kind": "query", + "input": { + "comment": " \t\r\n", + "purpose": "render orders" + }, + "error": { + "code": "REQUEST_COMMENT_REQUIRED", + "field": "comment" + } + }, + { + "id": "Q05-unicode-blank", + "kind": "query", + "input": { + "comment": " ", + "purpose": "render orders" + }, + "error": { + "code": "REQUEST_COMMENT_REQUIRED", + "field": "comment" + } + }, + { + "id": "Q06-purpose-missing", + "kind": "query", + "input": { + "comment": "load orders" + }, + "error": { + "code": "QUERY_PURPOSE_REQUIRED", + "field": "purpose" + } + }, + { + "id": "Q07-purpose-blank", + "kind": "query", + "input": { + "comment": "load orders", + "purpose": " " + }, + "error": { + "code": "QUERY_PURPOSE_REQUIRED", + "field": "purpose" + } + }, + { + "id": "Q08-preserve", + "kind": "query", + "input": { + "comment": " load orders ", + "purpose": "render orders" + }, + "expected": { + "comment": " load orders ", + "purpose": "render orders" + } + }, + { + "id": "Q09-trace-only", + "kind": "query", + "input": { + "trace": [ + { + "kind": "Comment", + "detail": "load orders" + }, + { + "kind": "Purpose", + "detail": "render orders" + } + ] + }, + "error": { + "code": "REQUEST_COMMENT_REQUIRED", + "field": "comment" + } + }, + { + "id": "Q10-logs-disabled", + "kind": "query", + "input": { + "purpose": "render orders", + "logging": false + }, + "error": { + "code": "REQUEST_COMMENT_REQUIRED", + "field": "comment" + } + }, + { + "id": "M01-missing", + "kind": "mutation", + "input": {}, + "error": { + "code": "REQUEST_COMMENT_REQUIRED", + "field": "comment" + } + }, + { + "id": "M02-null", + "kind": "mutation", + "input": { + "comment": null + }, + "error": { + "code": "REQUEST_COMMENT_REQUIRED", + "field": "comment" + } + }, + { + "id": "M03-empty", + "kind": "mutation", + "input": { + "comment": "" + }, + "error": { + "code": "REQUEST_COMMENT_REQUIRED", + "field": "comment" + } + }, + { + "id": "M04-ascii-blank", + "kind": "mutation", + "input": { + "comment": " \t\r\n" + }, + "error": { + "code": "REQUEST_COMMENT_REQUIRED", + "field": "comment" + } + }, + { + "id": "M05-unicode-blank", + "kind": "mutation", + "input": { + "comment": " " + }, + "error": { + "code": "REQUEST_COMMENT_REQUIRED", + "field": "comment" + } + }, + { + "id": "M06-preserve", + "kind": "mutation", + "input": { + "comment": " submit order " + }, + "expected": { + "comment": " submit order " + } + }, + { + "id": "M07-trace-only", + "kind": "mutation", + "input": { + "trace": [ + { + "kind": "AuditReason", + "detail": "submit order" + } + ] + }, + "error": { + "code": "REQUEST_COMMENT_REQUIRED", + "field": "comment" + } + }, + { + "id": "M08-batch-child-only", + "kind": "mutation", + "input": { + "children": [ + { + "comment": "create item" + }, + { + "comment": "delete item" + } + ] + }, + "error": { + "code": "REQUEST_COMMENT_REQUIRED", + "field": "comment" + } + }, + { + "id": "M09-blank-route-tail", + "kind": "mutation", + "input": { + "comment": "submit order", + "trace": [ + { + "kind": "AuditReason", + "detail": "authorize payment" + }, + { + "kind": "Entity", + "detail": "" + } + ] + }, + "expected": { + "comment": "submit order" + } + }, + { + "id": "M10-logs-disabled", + "kind": "mutation", + "input": { + "logging": false + }, + "error": { + "code": "REQUEST_COMMENT_REQUIRED", + "field": "comment" + } + } + ] +} diff --git a/tests/provider/sqlite/test_sqlite.py b/tests/provider/sqlite/test_sqlite.py index 9d448b9..09d322a 100644 --- a/tests/provider/sqlite/test_sqlite.py +++ b/tests/provider/sqlite/test_sqlite.py @@ -73,7 +73,7 @@ async def test_dynamic_search_preserves_scoped_sql_and_nested_filter(temp_db): if name == 'Order': command.value('customer', tenant) command.trace_chain = [TraceNode(comment='seed dynamic search fixture')] - await service.mutate(context, MutationRequest(command)) + await service.mutate(context, MutationRequest(command, comment='what: runtime regression fixture')) models = { 'Order': {'fields': {'name': 'string', 'id': 'integer'}, 'relations': {'customer': 'Customer'}}, 'Customer': {'fields': {'name': 'string'}, 'relations': {}}, @@ -90,7 +90,7 @@ def bind(path, predicate): 'removed': 'SECRET', 'missing.name': 'SECRET', 'customer.removed': 'SECRET', 'customer.name': 'Ada', 'name': 'Ada'}, 'orderBy': [{'field': 'removed', 'direction': 'desc'}]}, models, bind, lambda path, direction: OrderBy.asc(path), lambda _: None) - rows = (await service.query(context, QueryRequest(query).comment('what: scoped search').purpose('why: conformance'))).rows + rows = (await service.query(context, QueryRequest(query, _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior').comment('what: scoped search').purpose('why: conformance'))).rows assert len(rows) == 1 and rows[0]['tenant'] == 1 assert len(warnings) == 4 and 'SECRET' not in repr(warnings) assert repr(base) == original @@ -109,6 +109,41 @@ def schema_provider(): provider.register_entity(entity) return provider + +@pytest.mark.asyncio +async def test_atomic_batch_owns_root_comment_independently_of_child_route(temp_db, schema_provider): + from teaql.core import RequestIntentError + from teaql.runtime.context import SqlLogOptions + # The legacy mock does not mark _is_id, which SQLite DDL uses for its PK. + entity = (EntityDescriptor('User').table_name('users') + .property(PropertyDescriptor('id', DataType.I64).is_id()) + .property(PropertyDescriptor('version', DataType.I64).is_version()) + .property(PropertyDescriptor('name', DataType.Text))) + schema_provider = SimpleSchemaProvider() + schema_provider.register_entity(entity) + service = create_sqlite_service(temp_db, schema_provider) + context = RuntimeModule.new().entity(schema_provider.get_entity('User')).into_context() + context.with_schema_provider(service).with_sql_log_options(SqlLogOptions.disabled()) + await context.ensure_schema() + child = MutationRequest(InsertCommand('User').value('id', 7).value('name', 'Batch user'), comment='create child') + child._data.trace_chain.append(TraceNode(kind='provider', comment='')) + with pytest.raises(RequestIntentError): + await service.mutate(context, MutationRequest.Batch([child])) + result = await service.mutate(context, MutationRequest.Batch([child], comment='save batch')) + assert result.affected_rows == 1 + assert result.metadata.audit_reason == 'save batch' + assert child.comment() == 'create child' + rows = await service.query(context, QueryRequest(SelectQuery('User'), _comment='verify batch', _purpose='check result')) + assert len(rows.rows) == 1 and rows.rows[0]['id'] == 7 + first = MutationRequest(InsertCommand('User').value('id', 8).value('name', 'must roll back'), comment='create first') + duplicate = MutationRequest(InsertCommand('User').value('id', 7).value('name', 'duplicate'), comment='create second') + from teaql.sql.executor import TransportError + with pytest.raises(TransportError): + await service.mutate(context, MutationRequest.Batch([first, duplicate], comment='atomic failing batch')) + rows = await service.query(context, QueryRequest(SelectQuery('User'), _comment='verify rollback', _purpose='check atomic batch')) + assert [row['id'] for row in rows.rows] == [7] + await service.close() + @pytest.fixture def service(temp_db, schema_provider): return create_sqlite_service(temp_db, schema_provider) @@ -157,7 +192,7 @@ async def test_query_attaches_batched_relation_aggregate_aliases(temp_db): Aggregate(AggregateFunction.Sum, "score", "inner_total")]), True), ]) - rows = (await service.query(None, QueryRequest(query))).rows + rows = (await service.query(None, QueryRequest(query, _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior'))).rows assert rows[0]["record_count"] == 2 assert rows[0]["score_total"] == 42 @@ -194,8 +229,8 @@ async def test_schema_provider_does_not_interpret_legacy_bootstrap_graphs(temp_d context.with_schema_provider(service) await context.ensure_schema() - platforms = (await service.query(context, QueryRequest(SelectQuery("Platform")))).rows - constants = (await service.query(context, QueryRequest(SelectQuery("SchoolType")))).rows + platforms = (await service.query(context, QueryRequest(SelectQuery("Platform"), _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior'))).rows + constants = (await service.query(context, QueryRequest(SelectQuery("SchoolType"), _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior'))).rows assert platforms == [] assert constants == [] @@ -206,13 +241,13 @@ async def test_crud(temp_db, schema_provider, service): await db.commit() # Insert - insert_req = MutationRequest(InsertCommand("User", {"id": Value.I64(1), "name": Value.Text("Alice"), "version": Value.I64(1)})) + insert_req = MutationRequest(InsertCommand("User", {"id": Value.I64(1), "name": Value.Text("Alice"), "version": Value.I64(1)}), comment='what: runtime regression fixture') res = await service.mutate(None, insert_req) assert res.affected_rows == 1 assert res.persisted_record == {"id": 1, "name": "Alice", "version": 1} # Query - query_req = QueryRequest(SelectQuery("User")) + query_req = QueryRequest(SelectQuery("User"), _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior') query_res = await service.query(None, query_req) assert len(query_res.rows) == 1 assert query_res.rows[0]["id"] == 1 @@ -220,7 +255,7 @@ async def test_crud(temp_db, schema_provider, service): assert query_res.rows[0]["version"] == 1 # Update - update_req = MutationRequest(UpdateCommand("User", Value.I64(1)).value("name", Value.Text("Bob"))) + update_req = MutationRequest(UpdateCommand("User", Value.I64(1)).value("name", Value.Text("Bob")), comment='what: runtime regression fixture') res = await service.mutate(None, update_req) assert res.affected_rows == 1 assert res.persisted_record["name"] == "Bob" @@ -229,7 +264,7 @@ async def test_crud(temp_db, schema_provider, service): assert query_res.rows[0]["name"] == "Bob" # Delete - delete_req = MutationRequest(DeleteCommand("User", Value.I64(1)).hard_delete()) + delete_req = MutationRequest(DeleteCommand("User", Value.I64(1)).hard_delete(), comment='what: runtime regression fixture') res = await service.mutate(None, delete_req) assert res.affected_rows == 1 @@ -251,18 +286,18 @@ async def test_crud_privacy_preserves_sqlite_values_and_failure(temp_db, service context.set_diagnostic_sql_log_sink(TextDiagnosticSqlLogSink(lambda line: print(line, file=output))) def insert(name): return MutationRequest(InsertCommand("User", { - "id": Value.I64(1), "name": Value.Text(name), "version": Value.I64(1)})) + "id": Value.I64(1), "name": Value.Text(name), "version": Value.I64(1)}), comment='what: runtime regression fixture') created = await service.mutate(context, insert(first)) assert created.persisted_record["name"] == first - query = QueryRequest(SelectQuery("User").limit(1)).comment("read privacy fixture").purpose("verify original stored values") + query = QueryRequest(SelectQuery("User").limit(1), _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior').comment("read privacy fixture").purpose("verify original stored values") assert (await service.query(context, query)).rows[0]["name"] == first updated = await service.mutate(context, MutationRequest( - UpdateCommand("User", Value.I64(1)).value("name", Value.Text(second)))) + UpdateCommand("User", Value.I64(1)).value("name", Value.Text(second)), comment='what: runtime regression fixture')) assert updated.persisted_record["name"] == second with pytest.raises(Exception): await service.mutate(context, insert(failed)) assert (await service.query(context, query)).rows[0]["name"] == second - await service.mutate(context, MutationRequest(DeleteCommand("User", Value.I64(1)).hard_delete())) + await service.mutate(context, MutationRequest(DeleteCommand("User", Value.I64(1)).hard_delete(), comment='what: runtime regression fixture')) assert (await service.query(context, query)).rows == [] logged = path.read_text() + repr(context.sql_logs()) for marker in (first, second, failed): @@ -292,14 +327,14 @@ async def counted_fetch(compiled): jumped = SelectQuery("User").order_desc("id").offset(2).limit(2) jumped.optimize_pagination_with_id_set_config("users", 60, 100) - rows = (await service.query(context, QueryRequest(jumped))).rows + rows = (await service.query(context, QueryRequest(jumped, _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior'))).rows assert [row["id"] for row in rows] == [3, 2] assert context.id_set_count() == (5, "EXACT") assert context.id_set_plan() == "ID_SET_BUILD" first = SelectQuery("User").order_desc("id").offset(0).limit(2) first.optimize_pagination_with_id_set_config("users", 60, 100) - rows = (await service.query(context, QueryRequest(first))).rows + rows = (await service.query(context, QueryRequest(first, _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior'))).rows assert [row["id"] for row in rows] == [5, 4] assert context.id_set_count() == (5, "EXACT") assert context.id_set_plan() == "ID_SET_HIT" @@ -343,7 +378,7 @@ async def counted_fetch(compiled): ColumnExpr("name"), BinaryOp.Eq, ValueExpr(Value.Text("missing")))) empty.order_asc("name").offset(0).limit(2) empty.optimize_pagination_with_id_set_config("empty", 60, 10) - assert (await service.query(context, QueryRequest(empty))).rows == [] + assert (await service.query(context, QueryRequest(empty, _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior'))).rows == [] assert calls == 1 assert "ORDER BY name ASC, id ASC" in compiled_sql[0] assert context.id_set_count() == (0, "EXACT") @@ -352,14 +387,14 @@ async def counted_fetch(compiled): # max_ids + 1 is a lower bound and visibly falls back. overflow = SelectQuery("User").order_desc("id").offset(0).limit(2) overflow.optimize_pagination_with_id_set_config("overflow", 60, 3) - assert len((await service.query(context, QueryRequest(overflow))).rows) == 2 + assert len((await service.query(context, QueryRequest(overflow, _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior'))).rows) == 2 assert context.id_set_count() == (4, "LOWER_BOUND") assert context.id_set_plan() == "ID_SET_FALLBACK_LIMIT_EXCEEDED" # Unsupported grouped shape and store failure preserve ordinary results. unsupported = SelectQuery("User").count("row_count").limit(2) unsupported.optimize_pagination_with_id_set_config("unsupported", 60, 10) - assert (await service.query(context, QueryRequest(unsupported))).rows[0]["row_count"] == 5 + assert (await service.query(context, QueryRequest(unsupported, _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior'))).rows[0]["row_count"] == 5 assert context.id_set_plan() == "ID_SET_FALLBACK_UNSUPPORTED_SHAPE" class UnavailableStore: @@ -369,7 +404,7 @@ def invalidate(self, _key): raise RuntimeError("down") context.set_id_set_store(UnavailableStore()) fallback = SelectQuery("User").order_desc("id").offset(0).limit(2) fallback.optimize_pagination_with_id_set_config("store-down", 60, 10) - assert [row["id"] for row in (await service.query(context, QueryRequest(fallback))).rows] == [5, 4] + assert [row["id"] for row in (await service.query(context, QueryRequest(fallback, _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior'))).rows] == [5, 4] assert context.id_set_plan() == "ID_SET_FALLBACK_STORE_UNAVAILABLE" # TTL expiry rebuilds; principal, predicate, and active root produce separate keys. @@ -377,11 +412,11 @@ def invalidate(self, _key): raise RuntimeError("down") context = new_context(store) ttl = SelectQuery("User").order_desc("id").offset(0).limit(1) ttl.optimize_pagination_with_id_set_config("ttl", 1, 10) - await service.query(context, QueryRequest(ttl)) + await service.query(context, QueryRequest(ttl, _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior')) await asyncio.sleep(1.05) ttl = SelectQuery("User").order_desc("id").offset(0).limit(1) ttl.optimize_pagination_with_id_set_config("ttl", 1, 10) - await service.query(context, QueryRequest(ttl)) + await service.query(context, QueryRequest(ttl, _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior')) assert context.id_set_plan() == "ID_SET_BUILD" source_one, source_two, policy_one, policy_two = object(), object(), object(), object() @@ -399,20 +434,20 @@ def invalidate(self, _key): raise RuntimeError("down") ColumnExpr("name"), BinaryOp.Eq, ValueExpr(Value.Text(name)))) query.order_desc("id").offset(0).limit(1) query.optimize_pagination_with_id_set_config("isolation", 60, 10) - await service.query(isolated, QueryRequest(query)) + await service.query(isolated, QueryRequest(query, _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior')) assert isolated.id_set_plan() == "ID_SET_BUILD" # A retained page does not shift when one of its IDs is deleted. context = new_context(InMemoryIdSetStore()) snapshot = SelectQuery("User").order_desc("id").offset(2).limit(2) snapshot.optimize_pagination_with_id_set_config("deletion", 60, 10) - assert [row["id"] for row in (await service.query(context, QueryRequest(snapshot))).rows] == [3, 2] + assert [row["id"] for row in (await service.query(context, QueryRequest(snapshot, _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior'))).rows] == [3, 2] async with aiosqlite.connect(temp_db) as db: await db.execute("DELETE FROM users WHERE id=3") await db.commit() snapshot = SelectQuery("User").order_desc("id").offset(2).limit(2) snapshot.optimize_pagination_with_id_set_config("deletion", 60, 10) - assert [row["id"] for row in (await service.query(context, QueryRequest(snapshot))).rows] == [2] + assert [row["id"] for row in (await service.query(context, QueryRequest(snapshot, _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior'))).rows] == [2] assert context.id_set_plan() == "ID_SET_HIT" @pytest.mark.asyncio @@ -438,7 +473,7 @@ async def execute(): context.set_id_set_store(store) query = SelectQuery("User").order_desc("id").offset(0).limit(1) query.optimize_pagination_with_id_set_config("single-flight", 60, 10) - return await service.query(context, QueryRequest(query)) + return await service.query(context, QueryRequest(query, _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior')) results = await asyncio.gather(execute(), execute()) assert [[row["id"] for row in result.rows] for result in results] == [[2], [2]] @@ -484,11 +519,11 @@ async def test_relation_subquery_resolves_generated_entity_name_and_executes(tem excluded = SelectQuery("QueryRecord").and_filter( not_in_subquery(column("query_group"), "QueryGroup", child)) - assert [row["name"] for row in (await service.query(context, QueryRequest(included))).rows] == ["included"] - assert [row["name"] for row in (await service.query(context, QueryRequest(excluded))).rows] == ["excluded"] + assert [row["name"] for row in (await service.query(context, QueryRequest(included, _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior'))).rows] == ["included"] + assert [row["name"] for row in (await service.query(context, QueryRequest(excluded, _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior'))).rows] == ["excluded"] async def ids(query): - return [row["id"] for row in (await service.query(context, QueryRequest(query))).rows] + return [row["id"] for row in (await service.query(context, QueryRequest(query, _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior'))).rows] assert await ids(SelectQuery("QueryRecord").and_filter( is_not_null(column("query_group"))).order_asc("id")) == [11, 12] @@ -546,7 +581,7 @@ async def test_complete_scalar_fixture_including_nullable_boolean_executes(temp_ async def ids(expr): query = SelectQuery("QueryRecord").project("id").and_filter(expr).order_asc("id") - return [row["id"] for row in (await service.query(context, QueryRequest(query))).rows] + return [row["id"] for row in (await service.query(context, QueryRequest(query, _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior'))).rows] assert await ids(BinaryExpr(column("required_text"), BinaryOp.Eq, value("Alpha"))) == [1] assert await ids(BinaryExpr(column("required_text"), BinaryOp.Ne, value("Alpha"))) == [2, 3] @@ -584,8 +619,8 @@ def failure(self, error): events.append(("failure", operation.family)) context = RuntimeModule.new().into_context().with_runtime_telemetry(Telemetry()) await service.mutate(context, MutationRequest(InsertCommand("User", { "id": Value.I64(1), "name": Value.Text("Alice"), "version": Value.I64(1) - }))) - await service.query(context, QueryRequest(SelectQuery("User"))) + }), comment='what: runtime regression fixture')) + await service.query(context, QueryRequest(SelectQuery("User"), _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior')) starts = [family for phase, family in events if phase == "start"] assert set(starts) >= {"mutation", "provider", "audit", "query"} @@ -611,7 +646,7 @@ async def test_mutation_returns_external_database_default_in_same_transaction(te service = create_sqlite_service(temp_db, provider) result = await service.mutate( - None, MutationRequest(InsertCommand("Widget", {"version": Value.I64(1)}))) + None, MutationRequest(InsertCommand("Widget", {"version": Value.I64(1)}), comment='what: runtime regression fixture')) assert result.persisted_record["id"] > 0 assert result.persisted_record["name"] == "database-default" @@ -628,7 +663,7 @@ async def test_structured_sql_evidence_is_parameterized_and_filterable(temp_db, secret = "secret-customer-value" insert = MutationRequest(InsertCommand("User", { "id": Value.I64(1), "name": Value.Text(secret), "version": Value.I64(1) - })) + }), comment='what: runtime regression fixture') await service.mutate(context, insert) query = SelectQuery("User").filter( BinaryExpr(ColumnExpr("name"), BinaryOp.Eq, ValueExpr(Value.Text(secret)))) @@ -636,7 +671,7 @@ async def test_structured_sql_evidence_is_parameterized_and_filterable(temp_db, TraceNode(kind="relation", name="User.organization", comment="organization"), TraceNode(kind="relation", name="Organization.region", comment="region"), TraceNode(kind="relation", name="Region.country", comment="country"), - ]).comment("what: load governed users").purpose("why: verify trace inheritance") + ], _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior').comment("what: load governed users").purpose("why: verify trace inheritance") await service.query(context, request) entries = context.sql_logs() @@ -655,10 +690,10 @@ async def test_structured_sql_evidence_is_parameterized_and_filterable(temp_db, context.enable_select_sql_log() await service.mutate(context, MutationRequest(InsertCommand("User", { "id": Value.I64(2), "name": Value.Text("ignored"), "version": Value.I64(1) - }))) + }), comment='what: runtime regression fixture')) assert context.sql_logs() == [] context.enable_mutation_sql_log() - await service.query(context, QueryRequest(SelectQuery("User"))) + await service.query(context, QueryRequest(SelectQuery("User"), _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior')) assert context.sql_logs() == [] context.disable_sql_log() assert context.sql_logs() == [] @@ -737,7 +772,7 @@ async def on_safe_event(self, context, event): self.events.append(event) context = RuntimeModule.new().entity(entity).audit_event_sink(raw).into_context().with_app_audit_event_sink(app) command = InsertCommand("User", {"id": Value.I64(1), "name": Value.Text("Alice Example"), "version": Value.I64(1)}) command.trace_chain.append(type("Trace", (), {"comment": "approved change"})()) - result = await service.mutate(context, MutationRequest(command)) + result = await service.mutate(context, MutationRequest(command, comment='what: runtime regression fixture')) assert result.affected_rows == 1 assert len(raw.events) == 1 and raw.events[0].changes[1].new_value.val == "Alice Example" @@ -809,7 +844,7 @@ def relation_ids(rows): return {parent["id"]: [child["id"] for child in parent["lines"]] for parent in rows} - result = await service.query(context, QueryRequest(nested())) + result = await service.query(context, QueryRequest(nested(), _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior')) assert len(result.rows) == 3 assert [len(parent["lines"]) for parent in result.rows] == [3, 3, 0] assert len(queries) == 4 @@ -826,17 +861,17 @@ def relation_ids(rows): probe_ids = relation_ids(result.rows) queries.clear() - window = await service.query(context, QueryRequest(nested(0))) + window = await service.query(context, QueryRequest(nested(0), _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior')) assert len(queries) == 2 and "ROW_NUMBER() OVER" in queries[1] assert relation_ids(window.rows) == probe_ids assert "state" in queries[1] and "version" in queries[1] for threshold, expected_queries in ((3, 4), (2, 2)): queries.clear() - first = await service.query(context, QueryRequest(nested(threshold))) + first = await service.query(context, QueryRequest(nested(threshold), _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior')) first_sql = list(queries) queries.clear() - second = await service.query(context, QueryRequest(nested(threshold))) + second = await service.query(context, QueryRequest(nested(threshold), _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior')) assert relation_ids(first.rows) == relation_ids(second.rows) == probe_ids assert queries == first_sql assert len(queries) == expected_queries @@ -901,10 +936,10 @@ async def test_relation_facet_merges_outer_filter_and_supports_include_all(temp_ outer = (SelectQuery("School") .and_filter(contain("name", "Riverside")) .facet_by("types", "school_type", nested)) - result = await service.query(None, QueryRequest(outer)) + result = await service.query(None, QueryRequest(outer, _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior')) assert [(row["code"], row["school_count"]) for row in result.facets["types"]] == [ ("PRIMARY", 2), ("SECONDARY", 0), ("VOCATIONAL", 0)] outer.facets[0].include_all_facets = False - matched = await service.query(None, QueryRequest(outer)) + matched = await service.query(None, QueryRequest(outer, _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior')) assert [(row["code"], row["school_count"]) for row in matched.facets["types"]] == [("PRIMARY", 2)] diff --git a/tests/provider/test_sql_masking_real_databases.py b/tests/provider/test_sql_masking_real_databases.py index daed979..16dc4dc 100644 --- a/tests/provider/test_sql_masking_real_databases.py +++ b/tests/provider/test_sql_masking_real_databases.py @@ -84,7 +84,7 @@ def capture(entry): .value("password_hash", "PASSWORD-CANARY") ) command.trace_chain = [TraceNode(comment="what: create masked customer")] - await service.mutate(context, MutationRequest(command)) + await service.mutate(context, MutationRequest(command, comment='what: runtime regression fixture')) query = SelectQuery("Customer").filter( Expr.new_and( Expr.eq("display_name", "Riverside"), @@ -94,7 +94,7 @@ def capture(entry): rows = ( await service.query( context, - QueryRequest(query) + QueryRequest(query, _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior') .comment("what: read masked customer") .purpose("why: verify live-provider SQL masking"), ) diff --git a/tests/provider/test_tfp_client.py b/tests/provider/test_tfp_client.py index 2709941..b225d3a 100644 --- a/tests/provider/test_tfp_client.py +++ b/tests/provider/test_tfp_client.py @@ -117,13 +117,13 @@ async def handler(request): provider = TfpHttpProvider("https://tfp.test", client=http) query = SelectQuery.new("CustomerOrder").filter(Expr.eq("status", "NEW")) query.slice = Slice(offset=0, limit=20) - result = await provider.query(None, QueryRequest(query).comment("List orders").purpose("Render queue")) + result = await provider.query(None, QueryRequest(query, _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior').comment("List orders").purpose("Render queue")) assert isinstance(result.rows, SmartList) assert payloads[0]["filterCondition"] == {"status": {"$eq": "NEW"}} command = UpdateCommand.new("CustomerOrder", 42).expected_version(3).value("status", "PAID") command.trace_chain.append(TraceNode(comment="Mark paid")) - mutation = await provider.mutate(None, MutationRequest.Update(command)) + mutation = await provider.mutate(None, MutationRequest.Update(command, comment='what: runtime regression fixture')) assert mutation.affected_rows == 1 assert payloads[1]["expectedVersion"] == 3 @@ -163,7 +163,7 @@ def review(self, context, plan): command.trace_chain.append(TraceNode(comment="Mark paid")) with pytest.raises(MutationPolicyError, match="REMOTE_MUTATION_DENIED"): - await provider.mutate(context, MutationRequest.Update(command)) + await provider.mutate(context, MutationRequest.Update(command, comment='what: runtime regression fixture')) assert calls == 0 diff --git a/tests/runtime/test_mutation_policy.py b/tests/runtime/test_mutation_policy.py index ef516b6..93843db 100644 --- a/tests/runtime/test_mutation_policy.py +++ b/tests/runtime/test_mutation_policy.py @@ -187,10 +187,10 @@ async def save_graph(): # original reviewed operation rather than the changed command. transaction = context.require_resource("dataService") await transaction.mutate( - context, MutationRequest(_insert("Order", 42, "DRAFT"))) - return await transaction.mutate(context, MutationRequest(line)) + context, MutationRequest(_insert("Order", 42, "DRAFT"), comment='what: runtime regression fixture')) + return await transaction.mutate(context, MutationRequest(line, comment='what: runtime regression fixture')) - await context.execute_graph_save(save_graph) + await context.execute_graph_save(save_graph, comment='what: runtime regression fixture') assert observed == {"count": 2, "name": "DRAFT"} assert provider.mutations == 2 assert provider.commits == 1 @@ -222,10 +222,10 @@ async def denied_graph(): denied.preflight_mutation(order) denied.preflight_mutation(line) return await denied.require_resource("dataService").mutate( - denied, MutationRequest(order)) + denied, MutationRequest(order, comment='what: runtime regression fixture')) with pytest.raises(MutationPolicyError, match="ORDER_DENIED"): - await denied.execute_graph_save(denied_graph) + await denied.execute_graph_save(denied_graph, comment='what: runtime regression fixture') assert denied_provider.begins == 1 assert denied_provider.mutations == 0 assert denied_provider.rollbacks == 1 @@ -238,7 +238,7 @@ async def denied_graph(): with pytest.raises(MutationPolicyError, match="complete graph preflight"): await missing.execute_graph_save( lambda: missing.require_resource("dataService").mutate( - missing, MutationRequest(_insert("Order", 44, "MISSING")))) + missing, MutationRequest(_insert("Order", 44, "MISSING"), comment='what: runtime regression fixture')), comment='what: runtime regression fixture') assert missing_provider.mutations == 0 assert missing_provider.rollbacks == 1 @@ -254,11 +254,11 @@ async def test_generated_default_allows_legacy_graph_without_complete_preflight( async def legacy_graph(): transaction = context.require_resource("dataService") await transaction.mutate( - context, MutationRequest(_insert("Order", 47, "FIRST"))) + context, MutationRequest(_insert("Order", 47, "FIRST"), comment='what: runtime regression fixture')) await transaction.mutate( - context, MutationRequest(_insert("OrderLine", 102, "SECOND"))) + context, MutationRequest(_insert("OrderLine", 102, "SECOND"), comment='what: runtime regression fixture')) - await context.execute_graph_save(legacy_graph) + await context.execute_graph_save(legacy_graph, comment='what: runtime regression fixture') assert provider.mutations == 2 assert provider.commits == 1 assert provider.rollbacks == 0 @@ -277,10 +277,10 @@ async def test_unplanned_and_incomplete_operations_fail_closed(): async def unplanned_graph(): unplanned.preflight_mutation(_insert("Order", 45, "PLANNED")) return await unplanned.require_resource("dataService").mutate( - unplanned, MutationRequest(_insert("Order", 45, "DIFFERENT"))) + unplanned, MutationRequest(_insert("Order", 45, "DIFFERENT"), comment='what: runtime regression fixture')) with pytest.raises(MutationPolicyError, match="not present"): - await unplanned.execute_graph_save(unplanned_graph) + await unplanned.execute_graph_save(unplanned_graph, comment='what: runtime regression fixture') assert unplanned_provider.mutations == 0 assert unplanned_provider.rollbacks == 1 @@ -296,10 +296,10 @@ async def incomplete_graph(): incomplete.preflight_mutation(first) incomplete.preflight_mutation(second) return await incomplete.require_resource("dataService").mutate( - incomplete, MutationRequest(first)) + incomplete, MutationRequest(first, comment='what: runtime regression fixture')) with pytest.raises(MutationPolicyError, match="were not executed"): - await incomplete.execute_graph_save(incomplete_graph) + await incomplete.execute_graph_save(incomplete_graph, comment='what: runtime regression fixture') assert incomplete_provider.mutations == 1 assert incomplete_provider.commits == 0 assert incomplete_provider.rollbacks == 1 @@ -320,9 +320,9 @@ async def test_warning_sink_failure_is_fail_open(): async def graph(): context.preflight_mutation(order) return await context.require_resource("dataService").mutate( - context, MutationRequest(order)) + context, MutationRequest(order, comment='what: runtime regression fixture')) - await context.execute_graph_save(graph) + await context.execute_graph_save(graph, comment='what: runtime regression fixture') assert warnings.events[0].warning_code == MISSING_APPROVAL assert provider.mutations == 1 assert provider.commits == 1 diff --git a/tests/runtime/test_query_policy.py b/tests/runtime/test_query_policy.py index 0fe0ec7..d20f3d2 100644 --- a/tests/runtime/test_query_policy.py +++ b/tests/runtime/test_query_policy.py @@ -4,7 +4,7 @@ def test_prepare_query_clones_and_governs_every_query_node_once(): shared = SelectQuery("SchoolType").project("id") - root = SelectQuery("School").project("id") + root = SelectQuery("School").project("id").comment('govern query graph').purpose('verify policy') root.relations.append(RelationLoad("schoolType", shared)) root.facets.append(FacetRequest("types", "schoolType", shared)) calls = [] @@ -27,7 +27,7 @@ def policy(query): def test_prepare_query_without_policy_still_returns_independent_graph(): child = SelectQuery("SchoolType").project("id") - root = SelectQuery("School") + root = SelectQuery("School").comment('clone query graph').purpose('verify policy') root.relations.append(RelationLoad("schoolType", child)) prepared = UserContext().prepare_query(root) @@ -43,7 +43,7 @@ def deny(query): if query.entity == "SchoolType": raise PermissionError("query policy denied SchoolType") - root = SelectQuery("School") + root = SelectQuery("School").comment('deny query graph').purpose('verify policy') root.relations.append(RelationLoad("schoolType", SelectQuery("SchoolType"))) context = UserContext().with_request_policy(deny) @@ -57,7 +57,7 @@ def deny(query): def test_prepare_query_preserves_shared_nodes_when_policy_returns_replacement(): shared = SelectQuery("SchoolType") - root = SelectQuery("School") + root = SelectQuery("School").comment('replace query graph').purpose('verify policy') root.relations.append(RelationLoad("schoolType", shared)) root.facets.append(FacetRequest("types", "schoolType", shared)) diff --git a/tests/runtime/test_relation_masking.py b/tests/runtime/test_relation_masking.py index a99fa86..5079b01 100644 --- a/tests/runtime/test_relation_masking.py +++ b/tests/runtime/test_relation_masking.py @@ -104,7 +104,7 @@ def capture(entry): query.relation('orders') else: query.relation_query('orders', child) - request = (QueryRequest(query).comment('what: load Riverside PASSWORD-CANARY Lakeside graph') + request = (QueryRequest(query, _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior').comment('what: load Riverside PASSWORD-CANARY Lakeside graph') .purpose('why: verify inherited intent')) if failure: with pytest.raises(TransportError) as caught: @@ -145,6 +145,6 @@ def capture(entry): monkeypatch.delenv(PLAINTEXT_ENV, raising=False) assert 'Riverside' not in repr(sql_log_projection(entry)) transport.failing_table = None - await service.query(context, QueryRequest(SelectQuery('Customer').limit(1)) + await service.query(context, QueryRequest(SelectQuery('Customer').limit(1), _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior') .comment('what: independent Riverside').purpose('why: source isolation')) assert logs[-1].comment == 'what: independent Riverside' diff --git a/tests/runtime/test_runtime.py b/tests/runtime/test_runtime.py index 32124f5..40bf040 100644 --- a/tests/runtime/test_runtime.py +++ b/tests/runtime/test_runtime.py @@ -150,11 +150,11 @@ async def failing_graph(): async def nested_save(): context.after_graph_rollback(lambda: events.append("child rollback")) - await context.execute_graph_save(nested_save) + await context.execute_graph_save(nested_save, comment='what: runtime regression fixture') raise RuntimeError("injected graph failure") with pytest.raises(RuntimeError, match="injected graph failure"): - await context.execute_graph_save(failing_graph) + await context.execute_graph_save(failing_graph, comment='what: runtime regression fixture') assert events == ["begin", "rollback", "child rollback", "parent rollback"] assert context.require_resource("dataService") is provider @@ -170,7 +170,7 @@ async def successful_graph(): context.after_graph_commit(lambda: events.append("ledger clear")) return "saved" - assert await context.execute_graph_save(successful_graph) == "saved" + assert await context.execute_graph_save(successful_graph, comment='what: runtime regression fixture') == "saved" assert events == ["begin", "commit", "ledger clear"] assert context.require_resource("dataService") is provider @@ -196,7 +196,7 @@ async def graph(): await __import__("asyncio").sleep(0.005) context.check_and_fix_mutation(InsertCommand.new("Dummy")) - await context.execute_graph_save(graph) + await context.execute_graph_save(graph, comment='what: runtime regression fixture') assert len(observed) == 2 and observed[0] is observed[1] assert observed[0] == expected assert context.get_resource("fix_time") is None @@ -230,7 +230,7 @@ async def graph(): events.append(f"{name}:start") await __import__("asyncio").sleep(delay) events.append(f"{name}:end") - await context.execute_graph_save(graph) + await context.execute_graph_save(graph, comment='what: runtime regression fixture') await __import__("asyncio").gather(save("first", 0.01), save("second", 0)) assert events == [ diff --git a/tests/runtime/test_sql_mask_lifecycle.py b/tests/runtime/test_sql_mask_lifecycle.py index a90b60e..06adf8d 100644 --- a/tests/runtime/test_sql_mask_lifecycle.py +++ b/tests/runtime/test_sql_mask_lifecycle.py @@ -82,7 +82,7 @@ def request(): query = (SelectQuery('Customer').filter(Expr.eq('display_name', 'Riverside')) .and_filter(Expr.eq('public_address', '1 Runtime Road')) .and_filter(Expr.eq('password_hash', 'PASSWORD-CANARY')).limit(5)) - return QueryRequest(query).comment('what: inspect customers').purpose('why: lifecycle test') + return QueryRequest(query, _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior').comment('what: inspect customers').purpose('why: lifecycle test') @pytest.mark.asyncio @@ -137,7 +137,7 @@ async def test_failed_statement_logs_and_preserves_error(fixture, operation): } cmd = commands[operation] cmd.trace_chain = [TraceNode(comment='what: lifecycle mutation')] - await executor.mutate(context, MutationRequest(cmd)) + await executor.mutate(context, MutationRequest(cmd, comment='what: runtime regression fixture')) assert caught.value.error is failure assert_log(fixture, 'failure') assert entries[0].affected_rows is None @@ -327,7 +327,7 @@ async def begin_sql(self): cmd = DeleteCommand('Customer', Value.I64(1)).expected_version(1) cmd.trace_chain = [TraceNode(comment='what: cancel mutation')] with pytest.raises(asyncio.CancelledError) as caught: - await executor.mutate(context, MutationRequest(cmd)) + await executor.mutate(context, MutationRequest(cmd, comment='what: runtime regression fixture')) assert caught.value is failure assert tx.rolled_back and not tx.committed assert_log(fixture, 'cancelled') @@ -338,11 +338,10 @@ async def test_generated_mutation_string_comment_is_preserved(fixture): context, provider, _, entries = fixture executor = DomainStatementExecutor(SqliteDialect(), FaultTransport(), provider) command = InsertCommand('Customer').value('display_name', 'Riverside') - mutation = MutationRequest(command) - mutation.comment = 'what: generated audited save' + mutation = MutationRequest(command, comment='what: generated audited save') await executor.mutate(context, mutation) - assert entries[0].comment == mutation.comment - assert entries[0].audit_reason == mutation.comment + assert entries[0].comment == mutation.comment() + assert entries[0].audit_reason == mutation.comment() assert_log(fixture, 'success') @@ -362,8 +361,7 @@ async def test_sql_intent_scrubs_target_id_without_changing_bindings(fixture, op } command = commands[operation] command.trace_chain = [TraceNode(comment='what: mutate target 1001')] - mutation = MutationRequest(command) - mutation.comment = 'what: mutate target 1001' + mutation = MutationRequest(command, comment='what: mutate target 1001') if failure: with pytest.raises(TransportError): await executor.mutate(context, mutation) @@ -373,7 +371,7 @@ async def test_sql_intent_scrubs_target_id_without_changing_bindings(fixture, op assert '1001' not in repr(entries[0].trace_path) assert '1001' not in output[0].split('auditReason=', 1)[1].split('Debug SQL:', 1)[0] assert any(getattr(value, 'val', value) == 1001 for value in transport.params) - assert mutation.comment == 'what: mutate target 1001' + assert mutation.comment() == 'what: mutate target 1001' def test_short_target_id_does_not_redact_structural_row_count(fixture): @@ -419,7 +417,7 @@ def readback_request(): command = (UpdateCommand('Customer', Value.I64(1)).expected_version(1) .value('display_name', 'Riverside').value('password_hash', 'PASSWORD-CANARY')) command.trace_chain = [TraceNode(comment='what: update Riverside PASSWORD-CANARY')] - return MutationRequest(command) + return MutationRequest(command, comment='what: update Riverside PASSWORD-CANARY') @pytest.mark.asyncio @@ -527,7 +525,7 @@ async def work(): await service.mutate(context, readback_request()) await service.mutate(context, readback_request()) with pytest.raises(RuntimeError) as caught: - await context.execute_graph_save(work) + await context.execute_graph_save(work, comment='what: runtime regression fixture') assert caught.value is failure assert [entry.execution_outcome for entry in entries] == ['success','success','failure'] assert tx.writes == tx.reads == 2 and tx.rollbacks == 1 and tx.commits == 0 diff --git a/tests/runtime/test_sql_masking_policy.py b/tests/runtime/test_sql_masking_policy.py index 8dc0462..cc4c54e 100644 --- a/tests/runtime/test_sql_masking_policy.py +++ b/tests/runtime/test_sql_masking_policy.py @@ -220,12 +220,12 @@ def capture(log): context.set_diagnostic_sql_log_sink(SimpleNamespace(write=capture)) async def mutate(command): command.trace_chain = [TraceNode(comment='what: verify mutation log policy')] - return await service.mutate(context, MutationRequest(command)) + return await service.mutate(context, MutationRequest(command, comment='what: runtime regression fixture')) await mutate(InsertCommand('Customer').value('id', 1).value('version', 1) .value('display_name', 'Riverside').value('active', True)) query = SelectQuery('Customer').filter(Expr.new_and( Expr.eq('display_name', 'Riverside'), Expr.eq('active', True))).limit(1) - rows = (await service.query(context, QueryRequest(query) + rows = (await service.query(context, QueryRequest(query, _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior') .comment('what: read Riverside').purpose('why: check field policy'))).rows assert rows[0]['display_name'] == 'Riverside' assert entries[-1].parameter_log_policies == ['masked', 'plain'] From 7e9b5933f160d8533fd4d225cc774a965a483b89 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Fri, 2 Oct 2026 17:11:30 +0800 Subject: [PATCH 02/40] fix(trace): retain Python canonical SQL origins and descendant log privacy (#43) Signed-off-by: Philip Z --- README.md | 34 ++++- src/teaql/core/mutation.py | 2 + src/teaql/core/trace.py | 49 +++++++ src/teaql/data_service/__init__.py | 21 ++- src/teaql/sql/executor.py | 119 ++++++++++----- tests/core/test_trace.py | 58 ++++++++ tests/fixtures/sql-trace-path-v1.json | 35 +++++ tests/provider/sqlite/test_sqlite.py | 8 +- tests/provider/sqlite/test_trace_chain.py | 170 ++++++++++++++++++++++ tests/runtime/test_relation_masking.py | 1 + 10 files changed, 453 insertions(+), 44 deletions(-) create mode 100644 src/teaql/core/trace.py create mode 100644 tests/core/test_trace.py create mode 100644 tests/fixtures/sql-trace-path-v1.json create mode 100644 tests/provider/sqlite/test_trace_chain.py diff --git a/README.md b/README.md index a92bfc4..d54bbb2 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ configure those separately. This setting does not erase older plaintext files. Restrict access and retention when using plaintext diagnostics, then unset the variable and restart processes when troubleshooting is complete. -TeaQL Python SDK is a runtime engine and toolkit for building data-driven business applications. It provides seamless integration with the TeaQL ecosystem, fully aligned with the `teaql-rs` baseline. +TeaQL Python SDK is a runtime engine and toolkit for building data-driven business applications. It uses `teaql-rs` as the cross-language design baseline; verified coverage and remaining gaps are tracked in [conformance](https://github.com/teaql/teaql-conformance). ## Recommended Agent Harness @@ -41,8 +41,8 @@ and evidence-based verification as the generator and runtimes evolve. ## 2. Tests Performed -After rigorous AST semantic analysis and manual verification, this SDK has successfully passed the following tests: -* ✅ **100% API Signature and Logic Parity**: Scanned with Tree-Sitter and implemented all internal methods and logic to match the Rust baseline. +The SDK includes tests in the following areas. These suites do not establish complete Rust parity or acceptance of every external provider: +* **Cross-language contracts**: Shared fixtures and integration tests verify individual portable behaviors; unresolved coverage stays explicit in conformance. * ✅ **`teaql.core` Core Tests**: Extensively tested attribute extraction, safe nullability checks, and relationship building for `Value`, `GraphNode`, `Entity`, `Mutation`, `Query`, `Expr`, and `SafeExpression`. * ✅ **`teaql.runtime` Runtime Tests**: Verified the context propagation of `UserContext` and the complete lifecycle hooking for `record_sql_log` and `record_metadata_log`. * ✅ **`teaql.sql` / `teaql.data_service` Tests**: Tested the SQL AST compilation engine and the underlying command dispatch mechanism. @@ -92,6 +92,34 @@ orders = await client.execute_query(FederalQuery( await client.aclose() ``` +## Trace Chain Local Source Status + +Query and Mutation Requests own their required non-blank intent independently +of logging. Physical SQL paths use the Rust canonical algorithm, with twelve +byte-identical frozen vectors, owned query origins and qualified relation frames. +Native SQLite tests observe three real relation levels without inserting expected +frames, including a deepest-query failure. A successful write and a failed +authoritative readback keep separate canonical paths and statement outcomes; +the failed transaction rolls back. + +Parent query diagnostics include declared descendant binding provenance before +safe projection. The existing mask algorithm, expanded SQL and execution values +are unchanged; provenance is not stored on Context or exposed in log entries. +This additional compilation has not been performance benchmarked. + +This is local-source and native-provider evidence, not complete Trace Chain. +Immutable graph parent scopes, typed ledger replacement, per-item batch lineage, +commit-only graph audit, generated six-entity Q/E/Mutation acceptance, same-database +replay, real concurrent graph isolation, complete entry-point/privacy coverage +and immutable internal Registry replay remain open. Successful readback diagnostic +coverage also needs alignment. No released-package parity is claimed. + +```bash +PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=src python -m pytest -q +PYTHONDONTWRITEBYTECODE=1 bash scripts/verify-examples.sh +# Repeat both commands without source changes. +``` + ## Security Foundation Status Python currently provides governed local SQL execution and a **TFP client**; diff --git a/src/teaql/core/mutation.py b/src/teaql/core/mutation.py index e7696ff..90f9ea1 100644 --- a/src/teaql/core/mutation.py +++ b/src/teaql/core/mutation.py @@ -126,6 +126,8 @@ def intent(self) -> MutationIntent: def validate(self) -> None: MutationIntent(getattr(getattr(self, '_MutationRequest__intent', None), 'comment', None)) + if any(not isinstance(node, TraceNode) for node in self.trace_chain()): + raise TypeError('MutationRequest trace must contain typed TraceNode values') def with_root_intent(self, intent: MutationIntent) -> 'MutationRequest': return MutationRequest(self._data, comment=intent.comment) diff --git a/src/teaql/core/trace.py b/src/teaql/core/trace.py new file mode 100644 index 0000000..ea7a14c --- /dev/null +++ b/src/teaql/core/trace.py @@ -0,0 +1,49 @@ +"""Typed physical SQL paths, distinct from graph mutation audit lineage.""" +from copy import deepcopy +from dataclasses import replace +from typing import Iterable, List, Optional + +from .mutation import TraceNode + + +def _kind(node: TraceNode) -> str: + return str(node.kind).replace('_', '').lower() + + +def trace_name(node: TraceNode) -> str: + return node.name or node.entity_type + + +def canonical_sql_trace_path(source: Iterable[TraceNode], backend: str, + operation: str) -> List[TraceNode]: + """Rust baseline algorithm; owns output and is idempotent for canonical input.""" + nodes = list(source) + intent_kinds = {'comment', 'purpose', 'auditreason'} + kinds = {_kind(node) for node in nodes} + if {'operation', 'provider', 'sql'} <= kinds: + return deepcopy([node for node in nodes if _kind(node) not in intent_kinds]) + root = next((trace_name(node) for node in nodes if trace_name(node).strip()), 'unknown') + query = operation.lower() == 'select' + entity = root if query else next((trace_name(node) for node in reversed(nodes) + if _kind(node) == 'entity' and trace_name(node).strip()), root) + return [TraceNode(kind='operation', name=root, comment='query' if query else 'mutation'), + TraceNode(kind='request' if query else 'entity', name=entity), + *deepcopy([node for node in nodes if _kind(node) == 'relation']), + TraceNode(kind='provider', name=backend if backend and backend.strip() else 'unknown'), + TraceNode(kind='sql', name=operation.lower())] + + +def trace_intent(source: Iterable[TraceNode]) -> dict[str, Optional[str]]: + result = {'comment': None, 'purpose': None, 'auditReason': None} + names = {'comment': 'comment', 'purpose': 'purpose', 'auditreason': 'auditReason'} + for node in source: + name = names.get(_kind(node)) + if name is not None: + result[name] = node.comment + return result + + +def physical_readback_path(write_path: Iterable[TraceNode]) -> List[TraceNode]: + """Retain originating mutation route, replace its sole physical SQL leaf.""" + return [replace(node, name='select', comment='') if _kind(node) == 'sql' + else deepcopy(node) for node in write_path] diff --git a/src/teaql/data_service/__init__.py b/src/teaql/data_service/__init__.py index 650d8dd..e63cd86 100644 --- a/src/teaql/data_service/__init__.py +++ b/src/teaql/data_service/__init__.py @@ -28,13 +28,15 @@ class DataServiceCapabilities: class QueryRequest: - __slots__ = ('query', 'trace_chain', '__intent', '_log_intent_source') + __slots__ = ('query', 'trace_chain', '__intent', '__origin_entity', '_log_intent_source') _UNSET = object() - def __init__(self, query: SelectQuery, trace_chain=None, _comment=_UNSET, _purpose=_UNSET): + def __init__(self, query: SelectQuery, trace_chain=None, _comment=_UNSET, _purpose=_UNSET, + *, _origin_entity=None): comment = getattr(query, 'comment_text', None) if _comment is self._UNSET else _comment purpose = getattr(query, 'purpose_text', None) if _purpose is self._UNSET else _purpose self.__intent = QueryIntent(comment, purpose) + self.__origin_entity = query.entity if _origin_entity is None else _origin_entity self.query = deepcopy(query) self.query.comment_text = self.intent.comment self.query.purpose_text = self.intent.purpose @@ -44,6 +46,10 @@ def __init__(self, query: SelectQuery, trace_chain=None, _comment=_UNSET, _purpo def intent(self) -> QueryIntent: return self.__intent + @property + def origin_entity(self) -> str: + return self.__origin_entity + @property def _comment(self) -> str: return self.intent.comment @@ -55,18 +61,23 @@ def _purpose(self) -> str: def validate(self) -> None: intent = getattr(self, '_QueryRequest__intent', None) QueryIntent(getattr(intent, 'comment', None), getattr(intent, 'purpose', None)) + if any(not isinstance(node, TraceNode) for node in self.trace_chain): + raise TypeError('QueryRequest trace must contain typed TraceNode values') def with_query(self, query: SelectQuery) -> 'QueryRequest': - result = QueryRequest(query, self.trace_chain, self._comment, self._purpose) + result = QueryRequest(query, self.trace_chain, self._comment, self._purpose, + _origin_entity=self.origin_entity) if hasattr(self, '_log_intent_source'): result._log_intent_source = self._log_intent_source return result def comment(self, text: str) -> 'QueryRequest': - return QueryRequest(self.query, self.trace_chain, text, self._purpose) + return QueryRequest(self.query, self.trace_chain, text, self._purpose, + _origin_entity=self.origin_entity) def purpose(self, text: str) -> 'QueryRequest': - return QueryRequest(self.query, self.trace_chain, self._comment, text) + return QueryRequest(self.query, self.trace_chain, self._comment, text, + _origin_entity=self.origin_entity) class DataServiceOperation(Enum): diff --git a/src/teaql/sql/executor.py b/src/teaql/sql/executor.py index 99f2079..234345b 100644 --- a/src/teaql/sql/executor.py +++ b/src/teaql/sql/executor.py @@ -27,6 +27,7 @@ ) from teaql.core.query import Aggregate, AggregateFunction, SelectQuery from teaql.core.value import Value +from teaql.core.trace import canonical_sql_trace_path, physical_readback_path, trace_name from teaql.runtime.telemetry import RuntimeOperation, observe_runtime_operation, start_runtime_operation from teaql.runtime.context import RetainedIdSet @@ -36,8 +37,8 @@ class _QueryWithLogIntent(QueryRequest): """Invocation-local compiler plumbing, excluded from dataclass/wire fields.""" - def __init__(self, query, trace_chain, comment, purpose, source): - super().__init__(query, trace_chain, comment, purpose) + def __init__(self, query, trace_chain, comment, purpose, source, origin_entity=None): + super().__init__(query, trace_chain, comment, purpose, _origin_entity=origin_entity) self._log_intent_source = source @@ -132,6 +133,54 @@ def __init__(self, dialect: SqlDialect, transport: SqlTransport, schema_provider self.transport = transport self.schema_provider = schema_provider + def _backend_name(self) -> str: + kind = self.dialect.kind() + name = getattr(kind, 'name', str(kind)).lower() + return 'postgres' if name == 'postgresql' else name + + def _query_intent_bindings(self, compiled, request): + """Capture declared descendant bindings before any physical parent log. + + Compile copies only for policy provenance: no SQL, list preparation, + query-builder mutation or Context-owned redaction state. + """ + from teaql.runtime.log_privacy import _binding_policies + source = _intent_bindings(compiled, request) + pending = [(request.query, request.query.entity)] + visited = set() + while pending: + query, entity = pending.pop() + key = (id(query), entity) + if key in visited: + continue + visited.add(key) + descriptor = self.schema_provider.get_entity(entity) + children = [] + for relation in query.relations: + model = descriptor.relation_by_name(relation.name) if descriptor else None + if relation.query is not None and model is not None: + children.append((relation.query, model.target_entity)) + for aggregate in query.relation_aggregates: + model = descriptor.relation_by_name(aggregate.relation_name) if descriptor else None + if model is not None: + children.append((aggregate.query, model.target_entity)) + for facet in query.facets: + children.append((facet.query, facet.query.entity)) + for child, entity in children: + if (id(child), entity) in visited: + continue + child_descriptor = self.schema_provider.get_entity(entity) + if child_descriptor is None: + continue # execution still owns missing-schema diagnostics + candidate = deepcopy(child) + candidate.entity = entity + self._resolve_subquery_entities(candidate.filter_expr) + bindings = self.dialect.compile_select(child_descriptor, candidate) + source.params.extend(deepcopy(bindings.params)) + source.parameter_log_policies.extend(_binding_policies(bindings)) + pending.append((child, entity)) + return source + def _sync_generated_schema(self, context: 'UserContext') -> None: register = getattr(self.schema_provider, "register_entity", None) if callable(register) and context is not None: @@ -196,8 +245,17 @@ def _record_statement(self, context, request, compiled, started_at, operation, comment = request._comment if query else getattr(request, 'comment', None) if not query and callable(comment): comment = comment() - provider = str(self.dialect.kind()).lower() + provider = self._backend_name() sql_operation = 'select' if query else operation.name.lower() + if query: + trace_source = [TraceNode(kind='comment', name=request.origin_entity, comment=comment), + TraceNode(kind='purpose', name=request.origin_entity, comment=request._purpose), + *request.trace_chain] + else: + chain = request.trace_chain() + root = next((trace_name(node) for node in chain if trace_name(node).strip()), entity) + trace_source = [TraceNode(kind='auditReason', name=root, comment=comment), + *chain, TraceNode(kind='entity', name=entity)] metadata = ExecutionMetadata( backend=provider, operation=operation, started_at=started_at, ended_at=datetime.now(), execution_outcome=outcome, @@ -207,13 +265,7 @@ def _record_statement(self, context, request, compiled, started_at, operation, result_count=result_count, affected_rows=affected_rows, comment=comment, purpose=request._purpose if query else None, audit_reason=None if query else comment, - trace_chain=[ - TraceNode(kind='operation', name='query' if query else 'mutation', comment='query' if query else 'mutation'), - TraceNode(kind='request' if query else 'entity', name=entity, comment=entity), - *(request.trace_chain if query else request.trace_chain()), - TraceNode(kind='provider', name=provider, comment=provider), - TraceNode(kind='sql', name=sql_operation, comment=sql_operation), - ], + trace_chain=canonical_sql_trace_path(trace_source, provider, sql_operation), ) if context is not None: try: @@ -310,18 +362,13 @@ async def _query(self, context: 'UserContext', request: QueryRequest) -> QueryRe execution_query, retained_order, retained_empty = await self._prepare_id_set_page(context, request.query) if retained_empty: now = datetime.now() - provider = str(self.dialect.kind()).lower() + provider = self._backend_name() metadata = ExecutionMetadata( backend=provider, operation=DataServiceOperation.Query, started_at=now, ended_at=now, result_count=0, - trace_chain=[ - TraceNode(kind="operation", name="query", comment="query"), - TraceNode(kind="request", name=request.query.entity, - comment=request.query.entity), - *request.trace_chain, - TraceNode(kind="provider", name=provider, comment=provider), - TraceNode(kind="sql", name="select", comment="select"), - ], + trace_chain=canonical_sql_trace_path( + [TraceNode(kind='comment', name=request.origin_entity, comment=request._comment), + *request.trace_chain], provider, 'select'), comment=request._comment, purpose=request._purpose, ) if context is not None: @@ -329,8 +376,8 @@ async def _query(self, context: 'UserContext', request: QueryRequest) -> QueryRe return QueryResult(rows=[], metadata=metadata) source = getattr(request, '_log_intent_source', None) request = (_QueryWithLogIntent(execution_query, request.trace_chain, request._comment, - request._purpose, source) if source is not None else - QueryRequest(execution_query, request.trace_chain, request._comment, request._purpose)) + request._purpose, source, request.origin_entity) if source is not None else + request.with_query(execution_query)) entity_desc = self.schema_provider.get_entity(request.query.entity) if not entity_desc and context: entities = context.get_resource("entities") @@ -347,6 +394,9 @@ async def _query(self, context: 'UserContext', request: QueryRequest) -> QueryRe compiled = self.dialect.compile_select(entity_desc, request.query) except SqlCompileError as e: raise CompileError(e) + + source = self._query_intent_bindings(compiled, request) + request._log_intent_source = source start = datetime.now() try: @@ -368,8 +418,6 @@ async def _query(self, context: 'UserContext', request: QueryRequest) -> QueryRe metadata = self._record_statement(context, request, compiled, start, DataServiceOperation.Query, 'success', result_count=len(rows)) - source = (_intent_bindings(compiled, request) if rows and - (request.query.relations or request.query.relation_aggregates) else None) await self._enhance_relations(context, rows, request, source) await self._enhance_relation_aggregates(context, rows, request, source) if retained_order: @@ -563,10 +611,11 @@ async def _enhance_relations(self, context, parents: List[Dict[str, Any]], reque ColumnExpr(relation.foreign_key), BinaryOp.Eq, ValueExpr(Value.from_any(parent_id)))) child_trace = [*request.trace_chain, TraceNode( - kind="relation", name=f"{query.entity}.{load.name}", - comment=load.name)] + kind="relation", name=load.name, + comment=f"{query.entity}.{load.name}")] children.extend((await self.query(context, _QueryWithLogIntent( - probe, child_trace, request._comment, request._purpose, intent_source))).rows) + probe, child_trace, request._comment, request._purpose, intent_source, + request.origin_entity))).rows) selected_plan = "bounded_probes" probe_count = len(parent_ids) else: @@ -576,10 +625,11 @@ async def _enhance_relations(self, context, parents: List[Dict[str, Any]], reque if limited: child_query.partition_by_field(relation.foreign_key) child_trace = [*request.trace_chain, TraceNode( - kind="relation", name=f"{query.entity}.{load.name}", - comment=load.name)] + kind="relation", name=load.name, + comment=f"{query.entity}.{load.name}")] children = (await self.query(context, _QueryWithLogIntent( - child_query, child_trace, request._comment, request._purpose, intent_source))).rows + child_query, child_trace, request._comment, request._purpose, intent_source, + request.origin_entity))).rows selected_plan = "window" if limited else "batch" probe_count = 0 for child in children: @@ -638,10 +688,11 @@ async def _enhance_relation_aggregates(self, context, parents: List[Dict[str, An child_query.and_filter(BinaryExpr( ColumnExpr(relation.foreign_key), BinaryOp.In, ValueExpr(values))) child_trace = [*request.trace_chain, TraceNode( - kind="relation", name=f"{query.entity}.{aggregate.relation_name}", - comment=aggregate.relation_name)] + kind="relation", name=aggregate.relation_name, + comment=f"{query.entity}.{aggregate.relation_name}")] rows = (await self.query(context, _QueryWithLogIntent( - child_query, child_trace, request._comment, request._purpose, intent_source))).rows + child_query, child_trace, request._comment, request._purpose, intent_source, + request.origin_entity))).rows child_desc = self.schema_provider.get_entity(relation.target_entity) foreign_property = child_desc.property_by_name(relation.foreign_key) if child_desc else None if foreign_property and foreign_property.column_name_val != relation.foreign_key: @@ -735,7 +786,7 @@ async def _mutate(self, context: 'UserContext', request: MutationRequest) -> Mut results.append(await self._mutate(context, child_request)) affected = sum(result.affected_rows for result in results) return MutationResult(affected, {}, ExecutionMetadata( - backend=str(self.dialect.kind()).lower(), operation=DataServiceOperation.Batch, + backend=self._backend_name(), operation=DataServiceOperation.Batch, started_at=start, ended_at=datetime.now(), affected_rows=affected, comment=request.intent.comment, audit_reason=request.intent.comment)) entity_desc = self.schema_provider.get_entity(req_data.entity) @@ -916,7 +967,7 @@ def _record_readback(self, context, readback, source, write_metadata, started_at parameterized_sql=readback.sql, parameters=list(readback.params), parameter_log_policies=readback.parameter_log_policies, sql_origin=readback.sql_origin, affected_rows=None, result_count=len(rows) if rows is not None else None, - trace_chain=[*write_metadata.trace_chain, TraceNode(kind='sql', name='readback', comment='readback')]) + trace_chain=physical_readback_path(write_metadata.trace_chain)) try: context._record_metadata_log(metadata, intent_source=source, intent_values=tuple(readback.params[:1])) diff --git a/tests/core/test_trace.py b/tests/core/test_trace.py new file mode 100644 index 0000000..6b9edac --- /dev/null +++ b/tests/core/test_trace.py @@ -0,0 +1,58 @@ +"""Shared frozen SQL vectors are pure algorithm evidence, not generated proof.""" +import json +from pathlib import Path + +import pytest + +from teaql.core.mutation import TraceNode +from teaql.core.trace import canonical_sql_trace_path, trace_intent + + +CASES = json.loads((Path(__file__).parents[1] / 'fixtures' / 'sql-trace-path-v1.json').read_text())['cases'] + + +def decode(node): + return TraceNode(kind=node['kind'][0].lower() + node['kind'][1:], name=node['name'], + entity_id=node['entityId'], comment=node['detail']) + + +def logical(node): + return {'kind': node.kind[0].upper() + node.kind[1:], 'name': node.name, + 'entityId': node.entity_id, 'detail': node.comment} + + +@pytest.mark.parametrize('case', CASES, ids=[case['id'] for case in CASES]) +def test_frozen_sql_path(case): + source = [decode(node) for node in case['source']] + assert trace_intent(source) == case['expectedIntent'] + result = canonical_sql_trace_path(source, case['backend'], case['operation']) + assert [logical(node) for node in result] == case['expectedPath'] + assert canonical_sql_trace_path(result, case['backend'], case['operation']) == result + assert [logical(node) for node in source] == case['source'] + if result: + result[0].name = 'consumer-owned change' + assert [logical(node) for node in source] == case['source'] + + +def test_derived_query_origin_is_owned_and_survives_intent_changes(): + from teaql.core.query import SelectQuery + from teaql.data_service import QueryRequest + request = QueryRequest(SelectQuery('CustomerOrder'), _comment='load graph', _purpose='render graph') + derived = request.with_query(SelectQuery('Payment')).comment('updated intent').purpose('updated purpose') + assert derived.query.entity == 'Payment' and derived.origin_entity == 'CustomerOrder' + with pytest.raises(AttributeError): + derived.origin_entity = 'Shipment' + + +@pytest.mark.parametrize('kind', ['query', 'mutation']) +def test_untyped_trace_is_rejected_without_echoing_payload(kind): + from teaql.core.mutation import InsertCommand, MutationRequest + from teaql.core.query import SelectQuery + from teaql.data_service import QueryRequest + opaque = {'comment': 'SENSITIVE-FIXTURE-CANARY'} + request = (QueryRequest(SelectQuery('CustomerOrder'), [opaque], _comment='load graph', _purpose='render graph') + if kind == 'query' else MutationRequest( + InsertCommand('CustomerOrder', trace_chain=[opaque]), comment='save graph')) + with pytest.raises(TypeError, match='typed TraceNode') as caught: + request.validate() + assert 'SENSITIVE-FIXTURE-CANARY' not in str(caught.value) diff --git a/tests/fixtures/sql-trace-path-v1.json b/tests/fixtures/sql-trace-path-v1.json new file mode 100644 index 0000000..9035418 --- /dev/null +++ b/tests/fixtures/sql-trace-path-v1.json @@ -0,0 +1,35 @@ +{ + "contract": "teaql.sql-trace-path.v1", + "owningIssue": "https://github.com/teaql/teaql-conformance/issues/99", + "evidenceLevel": "pure-algorithm", + "notes": [ + "Input source frames here are authored unit-test inputs, not proof of generated Q or Mutation execution.", + "Every case must be idempotent. Intent values are compared separately; source text is preserved verbatim.", + "A rebuilt noncanonical mutation path currently has no Entity ID; an already canonical path preserves its existing IDs.", + "Empty-source is a helper boundary, not permission to execute a request without required intent." + ], + "cases": [ + {"id":"TC-SQL-01","operation":"select","backend":"sqlite","source":[{"kind":"Comment","name":"School","entityId":null,"detail":"what: load school graph"},{"kind":"Purpose","name":"School","entityId":null,"detail":"why: render details"},{"kind":"Relation","name":"platform","entityId":null,"detail":"School.platform"},{"kind":"Relation","name":"organization","entityId":null,"detail":"Platform.organization"}],"expectedIntent":{"comment":"what: load school graph","purpose":"why: render details","auditReason":null},"expectedPath":[{"kind":"Operation","name":"School","entityId":null,"detail":"query"},{"kind":"Request","name":"School","entityId":null,"detail":""},{"kind":"Relation","name":"platform","entityId":null,"detail":"School.platform"},{"kind":"Relation","name":"organization","entityId":null,"detail":"Platform.organization"},{"kind":"Provider","name":"sqlite","entityId":null,"detail":""},{"kind":"Sql","name":"select","entityId":null,"detail":""}]}, + {"id":"TC-SQL-02","operation":"update","backend":"ignored-new-backend","source":[{"kind":"Operation","name":"Order","entityId":null,"detail":"mutation"},{"kind":"AuditReason","name":"Order","entityId":100,"detail":"submit order"},{"kind":"Entity","name":"OrderItem","entityId":201,"detail":""},{"kind":"Relation","name":"items","entityId":null,"detail":"Order.items"},{"kind":"Comment","name":"Order","entityId":null,"detail":"update graph"},{"kind":"Provider","name":"postgres","entityId":null,"detail":""},{"kind":"Sql","name":"update","entityId":null,"detail":""}],"expectedIntent":{"comment":"update graph","purpose":null,"auditReason":"submit order"},"expectedPath":[{"kind":"Operation","name":"Order","entityId":null,"detail":"mutation"},{"kind":"Entity","name":"OrderItem","entityId":201,"detail":""},{"kind":"Relation","name":"items","entityId":null,"detail":"Order.items"},{"kind":"Provider","name":"postgres","entityId":null,"detail":""},{"kind":"Sql","name":"update","entityId":null,"detail":""}]}, + {"id":"TC-SQL-03","operation":"update","backend":"postgres","source":[{"kind":"AuditReason","name":"Order","entityId":100,"detail":"submit order"},{"kind":"Entity","name":"Order","entityId":100,"detail":""},{"kind":"Entity","name":"OrderItem","entityId":201,"detail":""}],"expectedIntent":{"comment":null,"purpose":null,"auditReason":"submit order"},"expectedPath":[{"kind":"Operation","name":"Order","entityId":null,"detail":"mutation"},{"kind":"Entity","name":"OrderItem","entityId":null,"detail":""},{"kind":"Provider","name":"postgres","entityId":null,"detail":""},{"kind":"Sql","name":"update","entityId":null,"detail":""}]}, + {"id":"TC-SQL-04-insert","operation":"insert","backend":"postgres","source":[{"kind":"AuditReason","name":"Order","entityId":100,"detail":"insert order"},{"kind":"Entity","name":"Order","entityId":100,"detail":""}],"expectedIntent":{"comment":null,"purpose":null,"auditReason":"insert order"},"expectedPath":[{"kind":"Operation","name":"Order","entityId":null,"detail":"mutation"},{"kind":"Entity","name":"Order","entityId":null,"detail":""},{"kind":"Provider","name":"postgres","entityId":null,"detail":""},{"kind":"Sql","name":"insert","entityId":null,"detail":""}]}, + {"id":"TC-SQL-04-update","operation":"update","backend":"postgres","source":[{"kind":"AuditReason","name":"Order","entityId":100,"detail":"update order"},{"kind":"Entity","name":"Order","entityId":100,"detail":""}],"expectedIntent":{"comment":null,"purpose":null,"auditReason":"update order"},"expectedPath":[{"kind":"Operation","name":"Order","entityId":null,"detail":"mutation"},{"kind":"Entity","name":"Order","entityId":null,"detail":""},{"kind":"Provider","name":"postgres","entityId":null,"detail":""},{"kind":"Sql","name":"update","entityId":null,"detail":""}]}, + {"id":"TC-SQL-04-delete","operation":"delete","backend":"postgres","source":[{"kind":"AuditReason","name":"Order","entityId":100,"detail":"delete order"},{"kind":"Entity","name":"Order","entityId":100,"detail":""}],"expectedIntent":{"comment":null,"purpose":null,"auditReason":"delete order"},"expectedPath":[{"kind":"Operation","name":"Order","entityId":null,"detail":"mutation"},{"kind":"Entity","name":"Order","entityId":null,"detail":""},{"kind":"Provider","name":"postgres","entityId":null,"detail":""},{"kind":"Sql","name":"delete","entityId":null,"detail":""}]}, + {"id":"TC-SQL-04-recover","operation":"recover","backend":"postgres","source":[{"kind":"AuditReason","name":"Order","entityId":100,"detail":"recover order"},{"kind":"Entity","name":"Order","entityId":100,"detail":""}],"expectedIntent":{"comment":null,"purpose":null,"auditReason":"recover order"},"expectedPath":[{"kind":"Operation","name":"Order","entityId":null,"detail":"mutation"},{"kind":"Entity","name":"Order","entityId":null,"detail":""},{"kind":"Provider","name":"postgres","entityId":null,"detail":""},{"kind":"Sql","name":"recover","entityId":null,"detail":""}]}, + {"id":"TC-SQL-05","operation":"select","backend":" \t ","source":[{"kind":"Comment","name":"School","entityId":null,"detail":"load school"},{"kind":"Purpose","name":"School","entityId":null,"detail":"render school"}],"expectedIntent":{"comment":"load school","purpose":"render school","auditReason":null},"expectedPath":[{"kind":"Operation","name":"School","entityId":null,"detail":"query"},{"kind":"Request","name":"School","entityId":null,"detail":""},{"kind":"Provider","name":"unknown","entityId":null,"detail":""},{"kind":"Sql","name":"select","entityId":null,"detail":""}]}, + {"id":"TC-SQL-06","operation":"select","backend":"sqlite","source":[{"kind":"Comment","name":"School","entityId":null,"detail":"old comment"},{"kind":"Purpose","name":"School","entityId":null,"detail":"old purpose"},{"kind":"AuditReason","name":"School","entityId":1,"detail":"old reason"},{"kind":"Comment","name":"School","entityId":null,"detail":" new comment "},{"kind":"Purpose","name":"School","entityId":null,"detail":"new purpose"},{"kind":"AuditReason","name":"School","entityId":1,"detail":"new reason"},{"kind":"Entity","name":"School","entityId":1,"detail":""}],"expectedIntent":{"comment":" new comment ","purpose":"new purpose","auditReason":"new reason"},"expectedPath":[{"kind":"Operation","name":"School","entityId":null,"detail":"query"},{"kind":"Request","name":"School","entityId":null,"detail":""},{"kind":"Provider","name":"sqlite","entityId":null,"detail":""},{"kind":"Sql","name":"select","entityId":null,"detail":""}]}, + {"id":"BOUNDARY-empty-source","operation":"select","backend":"","source":[],"expectedIntent":{"comment":null,"purpose":null,"auditReason":null},"expectedPath":[{"kind":"Operation","name":"unknown","entityId":null,"detail":"query"},{"kind":"Request","name":"unknown","entityId":null,"detail":""},{"kind":"Provider","name":"unknown","entityId":null,"detail":""},{"kind":"Sql","name":"select","entityId":null,"detail":""}]}, + {"id":"BOUNDARY-blank-first-name","operation":"update","backend":"postgres","source":[{"kind":"Entity","name":"  ","entityId":null,"detail":"not intent"},{"kind":"AuditReason","name":"Order","entityId":100,"detail":"submit order"},{"kind":"Entity","name":"OrderItem","entityId":201,"detail":""}],"expectedIntent":{"comment":null,"purpose":null,"auditReason":"submit order"},"expectedPath":[{"kind":"Operation","name":"Order","entityId":null,"detail":"mutation"},{"kind":"Entity","name":"OrderItem","entityId":null,"detail":""},{"kind":"Provider","name":"postgres","entityId":null,"detail":""},{"kind":"Sql","name":"update","entityId":null,"detail":""}]}, + {"id":"BOUNDARY-three-level-shape","operation":"select","backend":"sqlite","source":[{"kind":"Comment","name":"School","entityId":null,"detail":"load school graph"},{"kind":"Purpose","name":"School","entityId":null,"detail":"render school"},{"kind":"Relation","name":"platform","entityId":null,"detail":"School.platform"},{"kind":"Relation","name":"organization","entityId":null,"detail":"Platform.organization"},{"kind":"Relation","name":"region","entityId":null,"detail":"Organization.region"}],"expectedIntent":{"comment":"load school graph","purpose":"render school","auditReason":null},"expectedPath":[{"kind":"Operation","name":"School","entityId":null,"detail":"query"},{"kind":"Request","name":"School","entityId":null,"detail":""},{"kind":"Relation","name":"platform","entityId":null,"detail":"School.platform"},{"kind":"Relation","name":"organization","entityId":null,"detail":"Platform.organization"},{"kind":"Relation","name":"region","entityId":null,"detail":"Organization.region"},{"kind":"Provider","name":"sqlite","entityId":null,"detail":""},{"kind":"Sql","name":"select","entityId":null,"detail":""}]} + ], + "requiredIntegrationCases": [ + { + "id": "TC-SQL-07", + "requirement": "Generated Q constructs three relation levels; observe real provider metadata without test-injected frames." + }, + { + "id": "TC-SQL-08", + "requirement": "Existing privacy projection masks secret intent before every sink without changing the path structure." + } + ] +} diff --git a/tests/provider/sqlite/test_sqlite.py b/tests/provider/sqlite/test_sqlite.py index 09d322a..442486d 100644 --- a/tests/provider/sqlite/test_sqlite.py +++ b/tests/provider/sqlite/test_sqlite.py @@ -771,7 +771,8 @@ async def on_safe_event(self, context, event): self.events.append(event) raw, app = RawSink(), AppSink() context = RuntimeModule.new().entity(entity).audit_event_sink(raw).into_context().with_app_audit_event_sink(app) command = InsertCommand("User", {"id": Value.I64(1), "name": Value.Text("Alice Example"), "version": Value.I64(1)}) - command.trace_chain.append(type("Trace", (), {"comment": "approved change"})()) + command.trace_chain.append(TraceNode(kind='auditReason', entity_type='User', + entity_id=1, comment='approved change')) result = await service.mutate(context, MutationRequest(command, comment='what: runtime regression fixture')) assert result.affected_rows == 1 @@ -857,7 +858,10 @@ def relation_ids(rows): assert relation_entries assert [node.kind for node in relation_entries[0].trace_path] == [ "operation", "request", "relation", "provider", "sql"] - assert relation_entries[0].trace_path[2].name == "Order.lines" + assert relation_entries[0].trace_path[2].name == "lines" + assert relation_entries[0].trace_path[2].comment == "Order.lines" + assert relation_entries[0].trace_path[0].name == "Order" + assert relation_entries[0].trace_path[1].name == "Order" probe_ids = relation_ids(result.rows) queries.clear() diff --git a/tests/provider/sqlite/test_trace_chain.py b/tests/provider/sqlite/test_trace_chain.py new file mode 100644 index 0000000..611975e --- /dev/null +++ b/tests/provider/sqlite/test_trace_chain.py @@ -0,0 +1,170 @@ +"""Native SQLite observations; no expected trace frames are supplied to requests.""" +from types import SimpleNamespace + +import aiosqlite +import pytest + +from teaql.core.expr import Expr +from teaql.core.meta import EntityDescriptor, PropertyDescriptor, RelationDescriptor +from teaql.core.mutation import InsertCommand, MutationRequest +from teaql.core.query import SelectQuery +from teaql.core.value import DataType +from teaql.data_service import QueryRequest +from teaql.provider.sqlite import SimpleSchemaProvider +from teaql.provider.sqlite.dialect import SqliteDialect +from teaql.provider.sqlite.transport import SqliteTransport +from teaql.runtime import RuntimeModule +from teaql.sql.executor import SqlDataServiceExecutor, TransportError + + +class RecordingTransport(SqliteTransport): + def __init__(self, path): + super().__init__(path) + self.reads = [] + self.fail_table = None + self.failure = RuntimeError('synthetic readback failure') + + async def fetch_all_sql(self, compiled): + self.reads.append(compiled) + if self.fail_table and self.fail_table in compiled.sql: + raise self.failure + return await super().fetch_all_sql(compiled) + + async def begin_sql(self): + transaction = await super().begin_sql() + fetch = transaction.fetch_all_sql + + async def capture(compiled): + self.reads.append(compiled) + if self.fail_table and self.fail_table in compiled.sql: + raise self.failure + return await fetch(compiled) + + transaction.fetch_all_sql = capture + return transaction + + +async def fixture(tmp_path): + module, schema = RuntimeModule.new(), SimpleSchemaProvider() + names = ('CustomerOrder', 'Payment', 'PaymentAttempt', 'Shipment') + for index, name in enumerate(names): + descriptor = EntityDescriptor(name).table_name(name.lower() + '_data') + descriptor.property(PropertyDescriptor('id', DataType.I64).is_id()) + descriptor.property(PropertyDescriptor('version', DataType.I64).is_version()) + descriptor.property(PropertyDescriptor('name', DataType.Text)) + if index < 3: + descriptor.relation(RelationDescriptor('children', names[index + 1]) + .local('id').foreign('parent_id').many()) + if index: + descriptor.property(PropertyDescriptor('parent_id', DataType.I64)) + module.entity(descriptor) + schema.register_entity(descriptor) + context = module.into_context() + entries = [] + context.set_diagnostic_sql_log_sink(SimpleNamespace(write=entries.append)) + transport = RecordingTransport(str(tmp_path / 'trace.db')) + service = SqlDataServiceExecutor(SqliteDialect(), transport, schema) + # Native provider fixture, not generated-example or bootstrap proof. + async with aiosqlite.connect(transport.db_path) as database: + for index, name in enumerate(names): + tail = ', parent_id INTEGER' if index else '' + await database.execute(f'CREATE TABLE {name.lower()}_data ' + f'(id INTEGER PRIMARY KEY, version INTEGER, name TEXT{tail})') + await database.commit() + for index, name in enumerate(names): + command = InsertCommand.new(name).value('name', name) + if index: + command.value('parent_id', 1) + await service.mutate(context, MutationRequest(command, comment='seed native trace fixture')) + entries.clear() + context.clear_sql_logs() + transport.reads.clear() + return context, service, transport, entries + + +def three_levels(): + return (SelectQuery('CustomerOrder').project('id', 'name').limit(1) + .relation_query('children', SelectQuery('Payment').project('id').limit(1) + .relation_query('children', SelectQuery('PaymentAttempt').project('id').limit(1) + .relation_query('children', SelectQuery('Shipment').project('id').limit(1))))) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('failure', [False, True]) +async def test_three_real_relation_levels_keep_origin_and_qualified_frames(tmp_path, failure): + context, service, transport, entries = await fixture(tmp_path) + request = QueryRequest(three_levels(), _comment='load payment execution graph', + _purpose='observe business trace without injected frames') + if failure: + transport.fail_table = 'shipment_data' + with pytest.raises(TransportError) as caught: + await service.query(context, request) + assert caught.value.error is transport.failure + else: + result = await service.query(context, request) + assert result.rows[0]['children'][0]['children'][0]['children'][0]['id'] == 1 + assert len(transport.reads) == len(entries) == 4 + qualified = ['CustomerOrder.children', 'Payment.children', 'PaymentAttempt.children'] + for depth, entry in enumerate(entries): + assert entry.comment == request.intent.comment + assert entry.purpose == request.intent.purpose + nodes = entry.trace_path + assert [node.kind for node in nodes] == ['operation', 'request'] + ['relation'] * depth + ['provider', 'sql'] + assert [node.name for node in nodes[:2]] == ['CustomerOrder', 'CustomerOrder'] + assert nodes[0].comment == 'query' + assert [node.name for node in nodes[2:-2]] == ['children'] * depth + assert [node.comment for node in nodes[2:-2]] == qualified[:depth] + assert [(node.name, node.comment) for node in nodes[-2:]] == [('sqlite', ''), ('select', '')] + assert entry.execution_outcome == ('failure' if failure and depth == 3 else 'success') + + +@pytest.mark.asyncio +async def test_successful_write_and_failed_readback_have_separate_canonical_paths(tmp_path): + context, service, transport, entries = await fixture(tmp_path) + transport.fail_table = 'payment_data' + command = InsertCommand.new('Payment').value('name', 'pending payment').value('parent_id', 1) + request = MutationRequest(command, comment='create payment before verifying authoritative row') + with pytest.raises(RuntimeError) as caught: + await service.mutate(context, request) + assert caught.value is transport.failure + assert len(entries) == 2 + write, read = entries + assert write.execution_outcome == 'success' and read.execution_outcome == 'failure' + assert write.audit_reason == read.audit_reason == request.comment() + for entry, operation in ((write, 'insert'), (read, 'select')): + assert [node.kind for node in entry.trace_path] == ['operation', 'entity', 'provider', 'sql'] + assert entry.trace_path[0].name == 'Payment' + assert entry.trace_path[-1].name == operation + async with aiosqlite.connect(transport.db_path) as database: + cursor = await database.execute('SELECT COUNT(*) FROM payment_data') + assert (await cursor.fetchone())[0] == 1 # failed graph transaction rolled back + + +@pytest.mark.asyncio +async def test_query_trace_source_does_not_override_request_owned_intent(tmp_path): + context, service, transport, entries = await fixture(tmp_path) + from teaql.core.mutation import TraceNode + request = QueryRequest(SelectQuery('CustomerOrder').filter(Expr.eq('id', 1)).limit(1), + trace_chain=[TraceNode(kind='comment', name='CustomerOrder', comment='untrusted old text'), + TraceNode(kind='purpose', name='CustomerOrder', comment='old purpose')], + _comment='explicit request comment', _purpose='explicit request purpose') + await service.query(context, request) + assert entries[0].comment == 'explicit request comment' + assert entries[0].purpose == 'explicit request purpose' + assert not {'comment', 'purpose', 'auditReason'}.intersection(node.kind for node in entries[0].trace_path) + + +@pytest.mark.asyncio +async def test_unexecuted_aggregate_provenance_does_not_modify_query_builders(tmp_path): + from copy import deepcopy + from teaql.core.query import RelationAggregate + context, service, transport, entries = await fixture(tmp_path) + child = SelectQuery('Payment').count('count').relation_query('children', SelectQuery('PaymentAttempt')) + parent = SelectQuery('CustomerOrder').project('id').filter(Expr.eq('name', 'missing')).limit(1) + parent.relation_aggregates.append(RelationAggregate('children', 'child_count', child, True)) + request = QueryRequest(parent, _comment='inspect absent parent', _purpose='test provenance isolation') + before = deepcopy(request.query) + result = await service.query(context, request) + assert result.rows == [] and len(entries) == len(transport.reads) == 1 + assert request.query == before and child.slice is None + assert child.relations[0].query.slice is None diff --git a/tests/runtime/test_relation_masking.py b/tests/runtime/test_relation_masking.py index 5079b01..595471f 100644 --- a/tests/runtime/test_relation_masking.py +++ b/tests/runtime/test_relation_masking.py @@ -134,6 +134,7 @@ def capture(entry): assert 'Riverside' not in repr(logs) + '\n'.join(output) if shape == 'nested': assert 'Lakeside' not in entry.comment + assert 'Lakeside' not in repr(logs) + '\n'.join(output) + repr(context.sql_logs()) assert len(entry.params) == len(transport.reads[-1].params) assert 'PASSWORD-CANARY' in [v.val for v in transport.reads[0].params] if shape == 'batch': From b56c8c0b29efeedacc714556081341600ab58fea Mon Sep 17 00:00:00 2001 From: Philip Z Date: Fri, 2 Oct 2026 18:12:13 +0800 Subject: [PATCH 03/40] fix(trace): isolate graph lineage and defer audits until commit (#43) Signed-off-by: Philip Z --- README.md | 36 +- examples/conformance/app/masking_lifecycle.py | 13 +- examples/conformance/models/platform.py | 49 +- examples/conformance/models/work_item.py | 44 +- examples/mutation-policy/main.py | 5 +- .../models/commerce_platform.py | 74 +- .../python-lib-core/models/customer.py | 49 +- .../python-lib-core/models/customer_order.py | 49 +- .../python-lib-core/models/order_line.py | 44 +- .../models/order_search_preset.py | 44 +- .../python-lib-core/models/order_status.py | 49 +- .../python-lib-core/models/product.py | 49 +- examples/school-management/models/platform.py | 54 +- examples/school-management/models/school.py | 44 +- .../school-management/models/school_type.py | 49 +- .../task_board/generated/models/platform.py | 54 +- examples/task_board/generated/models/task.py | 49 +- .../generated/models/task_execution_log.py | 44 +- .../generated/models/task_status.py | 49 +- examples/trace-chain/AGENTS.md | 31 + examples/trace-chain/README.md | 34 + .../python-assist-create/customer_order.md | 94 +++ .../assist/python-assist-create/order_item.md | 77 +++ .../assist/python-assist-create/payment.md | 90 +++ .../python-assist-create/payment_attempt.md | 77 +++ .../assist/python-assist-create/platform.md | 88 +++ .../assist/python-assist-create/shipment.md | 77 +++ .../python-assist-delete/customer_order.md | 67 ++ .../assist/python-assist-delete/order_item.md | 67 ++ .../assist/python-assist-delete/payment.md | 67 ++ .../python-assist-delete/payment_attempt.md | 67 ++ .../assist/python-assist-delete/platform.md | 67 ++ .../assist/python-assist-delete/shipment.md | 67 ++ .../customer_order.md | 132 ++++ .../python-assist-expression/order_item.md | 88 +++ .../python-assist-expression/payment.md | 100 +++ .../payment_attempt.md | 88 +++ .../python-assist-expression/platform.md | 92 +++ .../python-assist-expression/shipment.md | 88 +++ .../python-assist-query/customer_order.md | 102 +++ .../customer_order.order_item_list.md | 66 ++ .../customer_order.payment_list.md | 66 ++ .../customer_order.platform.md | 68 ++ .../customer_order.shipment_list.md | 66 ++ .../assist/python-assist-query/order_item.md | 93 +++ .../payment.customer_order.md | 68 ++ .../assist/python-assist-query/payment.md | 99 +++ .../payment.payment_attempt_list.md | 66 ++ .../payment.reference_code.md | 70 ++ .../python-assist-query/payment_attempt.md | 93 +++ .../payment_attempt.payment.md | 68 ++ .../payment_attempt.reference_code.md | 70 ++ .../assist/python-assist-query/platform.md | 98 +++ .../assist/python-assist-query/shipment.md | 93 +++ .../python-assist-update/customer_order.md | 82 +++ .../assist/python-assist-update/order_item.md | 80 +++ .../assist/python-assist-update/payment.md | 80 +++ .../python-assist-update/payment_attempt.md | 80 +++ .../assist/python-assist-update/platform.md | 80 +++ .../assist/python-assist-update/shipment.md | 80 +++ examples/trace-chain/evidence/evaluation.md | 30 + .../trace-chain/evidence/library-sha256.txt | 19 + examples/trace-chain/lib/E.py | 245 +++++++ examples/trace-chain/lib/Q.py | 58 ++ examples/trace-chain/lib/models/__init__.py | 0 .../trace-chain/lib/models/customer_order.py | 397 +++++++++++ examples/trace-chain/lib/models/order_item.py | 286 ++++++++ examples/trace-chain/lib/models/payment.py | 316 +++++++++ .../trace-chain/lib/models/payment_attempt.py | 286 ++++++++ examples/trace-chain/lib/models/platform.py | 288 ++++++++ examples/trace-chain/lib/models/shipment.py | 286 ++++++++ examples/trace-chain/lib/pyproject.toml | 16 + examples/trace-chain/lib/requests/__init__.py | 0 .../lib/requests/customer_order_request.py | 632 ++++++++++++++++++ .../lib/requests/order_item_request.py | 425 ++++++++++++ .../lib/requests/payment_attempt_request.py | 425 ++++++++++++ .../lib/requests/payment_request.py | 464 +++++++++++++ .../lib/requests/platform_request.py | 425 ++++++++++++ .../lib/requests/shipment_request.py | 425 ++++++++++++ examples/trace-chain/lib/runtime_module.py | 214 ++++++ examples/trace-chain/lib/teaql-i18n.json | 126 ++++ examples/trace-chain/main.py | 255 +++++++ examples/trace-chain/model.xml | 17 + examples/trace-chain/verify.sh | 18 + scripts/verify-examples.sh | 3 +- src/teaql/core/entity.py | 24 + src/teaql/core/mutation.py | 19 +- src/teaql/core/trace_scope.py | 55 ++ src/teaql/data_service/__init__.py | 1 + src/teaql/runtime/__init__.py | 2 + src/teaql/runtime/audit.py | 40 ++ src/teaql/runtime/context.py | 143 ++-- src/teaql/runtime/graph_session.py | 117 ++++ src/teaql/runtime/log_privacy.py | 3 +- src/teaql/runtime/mutation_policy.py | 10 + src/teaql/sql/executor.py | 58 +- tests/core/test_trace_scope.py | 74 ++ tests/fixtures/graph-mutation-lineage-v1.json | 37 + tests/provider/sqlite/test_sqlite.py | 6 +- tests/provider/sqlite/test_trace_chain.py | 61 ++ tests/runtime/test_graph_audit.py | 141 ++++ tests/runtime/test_mutation_policy.py | 61 +- tests/runtime/test_runtime.py | 25 +- tests/runtime/test_sql_mask_lifecycle.py | 11 +- 104 files changed, 10124 insertions(+), 494 deletions(-) create mode 100644 examples/trace-chain/AGENTS.md create mode 100644 examples/trace-chain/README.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-create/customer_order.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-create/order_item.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-create/payment.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-create/payment_attempt.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-create/platform.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-create/shipment.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-delete/customer_order.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-delete/order_item.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-delete/payment.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-delete/payment_attempt.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-delete/platform.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-delete/shipment.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-expression/customer_order.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-expression/order_item.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-expression/payment.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-expression/payment_attempt.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-expression/platform.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-expression/shipment.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-query/customer_order.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-query/customer_order.order_item_list.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-query/customer_order.payment_list.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-query/customer_order.platform.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-query/customer_order.shipment_list.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-query/order_item.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-query/payment.customer_order.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-query/payment.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-query/payment.payment_attempt_list.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-query/payment.reference_code.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-query/payment_attempt.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-query/payment_attempt.payment.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-query/payment_attempt.reference_code.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-query/platform.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-query/shipment.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-update/customer_order.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-update/order_item.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-update/payment.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-update/payment_attempt.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-update/platform.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-update/shipment.md create mode 100644 examples/trace-chain/evidence/evaluation.md create mode 100644 examples/trace-chain/evidence/library-sha256.txt create mode 100644 examples/trace-chain/lib/E.py create mode 100644 examples/trace-chain/lib/Q.py create mode 100644 examples/trace-chain/lib/models/__init__.py create mode 100644 examples/trace-chain/lib/models/customer_order.py create mode 100644 examples/trace-chain/lib/models/order_item.py create mode 100644 examples/trace-chain/lib/models/payment.py create mode 100644 examples/trace-chain/lib/models/payment_attempt.py create mode 100644 examples/trace-chain/lib/models/platform.py create mode 100644 examples/trace-chain/lib/models/shipment.py create mode 100644 examples/trace-chain/lib/pyproject.toml create mode 100644 examples/trace-chain/lib/requests/__init__.py create mode 100644 examples/trace-chain/lib/requests/customer_order_request.py create mode 100644 examples/trace-chain/lib/requests/order_item_request.py create mode 100644 examples/trace-chain/lib/requests/payment_attempt_request.py create mode 100644 examples/trace-chain/lib/requests/payment_request.py create mode 100644 examples/trace-chain/lib/requests/platform_request.py create mode 100644 examples/trace-chain/lib/requests/shipment_request.py create mode 100644 examples/trace-chain/lib/runtime_module.py create mode 100644 examples/trace-chain/lib/teaql-i18n.json create mode 100644 examples/trace-chain/main.py create mode 100644 examples/trace-chain/model.xml create mode 100644 examples/trace-chain/verify.sh create mode 100644 src/teaql/core/trace_scope.py create mode 100644 src/teaql/runtime/graph_session.py create mode 100644 tests/core/test_trace_scope.py create mode 100644 tests/fixtures/graph-mutation-lineage-v1.json create mode 100644 tests/runtime/test_graph_audit.py diff --git a/README.md b/README.md index d54bbb2..92a313b 100644 --- a/README.md +++ b/README.md @@ -107,12 +107,36 @@ safe projection. The existing mask algorithm, expanded SQL and execution values are unchanged; provenance is not stored on Context or exposed in log entries. This additional compilation has not been performance benchmarked. -This is local-source and native-provider evidence, not complete Trace Chain. -Immutable graph parent scopes, typed ledger replacement, per-item batch lineage, -commit-only graph audit, generated six-entity Q/E/Mutation acceptance, same-database -replay, real concurrent graph isolation, complete entry-point/privacy coverage -and immutable internal Registry replay remain open. Successful readback diagnostic -coverage also needs alignment. No released-package parity is claimed. +Graph saves now use an explicit invocation-owned `GraphMutationSession` and an +immutable persistent parent scope. The original Context's data service is never +swapped; child saves receive the session and their parent scope explicitly. +Unannotated children inherit, local reasons append once, and deletion creates its +scope before execution. The typed ledger can store an owned complete replacement +chain. Five shared graph fixtures verify fifteen per-entity expectations; these +helper tests are separate from generated traversal evidence. + +[The generated Trace Chain example](examples/trace-chain/) drives six mutations +through actual Q/E/save APIs and SQLite, observing request lineage, SQL metadata +and safe audits after commit. It also verifies three query relation levels, +concurrent independent saves in one Context, write/readback failure rollback, +and missing root intent before transaction access. Its verifier executes twice +on one SQLite file without cleanup and checks all generated-library hashes. +All ten example groups and the source suite pass twice locally (424 tests and +eight explicit external-provider skips per source run). + +SQL transactions queue owned audit snapshots until commit, including explicit +transactions and automatic batches. Rollback discards them. A completion failure +is reported with `GraphCommittedError.committed == True`; all remaining audit +deliveries and graph cleanup callbacks are still attempted, with no postcommit +rollback. Reentrant independent root saves fail instead of silently joining. +The private generated save implementation has changed; old generated libraries +must be regenerated, while public `audit_as(...).save(context)` stays unchanged. + +This is local-source/generated-consumer evidence, not complete Trace Chain. +Prepared-batch grouping, generated ledger overrides, complete entry-point and +inherited mutation privacy coverage, successful readback diagnostic alignment, +live PostgreSQL/MySQL graph acceptance, and immutable internal Registry replay +remain open. No released-package parity is claimed. ```bash PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=src python -m pytest -q diff --git a/examples/conformance/app/masking_lifecycle.py b/examples/conformance/app/masking_lifecycle.py index 4969747..8e85468 100644 --- a/examples/conformance/app/masking_lifecycle.py +++ b/examples/conformance/app/masking_lifecycle.py @@ -46,11 +46,11 @@ def capture(entry): entries.append(entry) sink.write(entry) context.set_diagnostic_sql_log_sink(SimpleNamespace(write=capture)) - async def insert(entity_id, target=None): + async def insert(entity_id, target=None, request_context=None): command = (InsertCommand('MaskCustomer').value('id', entity_id).value('version', 1) .value('display_name', 'Riverside')) command.trace_chain = [TraceNode(comment='what: seed Riverside lifecycle fixture')] - return await (target or service).mutate(context, MutationRequest(command, comment='what: seed Riverside lifecycle fixture')) + return await (target or service).mutate(request_context or context, MutationRequest(command, comment='what: seed Riverside lifecycle fixture')) try: for entity_id in [1, 2, 3]: await insert(entity_id) @@ -100,11 +100,10 @@ async def insert(entity_id, target=None): context.insert_resource('dataService', service) entries.clear() - async def partial_graph(): - target = context.require_resource('dataService') - await insert(30, target) - await insert(777, target) - await insert(31, target) + async def partial_graph(graph): + await insert(30, graph.transaction, graph.context) + await insert(777, graph.transaction, graph.context) + await insert(31, graph.transaction, graph.context) try: await context.execute_graph_save(partial_graph, comment='what: runtime regression fixture') raise AssertionError('partial graph unexpectedly committed') diff --git a/examples/conformance/models/platform.py b/examples/conformance/models/platform.py index 098578c..7f6ed23 100644 --- a/examples/conformance/models/platform.py +++ b/examples/conformance/models/platform.py @@ -78,18 +78,14 @@ def audit_as(self, comment: str): async def save(self, context): intent = MutationIntent(self._comment) - # Reserve stable internal IDs before the transaction and before policy - # review. A relation to a new parent must have the same identity in the - # reviewed graph plan and in the commands eventually sent to SQL. - await self._teaql_reserve_graph_ids(context, set()) - await self._teaql_ensure_business_ids(context) - return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context), comment=intent.comment) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) async def _teaql_ensure_business_ids(self, context): if self._action != "Create": return - async def _teaql_reserve_graph_ids(self, context, visited): + async def _teaql_reserve_graph_ids(self, graph, visited): + context = graph.context object_identity = id(self) if object_identity in visited: return @@ -102,19 +98,29 @@ async def _teaql_reserve_graph_ids(self, context, visited): "Configured dataService does not support stable ID reservation" ) old_key = self._teaql_entity_key() + old_loaded = set(self._loaded_fields) self.id = int(await allocator("Platform")) self._loaded_fields.add("id") self._ledger_id = self.id new_key = self._teaql_entity_key() self._entity_root.rekey(old_key, new_key) self._entity_root.set(new_key, "id", Value.I64(self.id)) + def rollback_reservation(): + self.id = None + self._ledger_id = old_key.id + self._loaded_fields = old_loaded + self._entity_root.rekey(new_key, old_key) + self._entity_root.set(old_key, "id", Value.from_any(None)) + graph.after_rollback(rollback_reservation) + await self._teaql_ensure_business_ids(context) for child in self._work_item_list: child._teaql_attach_root(self._entity_root) - await child._teaql_reserve_graph_ids(context, visited) + await child._teaql_reserve_graph_ids(graph, visited) - async def _teaql_preflight_and_save(self, context): - self._teaql_preflight_graph(context) - return await self._teaql_save_within_graph(context) + async def _teaql_preflight_and_save(self, graph): + await self._teaql_reserve_graph_ids(graph, set()) + self._teaql_preflight_graph(graph) + return await self._teaql_save_within_graph(graph) def _teaql_build_command(self): payload = {} @@ -141,8 +147,8 @@ def _teaql_build_command(self): cmd = DeleteCommand("Platform", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) return action, cmd - def _teaql_preflight_graph(self, context): - MutationIntent(self._comment) + def _teaql_preflight_graph(self, graph): + context = graph.context if self._action == "Update": if "id" not in self._loaded_fields: raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) @@ -160,9 +166,8 @@ def _teaql_preflight_graph(self, context): for index, child in enumerate(self._work_item_list): child._teaql_attach_root(self._entity_root) child.update_platform(self) - child.audit_as(self._comment) try: - child._teaql_preflight_graph(context) + child._teaql_preflight_graph(graph) except CheckException as error: prefix = ObjectLocation().property("work_item_list").index(index) raise CheckException([ @@ -170,14 +175,15 @@ def _teaql_preflight_graph(self, context): for v in error.violations ]) from error - async def _teaql_save_within_graph(self, context): - intent = MutationIntent(self._comment) + async def _teaql_save_within_graph(self, graph, parent_scope=None): + context = graph.context + scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() - req = MutationRequest(cmd, comment=intent.comment) + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) try: context.check_and_fix_mutation(cmd) @@ -228,7 +234,7 @@ def rollback_entity(): self._loaded_fields = rollback_loaded_fields if old_key != new_key: self._entity_root.rekey(new_key, old_key) - context.after_graph_rollback(rollback_entity) + graph.after_rollback(rollback_entity) if action != "Delete": self._action = "Update" @@ -239,9 +245,8 @@ def rollback_entity(): for index, child in enumerate(children): child._teaql_attach_root(self._entity_root) getattr(child, updater)(self) - child.audit_as(self._comment) try: - await child._teaql_save_within_graph(context) + await child._teaql_save_within_graph(graph, scope) except CheckException as error: prefix = ObjectLocation().property(relation_name).index(index) raise CheckException([ @@ -258,7 +263,7 @@ def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.set_original_version(new_key, int(self.version)) - context.after_graph_commit(commit_entity) + graph.after_commit(commit_entity) return self def update_id(self, value): diff --git a/examples/conformance/models/work_item.py b/examples/conformance/models/work_item.py index 76b7f25..6ed24d2 100644 --- a/examples/conformance/models/work_item.py +++ b/examples/conformance/models/work_item.py @@ -78,18 +78,14 @@ def audit_as(self, comment: str): async def save(self, context): intent = MutationIntent(self._comment) - # Reserve stable internal IDs before the transaction and before policy - # review. A relation to a new parent must have the same identity in the - # reviewed graph plan and in the commands eventually sent to SQL. - await self._teaql_reserve_graph_ids(context, set()) - await self._teaql_ensure_business_ids(context) - return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context), comment=intent.comment) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) async def _teaql_ensure_business_ids(self, context): if self._action != "Create": return - async def _teaql_reserve_graph_ids(self, context, visited): + async def _teaql_reserve_graph_ids(self, graph, visited): + context = graph.context object_identity = id(self) if object_identity in visited: return @@ -102,16 +98,26 @@ async def _teaql_reserve_graph_ids(self, context, visited): "Configured dataService does not support stable ID reservation" ) old_key = self._teaql_entity_key() + old_loaded = set(self._loaded_fields) self.id = int(await allocator("WorkItem")) self._loaded_fields.add("id") self._ledger_id = self.id new_key = self._teaql_entity_key() self._entity_root.rekey(old_key, new_key) self._entity_root.set(new_key, "id", Value.I64(self.id)) + def rollback_reservation(): + self.id = None + self._ledger_id = old_key.id + self._loaded_fields = old_loaded + self._entity_root.rekey(new_key, old_key) + self._entity_root.set(old_key, "id", Value.from_any(None)) + graph.after_rollback(rollback_reservation) + await self._teaql_ensure_business_ids(context) - async def _teaql_preflight_and_save(self, context): - self._teaql_preflight_graph(context) - return await self._teaql_save_within_graph(context) + async def _teaql_preflight_and_save(self, graph): + await self._teaql_reserve_graph_ids(graph, set()) + self._teaql_preflight_graph(graph) + return await self._teaql_save_within_graph(graph) def _teaql_build_command(self): payload = {} @@ -148,8 +154,8 @@ def _teaql_build_command(self): cmd = DeleteCommand("WorkItem", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) return action, cmd - def _teaql_preflight_graph(self, context): - MutationIntent(self._comment) + def _teaql_preflight_graph(self, graph): + context = graph.context if self._action == "Update": if "id" not in self._loaded_fields: raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) @@ -169,14 +175,15 @@ def _teaql_preflight_graph(self, context): if field not in ("id", "version"): self._entity_root.set(self._teaql_entity_key(), field, value) - async def _teaql_save_within_graph(self, context): - intent = MutationIntent(self._comment) + async def _teaql_save_within_graph(self, graph, parent_scope=None): + context = graph.context + scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() - req = MutationRequest(cmd, comment=intent.comment) + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) try: context.check_and_fix_mutation(cmd) @@ -239,7 +246,7 @@ def rollback_entity(): self._loaded_fields = rollback_loaded_fields if old_key != new_key: self._entity_root.rekey(new_key, old_key) - context.after_graph_rollback(rollback_entity) + graph.after_rollback(rollback_entity) if action != "Delete": self._action = "Update" @@ -249,9 +256,8 @@ def rollback_entity(): for index, child in enumerate(children): child._teaql_attach_root(self._entity_root) getattr(child, updater)(self) - child.audit_as(self._comment) try: - await child._teaql_save_within_graph(context) + await child._teaql_save_within_graph(graph, scope) except CheckException as error: prefix = ObjectLocation().property(relation_name).index(index) raise CheckException([ @@ -268,7 +274,7 @@ def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.set_original_version(new_key, int(self.version)) - context.after_graph_commit(commit_entity) + graph.after_commit(commit_entity) return self def update_id(self, value): diff --git a/examples/mutation-policy/main.py b/examples/mutation-policy/main.py index d820776..e31380a 100644 --- a/examples/mutation-policy/main.py +++ b/examples/mutation-policy/main.py @@ -127,8 +127,9 @@ def context_for(provider, audit): async def save(context, *commands): - async def graph(): - transaction = context.require_resource("dataService") + async def graph(session): + context = session.context + transaction = session.transaction for command in commands: context.preflight_mutation(command) for command in commands: diff --git a/examples/order-management/python-lib-core/models/commerce_platform.py b/examples/order-management/python-lib-core/models/commerce_platform.py index 04e1a4e..61147e8 100644 --- a/examples/order-management/python-lib-core/models/commerce_platform.py +++ b/examples/order-management/python-lib-core/models/commerce_platform.py @@ -141,18 +141,14 @@ def audit_as(self, comment: str): async def save(self, context): intent = MutationIntent(self._comment) - # Reserve stable internal IDs before the transaction and before policy - # review. A relation to a new parent must have the same identity in the - # reviewed graph plan and in the commands eventually sent to SQL. - await self._teaql_reserve_graph_ids(context, set()) - await self._teaql_ensure_business_ids(context) - return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context), comment=intent.comment) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) async def _teaql_ensure_business_ids(self, context): if self._action != "Create": return - async def _teaql_reserve_graph_ids(self, context, visited): + async def _teaql_reserve_graph_ids(self, graph, visited): + context = graph.context object_identity = id(self) if object_identity in visited: return @@ -165,34 +161,44 @@ async def _teaql_reserve_graph_ids(self, context, visited): "Configured dataService does not support stable ID reservation" ) old_key = self._teaql_entity_key() + old_loaded = set(self._loaded_fields) self.id = int(await allocator("CommercePlatform")) self._loaded_fields.add("id") self._ledger_id = self.id new_key = self._teaql_entity_key() self._entity_root.rekey(old_key, new_key) self._entity_root.set(new_key, "id", Value.I64(self.id)) + def rollback_reservation(): + self.id = None + self._ledger_id = old_key.id + self._loaded_fields = old_loaded + self._entity_root.rekey(new_key, old_key) + self._entity_root.set(old_key, "id", Value.from_any(None)) + graph.after_rollback(rollback_reservation) + await self._teaql_ensure_business_ids(context) for child in self._customer_list: child._teaql_attach_root(self._entity_root) - await child._teaql_reserve_graph_ids(context, visited) + await child._teaql_reserve_graph_ids(graph, visited) for child in self._order_status_list: child._teaql_attach_root(self._entity_root) - await child._teaql_reserve_graph_ids(context, visited) + await child._teaql_reserve_graph_ids(graph, visited) for child in self._customer_order_list: child._teaql_attach_root(self._entity_root) - await child._teaql_reserve_graph_ids(context, visited) + await child._teaql_reserve_graph_ids(graph, visited) for child in self._product_list: child._teaql_attach_root(self._entity_root) - await child._teaql_reserve_graph_ids(context, visited) + await child._teaql_reserve_graph_ids(graph, visited) for child in self._order_line_list: child._teaql_attach_root(self._entity_root) - await child._teaql_reserve_graph_ids(context, visited) + await child._teaql_reserve_graph_ids(graph, visited) for child in self._order_search_preset_list: child._teaql_attach_root(self._entity_root) - await child._teaql_reserve_graph_ids(context, visited) + await child._teaql_reserve_graph_ids(graph, visited) - async def _teaql_preflight_and_save(self, context): - self._teaql_preflight_graph(context) - return await self._teaql_save_within_graph(context) + async def _teaql_preflight_and_save(self, graph): + await self._teaql_reserve_graph_ids(graph, set()) + self._teaql_preflight_graph(graph) + return await self._teaql_save_within_graph(graph) def _teaql_build_command(self): payload = {} @@ -225,8 +231,8 @@ def _teaql_build_command(self): cmd = DeleteCommand("CommercePlatform", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) return action, cmd - def _teaql_preflight_graph(self, context): - MutationIntent(self._comment) + def _teaql_preflight_graph(self, graph): + context = graph.context if self._action == "Update": if "id" not in self._loaded_fields: raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) @@ -248,9 +254,8 @@ def _teaql_preflight_graph(self, context): for index, child in enumerate(self._customer_list): child._teaql_attach_root(self._entity_root) child.update_commerce_platform(self) - child.audit_as(self._comment) try: - child._teaql_preflight_graph(context) + child._teaql_preflight_graph(graph) except CheckException as error: prefix = ObjectLocation().property("customer_list").index(index) raise CheckException([ @@ -260,9 +265,8 @@ def _teaql_preflight_graph(self, context): for index, child in enumerate(self._order_status_list): child._teaql_attach_root(self._entity_root) child.update_commerce_platform(self) - child.audit_as(self._comment) try: - child._teaql_preflight_graph(context) + child._teaql_preflight_graph(graph) except CheckException as error: prefix = ObjectLocation().property("order_status_list").index(index) raise CheckException([ @@ -272,9 +276,8 @@ def _teaql_preflight_graph(self, context): for index, child in enumerate(self._customer_order_list): child._teaql_attach_root(self._entity_root) child.update_commerce_platform(self) - child.audit_as(self._comment) try: - child._teaql_preflight_graph(context) + child._teaql_preflight_graph(graph) except CheckException as error: prefix = ObjectLocation().property("customer_order_list").index(index) raise CheckException([ @@ -284,9 +287,8 @@ def _teaql_preflight_graph(self, context): for index, child in enumerate(self._product_list): child._teaql_attach_root(self._entity_root) child.update_commerce_platform(self) - child.audit_as(self._comment) try: - child._teaql_preflight_graph(context) + child._teaql_preflight_graph(graph) except CheckException as error: prefix = ObjectLocation().property("product_list").index(index) raise CheckException([ @@ -296,9 +298,8 @@ def _teaql_preflight_graph(self, context): for index, child in enumerate(self._order_line_list): child._teaql_attach_root(self._entity_root) child.update_commerce_platform(self) - child.audit_as(self._comment) try: - child._teaql_preflight_graph(context) + child._teaql_preflight_graph(graph) except CheckException as error: prefix = ObjectLocation().property("order_line_list").index(index) raise CheckException([ @@ -308,9 +309,8 @@ def _teaql_preflight_graph(self, context): for index, child in enumerate(self._order_search_preset_list): child._teaql_attach_root(self._entity_root) child.update_commerce_platform(self) - child.audit_as(self._comment) try: - child._teaql_preflight_graph(context) + child._teaql_preflight_graph(graph) except CheckException as error: prefix = ObjectLocation().property("order_search_preset_list").index(index) raise CheckException([ @@ -318,14 +318,15 @@ def _teaql_preflight_graph(self, context): for v in error.violations ]) from error - async def _teaql_save_within_graph(self, context): - intent = MutationIntent(self._comment) + async def _teaql_save_within_graph(self, graph, parent_scope=None): + context = graph.context + scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() - req = MutationRequest(cmd, comment=intent.comment) + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) try: context.check_and_fix_mutation(cmd) @@ -388,7 +389,7 @@ def rollback_entity(): self._loaded_fields = rollback_loaded_fields if old_key != new_key: self._entity_root.rekey(new_key, old_key) - context.after_graph_rollback(rollback_entity) + graph.after_rollback(rollback_entity) if action != "Delete": self._action = "Update" @@ -404,9 +405,8 @@ def rollback_entity(): for index, child in enumerate(children): child._teaql_attach_root(self._entity_root) getattr(child, updater)(self) - child.audit_as(self._comment) try: - await child._teaql_save_within_graph(context) + await child._teaql_save_within_graph(graph, scope) except CheckException as error: prefix = ObjectLocation().property(relation_name).index(index) raise CheckException([ @@ -423,7 +423,7 @@ def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.set_original_version(new_key, int(self.version)) - context.after_graph_commit(commit_entity) + graph.after_commit(commit_entity) return self def update_id(self, value): diff --git a/examples/order-management/python-lib-core/models/customer.py b/examples/order-management/python-lib-core/models/customer.py index 01382e0..d9742dc 100644 --- a/examples/order-management/python-lib-core/models/customer.py +++ b/examples/order-management/python-lib-core/models/customer.py @@ -97,18 +97,14 @@ def audit_as(self, comment: str): async def save(self, context): intent = MutationIntent(self._comment) - # Reserve stable internal IDs before the transaction and before policy - # review. A relation to a new parent must have the same identity in the - # reviewed graph plan and in the commands eventually sent to SQL. - await self._teaql_reserve_graph_ids(context, set()) - await self._teaql_ensure_business_ids(context) - return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context), comment=intent.comment) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) async def _teaql_ensure_business_ids(self, context): if self._action != "Create": return - async def _teaql_reserve_graph_ids(self, context, visited): + async def _teaql_reserve_graph_ids(self, graph, visited): + context = graph.context object_identity = id(self) if object_identity in visited: return @@ -121,19 +117,29 @@ async def _teaql_reserve_graph_ids(self, context, visited): "Configured dataService does not support stable ID reservation" ) old_key = self._teaql_entity_key() + old_loaded = set(self._loaded_fields) self.id = int(await allocator("Customer")) self._loaded_fields.add("id") self._ledger_id = self.id new_key = self._teaql_entity_key() self._entity_root.rekey(old_key, new_key) self._entity_root.set(new_key, "id", Value.I64(self.id)) + def rollback_reservation(): + self.id = None + self._ledger_id = old_key.id + self._loaded_fields = old_loaded + self._entity_root.rekey(new_key, old_key) + self._entity_root.set(old_key, "id", Value.from_any(None)) + graph.after_rollback(rollback_reservation) + await self._teaql_ensure_business_ids(context) for child in self._customer_order_list: child._teaql_attach_root(self._entity_root) - await child._teaql_reserve_graph_ids(context, visited) + await child._teaql_reserve_graph_ids(graph, visited) - async def _teaql_preflight_and_save(self, context): - self._teaql_preflight_graph(context) - return await self._teaql_save_within_graph(context) + async def _teaql_preflight_and_save(self, graph): + await self._teaql_reserve_graph_ids(graph, set()) + self._teaql_preflight_graph(graph) + return await self._teaql_save_within_graph(graph) def _teaql_build_command(self): payload = {} @@ -176,8 +182,8 @@ def _teaql_build_command(self): cmd = DeleteCommand("Customer", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) return action, cmd - def _teaql_preflight_graph(self, context): - MutationIntent(self._comment) + def _teaql_preflight_graph(self, graph): + context = graph.context if self._action == "Update": if "id" not in self._loaded_fields: raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) @@ -203,9 +209,8 @@ def _teaql_preflight_graph(self, context): for index, child in enumerate(self._customer_order_list): child._teaql_attach_root(self._entity_root) child.update_customer(self) - child.audit_as(self._comment) try: - child._teaql_preflight_graph(context) + child._teaql_preflight_graph(graph) except CheckException as error: prefix = ObjectLocation().property("customer_order_list").index(index) raise CheckException([ @@ -213,14 +218,15 @@ def _teaql_preflight_graph(self, context): for v in error.violations ]) from error - async def _teaql_save_within_graph(self, context): - intent = MutationIntent(self._comment) + async def _teaql_save_within_graph(self, graph, parent_scope=None): + context = graph.context + scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() - req = MutationRequest(cmd, comment=intent.comment) + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) try: context.check_and_fix_mutation(cmd) @@ -295,7 +301,7 @@ def rollback_entity(): self._loaded_fields = rollback_loaded_fields if old_key != new_key: self._entity_root.rekey(new_key, old_key) - context.after_graph_rollback(rollback_entity) + graph.after_rollback(rollback_entity) if action != "Delete": self._action = "Update" @@ -306,9 +312,8 @@ def rollback_entity(): for index, child in enumerate(children): child._teaql_attach_root(self._entity_root) getattr(child, updater)(self) - child.audit_as(self._comment) try: - await child._teaql_save_within_graph(context) + await child._teaql_save_within_graph(graph, scope) except CheckException as error: prefix = ObjectLocation().property(relation_name).index(index) raise CheckException([ @@ -325,7 +330,7 @@ def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.set_original_version(new_key, int(self.version)) - context.after_graph_commit(commit_entity) + graph.after_commit(commit_entity) return self def update_id(self, value): diff --git a/examples/order-management/python-lib-core/models/customer_order.py b/examples/order-management/python-lib-core/models/customer_order.py index 5754f29..4b4474f 100644 --- a/examples/order-management/python-lib-core/models/customer_order.py +++ b/examples/order-management/python-lib-core/models/customer_order.py @@ -115,18 +115,14 @@ def audit_as(self, comment: str): async def save(self, context): intent = MutationIntent(self._comment) - # Reserve stable internal IDs before the transaction and before policy - # review. A relation to a new parent must have the same identity in the - # reviewed graph plan and in the commands eventually sent to SQL. - await self._teaql_reserve_graph_ids(context, set()) - await self._teaql_ensure_business_ids(context) - return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context), comment=intent.comment) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) async def _teaql_ensure_business_ids(self, context): if self._action != "Create": return - async def _teaql_reserve_graph_ids(self, context, visited): + async def _teaql_reserve_graph_ids(self, graph, visited): + context = graph.context object_identity = id(self) if object_identity in visited: return @@ -139,19 +135,29 @@ async def _teaql_reserve_graph_ids(self, context, visited): "Configured dataService does not support stable ID reservation" ) old_key = self._teaql_entity_key() + old_loaded = set(self._loaded_fields) self.id = int(await allocator("CustomerOrder")) self._loaded_fields.add("id") self._ledger_id = self.id new_key = self._teaql_entity_key() self._entity_root.rekey(old_key, new_key) self._entity_root.set(new_key, "id", Value.I64(self.id)) + def rollback_reservation(): + self.id = None + self._ledger_id = old_key.id + self._loaded_fields = old_loaded + self._entity_root.rekey(new_key, old_key) + self._entity_root.set(old_key, "id", Value.from_any(None)) + graph.after_rollback(rollback_reservation) + await self._teaql_ensure_business_ids(context) for child in self._order_line_list: child._teaql_attach_root(self._entity_root) - await child._teaql_reserve_graph_ids(context, visited) + await child._teaql_reserve_graph_ids(graph, visited) - async def _teaql_preflight_and_save(self, context): - self._teaql_preflight_graph(context) - return await self._teaql_save_within_graph(context) + async def _teaql_preflight_and_save(self, graph): + await self._teaql_reserve_graph_ids(graph, set()) + self._teaql_preflight_graph(graph) + return await self._teaql_save_within_graph(graph) def _teaql_build_command(self): payload = {} @@ -211,8 +217,8 @@ def _teaql_build_command(self): cmd = DeleteCommand("CustomerOrder", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) return action, cmd - def _teaql_preflight_graph(self, context): - MutationIntent(self._comment) + def _teaql_preflight_graph(self, graph): + context = graph.context if self._action == "Update": if "id" not in self._loaded_fields: raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) @@ -244,9 +250,8 @@ def _teaql_preflight_graph(self, context): for index, child in enumerate(self._order_line_list): child._teaql_attach_root(self._entity_root) child.update_customer_order(self) - child.audit_as(self._comment) try: - child._teaql_preflight_graph(context) + child._teaql_preflight_graph(graph) except CheckException as error: prefix = ObjectLocation().property("order_line_list").index(index) raise CheckException([ @@ -254,14 +259,15 @@ def _teaql_preflight_graph(self, context): for v in error.violations ]) from error - async def _teaql_save_within_graph(self, context): - intent = MutationIntent(self._comment) + async def _teaql_save_within_graph(self, graph, parent_scope=None): + context = graph.context + scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() - req = MutationRequest(cmd, comment=intent.comment) + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) try: context.check_and_fix_mutation(cmd) @@ -354,7 +360,7 @@ def rollback_entity(): self._loaded_fields = rollback_loaded_fields if old_key != new_key: self._entity_root.rekey(new_key, old_key) - context.after_graph_rollback(rollback_entity) + graph.after_rollback(rollback_entity) if action != "Delete": self._action = "Update" @@ -365,9 +371,8 @@ def rollback_entity(): for index, child in enumerate(children): child._teaql_attach_root(self._entity_root) getattr(child, updater)(self) - child.audit_as(self._comment) try: - await child._teaql_save_within_graph(context) + await child._teaql_save_within_graph(graph, scope) except CheckException as error: prefix = ObjectLocation().property(relation_name).index(index) raise CheckException([ @@ -384,7 +389,7 @@ def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.set_original_version(new_key, int(self.version)) - context.after_graph_commit(commit_entity) + graph.after_commit(commit_entity) return self def update_id(self, value): diff --git a/examples/order-management/python-lib-core/models/order_line.py b/examples/order-management/python-lib-core/models/order_line.py index f91d252..6f87ee0 100644 --- a/examples/order-management/python-lib-core/models/order_line.py +++ b/examples/order-management/python-lib-core/models/order_line.py @@ -100,18 +100,14 @@ def audit_as(self, comment: str): async def save(self, context): intent = MutationIntent(self._comment) - # Reserve stable internal IDs before the transaction and before policy - # review. A relation to a new parent must have the same identity in the - # reviewed graph plan and in the commands eventually sent to SQL. - await self._teaql_reserve_graph_ids(context, set()) - await self._teaql_ensure_business_ids(context) - return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context), comment=intent.comment) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) async def _teaql_ensure_business_ids(self, context): if self._action != "Create": return - async def _teaql_reserve_graph_ids(self, context, visited): + async def _teaql_reserve_graph_ids(self, graph, visited): + context = graph.context object_identity = id(self) if object_identity in visited: return @@ -124,16 +120,26 @@ async def _teaql_reserve_graph_ids(self, context, visited): "Configured dataService does not support stable ID reservation" ) old_key = self._teaql_entity_key() + old_loaded = set(self._loaded_fields) self.id = int(await allocator("OrderLine")) self._loaded_fields.add("id") self._ledger_id = self.id new_key = self._teaql_entity_key() self._entity_root.rekey(old_key, new_key) self._entity_root.set(new_key, "id", Value.I64(self.id)) + def rollback_reservation(): + self.id = None + self._ledger_id = old_key.id + self._loaded_fields = old_loaded + self._entity_root.rekey(new_key, old_key) + self._entity_root.set(old_key, "id", Value.from_any(None)) + graph.after_rollback(rollback_reservation) + await self._teaql_ensure_business_ids(context) - async def _teaql_preflight_and_save(self, context): - self._teaql_preflight_graph(context) - return await self._teaql_save_within_graph(context) + async def _teaql_preflight_and_save(self, graph): + await self._teaql_reserve_graph_ids(graph, set()) + self._teaql_preflight_graph(graph) + return await self._teaql_save_within_graph(graph) def _teaql_build_command(self): payload = {} @@ -190,8 +196,8 @@ def _teaql_build_command(self): cmd = DeleteCommand("OrderLine", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) return action, cmd - def _teaql_preflight_graph(self, context): - MutationIntent(self._comment) + def _teaql_preflight_graph(self, graph): + context = graph.context if self._action == "Update": if "id" not in self._loaded_fields: raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) @@ -219,14 +225,15 @@ def _teaql_preflight_graph(self, context): if field not in ("id", "version"): self._entity_root.set(self._teaql_entity_key(), field, value) - async def _teaql_save_within_graph(self, context): - intent = MutationIntent(self._comment) + async def _teaql_save_within_graph(self, graph, parent_scope=None): + context = graph.context + scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() - req = MutationRequest(cmd, comment=intent.comment) + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) try: context.check_and_fix_mutation(cmd) @@ -313,7 +320,7 @@ def rollback_entity(): self._loaded_fields = rollback_loaded_fields if old_key != new_key: self._entity_root.rekey(new_key, old_key) - context.after_graph_rollback(rollback_entity) + graph.after_rollback(rollback_entity) if action != "Delete": self._action = "Update" @@ -323,9 +330,8 @@ def rollback_entity(): for index, child in enumerate(children): child._teaql_attach_root(self._entity_root) getattr(child, updater)(self) - child.audit_as(self._comment) try: - await child._teaql_save_within_graph(context) + await child._teaql_save_within_graph(graph, scope) except CheckException as error: prefix = ObjectLocation().property(relation_name).index(index) raise CheckException([ @@ -342,7 +348,7 @@ def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.set_original_version(new_key, int(self.version)) - context.after_graph_commit(commit_entity) + graph.after_commit(commit_entity) return self def update_id(self, value): diff --git a/examples/order-management/python-lib-core/models/order_search_preset.py b/examples/order-management/python-lib-core/models/order_search_preset.py index bf817d7..8d3b5fe 100644 --- a/examples/order-management/python-lib-core/models/order_search_preset.py +++ b/examples/order-management/python-lib-core/models/order_search_preset.py @@ -94,18 +94,14 @@ def audit_as(self, comment: str): async def save(self, context): intent = MutationIntent(self._comment) - # Reserve stable internal IDs before the transaction and before policy - # review. A relation to a new parent must have the same identity in the - # reviewed graph plan and in the commands eventually sent to SQL. - await self._teaql_reserve_graph_ids(context, set()) - await self._teaql_ensure_business_ids(context) - return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context), comment=intent.comment) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) async def _teaql_ensure_business_ids(self, context): if self._action != "Create": return - async def _teaql_reserve_graph_ids(self, context, visited): + async def _teaql_reserve_graph_ids(self, graph, visited): + context = graph.context object_identity = id(self) if object_identity in visited: return @@ -118,16 +114,26 @@ async def _teaql_reserve_graph_ids(self, context, visited): "Configured dataService does not support stable ID reservation" ) old_key = self._teaql_entity_key() + old_loaded = set(self._loaded_fields) self.id = int(await allocator("OrderSearchPreset")) self._loaded_fields.add("id") self._ledger_id = self.id new_key = self._teaql_entity_key() self._entity_root.rekey(old_key, new_key) self._entity_root.set(new_key, "id", Value.I64(self.id)) + def rollback_reservation(): + self.id = None + self._ledger_id = old_key.id + self._loaded_fields = old_loaded + self._entity_root.rekey(new_key, old_key) + self._entity_root.set(old_key, "id", Value.from_any(None)) + graph.after_rollback(rollback_reservation) + await self._teaql_ensure_business_ids(context) - async def _teaql_preflight_and_save(self, context): - self._teaql_preflight_graph(context) - return await self._teaql_save_within_graph(context) + async def _teaql_preflight_and_save(self, graph): + await self._teaql_reserve_graph_ids(graph, set()) + self._teaql_preflight_graph(graph) + return await self._teaql_save_within_graph(graph) def _teaql_build_command(self): payload = {} @@ -176,8 +182,8 @@ def _teaql_build_command(self): cmd = DeleteCommand("OrderSearchPreset", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) return action, cmd - def _teaql_preflight_graph(self, context): - MutationIntent(self._comment) + def _teaql_preflight_graph(self, graph): + context = graph.context if self._action == "Update": if "id" not in self._loaded_fields: raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) @@ -205,14 +211,15 @@ def _teaql_preflight_graph(self, context): if field not in ("id", "version"): self._entity_root.set(self._teaql_entity_key(), field, value) - async def _teaql_save_within_graph(self, context): - intent = MutationIntent(self._comment) + async def _teaql_save_within_graph(self, graph, parent_scope=None): + context = graph.context + scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() - req = MutationRequest(cmd, comment=intent.comment) + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) try: context.check_and_fix_mutation(cmd) @@ -299,7 +306,7 @@ def rollback_entity(): self._loaded_fields = rollback_loaded_fields if old_key != new_key: self._entity_root.rekey(new_key, old_key) - context.after_graph_rollback(rollback_entity) + graph.after_rollback(rollback_entity) if action != "Delete": self._action = "Update" @@ -309,9 +316,8 @@ def rollback_entity(): for index, child in enumerate(children): child._teaql_attach_root(self._entity_root) getattr(child, updater)(self) - child.audit_as(self._comment) try: - await child._teaql_save_within_graph(context) + await child._teaql_save_within_graph(graph, scope) except CheckException as error: prefix = ObjectLocation().property(relation_name).index(index) raise CheckException([ @@ -328,7 +334,7 @@ def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.set_original_version(new_key, int(self.version)) - context.after_graph_commit(commit_entity) + graph.after_commit(commit_entity) return self def update_id(self, value): diff --git a/examples/order-management/python-lib-core/models/order_status.py b/examples/order-management/python-lib-core/models/order_status.py index ea7e4da..8b6a744 100644 --- a/examples/order-management/python-lib-core/models/order_status.py +++ b/examples/order-management/python-lib-core/models/order_status.py @@ -97,18 +97,14 @@ def audit_as(self, comment: str): async def save(self, context): intent = MutationIntent(self._comment) - # Reserve stable internal IDs before the transaction and before policy - # review. A relation to a new parent must have the same identity in the - # reviewed graph plan and in the commands eventually sent to SQL. - await self._teaql_reserve_graph_ids(context, set()) - await self._teaql_ensure_business_ids(context) - return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context), comment=intent.comment) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) async def _teaql_ensure_business_ids(self, context): if self._action != "Create": return - async def _teaql_reserve_graph_ids(self, context, visited): + async def _teaql_reserve_graph_ids(self, graph, visited): + context = graph.context object_identity = id(self) if object_identity in visited: return @@ -121,19 +117,29 @@ async def _teaql_reserve_graph_ids(self, context, visited): "Configured dataService does not support stable ID reservation" ) old_key = self._teaql_entity_key() + old_loaded = set(self._loaded_fields) self.id = int(await allocator("OrderStatus")) self._loaded_fields.add("id") self._ledger_id = self.id new_key = self._teaql_entity_key() self._entity_root.rekey(old_key, new_key) self._entity_root.set(new_key, "id", Value.I64(self.id)) + def rollback_reservation(): + self.id = None + self._ledger_id = old_key.id + self._loaded_fields = old_loaded + self._entity_root.rekey(new_key, old_key) + self._entity_root.set(old_key, "id", Value.from_any(None)) + graph.after_rollback(rollback_reservation) + await self._teaql_ensure_business_ids(context) for child in self._customer_order_list: child._teaql_attach_root(self._entity_root) - await child._teaql_reserve_graph_ids(context, visited) + await child._teaql_reserve_graph_ids(graph, visited) - async def _teaql_preflight_and_save(self, context): - self._teaql_preflight_graph(context) - return await self._teaql_save_within_graph(context) + async def _teaql_preflight_and_save(self, graph): + await self._teaql_reserve_graph_ids(graph, set()) + self._teaql_preflight_graph(graph) + return await self._teaql_save_within_graph(graph) def _teaql_build_command(self): payload = {} @@ -176,8 +182,8 @@ def _teaql_build_command(self): cmd = DeleteCommand("OrderStatus", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) return action, cmd - def _teaql_preflight_graph(self, context): - MutationIntent(self._comment) + def _teaql_preflight_graph(self, graph): + context = graph.context if self._action == "Update": if "id" not in self._loaded_fields: raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) @@ -203,9 +209,8 @@ def _teaql_preflight_graph(self, context): for index, child in enumerate(self._customer_order_list): child._teaql_attach_root(self._entity_root) child.update_status(self) - child.audit_as(self._comment) try: - child._teaql_preflight_graph(context) + child._teaql_preflight_graph(graph) except CheckException as error: prefix = ObjectLocation().property("customer_order_list").index(index) raise CheckException([ @@ -213,14 +218,15 @@ def _teaql_preflight_graph(self, context): for v in error.violations ]) from error - async def _teaql_save_within_graph(self, context): - intent = MutationIntent(self._comment) + async def _teaql_save_within_graph(self, graph, parent_scope=None): + context = graph.context + scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() - req = MutationRequest(cmd, comment=intent.comment) + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) try: context.check_and_fix_mutation(cmd) @@ -295,7 +301,7 @@ def rollback_entity(): self._loaded_fields = rollback_loaded_fields if old_key != new_key: self._entity_root.rekey(new_key, old_key) - context.after_graph_rollback(rollback_entity) + graph.after_rollback(rollback_entity) if action != "Delete": self._action = "Update" @@ -306,9 +312,8 @@ def rollback_entity(): for index, child in enumerate(children): child._teaql_attach_root(self._entity_root) getattr(child, updater)(self) - child.audit_as(self._comment) try: - await child._teaql_save_within_graph(context) + await child._teaql_save_within_graph(graph, scope) except CheckException as error: prefix = ObjectLocation().property(relation_name).index(index) raise CheckException([ @@ -325,7 +330,7 @@ def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.set_original_version(new_key, int(self.version)) - context.after_graph_commit(commit_entity) + graph.after_commit(commit_entity) return self def update_id(self, value): diff --git a/examples/order-management/python-lib-core/models/product.py b/examples/order-management/python-lib-core/models/product.py index ab45aa0..08e50e2 100644 --- a/examples/order-management/python-lib-core/models/product.py +++ b/examples/order-management/python-lib-core/models/product.py @@ -101,18 +101,14 @@ def audit_as(self, comment: str): async def save(self, context): intent = MutationIntent(self._comment) - # Reserve stable internal IDs before the transaction and before policy - # review. A relation to a new parent must have the same identity in the - # reviewed graph plan and in the commands eventually sent to SQL. - await self._teaql_reserve_graph_ids(context, set()) - await self._teaql_ensure_business_ids(context) - return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context), comment=intent.comment) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) async def _teaql_ensure_business_ids(self, context): if self._action != "Create": return - async def _teaql_reserve_graph_ids(self, context, visited): + async def _teaql_reserve_graph_ids(self, graph, visited): + context = graph.context object_identity = id(self) if object_identity in visited: return @@ -125,19 +121,29 @@ async def _teaql_reserve_graph_ids(self, context, visited): "Configured dataService does not support stable ID reservation" ) old_key = self._teaql_entity_key() + old_loaded = set(self._loaded_fields) self.id = int(await allocator("Product")) self._loaded_fields.add("id") self._ledger_id = self.id new_key = self._teaql_entity_key() self._entity_root.rekey(old_key, new_key) self._entity_root.set(new_key, "id", Value.I64(self.id)) + def rollback_reservation(): + self.id = None + self._ledger_id = old_key.id + self._loaded_fields = old_loaded + self._entity_root.rekey(new_key, old_key) + self._entity_root.set(old_key, "id", Value.from_any(None)) + graph.after_rollback(rollback_reservation) + await self._teaql_ensure_business_ids(context) for child in self._order_line_list: child._teaql_attach_root(self._entity_root) - await child._teaql_reserve_graph_ids(context, visited) + await child._teaql_reserve_graph_ids(graph, visited) - async def _teaql_preflight_and_save(self, context): - self._teaql_preflight_graph(context) - return await self._teaql_save_within_graph(context) + async def _teaql_preflight_and_save(self, graph): + await self._teaql_reserve_graph_ids(graph, set()) + self._teaql_preflight_graph(graph) + return await self._teaql_save_within_graph(graph) def _teaql_build_command(self): payload = {} @@ -183,8 +189,8 @@ def _teaql_build_command(self): cmd = DeleteCommand("Product", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) return action, cmd - def _teaql_preflight_graph(self, context): - MutationIntent(self._comment) + def _teaql_preflight_graph(self, graph): + context = graph.context if self._action == "Update": if "id" not in self._loaded_fields: raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) @@ -212,9 +218,8 @@ def _teaql_preflight_graph(self, context): for index, child in enumerate(self._order_line_list): child._teaql_attach_root(self._entity_root) child.update_product(self) - child.audit_as(self._comment) try: - child._teaql_preflight_graph(context) + child._teaql_preflight_graph(graph) except CheckException as error: prefix = ObjectLocation().property("order_line_list").index(index) raise CheckException([ @@ -222,14 +227,15 @@ def _teaql_preflight_graph(self, context): for v in error.violations ]) from error - async def _teaql_save_within_graph(self, context): - intent = MutationIntent(self._comment) + async def _teaql_save_within_graph(self, graph, parent_scope=None): + context = graph.context + scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() - req = MutationRequest(cmd, comment=intent.comment) + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) try: context.check_and_fix_mutation(cmd) @@ -310,7 +316,7 @@ def rollback_entity(): self._loaded_fields = rollback_loaded_fields if old_key != new_key: self._entity_root.rekey(new_key, old_key) - context.after_graph_rollback(rollback_entity) + graph.after_rollback(rollback_entity) if action != "Delete": self._action = "Update" @@ -321,9 +327,8 @@ def rollback_entity(): for index, child in enumerate(children): child._teaql_attach_root(self._entity_root) getattr(child, updater)(self) - child.audit_as(self._comment) try: - await child._teaql_save_within_graph(context) + await child._teaql_save_within_graph(graph, scope) except CheckException as error: prefix = ObjectLocation().property(relation_name).index(index) raise CheckException([ @@ -340,7 +345,7 @@ def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.set_original_version(new_key, int(self.version)) - context.after_graph_commit(commit_entity) + graph.after_commit(commit_entity) return self def update_id(self, value): diff --git a/examples/school-management/models/platform.py b/examples/school-management/models/platform.py index 3579720..7a53552 100644 --- a/examples/school-management/models/platform.py +++ b/examples/school-management/models/platform.py @@ -101,18 +101,14 @@ def audit_as(self, comment: str): async def save(self, context): intent = MutationIntent(self._comment) - # Reserve stable internal IDs before the transaction and before policy - # review. A relation to a new parent must have the same identity in the - # reviewed graph plan and in the commands eventually sent to SQL. - await self._teaql_reserve_graph_ids(context, set()) - await self._teaql_ensure_business_ids(context) - return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context), comment=intent.comment) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) async def _teaql_ensure_business_ids(self, context): if self._action != "Create": return - async def _teaql_reserve_graph_ids(self, context, visited): + async def _teaql_reserve_graph_ids(self, graph, visited): + context = graph.context object_identity = id(self) if object_identity in visited: return @@ -125,22 +121,32 @@ async def _teaql_reserve_graph_ids(self, context, visited): "Configured dataService does not support stable ID reservation" ) old_key = self._teaql_entity_key() + old_loaded = set(self._loaded_fields) self.id = int(await allocator("Platform")) self._loaded_fields.add("id") self._ledger_id = self.id new_key = self._teaql_entity_key() self._entity_root.rekey(old_key, new_key) self._entity_root.set(new_key, "id", Value.I64(self.id)) + def rollback_reservation(): + self.id = None + self._ledger_id = old_key.id + self._loaded_fields = old_loaded + self._entity_root.rekey(new_key, old_key) + self._entity_root.set(old_key, "id", Value.from_any(None)) + graph.after_rollback(rollback_reservation) + await self._teaql_ensure_business_ids(context) for child in self._school_type_list: child._teaql_attach_root(self._entity_root) - await child._teaql_reserve_graph_ids(context, visited) + await child._teaql_reserve_graph_ids(graph, visited) for child in self._school_list: child._teaql_attach_root(self._entity_root) - await child._teaql_reserve_graph_ids(context, visited) + await child._teaql_reserve_graph_ids(graph, visited) - async def _teaql_preflight_and_save(self, context): - self._teaql_preflight_graph(context) - return await self._teaql_save_within_graph(context) + async def _teaql_preflight_and_save(self, graph): + await self._teaql_reserve_graph_ids(graph, set()) + self._teaql_preflight_graph(graph) + return await self._teaql_save_within_graph(graph) def _teaql_build_command(self): payload = {} @@ -176,8 +182,8 @@ def _teaql_build_command(self): cmd = DeleteCommand("Platform", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) return action, cmd - def _teaql_preflight_graph(self, context): - MutationIntent(self._comment) + def _teaql_preflight_graph(self, graph): + context = graph.context if self._action == "Update": if "id" not in self._loaded_fields: raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) @@ -201,9 +207,8 @@ def _teaql_preflight_graph(self, context): for index, child in enumerate(self._school_type_list): child._teaql_attach_root(self._entity_root) child.update_platform(self) - child.audit_as(self._comment) try: - child._teaql_preflight_graph(context) + child._teaql_preflight_graph(graph) except CheckException as error: prefix = ObjectLocation().property("school_type_list").index(index) raise CheckException([ @@ -213,9 +218,8 @@ def _teaql_preflight_graph(self, context): for index, child in enumerate(self._school_list): child._teaql_attach_root(self._entity_root) child.update_platform(self) - child.audit_as(self._comment) try: - child._teaql_preflight_graph(context) + child._teaql_preflight_graph(graph) except CheckException as error: prefix = ObjectLocation().property("school_list").index(index) raise CheckException([ @@ -223,14 +227,15 @@ def _teaql_preflight_graph(self, context): for v in error.violations ]) from error - async def _teaql_save_within_graph(self, context): - intent = MutationIntent(self._comment) + async def _teaql_save_within_graph(self, graph, parent_scope=None): + context = graph.context + scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() - req = MutationRequest(cmd, comment=intent.comment) + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) try: context.check_and_fix_mutation(cmd) @@ -299,7 +304,7 @@ def rollback_entity(): self._loaded_fields = rollback_loaded_fields if old_key != new_key: self._entity_root.rekey(new_key, old_key) - context.after_graph_rollback(rollback_entity) + graph.after_rollback(rollback_entity) if action != "Delete": self._action = "Update" @@ -311,9 +316,8 @@ def rollback_entity(): for index, child in enumerate(children): child._teaql_attach_root(self._entity_root) getattr(child, updater)(self) - child.audit_as(self._comment) try: - await child._teaql_save_within_graph(context) + await child._teaql_save_within_graph(graph, scope) except CheckException as error: prefix = ObjectLocation().property(relation_name).index(index) raise CheckException([ @@ -330,7 +334,7 @@ def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.set_original_version(new_key, int(self.version)) - context.after_graph_commit(commit_entity) + graph.after_commit(commit_entity) return self def update_id(self, value): diff --git a/examples/school-management/models/school.py b/examples/school-management/models/school.py index 7742ee6..04c9079 100644 --- a/examples/school-management/models/school.py +++ b/examples/school-management/models/school.py @@ -105,18 +105,14 @@ def audit_as(self, comment: str): async def save(self, context): intent = MutationIntent(self._comment) - # Reserve stable internal IDs before the transaction and before policy - # review. A relation to a new parent must have the same identity in the - # reviewed graph plan and in the commands eventually sent to SQL. - await self._teaql_reserve_graph_ids(context, set()) - await self._teaql_ensure_business_ids(context) - return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context), comment=intent.comment) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) async def _teaql_ensure_business_ids(self, context): if self._action != "Create": return - async def _teaql_reserve_graph_ids(self, context, visited): + async def _teaql_reserve_graph_ids(self, graph, visited): + context = graph.context object_identity = id(self) if object_identity in visited: return @@ -129,16 +125,26 @@ async def _teaql_reserve_graph_ids(self, context, visited): "Configured dataService does not support stable ID reservation" ) old_key = self._teaql_entity_key() + old_loaded = set(self._loaded_fields) self.id = int(await allocator("School")) self._loaded_fields.add("id") self._ledger_id = self.id new_key = self._teaql_entity_key() self._entity_root.rekey(old_key, new_key) self._entity_root.set(new_key, "id", Value.I64(self.id)) + def rollback_reservation(): + self.id = None + self._ledger_id = old_key.id + self._loaded_fields = old_loaded + self._entity_root.rekey(new_key, old_key) + self._entity_root.set(old_key, "id", Value.from_any(None)) + graph.after_rollback(rollback_reservation) + await self._teaql_ensure_business_ids(context) - async def _teaql_preflight_and_save(self, context): - self._teaql_preflight_graph(context) - return await self._teaql_save_within_graph(context) + async def _teaql_preflight_and_save(self, graph): + await self._teaql_reserve_graph_ids(graph, set()) + self._teaql_preflight_graph(graph) + return await self._teaql_save_within_graph(graph) def _teaql_build_command(self): payload = {} @@ -197,8 +203,8 @@ def _teaql_build_command(self): cmd = DeleteCommand("School", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) return action, cmd - def _teaql_preflight_graph(self, context): - MutationIntent(self._comment) + def _teaql_preflight_graph(self, graph): + context = graph.context if self._action == "Update": if "id" not in self._loaded_fields: raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) @@ -230,14 +236,15 @@ def _teaql_preflight_graph(self, context): if field not in ("id", "version"): self._entity_root.set(self._teaql_entity_key(), field, value) - async def _teaql_save_within_graph(self, context): - intent = MutationIntent(self._comment) + async def _teaql_save_within_graph(self, graph, parent_scope=None): + context = graph.context + scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() - req = MutationRequest(cmd, comment=intent.comment) + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) try: context.check_and_fix_mutation(cmd) @@ -336,7 +343,7 @@ def rollback_entity(): self._loaded_fields = rollback_loaded_fields if old_key != new_key: self._entity_root.rekey(new_key, old_key) - context.after_graph_rollback(rollback_entity) + graph.after_rollback(rollback_entity) if action != "Delete": self._action = "Update" @@ -346,9 +353,8 @@ def rollback_entity(): for index, child in enumerate(children): child._teaql_attach_root(self._entity_root) getattr(child, updater)(self) - child.audit_as(self._comment) try: - await child._teaql_save_within_graph(context) + await child._teaql_save_within_graph(graph, scope) except CheckException as error: prefix = ObjectLocation().property(relation_name).index(index) raise CheckException([ @@ -365,7 +371,7 @@ def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.set_original_version(new_key, int(self.version)) - context.after_graph_commit(commit_entity) + graph.after_commit(commit_entity) return self def update_id(self, value): diff --git a/examples/school-management/models/school_type.py b/examples/school-management/models/school_type.py index a70be0a..3cf3c17 100644 --- a/examples/school-management/models/school_type.py +++ b/examples/school-management/models/school_type.py @@ -93,18 +93,14 @@ def audit_as(self, comment: str): async def save(self, context): intent = MutationIntent(self._comment) - # Reserve stable internal IDs before the transaction and before policy - # review. A relation to a new parent must have the same identity in the - # reviewed graph plan and in the commands eventually sent to SQL. - await self._teaql_reserve_graph_ids(context, set()) - await self._teaql_ensure_business_ids(context) - return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context), comment=intent.comment) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) async def _teaql_ensure_business_ids(self, context): if self._action != "Create": return - async def _teaql_reserve_graph_ids(self, context, visited): + async def _teaql_reserve_graph_ids(self, graph, visited): + context = graph.context object_identity = id(self) if object_identity in visited: return @@ -117,19 +113,29 @@ async def _teaql_reserve_graph_ids(self, context, visited): "Configured dataService does not support stable ID reservation" ) old_key = self._teaql_entity_key() + old_loaded = set(self._loaded_fields) self.id = int(await allocator("SchoolType")) self._loaded_fields.add("id") self._ledger_id = self.id new_key = self._teaql_entity_key() self._entity_root.rekey(old_key, new_key) self._entity_root.set(new_key, "id", Value.I64(self.id)) + def rollback_reservation(): + self.id = None + self._ledger_id = old_key.id + self._loaded_fields = old_loaded + self._entity_root.rekey(new_key, old_key) + self._entity_root.set(old_key, "id", Value.from_any(None)) + graph.after_rollback(rollback_reservation) + await self._teaql_ensure_business_ids(context) for child in self._school_list: child._teaql_attach_root(self._entity_root) - await child._teaql_reserve_graph_ids(context, visited) + await child._teaql_reserve_graph_ids(graph, visited) - async def _teaql_preflight_and_save(self, context): - self._teaql_preflight_graph(context) - return await self._teaql_save_within_graph(context) + async def _teaql_preflight_and_save(self, graph): + await self._teaql_reserve_graph_ids(graph, set()) + self._teaql_preflight_graph(graph) + return await self._teaql_save_within_graph(graph) def _teaql_build_command(self): payload = {} @@ -169,8 +175,8 @@ def _teaql_build_command(self): cmd = DeleteCommand("SchoolType", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) return action, cmd - def _teaql_preflight_graph(self, context): - MutationIntent(self._comment) + def _teaql_preflight_graph(self, graph): + context = graph.context if self._action == "Update": if "platform" not in self._loaded_fields: raise CheckException([CheckResult("invalid_type", ObjectLocation().property("platform"), message="Mutation requires a fully loaded entity")]) @@ -194,9 +200,8 @@ def _teaql_preflight_graph(self, context): for index, child in enumerate(self._school_list): child._teaql_attach_root(self._entity_root) child.update_school_type(self) - child.audit_as(self._comment) try: - child._teaql_preflight_graph(context) + child._teaql_preflight_graph(graph) except CheckException as error: prefix = ObjectLocation().property("school_list").index(index) raise CheckException([ @@ -204,14 +209,15 @@ def _teaql_preflight_graph(self, context): for v in error.violations ]) from error - async def _teaql_save_within_graph(self, context): - intent = MutationIntent(self._comment) + async def _teaql_save_within_graph(self, graph, parent_scope=None): + context = graph.context + scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() - req = MutationRequest(cmd, comment=intent.comment) + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) try: context.check_and_fix_mutation(cmd) @@ -280,7 +286,7 @@ def rollback_entity(): self._loaded_fields = rollback_loaded_fields if old_key != new_key: self._entity_root.rekey(new_key, old_key) - context.after_graph_rollback(rollback_entity) + graph.after_rollback(rollback_entity) if action != "Delete": self._action = "Update" @@ -291,9 +297,8 @@ def rollback_entity(): for index, child in enumerate(children): child._teaql_attach_root(self._entity_root) getattr(child, updater)(self) - child.audit_as(self._comment) try: - await child._teaql_save_within_graph(context) + await child._teaql_save_within_graph(graph, scope) except CheckException as error: prefix = ObjectLocation().property(relation_name).index(index) raise CheckException([ @@ -310,7 +315,7 @@ def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.set_original_version(new_key, int(self.version)) - context.after_graph_commit(commit_entity) + graph.after_commit(commit_entity) return self def update_id(self, value): diff --git a/examples/task_board/generated/models/platform.py b/examples/task_board/generated/models/platform.py index 2db2f33..16c2c17 100644 --- a/examples/task_board/generated/models/platform.py +++ b/examples/task_board/generated/models/platform.py @@ -97,18 +97,14 @@ def audit_as(self, comment: str): async def save(self, context): intent = MutationIntent(self._comment) - # Reserve stable internal IDs before the transaction and before policy - # review. A relation to a new parent must have the same identity in the - # reviewed graph plan and in the commands eventually sent to SQL. - await self._teaql_reserve_graph_ids(context, set()) - await self._teaql_ensure_business_ids(context) - return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context), comment=intent.comment) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) async def _teaql_ensure_business_ids(self, context): if self._action != "Create": return - async def _teaql_reserve_graph_ids(self, context, visited): + async def _teaql_reserve_graph_ids(self, graph, visited): + context = graph.context object_identity = id(self) if object_identity in visited: return @@ -121,22 +117,32 @@ async def _teaql_reserve_graph_ids(self, context, visited): "Configured dataService does not support stable ID reservation" ) old_key = self._teaql_entity_key() + old_loaded = set(self._loaded_fields) self.id = int(await allocator("Platform")) self._loaded_fields.add("id") self._ledger_id = self.id new_key = self._teaql_entity_key() self._entity_root.rekey(old_key, new_key) self._entity_root.set(new_key, "id", Value.I64(self.id)) + def rollback_reservation(): + self.id = None + self._ledger_id = old_key.id + self._loaded_fields = old_loaded + self._entity_root.rekey(new_key, old_key) + self._entity_root.set(old_key, "id", Value.from_any(None)) + graph.after_rollback(rollback_reservation) + await self._teaql_ensure_business_ids(context) for child in self._task_status_list: child._teaql_attach_root(self._entity_root) - await child._teaql_reserve_graph_ids(context, visited) + await child._teaql_reserve_graph_ids(graph, visited) for child in self._task_list: child._teaql_attach_root(self._entity_root) - await child._teaql_reserve_graph_ids(context, visited) + await child._teaql_reserve_graph_ids(graph, visited) - async def _teaql_preflight_and_save(self, context): - self._teaql_preflight_graph(context) - return await self._teaql_save_within_graph(context) + async def _teaql_preflight_and_save(self, graph): + await self._teaql_reserve_graph_ids(graph, set()) + self._teaql_preflight_graph(graph) + return await self._teaql_save_within_graph(graph) def _teaql_build_command(self): payload = {} @@ -169,8 +175,8 @@ def _teaql_build_command(self): cmd = DeleteCommand("Platform", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) return action, cmd - def _teaql_preflight_graph(self, context): - MutationIntent(self._comment) + def _teaql_preflight_graph(self, graph): + context = graph.context if self._action == "Update": if "id" not in self._loaded_fields: raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) @@ -192,9 +198,8 @@ def _teaql_preflight_graph(self, context): for index, child in enumerate(self._task_status_list): child._teaql_attach_root(self._entity_root) child.update_platform(self) - child.audit_as(self._comment) try: - child._teaql_preflight_graph(context) + child._teaql_preflight_graph(graph) except CheckException as error: prefix = ObjectLocation().property("task_status_list").index(index) raise CheckException([ @@ -204,9 +209,8 @@ def _teaql_preflight_graph(self, context): for index, child in enumerate(self._task_list): child._teaql_attach_root(self._entity_root) child.update_platform(self) - child.audit_as(self._comment) try: - child._teaql_preflight_graph(context) + child._teaql_preflight_graph(graph) except CheckException as error: prefix = ObjectLocation().property("task_list").index(index) raise CheckException([ @@ -214,14 +218,15 @@ def _teaql_preflight_graph(self, context): for v in error.violations ]) from error - async def _teaql_save_within_graph(self, context): - intent = MutationIntent(self._comment) + async def _teaql_save_within_graph(self, graph, parent_scope=None): + context = graph.context + scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() - req = MutationRequest(cmd, comment=intent.comment) + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) try: context.check_and_fix_mutation(cmd) @@ -284,7 +289,7 @@ def rollback_entity(): self._loaded_fields = rollback_loaded_fields if old_key != new_key: self._entity_root.rekey(new_key, old_key) - context.after_graph_rollback(rollback_entity) + graph.after_rollback(rollback_entity) if action != "Delete": self._action = "Update" @@ -296,9 +301,8 @@ def rollback_entity(): for index, child in enumerate(children): child._teaql_attach_root(self._entity_root) getattr(child, updater)(self) - child.audit_as(self._comment) try: - await child._teaql_save_within_graph(context) + await child._teaql_save_within_graph(graph, scope) except CheckException as error: prefix = ObjectLocation().property(relation_name).index(index) raise CheckException([ @@ -315,7 +319,7 @@ def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.set_original_version(new_key, int(self.version)) - context.after_graph_commit(commit_entity) + graph.after_commit(commit_entity) return self def update_id(self, value): diff --git a/examples/task_board/generated/models/task.py b/examples/task_board/generated/models/task.py index 2a1cb78..061810d 100644 --- a/examples/task_board/generated/models/task.py +++ b/examples/task_board/generated/models/task.py @@ -92,18 +92,14 @@ def audit_as(self, comment: str): async def save(self, context): intent = MutationIntent(self._comment) - # Reserve stable internal IDs before the transaction and before policy - # review. A relation to a new parent must have the same identity in the - # reviewed graph plan and in the commands eventually sent to SQL. - await self._teaql_reserve_graph_ids(context, set()) - await self._teaql_ensure_business_ids(context) - return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context), comment=intent.comment) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) async def _teaql_ensure_business_ids(self, context): if self._action != "Create": return - async def _teaql_reserve_graph_ids(self, context, visited): + async def _teaql_reserve_graph_ids(self, graph, visited): + context = graph.context object_identity = id(self) if object_identity in visited: return @@ -116,19 +112,29 @@ async def _teaql_reserve_graph_ids(self, context, visited): "Configured dataService does not support stable ID reservation" ) old_key = self._teaql_entity_key() + old_loaded = set(self._loaded_fields) self.id = int(await allocator("Task")) self._loaded_fields.add("id") self._ledger_id = self.id new_key = self._teaql_entity_key() self._entity_root.rekey(old_key, new_key) self._entity_root.set(new_key, "id", Value.I64(self.id)) + def rollback_reservation(): + self.id = None + self._ledger_id = old_key.id + self._loaded_fields = old_loaded + self._entity_root.rekey(new_key, old_key) + self._entity_root.set(old_key, "id", Value.from_any(None)) + graph.after_rollback(rollback_reservation) + await self._teaql_ensure_business_ids(context) for child in self._task_execution_log_list: child._teaql_attach_root(self._entity_root) - await child._teaql_reserve_graph_ids(context, visited) + await child._teaql_reserve_graph_ids(graph, visited) - async def _teaql_preflight_and_save(self, context): - self._teaql_preflight_graph(context) - return await self._teaql_save_within_graph(context) + async def _teaql_preflight_and_save(self, graph): + await self._teaql_reserve_graph_ids(graph, set()) + self._teaql_preflight_graph(graph) + return await self._teaql_save_within_graph(graph) def _teaql_build_command(self): payload = {} @@ -169,8 +175,8 @@ def _teaql_build_command(self): cmd = DeleteCommand("Task", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) return action, cmd - def _teaql_preflight_graph(self, context): - MutationIntent(self._comment) + def _teaql_preflight_graph(self, graph): + context = graph.context if self._action == "Update": if "id" not in self._loaded_fields: raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) @@ -192,9 +198,8 @@ def _teaql_preflight_graph(self, context): for index, child in enumerate(self._task_execution_log_list): child._teaql_attach_root(self._entity_root) child.update_task(self) - child.audit_as(self._comment) try: - child._teaql_preflight_graph(context) + child._teaql_preflight_graph(graph) except CheckException as error: prefix = ObjectLocation().property("task_execution_log_list").index(index) raise CheckException([ @@ -202,14 +207,15 @@ def _teaql_preflight_graph(self, context): for v in error.violations ]) from error - async def _teaql_save_within_graph(self, context): - intent = MutationIntent(self._comment) + async def _teaql_save_within_graph(self, graph, parent_scope=None): + context = graph.context + scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() - req = MutationRequest(cmd, comment=intent.comment) + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) try: context.check_and_fix_mutation(cmd) @@ -272,7 +278,7 @@ def rollback_entity(): self._loaded_fields = rollback_loaded_fields if old_key != new_key: self._entity_root.rekey(new_key, old_key) - context.after_graph_rollback(rollback_entity) + graph.after_rollback(rollback_entity) if action != "Delete": self._action = "Update" @@ -283,9 +289,8 @@ def rollback_entity(): for index, child in enumerate(children): child._teaql_attach_root(self._entity_root) getattr(child, updater)(self) - child.audit_as(self._comment) try: - await child._teaql_save_within_graph(context) + await child._teaql_save_within_graph(graph, scope) except CheckException as error: prefix = ObjectLocation().property(relation_name).index(index) raise CheckException([ @@ -302,7 +307,7 @@ def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.set_original_version(new_key, int(self.version)) - context.after_graph_commit(commit_entity) + graph.after_commit(commit_entity) return self def update_id(self, value): diff --git a/examples/task_board/generated/models/task_execution_log.py b/examples/task_board/generated/models/task_execution_log.py index 1c6923c..d455db5 100644 --- a/examples/task_board/generated/models/task_execution_log.py +++ b/examples/task_board/generated/models/task_execution_log.py @@ -78,18 +78,14 @@ def audit_as(self, comment: str): async def save(self, context): intent = MutationIntent(self._comment) - # Reserve stable internal IDs before the transaction and before policy - # review. A relation to a new parent must have the same identity in the - # reviewed graph plan and in the commands eventually sent to SQL. - await self._teaql_reserve_graph_ids(context, set()) - await self._teaql_ensure_business_ids(context) - return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context), comment=intent.comment) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) async def _teaql_ensure_business_ids(self, context): if self._action != "Create": return - async def _teaql_reserve_graph_ids(self, context, visited): + async def _teaql_reserve_graph_ids(self, graph, visited): + context = graph.context object_identity = id(self) if object_identity in visited: return @@ -102,16 +98,26 @@ async def _teaql_reserve_graph_ids(self, context, visited): "Configured dataService does not support stable ID reservation" ) old_key = self._teaql_entity_key() + old_loaded = set(self._loaded_fields) self.id = int(await allocator("TaskExecutionLog")) self._loaded_fields.add("id") self._ledger_id = self.id new_key = self._teaql_entity_key() self._entity_root.rekey(old_key, new_key) self._entity_root.set(new_key, "id", Value.I64(self.id)) + def rollback_reservation(): + self.id = None + self._ledger_id = old_key.id + self._loaded_fields = old_loaded + self._entity_root.rekey(new_key, old_key) + self._entity_root.set(old_key, "id", Value.from_any(None)) + graph.after_rollback(rollback_reservation) + await self._teaql_ensure_business_ids(context) - async def _teaql_preflight_and_save(self, context): - self._teaql_preflight_graph(context) - return await self._teaql_save_within_graph(context) + async def _teaql_preflight_and_save(self, graph): + await self._teaql_reserve_graph_ids(graph, set()) + self._teaql_preflight_graph(graph) + return await self._teaql_save_within_graph(graph) def _teaql_build_command(self): payload = {} @@ -148,8 +154,8 @@ def _teaql_build_command(self): cmd = DeleteCommand("TaskExecutionLog", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) return action, cmd - def _teaql_preflight_graph(self, context): - MutationIntent(self._comment) + def _teaql_preflight_graph(self, graph): + context = graph.context if self._action == "Update": if "id" not in self._loaded_fields: raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) @@ -169,14 +175,15 @@ def _teaql_preflight_graph(self, context): if field not in ("id", "version"): self._entity_root.set(self._teaql_entity_key(), field, value) - async def _teaql_save_within_graph(self, context): - intent = MutationIntent(self._comment) + async def _teaql_save_within_graph(self, graph, parent_scope=None): + context = graph.context + scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() - req = MutationRequest(cmd, comment=intent.comment) + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) try: context.check_and_fix_mutation(cmd) @@ -239,7 +246,7 @@ def rollback_entity(): self._loaded_fields = rollback_loaded_fields if old_key != new_key: self._entity_root.rekey(new_key, old_key) - context.after_graph_rollback(rollback_entity) + graph.after_rollback(rollback_entity) if action != "Delete": self._action = "Update" @@ -249,9 +256,8 @@ def rollback_entity(): for index, child in enumerate(children): child._teaql_attach_root(self._entity_root) getattr(child, updater)(self) - child.audit_as(self._comment) try: - await child._teaql_save_within_graph(context) + await child._teaql_save_within_graph(graph, scope) except CheckException as error: prefix = ObjectLocation().property(relation_name).index(index) raise CheckException([ @@ -268,7 +274,7 @@ def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.set_original_version(new_key, int(self.version)) - context.after_graph_commit(commit_entity) + graph.after_commit(commit_entity) return self def update_id(self, value): diff --git a/examples/task_board/generated/models/task_status.py b/examples/task_board/generated/models/task_status.py index 06f1ed8..f5de881 100644 --- a/examples/task_board/generated/models/task_status.py +++ b/examples/task_board/generated/models/task_status.py @@ -101,18 +101,14 @@ def audit_as(self, comment: str): async def save(self, context): intent = MutationIntent(self._comment) - # Reserve stable internal IDs before the transaction and before policy - # review. A relation to a new parent must have the same identity in the - # reviewed graph plan and in the commands eventually sent to SQL. - await self._teaql_reserve_graph_ids(context, set()) - await self._teaql_ensure_business_ids(context) - return await context.execute_graph_save(lambda: self._teaql_preflight_and_save(context), comment=intent.comment) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) async def _teaql_ensure_business_ids(self, context): if self._action != "Create": return - async def _teaql_reserve_graph_ids(self, context, visited): + async def _teaql_reserve_graph_ids(self, graph, visited): + context = graph.context object_identity = id(self) if object_identity in visited: return @@ -125,19 +121,29 @@ async def _teaql_reserve_graph_ids(self, context, visited): "Configured dataService does not support stable ID reservation" ) old_key = self._teaql_entity_key() + old_loaded = set(self._loaded_fields) self.id = int(await allocator("TaskStatus")) self._loaded_fields.add("id") self._ledger_id = self.id new_key = self._teaql_entity_key() self._entity_root.rekey(old_key, new_key) self._entity_root.set(new_key, "id", Value.I64(self.id)) + def rollback_reservation(): + self.id = None + self._ledger_id = old_key.id + self._loaded_fields = old_loaded + self._entity_root.rekey(new_key, old_key) + self._entity_root.set(old_key, "id", Value.from_any(None)) + graph.after_rollback(rollback_reservation) + await self._teaql_ensure_business_ids(context) for child in self._task_list: child._teaql_attach_root(self._entity_root) - await child._teaql_reserve_graph_ids(context, visited) + await child._teaql_reserve_graph_ids(graph, visited) - async def _teaql_preflight_and_save(self, context): - self._teaql_preflight_graph(context) - return await self._teaql_save_within_graph(context) + async def _teaql_preflight_and_save(self, graph): + await self._teaql_reserve_graph_ids(graph, set()) + self._teaql_preflight_graph(graph) + return await self._teaql_save_within_graph(graph) def _teaql_build_command(self): payload = {} @@ -183,8 +189,8 @@ def _teaql_build_command(self): cmd = DeleteCommand("TaskStatus", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) return action, cmd - def _teaql_preflight_graph(self, context): - MutationIntent(self._comment) + def _teaql_preflight_graph(self, graph): + context = graph.context if self._action == "Update": if "id" not in self._loaded_fields: raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) @@ -212,9 +218,8 @@ def _teaql_preflight_graph(self, context): for index, child in enumerate(self._task_list): child._teaql_attach_root(self._entity_root) child.update_status(self) - child.audit_as(self._comment) try: - child._teaql_preflight_graph(context) + child._teaql_preflight_graph(graph) except CheckException as error: prefix = ObjectLocation().property("task_list").index(index) raise CheckException([ @@ -222,14 +227,15 @@ def _teaql_preflight_graph(self, context): for v in error.violations ]) from error - async def _teaql_save_within_graph(self, context): - intent = MutationIntent(self._comment) + async def _teaql_save_within_graph(self, graph, parent_scope=None): + context = graph.context + scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() - req = MutationRequest(cmd, comment=intent.comment) + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) try: context.check_and_fix_mutation(cmd) @@ -310,7 +316,7 @@ def rollback_entity(): self._loaded_fields = rollback_loaded_fields if old_key != new_key: self._entity_root.rekey(new_key, old_key) - context.after_graph_rollback(rollback_entity) + graph.after_rollback(rollback_entity) if action != "Delete": self._action = "Update" @@ -321,9 +327,8 @@ def rollback_entity(): for index, child in enumerate(children): child._teaql_attach_root(self._entity_root) getattr(child, updater)(self) - child.audit_as(self._comment) try: - await child._teaql_save_within_graph(context) + await child._teaql_save_within_graph(graph, scope) except CheckException as error: prefix = ObjectLocation().property(relation_name).index(index) raise CheckException([ @@ -340,7 +345,7 @@ def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.set_original_version(new_key, int(self.version)) - context.after_graph_commit(commit_entity) + graph.after_commit(commit_entity) return self def update_id(self, value): diff --git a/examples/trace-chain/AGENTS.md b/examples/trace-chain/AGENTS.md new file mode 100644 index 0000000..399301f --- /dev/null +++ b/examples/trace-chain/AGENTS.md @@ -0,0 +1,31 @@ +# trace-chain-service application instructions + +Implement only application-owned code. Generated models, requests, expressions, +Q, E and Runtime Module are read-only. Do not read or search generated library +source for API discovery, including verification-only discovery. + +Use current model-aware Assist for the next operation, not every operation: + +```text +cargo teaql --input model.xml python-assist-query/ksml_entity +cargo teaql --input model.xml python-assist-query/ksml_entity.ksml_field +cargo teaql --input model.xml python-assist-create/ksml_entity +cargo teaql --input model.xml python-assist-update/ksml_entity +cargo teaql --input model.xml python-assist-delete/ksml_entity +cargo teaql --input model.xml python-assist-expression/ksml_entity +``` + +Use actual KSML snake_case entity/field names and the actual model path. If +Assist cannot supply the required operation, report MISSING_ASSIST; do not guess +or open generated source. Compiler diagnostics and executable tests validate usage. + +Every query is bounded and has non-blank comment and purpose. Use generated Q +for loading and E for loaded traversal. Load all scalar fields before mutation; +mark_for_deletion() and save(context) the composed graph with a non-blank audit_as +reason. Child reasons remain local; unannotated children inherit their parent. +Public execution accepts only UserContext. Schema changes are explicit through +context.ensure_schema(), with generated bootstrap intact. + +Verify twice on one SQLite file without database cleanup. Use unique test labels, +keep generated library hashes unchanged, and retain commands, exit statuses and +skipped/unverified checks. Local source tests do not prove a published artifact. \ No newline at end of file diff --git a/examples/trace-chain/README.md b/examples/trace-chain/README.md new file mode 100644 index 0000000..92071fa --- /dev/null +++ b/examples/trace-chain/README.md @@ -0,0 +1,34 @@ +# Python Trace Chain Example + +This example uses an evaluated six-entity KSML definition and an unchanged +generated library. Application code uses generated Q for loading, E for loaded +traversal, and audited save for the composed graph. A transparent observer +captures actual requests and SQL metadata; the safe audit sink asserts that +database commit has already completed. No expected trace frames are supplied +to the planner or provider. + +```bash +cd /path/to/teaql-python +bash examples/trace-chain/verify.sh +``` + +The verifier uses this repository's runtime source, executes twice on one unique +SQLite file without cleanup, and checks the generated library hashes afterward. +Set `TEAQL_TRACE_CHAIN_DB` to retain and replay a particular database. It uses +unique business labels; it does not delete existing demo records. + +Seven scenario groups verify missing root reason before transaction access, +six-item branch/deletion lineage at request/SQL/audit boundaries, Q/E reload and +retained soft deletion, three relation levels with inherited query intent, two +concurrent graph saves in one Context, failed mutation, and failed authoritative +readback. Failures retain SQL evidence and emit no committed audits. + +The model evaluation reports zero errors, warnings and suggestions, with +seventeen Solids. Current model-aware Assist and evaluation evidence are retained +under `evidence/`. `AGENTS.md` governs application implementation. The library is +regenerated only by the upstream `PythonTraceChainExampleGenerationTest`; do not +edit generated files to fix application usage. + +Prepared batch grouping, generated ledger override, complete mutation privacy, +external database acceptance and immutable Registry consumer replay are not +established by this example. Local tests do not prove any published version. diff --git a/examples/trace-chain/evidence/assist/python-assist-create/customer_order.md b/examples/trace-chain/evidence/assist/python-assist-create/customer_order.md new file mode 100644 index 0000000..40eab47 --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-create/customer_order.md @@ -0,0 +1,94 @@ + + +# Python Assist — Create `CustomerOrder` + +Use the exact generated `Q.customer_orders()` entry point. Trusted actor, +tenant, policy, provider, initialization, and audit infrastructure belong to +`UserContext`, never to business input. + +```python +from Q import Q + + +async def create_customer_order( + platform, + order_number, + description, + context, +): + entity = ( + Q.customer_orders() + .comment("what: initialize Customer Order") + .purpose("why: create Customer Order") + .new_entity(context) + ) + + entity.update_platform(platform) + entity.update_order_number(order_number) + entity.update_description(description) + + await entity.audit_as( + "Create Customer Order for the requested business operation" + ).save(context) + return entity +``` + +Only the generated updater methods above are writable. Constant candidates, +when present, use these exact allow-listed public methods: + +Compile and execute the source unchanged. Verify persistence and query-back. +Missing/blank intent, missing/blank audit reason, unknown fields, and attempted +trusted-context overrides must fail. Do not edit generated sources. +## Compose a graph before one audited save + +Create children through their generated Q new_entity(context), populate their +required fields using child Create Assist, then append them to these exact lists: + +- `entity.order_item_list().append(child)` — generated `OrderItem` child; use `python-assist-create/order_item` +- `entity.payment_list().append(child)` — generated `Payment` child; use `python-assist-create/payment` +- `entity.shipment_list().append(child)` — generated `Shipment` child; use `python-assist-create/shipment` + + +For a branch-local reason call `child.audit_as("non-blank local reason")` before +appending. An unannotated child inherits its parent's lineage. Save the composed +root once with `entity.audit_as("root reason").save(context)`. Do not separately +save children or override their comments with the root's comment. Deletion is +`child.mark_for_deletion()` followed by saving the composed root. + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `create`. + +- Validate and allow-list writable business fields; never mass-assign dynamic JSON. +- Create through the generated request/entity API, attach a non-empty audit reason, + save with the same UserContext, and return the runtime's native save result. +- Add a negative test proving a missing audit reason cannot write. diff --git a/examples/trace-chain/evidence/assist/python-assist-create/order_item.md b/examples/trace-chain/evidence/assist/python-assist-create/order_item.md new file mode 100644 index 0000000..0e3378b --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-create/order_item.md @@ -0,0 +1,77 @@ + + +# Python Assist — Create `OrderItem` + +Use the exact generated `Q.order_items()` entry point. Trusted actor, +tenant, policy, provider, initialization, and audit infrastructure belong to +`UserContext`, never to business input. + +```python +from Q import Q + + +async def create_order_item( + customer_order, + name, + context, +): + entity = ( + Q.order_items() + .comment("what: initialize Order Item") + .purpose("why: create Order Item") + .new_entity(context) + ) + + entity.update_customer_order(customer_order) + entity.update_name(name) + + await entity.audit_as( + "Create Order Item for the requested business operation" + ).save(context) + return entity +``` + +Only the generated updater methods above are writable. Constant candidates, +when present, use these exact allow-listed public methods: + +Compile and execute the source unchanged. Verify persistence and query-back. +Missing/blank intent, missing/blank audit reason, unknown fields, and attempted +trusted-context overrides must fail. Do not edit generated sources. + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `create`. + +- Validate and allow-list writable business fields; never mass-assign dynamic JSON. +- Create through the generated request/entity API, attach a non-empty audit reason, + save with the same UserContext, and return the runtime's native save result. +- Add a negative test proving a missing audit reason cannot write. diff --git a/examples/trace-chain/evidence/assist/python-assist-create/payment.md b/examples/trace-chain/evidence/assist/python-assist-create/payment.md new file mode 100644 index 0000000..b62f08f --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-create/payment.md @@ -0,0 +1,90 @@ + + +# Python Assist — Create `Payment` + +Use the exact generated `Q.payments()` entry point. Trusted actor, +tenant, policy, provider, initialization, and audit infrastructure belong to +`UserContext`, never to business input. + +```python +from Q import Q + + +async def create_payment( + customer_order, + reference_code, + context, +): + entity = ( + Q.payments() + .comment("what: initialize Payment") + .purpose("why: create Payment") + .new_entity(context) + ) + + entity.update_customer_order(customer_order) + entity.update_reference_code(reference_code) + + await entity.audit_as( + "Create Payment for the requested business operation" + ).save(context) + return entity +``` + +Only the generated updater methods above are writable. Constant candidates, +when present, use these exact allow-listed public methods: + +Compile and execute the source unchanged. Verify persistence and query-back. +Missing/blank intent, missing/blank audit reason, unknown fields, and attempted +trusted-context overrides must fail. Do not edit generated sources. +## Compose a graph before one audited save + +Create children through their generated Q new_entity(context), populate their +required fields using child Create Assist, then append them to these exact lists: + +- `entity.payment_attempt_list().append(child)` — generated `PaymentAttempt` child; use `python-assist-create/payment_attempt` + + +For a branch-local reason call `child.audit_as("non-blank local reason")` before +appending. An unannotated child inherits its parent's lineage. Save the composed +root once with `entity.audit_as("root reason").save(context)`. Do not separately +save children or override their comments with the root's comment. Deletion is +`child.mark_for_deletion()` followed by saving the composed root. + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `create`. + +- Validate and allow-list writable business fields; never mass-assign dynamic JSON. +- Create through the generated request/entity API, attach a non-empty audit reason, + save with the same UserContext, and return the runtime's native save result. +- Add a negative test proving a missing audit reason cannot write. diff --git a/examples/trace-chain/evidence/assist/python-assist-create/payment_attempt.md b/examples/trace-chain/evidence/assist/python-assist-create/payment_attempt.md new file mode 100644 index 0000000..46b1733 --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-create/payment_attempt.md @@ -0,0 +1,77 @@ + + +# Python Assist — Create `PaymentAttempt` + +Use the exact generated `Q.payment_attempts()` entry point. Trusted actor, +tenant, policy, provider, initialization, and audit infrastructure belong to +`UserContext`, never to business input. + +```python +from Q import Q + + +async def create_payment_attempt( + payment, + reference_code, + context, +): + entity = ( + Q.payment_attempts() + .comment("what: initialize Payment Attempt") + .purpose("why: create Payment Attempt") + .new_entity(context) + ) + + entity.update_payment(payment) + entity.update_reference_code(reference_code) + + await entity.audit_as( + "Create Payment Attempt for the requested business operation" + ).save(context) + return entity +``` + +Only the generated updater methods above are writable. Constant candidates, +when present, use these exact allow-listed public methods: + +Compile and execute the source unchanged. Verify persistence and query-back. +Missing/blank intent, missing/blank audit reason, unknown fields, and attempted +trusted-context overrides must fail. Do not edit generated sources. + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `create`. + +- Validate and allow-list writable business fields; never mass-assign dynamic JSON. +- Create through the generated request/entity API, attach a non-empty audit reason, + save with the same UserContext, and return the runtime's native save result. +- Add a negative test proving a missing audit reason cannot write. diff --git a/examples/trace-chain/evidence/assist/python-assist-create/platform.md b/examples/trace-chain/evidence/assist/python-assist-create/platform.md new file mode 100644 index 0000000..e47ed31 --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-create/platform.md @@ -0,0 +1,88 @@ + + +# Python Assist — Create `Platform` + +Use the exact generated `Q.platforms()` entry point. Trusted actor, +tenant, policy, provider, initialization, and audit infrastructure belong to +`UserContext`, never to business input. + +```python +from Q import Q + + +async def create_platform( + name, + context, +): + entity = ( + Q.platforms() + .comment("what: initialize Platform") + .purpose("why: create Platform") + .new_entity(context) + ) + + entity.update_name(name) + + await entity.audit_as( + "Create Platform for the requested business operation" + ).save(context) + return entity +``` + +Only the generated updater methods above are writable. Constant candidates, +when present, use these exact allow-listed public methods: + +Compile and execute the source unchanged. Verify persistence and query-back. +Missing/blank intent, missing/blank audit reason, unknown fields, and attempted +trusted-context overrides must fail. Do not edit generated sources. +## Compose a graph before one audited save + +Create children through their generated Q new_entity(context), populate their +required fields using child Create Assist, then append them to these exact lists: + +- `entity.customer_order_list().append(child)` — generated `CustomerOrder` child; use `python-assist-create/customer_order` + + +For a branch-local reason call `child.audit_as("non-blank local reason")` before +appending. An unannotated child inherits its parent's lineage. Save the composed +root once with `entity.audit_as("root reason").save(context)`. Do not separately +save children or override their comments with the root's comment. Deletion is +`child.mark_for_deletion()` followed by saving the composed root. + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `create`. + +- Validate and allow-list writable business fields; never mass-assign dynamic JSON. +- Create through the generated request/entity API, attach a non-empty audit reason, + save with the same UserContext, and return the runtime's native save result. +- Add a negative test proving a missing audit reason cannot write. diff --git a/examples/trace-chain/evidence/assist/python-assist-create/shipment.md b/examples/trace-chain/evidence/assist/python-assist-create/shipment.md new file mode 100644 index 0000000..f74731d --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-create/shipment.md @@ -0,0 +1,77 @@ + + +# Python Assist — Create `Shipment` + +Use the exact generated `Q.shipments()` entry point. Trusted actor, +tenant, policy, provider, initialization, and audit infrastructure belong to +`UserContext`, never to business input. + +```python +from Q import Q + + +async def create_shipment( + customer_order, + reference_code, + context, +): + entity = ( + Q.shipments() + .comment("what: initialize Shipment") + .purpose("why: create Shipment") + .new_entity(context) + ) + + entity.update_customer_order(customer_order) + entity.update_reference_code(reference_code) + + await entity.audit_as( + "Create Shipment for the requested business operation" + ).save(context) + return entity +``` + +Only the generated updater methods above are writable. Constant candidates, +when present, use these exact allow-listed public methods: + +Compile and execute the source unchanged. Verify persistence and query-back. +Missing/blank intent, missing/blank audit reason, unknown fields, and attempted +trusted-context overrides must fail. Do not edit generated sources. + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `create`. + +- Validate and allow-list writable business fields; never mass-assign dynamic JSON. +- Create through the generated request/entity API, attach a non-empty audit reason, + save with the same UserContext, and return the runtime's native save result. +- Add a negative test proving a missing audit reason cannot write. diff --git a/examples/trace-chain/evidence/assist/python-assist-delete/customer_order.md b/examples/trace-chain/evidence/assist/python-assist-delete/customer_order.md new file mode 100644 index 0000000..da526d7 --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-delete/customer_order.md @@ -0,0 +1,67 @@ + + +# Python Assist — Delete `Customer Order` + +Load the current row and its original optimistic version. Delete is audited, +version-aware soft deletion; never reconstruct an entity from client-supplied +ID/version or issue physical-delete SQL. + +```python +from Q import Q + +async def delete_customer_order(context, entity_id) -> bool: + entity = await (Q.customer_orders() + .with_id_is(entity_id) + + .comment("what: load current Customer Order for deletion") + .purpose("why: preserve original version for optimistic locking") + .execute_for_one(context)) + if entity is None: + return False + + await (entity.mark_for_deletion() + .audit_as("Delete Customer Order for the requested business operation") + .save(context)) + return True +``` + +Compile the source unchanged. Prove retained negative-version persistence, +normal-query hiding, `deleted_rows_only()` visibility, stale-copy rejection, +not-found false, audit enforcement, and absence of physical-delete APIs. + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `delete`. + +- Load the tenant-scoped current entity and use the generated hard-delete or + domain-specific soft-delete API; do not invent a deletion method. +- Require an audit reason and optimistic version. Test missing audit and stale + version as explicit failures. diff --git a/examples/trace-chain/evidence/assist/python-assist-delete/order_item.md b/examples/trace-chain/evidence/assist/python-assist-delete/order_item.md new file mode 100644 index 0000000..80bbf93 --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-delete/order_item.md @@ -0,0 +1,67 @@ + + +# Python Assist — Delete `Order Item` + +Load the current row and its original optimistic version. Delete is audited, +version-aware soft deletion; never reconstruct an entity from client-supplied +ID/version or issue physical-delete SQL. + +```python +from Q import Q + +async def delete_order_item(context, entity_id) -> bool: + entity = await (Q.order_items() + .with_id_is(entity_id) + + .comment("what: load current Order Item for deletion") + .purpose("why: preserve original version for optimistic locking") + .execute_for_one(context)) + if entity is None: + return False + + await (entity.mark_for_deletion() + .audit_as("Delete Order Item for the requested business operation") + .save(context)) + return True +``` + +Compile the source unchanged. Prove retained negative-version persistence, +normal-query hiding, `deleted_rows_only()` visibility, stale-copy rejection, +not-found false, audit enforcement, and absence of physical-delete APIs. + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `delete`. + +- Load the tenant-scoped current entity and use the generated hard-delete or + domain-specific soft-delete API; do not invent a deletion method. +- Require an audit reason and optimistic version. Test missing audit and stale + version as explicit failures. diff --git a/examples/trace-chain/evidence/assist/python-assist-delete/payment.md b/examples/trace-chain/evidence/assist/python-assist-delete/payment.md new file mode 100644 index 0000000..9ad287a --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-delete/payment.md @@ -0,0 +1,67 @@ + + +# Python Assist — Delete `Payment` + +Load the current row and its original optimistic version. Delete is audited, +version-aware soft deletion; never reconstruct an entity from client-supplied +ID/version or issue physical-delete SQL. + +```python +from Q import Q + +async def delete_payment(context, entity_id) -> bool: + entity = await (Q.payments() + .with_id_is(entity_id) + + .comment("what: load current Payment for deletion") + .purpose("why: preserve original version for optimistic locking") + .execute_for_one(context)) + if entity is None: + return False + + await (entity.mark_for_deletion() + .audit_as("Delete Payment for the requested business operation") + .save(context)) + return True +``` + +Compile the source unchanged. Prove retained negative-version persistence, +normal-query hiding, `deleted_rows_only()` visibility, stale-copy rejection, +not-found false, audit enforcement, and absence of physical-delete APIs. + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `delete`. + +- Load the tenant-scoped current entity and use the generated hard-delete or + domain-specific soft-delete API; do not invent a deletion method. +- Require an audit reason and optimistic version. Test missing audit and stale + version as explicit failures. diff --git a/examples/trace-chain/evidence/assist/python-assist-delete/payment_attempt.md b/examples/trace-chain/evidence/assist/python-assist-delete/payment_attempt.md new file mode 100644 index 0000000..03b11c4 --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-delete/payment_attempt.md @@ -0,0 +1,67 @@ + + +# Python Assist — Delete `Payment Attempt` + +Load the current row and its original optimistic version. Delete is audited, +version-aware soft deletion; never reconstruct an entity from client-supplied +ID/version or issue physical-delete SQL. + +```python +from Q import Q + +async def delete_payment_attempt(context, entity_id) -> bool: + entity = await (Q.payment_attempts() + .with_id_is(entity_id) + + .comment("what: load current Payment Attempt for deletion") + .purpose("why: preserve original version for optimistic locking") + .execute_for_one(context)) + if entity is None: + return False + + await (entity.mark_for_deletion() + .audit_as("Delete Payment Attempt for the requested business operation") + .save(context)) + return True +``` + +Compile the source unchanged. Prove retained negative-version persistence, +normal-query hiding, `deleted_rows_only()` visibility, stale-copy rejection, +not-found false, audit enforcement, and absence of physical-delete APIs. + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `delete`. + +- Load the tenant-scoped current entity and use the generated hard-delete or + domain-specific soft-delete API; do not invent a deletion method. +- Require an audit reason and optimistic version. Test missing audit and stale + version as explicit failures. diff --git a/examples/trace-chain/evidence/assist/python-assist-delete/platform.md b/examples/trace-chain/evidence/assist/python-assist-delete/platform.md new file mode 100644 index 0000000..26323c1 --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-delete/platform.md @@ -0,0 +1,67 @@ + + +# Python Assist — Delete `Platform` + +Load the current row and its original optimistic version. Delete is audited, +version-aware soft deletion; never reconstruct an entity from client-supplied +ID/version or issue physical-delete SQL. + +```python +from Q import Q + +async def delete_platform(context, entity_id) -> bool: + entity = await (Q.platforms() + .with_id_is(entity_id) + + .comment("what: load current Platform for deletion") + .purpose("why: preserve original version for optimistic locking") + .execute_for_one(context)) + if entity is None: + return False + + await (entity.mark_for_deletion() + .audit_as("Delete Platform for the requested business operation") + .save(context)) + return True +``` + +Compile the source unchanged. Prove retained negative-version persistence, +normal-query hiding, `deleted_rows_only()` visibility, stale-copy rejection, +not-found false, audit enforcement, and absence of physical-delete APIs. + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `delete`. + +- Load the tenant-scoped current entity and use the generated hard-delete or + domain-specific soft-delete API; do not invent a deletion method. +- Require an audit reason and optimistic version. Test missing audit and stale + version as explicit failures. diff --git a/examples/trace-chain/evidence/assist/python-assist-delete/shipment.md b/examples/trace-chain/evidence/assist/python-assist-delete/shipment.md new file mode 100644 index 0000000..a7191c8 --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-delete/shipment.md @@ -0,0 +1,67 @@ + + +# Python Assist — Delete `Shipment` + +Load the current row and its original optimistic version. Delete is audited, +version-aware soft deletion; never reconstruct an entity from client-supplied +ID/version or issue physical-delete SQL. + +```python +from Q import Q + +async def delete_shipment(context, entity_id) -> bool: + entity = await (Q.shipments() + .with_id_is(entity_id) + + .comment("what: load current Shipment for deletion") + .purpose("why: preserve original version for optimistic locking") + .execute_for_one(context)) + if entity is None: + return False + + await (entity.mark_for_deletion() + .audit_as("Delete Shipment for the requested business operation") + .save(context)) + return True +``` + +Compile the source unchanged. Prove retained negative-version persistence, +normal-query hiding, `deleted_rows_only()` visibility, stale-copy rejection, +not-found false, audit enforcement, and absence of physical-delete APIs. + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `delete`. + +- Load the tenant-scoped current entity and use the generated hard-delete or + domain-specific soft-delete API; do not invent a deletion method. +- Require an audit reason and optimistic version. Test missing audit and stale + version as explicit failures. diff --git a/examples/trace-chain/evidence/assist/python-assist-expression/customer_order.md b/examples/trace-chain/evidence/assist/python-assist-expression/customer_order.md new file mode 100644 index 0000000..8daee67 --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-expression/customer_order.md @@ -0,0 +1,132 @@ + + +# Python Assist — Expression `Customer Order` + +The generated E facade preserves Value, loaded `None`, and NotLoaded. `eval()` +returns a native value for the first two and raises `TeaQLNotLoadedError` for +the third. `or_if_null` is null-only and never hides NotLoaded. + +```python +from E import E +from models.customer_order import CustomerOrder + + +def extract_customer_order_id(entity: CustomerOrder): + return E.customer_order(entity).id().eval() + + +def extract_customer_order_id_or_if_null(entity: CustomerOrder, fallback): + return E.customer_order(entity).id().or_if_null(fallback) + + +def extract_customer_order_order_number(entity: CustomerOrder): + return E.customer_order(entity).order_number().eval() + + +def extract_customer_order_order_number_or_if_null(entity: CustomerOrder, fallback): + return E.customer_order(entity).order_number().or_if_null(fallback) + + +def extract_customer_order_description(entity: CustomerOrder): + return E.customer_order(entity).description().eval() + + +def extract_customer_order_description_or_if_null(entity: CustomerOrder, fallback): + return E.customer_order(entity).description().or_if_null(fallback) + + +def extract_customer_order_version(entity: CustomerOrder): + return E.customer_order(entity).version().eval() + + +def extract_customer_order_version_or_if_null(entity: CustomerOrder, fallback): + return E.customer_order(entity).version().or_if_null(fallback) + + +def extract_customer_order_platform_id(entity: CustomerOrder): + return E.customer_order(entity).platform_id().eval() + + +def traverse_customer_order_platform(entity: CustomerOrder): + return E.customer_order(entity).platform().eval() + + +def aggregate_customer_order_order_item_list_size(entity: CustomerOrder): + return E.customer_order(entity).order_item_list().size().eval() + + +def first_customer_order_order_item_list_id(entity: CustomerOrder): + return E.customer_order(entity).order_item_list().first().id().eval() + + +def get_customer_order_order_item_list_id(entity: CustomerOrder, index: int): + return E.customer_order(entity).order_item_list().get(index).id().eval() + + +def aggregate_customer_order_payment_list_size(entity: CustomerOrder): + return E.customer_order(entity).payment_list().size().eval() + + +def first_customer_order_payment_list_id(entity: CustomerOrder): + return E.customer_order(entity).payment_list().first().id().eval() + + +def get_customer_order_payment_list_id(entity: CustomerOrder, index: int): + return E.customer_order(entity).payment_list().get(index).id().eval() + + +def aggregate_customer_order_shipment_list_size(entity: CustomerOrder): + return E.customer_order(entity).shipment_list().size().eval() + + +def first_customer_order_shipment_list_id(entity: CustomerOrder): + return E.customer_order(entity).shipment_list().first().id().eval() + + +def get_customer_order_shipment_list_id(entity: CustomerOrder, index: int): + return E.customer_order(entity).shipment_list().get(index).id().eval() + + + +``` + +Select every traversed field and relation. Never catch `TeaQLNotLoadedError` +merely to supply a default, and never replace generated E accessors with direct +attribute access. + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `expression`. + +- Distinguish a loaded null from a field or relation that was not projected. A + NotLoaded/coding error must remain visible; do not turn it into an ordinary null. +- Select every traversed relation first and use the generated E/expression API for + scalar, object, and list traversal. Do not translate Java accessor names by guess. diff --git a/examples/trace-chain/evidence/assist/python-assist-expression/order_item.md b/examples/trace-chain/evidence/assist/python-assist-expression/order_item.md new file mode 100644 index 0000000..e393781 --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-expression/order_item.md @@ -0,0 +1,88 @@ + + +# Python Assist — Expression `Order Item` + +The generated E facade preserves Value, loaded `None`, and NotLoaded. `eval()` +returns a native value for the first two and raises `TeaQLNotLoadedError` for +the third. `or_if_null` is null-only and never hides NotLoaded. + +```python +from E import E +from models.order_item import OrderItem + + +def extract_order_item_id(entity: OrderItem): + return E.order_item(entity).id().eval() + + +def extract_order_item_id_or_if_null(entity: OrderItem, fallback): + return E.order_item(entity).id().or_if_null(fallback) + + +def extract_order_item_name(entity: OrderItem): + return E.order_item(entity).name().eval() + + +def extract_order_item_name_or_if_null(entity: OrderItem, fallback): + return E.order_item(entity).name().or_if_null(fallback) + + +def extract_order_item_version(entity: OrderItem): + return E.order_item(entity).version().eval() + + +def extract_order_item_version_or_if_null(entity: OrderItem, fallback): + return E.order_item(entity).version().or_if_null(fallback) + + +def extract_order_item_customer_order_id(entity: OrderItem): + return E.order_item(entity).customer_order_id().eval() + + +def traverse_order_item_customer_order(entity: OrderItem): + return E.order_item(entity).customer_order().eval() + + + +``` + +Select every traversed field and relation. Never catch `TeaQLNotLoadedError` +merely to supply a default, and never replace generated E accessors with direct +attribute access. + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `expression`. + +- Distinguish a loaded null from a field or relation that was not projected. A + NotLoaded/coding error must remain visible; do not turn it into an ordinary null. +- Select every traversed relation first and use the generated E/expression API for + scalar, object, and list traversal. Do not translate Java accessor names by guess. diff --git a/examples/trace-chain/evidence/assist/python-assist-expression/payment.md b/examples/trace-chain/evidence/assist/python-assist-expression/payment.md new file mode 100644 index 0000000..6ea4873 --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-expression/payment.md @@ -0,0 +1,100 @@ + + +# Python Assist — Expression `Payment` + +The generated E facade preserves Value, loaded `None`, and NotLoaded. `eval()` +returns a native value for the first two and raises `TeaQLNotLoadedError` for +the third. `or_if_null` is null-only and never hides NotLoaded. + +```python +from E import E +from models.payment import Payment + + +def extract_payment_id(entity: Payment): + return E.payment(entity).id().eval() + + +def extract_payment_id_or_if_null(entity: Payment, fallback): + return E.payment(entity).id().or_if_null(fallback) + + +def extract_payment_reference_code(entity: Payment): + return E.payment(entity).reference_code().eval() + + +def extract_payment_reference_code_or_if_null(entity: Payment, fallback): + return E.payment(entity).reference_code().or_if_null(fallback) + + +def extract_payment_version(entity: Payment): + return E.payment(entity).version().eval() + + +def extract_payment_version_or_if_null(entity: Payment, fallback): + return E.payment(entity).version().or_if_null(fallback) + + +def extract_payment_customer_order_id(entity: Payment): + return E.payment(entity).customer_order_id().eval() + + +def traverse_payment_customer_order(entity: Payment): + return E.payment(entity).customer_order().eval() + + +def aggregate_payment_payment_attempt_list_size(entity: Payment): + return E.payment(entity).payment_attempt_list().size().eval() + + +def first_payment_payment_attempt_list_id(entity: Payment): + return E.payment(entity).payment_attempt_list().first().id().eval() + + +def get_payment_payment_attempt_list_id(entity: Payment, index: int): + return E.payment(entity).payment_attempt_list().get(index).id().eval() + + + +``` + +Select every traversed field and relation. Never catch `TeaQLNotLoadedError` +merely to supply a default, and never replace generated E accessors with direct +attribute access. + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `expression`. + +- Distinguish a loaded null from a field or relation that was not projected. A + NotLoaded/coding error must remain visible; do not turn it into an ordinary null. +- Select every traversed relation first and use the generated E/expression API for + scalar, object, and list traversal. Do not translate Java accessor names by guess. diff --git a/examples/trace-chain/evidence/assist/python-assist-expression/payment_attempt.md b/examples/trace-chain/evidence/assist/python-assist-expression/payment_attempt.md new file mode 100644 index 0000000..3dca7e1 --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-expression/payment_attempt.md @@ -0,0 +1,88 @@ + + +# Python Assist — Expression `Payment Attempt` + +The generated E facade preserves Value, loaded `None`, and NotLoaded. `eval()` +returns a native value for the first two and raises `TeaQLNotLoadedError` for +the third. `or_if_null` is null-only and never hides NotLoaded. + +```python +from E import E +from models.payment_attempt import PaymentAttempt + + +def extract_payment_attempt_id(entity: PaymentAttempt): + return E.payment_attempt(entity).id().eval() + + +def extract_payment_attempt_id_or_if_null(entity: PaymentAttempt, fallback): + return E.payment_attempt(entity).id().or_if_null(fallback) + + +def extract_payment_attempt_reference_code(entity: PaymentAttempt): + return E.payment_attempt(entity).reference_code().eval() + + +def extract_payment_attempt_reference_code_or_if_null(entity: PaymentAttempt, fallback): + return E.payment_attempt(entity).reference_code().or_if_null(fallback) + + +def extract_payment_attempt_version(entity: PaymentAttempt): + return E.payment_attempt(entity).version().eval() + + +def extract_payment_attempt_version_or_if_null(entity: PaymentAttempt, fallback): + return E.payment_attempt(entity).version().or_if_null(fallback) + + +def extract_payment_attempt_payment_id(entity: PaymentAttempt): + return E.payment_attempt(entity).payment_id().eval() + + +def traverse_payment_attempt_payment(entity: PaymentAttempt): + return E.payment_attempt(entity).payment().eval() + + + +``` + +Select every traversed field and relation. Never catch `TeaQLNotLoadedError` +merely to supply a default, and never replace generated E accessors with direct +attribute access. + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `expression`. + +- Distinguish a loaded null from a field or relation that was not projected. A + NotLoaded/coding error must remain visible; do not turn it into an ordinary null. +- Select every traversed relation first and use the generated E/expression API for + scalar, object, and list traversal. Do not translate Java accessor names by guess. diff --git a/examples/trace-chain/evidence/assist/python-assist-expression/platform.md b/examples/trace-chain/evidence/assist/python-assist-expression/platform.md new file mode 100644 index 0000000..4d081f3 --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-expression/platform.md @@ -0,0 +1,92 @@ + + +# Python Assist — Expression `Platform` + +The generated E facade preserves Value, loaded `None`, and NotLoaded. `eval()` +returns a native value for the first two and raises `TeaQLNotLoadedError` for +the third. `or_if_null` is null-only and never hides NotLoaded. + +```python +from E import E +from models.platform import Platform + + +def extract_platform_id(entity: Platform): + return E.platform(entity).id().eval() + + +def extract_platform_id_or_if_null(entity: Platform, fallback): + return E.platform(entity).id().or_if_null(fallback) + + +def extract_platform_name(entity: Platform): + return E.platform(entity).name().eval() + + +def extract_platform_name_or_if_null(entity: Platform, fallback): + return E.platform(entity).name().or_if_null(fallback) + + +def extract_platform_version(entity: Platform): + return E.platform(entity).version().eval() + + +def extract_platform_version_or_if_null(entity: Platform, fallback): + return E.platform(entity).version().or_if_null(fallback) + + +def aggregate_platform_customer_order_list_size(entity: Platform): + return E.platform(entity).customer_order_list().size().eval() + + +def first_platform_customer_order_list_id(entity: Platform): + return E.platform(entity).customer_order_list().first().id().eval() + + +def get_platform_customer_order_list_id(entity: Platform, index: int): + return E.platform(entity).customer_order_list().get(index).id().eval() + + + +``` + +Select every traversed field and relation. Never catch `TeaQLNotLoadedError` +merely to supply a default, and never replace generated E accessors with direct +attribute access. + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `expression`. + +- Distinguish a loaded null from a field or relation that was not projected. A + NotLoaded/coding error must remain visible; do not turn it into an ordinary null. +- Select every traversed relation first and use the generated E/expression API for + scalar, object, and list traversal. Do not translate Java accessor names by guess. diff --git a/examples/trace-chain/evidence/assist/python-assist-expression/shipment.md b/examples/trace-chain/evidence/assist/python-assist-expression/shipment.md new file mode 100644 index 0000000..0d01cf3 --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-expression/shipment.md @@ -0,0 +1,88 @@ + + +# Python Assist — Expression `Shipment` + +The generated E facade preserves Value, loaded `None`, and NotLoaded. `eval()` +returns a native value for the first two and raises `TeaQLNotLoadedError` for +the third. `or_if_null` is null-only and never hides NotLoaded. + +```python +from E import E +from models.shipment import Shipment + + +def extract_shipment_id(entity: Shipment): + return E.shipment(entity).id().eval() + + +def extract_shipment_id_or_if_null(entity: Shipment, fallback): + return E.shipment(entity).id().or_if_null(fallback) + + +def extract_shipment_reference_code(entity: Shipment): + return E.shipment(entity).reference_code().eval() + + +def extract_shipment_reference_code_or_if_null(entity: Shipment, fallback): + return E.shipment(entity).reference_code().or_if_null(fallback) + + +def extract_shipment_version(entity: Shipment): + return E.shipment(entity).version().eval() + + +def extract_shipment_version_or_if_null(entity: Shipment, fallback): + return E.shipment(entity).version().or_if_null(fallback) + + +def extract_shipment_customer_order_id(entity: Shipment): + return E.shipment(entity).customer_order_id().eval() + + +def traverse_shipment_customer_order(entity: Shipment): + return E.shipment(entity).customer_order().eval() + + + +``` + +Select every traversed field and relation. Never catch `TeaQLNotLoadedError` +merely to supply a default, and never replace generated E accessors with direct +attribute access. + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `expression`. + +- Distinguish a loaded null from a field or relation that was not projected. A + NotLoaded/coding error must remain visible; do not turn it into an ordinary null. +- Select every traversed relation first and use the generated E/expression API for + scalar, object, and list traversal. Do not translate Java accessor names by guess. diff --git a/examples/trace-chain/evidence/assist/python-assist-query/customer_order.md b/examples/trace-chain/evidence/assist/python-assist-query/customer_order.md new file mode 100644 index 0000000..204371f --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-query/customer_order.md @@ -0,0 +1,102 @@ + + +# Python Assist — Query `Customer Order` + +Use the exact generated `Q.customer_orders_minimal()` entry +point. Trusted runtime and policy state comes only from the context. + +```python +from Q import Q +from models.customer_order import CustomerOrder +from teaql.core.list import SmartList + + +async def query_customer_order_by_id(context, entity_id: int) -> SmartList[CustomerOrder]: + return await ( + Q.customer_orders_minimal() + .with_id_is(entity_id) + .order_by_id_ascending() + .limit(20) + .comment("what: load the requested Customer Order row") + .purpose("why: serve the authorized Customer Order view") + .execute_for_list(context) + ) +``` + +Compile and execute this source unchanged. Use only generated projection, +predicate, ordering, and relation-selection APIs. Missing or blank intent, +unknown fields, unbounded reads, and trusted-context inputs must fail. Use generated +relation loading rather than handwritten child-query loops; never guess a plural. + +## Field-specific Query Assist + +Use the canonical KSML field name from this list. Do not substitute a language member name, JSON name, or database column. + +| KSML field | Type | Field help | +| --- | --- | --- | +| `id` | `id` | `python-assist-query/customer_order.id` | +| `platform` | `relation` | `python-assist-query/customer_order.platform` | +| `order_number` | `string` | `python-assist-query/customer_order.order_number` | +| `description` | `string` | `python-assist-query/customer_order.description` | +| `version` | `version` | `python-assist-query/customer_order.version` | + +Generated reverse relations are derived from referencing KSML fields. Use their exact generated location for relation selection and access: + +| Generated reverse relation | Child entity | Field help | +| --- | --- | --- | +| `order_item_list` | `order_item` | `python-assist-query/customer_order.order_item_list` | +| `payment_list` | `payment` | `python-assist-query/customer_order.payment_list` | +| `shipment_list` | `shipment` | `python-assist-query/customer_order.shipment_list` | + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `query`. + +- Use generated projection, relation, predicate, sort, and aggregate APIs; never + concatenate application SQL or silently ignore an unsupported filter. +- Rows, record count, facets, and aggregates must share the same active filter. +- Add negative tests for missing purpose/comment and forbidden dynamic fields. + +### Optional per-parent Top-N optimization + +- Configure `top_n_probe_parent_threshold(threshold)` on the nested child request before its intent/execution + stage. Use the field-specific relation Assist for the exact generated selector. +- Give the child a per-parent limit and deterministic ordering with an ID tie-breaker; + bound the parent query separately. A single-parent detail query is not batch Top-N. +- Server providers default to a window query (`threshold = 0`). A positive threshold + permits bounded probes only when the already-loaded parent count is at or below it; + above it, use the window plan. SQLite uses its internal AlwaysProbe policy by default. +- Measure before opting in. Do not infer a threshold from table size or execute COUNT + or statistics queries to choose a plan. Keep an index on foreign key + sort + ID. +- Inspect selected-plan, parent-count, per-parent-limit and probe-count telemetry. + Runtime-managed probes are not application-owned N+1 loops; neither strategy changes + authorization, loaded-state semantics or the required comment/purpose. diff --git a/examples/trace-chain/evidence/assist/python-assist-query/customer_order.order_item_list.md b/examples/trace-chain/evidence/assist/python-assist-query/customer_order.order_item_list.md new file mode 100644 index 0000000..f119a08 --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-query/customer_order.order_item_list.md @@ -0,0 +1,66 @@ + + +# Query Reverse-Relation Assist — `customer_order.order_item_list` + +KSML entity: `customer_order` +Derived reverse relation: `order_item_list` +Child entity: `order_item` + +| Capability | Generated API | +| --- | --- | +| Select | `.select_order_item_list()`, `.select_order_item_list_with(child_request)` | + +Reverse relations are model-derived and never perform an implicit database query. Select the relation explicitly before expression access. Apply query methods before the executable purpose stage, then execute with exactly one trusted context. + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `query`. + +- Use generated projection, relation, predicate, sort, and aggregate APIs; never + concatenate application SQL or silently ignore an unsupported filter. +- Rows, record count, facets, and aggregates must share the same active filter. +- Add negative tests for missing purpose/comment and forbidden dynamic fields. + +### Optional per-parent Top-N optimization + +- Configure `top_n_probe_parent_threshold(threshold)` on the nested child request before its intent/execution + stage. Use the field-specific relation Assist for the exact generated selector. +- Give the child a per-parent limit and deterministic ordering with an ID tie-breaker; + bound the parent query separately. A single-parent detail query is not batch Top-N. +- Server providers default to a window query (`threshold = 0`). A positive threshold + permits bounded probes only when the already-loaded parent count is at or below it; + above it, use the window plan. SQLite uses its internal AlwaysProbe policy by default. +- Measure before opting in. Do not infer a threshold from table size or execute COUNT + or statistics queries to choose a plan. Keep an index on foreign key + sort + ID. +- Inspect selected-plan, parent-count, per-parent-limit and probe-count telemetry. + Runtime-managed probes are not application-owned N+1 loops; neither strategy changes + authorization, loaded-state semantics or the required comment/purpose. diff --git a/examples/trace-chain/evidence/assist/python-assist-query/customer_order.payment_list.md b/examples/trace-chain/evidence/assist/python-assist-query/customer_order.payment_list.md new file mode 100644 index 0000000..e077c8c --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-query/customer_order.payment_list.md @@ -0,0 +1,66 @@ + + +# Query Reverse-Relation Assist — `customer_order.payment_list` + +KSML entity: `customer_order` +Derived reverse relation: `payment_list` +Child entity: `payment` + +| Capability | Generated API | +| --- | --- | +| Select | `.select_payment_list()`, `.select_payment_list_with(child_request)` | + +Reverse relations are model-derived and never perform an implicit database query. Select the relation explicitly before expression access. Apply query methods before the executable purpose stage, then execute with exactly one trusted context. + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `query`. + +- Use generated projection, relation, predicate, sort, and aggregate APIs; never + concatenate application SQL or silently ignore an unsupported filter. +- Rows, record count, facets, and aggregates must share the same active filter. +- Add negative tests for missing purpose/comment and forbidden dynamic fields. + +### Optional per-parent Top-N optimization + +- Configure `top_n_probe_parent_threshold(threshold)` on the nested child request before its intent/execution + stage. Use the field-specific relation Assist for the exact generated selector. +- Give the child a per-parent limit and deterministic ordering with an ID tie-breaker; + bound the parent query separately. A single-parent detail query is not batch Top-N. +- Server providers default to a window query (`threshold = 0`). A positive threshold + permits bounded probes only when the already-loaded parent count is at or below it; + above it, use the window plan. SQLite uses its internal AlwaysProbe policy by default. +- Measure before opting in. Do not infer a threshold from table size or execute COUNT + or statistics queries to choose a plan. Keep an index on foreign key + sort + ID. +- Inspect selected-plan, parent-count, per-parent-limit and probe-count telemetry. + Runtime-managed probes are not application-owned N+1 loops; neither strategy changes + authorization, loaded-state semantics or the required comment/purpose. diff --git a/examples/trace-chain/evidence/assist/python-assist-query/customer_order.platform.md b/examples/trace-chain/evidence/assist/python-assist-query/customer_order.platform.md new file mode 100644 index 0000000..14dd6e1 --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-query/customer_order.platform.md @@ -0,0 +1,68 @@ + + +# Python Query Field Assist — `customer_order.platform` + +KSML entity: `Customer Order` +KSML field: `Platform` +Type: `Platform` + +| Capability | Generated API | +| --- | --- | +| Select | `select_platform_with(request)`` | +| Filter identity | `filter_by_platform(id)` | +| Filter nested request | `with_platform_matching(request)`, `without_platform_matching(request)` | +| Group | `group_by_platform()`, `group_by_platform_as(alias)` | +| Facet | `facet_by_platform_as(name, request, include_all_facets)` | +Execution requires `comment(...)`, `purpose(...)`, and exactly one `context`. + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `query`. + +- Use generated projection, relation, predicate, sort, and aggregate APIs; never + concatenate application SQL or silently ignore an unsupported filter. +- Rows, record count, facets, and aggregates must share the same active filter. +- Add negative tests for missing purpose/comment and forbidden dynamic fields. + +### Optional per-parent Top-N optimization + +- Configure `top_n_probe_parent_threshold(threshold)` on the nested child request before its intent/execution + stage. Use the field-specific relation Assist for the exact generated selector. +- Give the child a per-parent limit and deterministic ordering with an ID tie-breaker; + bound the parent query separately. A single-parent detail query is not batch Top-N. +- Server providers default to a window query (`threshold = 0`). A positive threshold + permits bounded probes only when the already-loaded parent count is at or below it; + above it, use the window plan. SQLite uses its internal AlwaysProbe policy by default. +- Measure before opting in. Do not infer a threshold from table size or execute COUNT + or statistics queries to choose a plan. Keep an index on foreign key + sort + ID. +- Inspect selected-plan, parent-count, per-parent-limit and probe-count telemetry. + Runtime-managed probes are not application-owned N+1 loops; neither strategy changes + authorization, loaded-state semantics or the required comment/purpose. diff --git a/examples/trace-chain/evidence/assist/python-assist-query/customer_order.shipment_list.md b/examples/trace-chain/evidence/assist/python-assist-query/customer_order.shipment_list.md new file mode 100644 index 0000000..422f4f6 --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-query/customer_order.shipment_list.md @@ -0,0 +1,66 @@ + + +# Query Reverse-Relation Assist — `customer_order.shipment_list` + +KSML entity: `customer_order` +Derived reverse relation: `shipment_list` +Child entity: `shipment` + +| Capability | Generated API | +| --- | --- | +| Select | `.select_shipment_list()`, `.select_shipment_list_with(child_request)` | + +Reverse relations are model-derived and never perform an implicit database query. Select the relation explicitly before expression access. Apply query methods before the executable purpose stage, then execute with exactly one trusted context. + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `query`. + +- Use generated projection, relation, predicate, sort, and aggregate APIs; never + concatenate application SQL or silently ignore an unsupported filter. +- Rows, record count, facets, and aggregates must share the same active filter. +- Add negative tests for missing purpose/comment and forbidden dynamic fields. + +### Optional per-parent Top-N optimization + +- Configure `top_n_probe_parent_threshold(threshold)` on the nested child request before its intent/execution + stage. Use the field-specific relation Assist for the exact generated selector. +- Give the child a per-parent limit and deterministic ordering with an ID tie-breaker; + bound the parent query separately. A single-parent detail query is not batch Top-N. +- Server providers default to a window query (`threshold = 0`). A positive threshold + permits bounded probes only when the already-loaded parent count is at or below it; + above it, use the window plan. SQLite uses its internal AlwaysProbe policy by default. +- Measure before opting in. Do not infer a threshold from table size or execute COUNT + or statistics queries to choose a plan. Keep an index on foreign key + sort + ID. +- Inspect selected-plan, parent-count, per-parent-limit and probe-count telemetry. + Runtime-managed probes are not application-owned N+1 loops; neither strategy changes + authorization, loaded-state semantics or the required comment/purpose. diff --git a/examples/trace-chain/evidence/assist/python-assist-query/order_item.md b/examples/trace-chain/evidence/assist/python-assist-query/order_item.md new file mode 100644 index 0000000..7e9d248 --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-query/order_item.md @@ -0,0 +1,93 @@ + + +# Python Assist — Query `Order Item` + +Use the exact generated `Q.order_items_minimal()` entry +point. Trusted runtime and policy state comes only from the context. + +```python +from Q import Q +from models.order_item import OrderItem +from teaql.core.list import SmartList + + +async def query_order_item_by_id(context, entity_id: int) -> SmartList[OrderItem]: + return await ( + Q.order_items_minimal() + .with_id_is(entity_id) + .order_by_id_ascending() + .limit(20) + .comment("what: load the requested Order Item row") + .purpose("why: serve the authorized Order Item view") + .execute_for_list(context) + ) +``` + +Compile and execute this source unchanged. Use only generated projection, +predicate, ordering, and relation-selection APIs. Missing or blank intent, +unknown fields, unbounded reads, and trusted-context inputs must fail. Use generated +relation loading rather than handwritten child-query loops; never guess a plural. + +## Field-specific Query Assist + +Use the canonical KSML field name from this list. Do not substitute a language member name, JSON name, or database column. + +| KSML field | Type | Field help | +| --- | --- | --- | +| `id` | `id` | `python-assist-query/order_item.id` | +| `customer_order` | `relation` | `python-assist-query/order_item.customer_order` | +| `name` | `string` | `python-assist-query/order_item.name` | +| `version` | `version` | `python-assist-query/order_item.version` | + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `query`. + +- Use generated projection, relation, predicate, sort, and aggregate APIs; never + concatenate application SQL or silently ignore an unsupported filter. +- Rows, record count, facets, and aggregates must share the same active filter. +- Add negative tests for missing purpose/comment and forbidden dynamic fields. + +### Optional per-parent Top-N optimization + +- Configure `top_n_probe_parent_threshold(threshold)` on the nested child request before its intent/execution + stage. Use the field-specific relation Assist for the exact generated selector. +- Give the child a per-parent limit and deterministic ordering with an ID tie-breaker; + bound the parent query separately. A single-parent detail query is not batch Top-N. +- Server providers default to a window query (`threshold = 0`). A positive threshold + permits bounded probes only when the already-loaded parent count is at or below it; + above it, use the window plan. SQLite uses its internal AlwaysProbe policy by default. +- Measure before opting in. Do not infer a threshold from table size or execute COUNT + or statistics queries to choose a plan. Keep an index on foreign key + sort + ID. +- Inspect selected-plan, parent-count, per-parent-limit and probe-count telemetry. + Runtime-managed probes are not application-owned N+1 loops; neither strategy changes + authorization, loaded-state semantics or the required comment/purpose. diff --git a/examples/trace-chain/evidence/assist/python-assist-query/payment.customer_order.md b/examples/trace-chain/evidence/assist/python-assist-query/payment.customer_order.md new file mode 100644 index 0000000..3f5bb28 --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-query/payment.customer_order.md @@ -0,0 +1,68 @@ + + +# Python Query Field Assist — `payment.customer_order` + +KSML entity: `Payment` +KSML field: `Customer Order` +Type: `Customer Order` + +| Capability | Generated API | +| --- | --- | +| Select | `select_customer_order_with(request)`` | +| Filter identity | `filter_by_customer_order(id)` | +| Filter nested request | `with_customer_order_matching(request)`, `without_customer_order_matching(request)` | +| Group | `group_by_customer_order()`, `group_by_customer_order_as(alias)` | +| Facet | `facet_by_customer_order_as(name, request, include_all_facets)` | +Execution requires `comment(...)`, `purpose(...)`, and exactly one `context`. + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `query`. + +- Use generated projection, relation, predicate, sort, and aggregate APIs; never + concatenate application SQL or silently ignore an unsupported filter. +- Rows, record count, facets, and aggregates must share the same active filter. +- Add negative tests for missing purpose/comment and forbidden dynamic fields. + +### Optional per-parent Top-N optimization + +- Configure `top_n_probe_parent_threshold(threshold)` on the nested child request before its intent/execution + stage. Use the field-specific relation Assist for the exact generated selector. +- Give the child a per-parent limit and deterministic ordering with an ID tie-breaker; + bound the parent query separately. A single-parent detail query is not batch Top-N. +- Server providers default to a window query (`threshold = 0`). A positive threshold + permits bounded probes only when the already-loaded parent count is at or below it; + above it, use the window plan. SQLite uses its internal AlwaysProbe policy by default. +- Measure before opting in. Do not infer a threshold from table size or execute COUNT + or statistics queries to choose a plan. Keep an index on foreign key + sort + ID. +- Inspect selected-plan, parent-count, per-parent-limit and probe-count telemetry. + Runtime-managed probes are not application-owned N+1 loops; neither strategy changes + authorization, loaded-state semantics or the required comment/purpose. diff --git a/examples/trace-chain/evidence/assist/python-assist-query/payment.md b/examples/trace-chain/evidence/assist/python-assist-query/payment.md new file mode 100644 index 0000000..49e91d1 --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-query/payment.md @@ -0,0 +1,99 @@ + + +# Python Assist — Query `Payment` + +Use the exact generated `Q.payments_minimal()` entry +point. Trusted runtime and policy state comes only from the context. + +```python +from Q import Q +from models.payment import Payment +from teaql.core.list import SmartList + + +async def query_payment_by_id(context, entity_id: int) -> SmartList[Payment]: + return await ( + Q.payments_minimal() + .with_id_is(entity_id) + .order_by_id_ascending() + .limit(20) + .comment("what: load the requested Payment row") + .purpose("why: serve the authorized Payment view") + .execute_for_list(context) + ) +``` + +Compile and execute this source unchanged. Use only generated projection, +predicate, ordering, and relation-selection APIs. Missing or blank intent, +unknown fields, unbounded reads, and trusted-context inputs must fail. Use generated +relation loading rather than handwritten child-query loops; never guess a plural. + +## Field-specific Query Assist + +Use the canonical KSML field name from this list. Do not substitute a language member name, JSON name, or database column. + +| KSML field | Type | Field help | +| --- | --- | --- | +| `id` | `id` | `python-assist-query/payment.id` | +| `customer_order` | `relation` | `python-assist-query/payment.customer_order` | +| `reference_code` | `string` | `python-assist-query/payment.reference_code` | +| `version` | `version` | `python-assist-query/payment.version` | + +Generated reverse relations are derived from referencing KSML fields. Use their exact generated location for relation selection and access: + +| Generated reverse relation | Child entity | Field help | +| --- | --- | --- | +| `payment_attempt_list` | `payment_attempt` | `python-assist-query/payment.payment_attempt_list` | + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `query`. + +- Use generated projection, relation, predicate, sort, and aggregate APIs; never + concatenate application SQL or silently ignore an unsupported filter. +- Rows, record count, facets, and aggregates must share the same active filter. +- Add negative tests for missing purpose/comment and forbidden dynamic fields. + +### Optional per-parent Top-N optimization + +- Configure `top_n_probe_parent_threshold(threshold)` on the nested child request before its intent/execution + stage. Use the field-specific relation Assist for the exact generated selector. +- Give the child a per-parent limit and deterministic ordering with an ID tie-breaker; + bound the parent query separately. A single-parent detail query is not batch Top-N. +- Server providers default to a window query (`threshold = 0`). A positive threshold + permits bounded probes only when the already-loaded parent count is at or below it; + above it, use the window plan. SQLite uses its internal AlwaysProbe policy by default. +- Measure before opting in. Do not infer a threshold from table size or execute COUNT + or statistics queries to choose a plan. Keep an index on foreign key + sort + ID. +- Inspect selected-plan, parent-count, per-parent-limit and probe-count telemetry. + Runtime-managed probes are not application-owned N+1 loops; neither strategy changes + authorization, loaded-state semantics or the required comment/purpose. diff --git a/examples/trace-chain/evidence/assist/python-assist-query/payment.payment_attempt_list.md b/examples/trace-chain/evidence/assist/python-assist-query/payment.payment_attempt_list.md new file mode 100644 index 0000000..3a325a4 --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-query/payment.payment_attempt_list.md @@ -0,0 +1,66 @@ + + +# Query Reverse-Relation Assist — `payment.payment_attempt_list` + +KSML entity: `payment` +Derived reverse relation: `payment_attempt_list` +Child entity: `payment_attempt` + +| Capability | Generated API | +| --- | --- | +| Select | `.select_payment_attempt_list()`, `.select_payment_attempt_list_with(child_request)` | + +Reverse relations are model-derived and never perform an implicit database query. Select the relation explicitly before expression access. Apply query methods before the executable purpose stage, then execute with exactly one trusted context. + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `query`. + +- Use generated projection, relation, predicate, sort, and aggregate APIs; never + concatenate application SQL or silently ignore an unsupported filter. +- Rows, record count, facets, and aggregates must share the same active filter. +- Add negative tests for missing purpose/comment and forbidden dynamic fields. + +### Optional per-parent Top-N optimization + +- Configure `top_n_probe_parent_threshold(threshold)` on the nested child request before its intent/execution + stage. Use the field-specific relation Assist for the exact generated selector. +- Give the child a per-parent limit and deterministic ordering with an ID tie-breaker; + bound the parent query separately. A single-parent detail query is not batch Top-N. +- Server providers default to a window query (`threshold = 0`). A positive threshold + permits bounded probes only when the already-loaded parent count is at or below it; + above it, use the window plan. SQLite uses its internal AlwaysProbe policy by default. +- Measure before opting in. Do not infer a threshold from table size or execute COUNT + or statistics queries to choose a plan. Keep an index on foreign key + sort + ID. +- Inspect selected-plan, parent-count, per-parent-limit and probe-count telemetry. + Runtime-managed probes are not application-owned N+1 loops; neither strategy changes + authorization, loaded-state semantics or the required comment/purpose. diff --git a/examples/trace-chain/evidence/assist/python-assist-query/payment.reference_code.md b/examples/trace-chain/evidence/assist/python-assist-query/payment.reference_code.md new file mode 100644 index 0000000..71773c4 --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-query/payment.reference_code.md @@ -0,0 +1,70 @@ + + +# Python Query Field Assist — `payment.reference_code` + +KSML entity: `Payment` +KSML field: `reference_code` +Type: `string` + +| Capability | Generated API | +| --- | --- | +| Select | `select_reference_code()` | +| Equality/set | `with_reference_code_is(value)`, `with_reference_code_is_not(value)`, `with_reference_code_in(values)`, `with_reference_code_not_in(values)` | +| Comparison | `with_reference_code_greater_than(value)`, `with_reference_code_greater_than_or_equal_to(value)`, `with_reference_code_less_than(value)`, `with_reference_code_less_than_or_equal_to(value)`, `with_reference_code_between(lower, upper)` | +| Null state | `with_reference_code_is_known()`, `with_reference_code_is_unknown()` | +| String | `with_reference_code_containing(value)`, `with_reference_code_not_containing(value)`, `with_reference_code_starting_with(value)`, `with_reference_code_ending_with(value)`, `with_reference_code_sounding_like(value)` | +| Order | `order_by_reference_code_ascending()`, `order_by_reference_code_descending()` | +| Group | `group_by_reference_code()`, `group_by_reference_code_as(alias)` | +Execution requires `comment(...)`, `purpose(...)`, and exactly one `context`. + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `query`. + +- Use generated projection, relation, predicate, sort, and aggregate APIs; never + concatenate application SQL or silently ignore an unsupported filter. +- Rows, record count, facets, and aggregates must share the same active filter. +- Add negative tests for missing purpose/comment and forbidden dynamic fields. + +### Optional per-parent Top-N optimization + +- Configure `top_n_probe_parent_threshold(threshold)` on the nested child request before its intent/execution + stage. Use the field-specific relation Assist for the exact generated selector. +- Give the child a per-parent limit and deterministic ordering with an ID tie-breaker; + bound the parent query separately. A single-parent detail query is not batch Top-N. +- Server providers default to a window query (`threshold = 0`). A positive threshold + permits bounded probes only when the already-loaded parent count is at or below it; + above it, use the window plan. SQLite uses its internal AlwaysProbe policy by default. +- Measure before opting in. Do not infer a threshold from table size or execute COUNT + or statistics queries to choose a plan. Keep an index on foreign key + sort + ID. +- Inspect selected-plan, parent-count, per-parent-limit and probe-count telemetry. + Runtime-managed probes are not application-owned N+1 loops; neither strategy changes + authorization, loaded-state semantics or the required comment/purpose. diff --git a/examples/trace-chain/evidence/assist/python-assist-query/payment_attempt.md b/examples/trace-chain/evidence/assist/python-assist-query/payment_attempt.md new file mode 100644 index 0000000..2acdca5 --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-query/payment_attempt.md @@ -0,0 +1,93 @@ + + +# Python Assist — Query `Payment Attempt` + +Use the exact generated `Q.payment_attempts_minimal()` entry +point. Trusted runtime and policy state comes only from the context. + +```python +from Q import Q +from models.payment_attempt import PaymentAttempt +from teaql.core.list import SmartList + + +async def query_payment_attempt_by_id(context, entity_id: int) -> SmartList[PaymentAttempt]: + return await ( + Q.payment_attempts_minimal() + .with_id_is(entity_id) + .order_by_id_ascending() + .limit(20) + .comment("what: load the requested Payment Attempt row") + .purpose("why: serve the authorized Payment Attempt view") + .execute_for_list(context) + ) +``` + +Compile and execute this source unchanged. Use only generated projection, +predicate, ordering, and relation-selection APIs. Missing or blank intent, +unknown fields, unbounded reads, and trusted-context inputs must fail. Use generated +relation loading rather than handwritten child-query loops; never guess a plural. + +## Field-specific Query Assist + +Use the canonical KSML field name from this list. Do not substitute a language member name, JSON name, or database column. + +| KSML field | Type | Field help | +| --- | --- | --- | +| `id` | `id` | `python-assist-query/payment_attempt.id` | +| `payment` | `relation` | `python-assist-query/payment_attempt.payment` | +| `reference_code` | `string` | `python-assist-query/payment_attempt.reference_code` | +| `version` | `version` | `python-assist-query/payment_attempt.version` | + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `query`. + +- Use generated projection, relation, predicate, sort, and aggregate APIs; never + concatenate application SQL or silently ignore an unsupported filter. +- Rows, record count, facets, and aggregates must share the same active filter. +- Add negative tests for missing purpose/comment and forbidden dynamic fields. + +### Optional per-parent Top-N optimization + +- Configure `top_n_probe_parent_threshold(threshold)` on the nested child request before its intent/execution + stage. Use the field-specific relation Assist for the exact generated selector. +- Give the child a per-parent limit and deterministic ordering with an ID tie-breaker; + bound the parent query separately. A single-parent detail query is not batch Top-N. +- Server providers default to a window query (`threshold = 0`). A positive threshold + permits bounded probes only when the already-loaded parent count is at or below it; + above it, use the window plan. SQLite uses its internal AlwaysProbe policy by default. +- Measure before opting in. Do not infer a threshold from table size or execute COUNT + or statistics queries to choose a plan. Keep an index on foreign key + sort + ID. +- Inspect selected-plan, parent-count, per-parent-limit and probe-count telemetry. + Runtime-managed probes are not application-owned N+1 loops; neither strategy changes + authorization, loaded-state semantics or the required comment/purpose. diff --git a/examples/trace-chain/evidence/assist/python-assist-query/payment_attempt.payment.md b/examples/trace-chain/evidence/assist/python-assist-query/payment_attempt.payment.md new file mode 100644 index 0000000..b10e930 --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-query/payment_attempt.payment.md @@ -0,0 +1,68 @@ + + +# Python Query Field Assist — `payment_attempt.payment` + +KSML entity: `Payment Attempt` +KSML field: `Payment` +Type: `Payment` + +| Capability | Generated API | +| --- | --- | +| Select | `select_payment_with(request)`` | +| Filter identity | `filter_by_payment(id)` | +| Filter nested request | `with_payment_matching(request)`, `without_payment_matching(request)` | +| Group | `group_by_payment()`, `group_by_payment_as(alias)` | +| Facet | `facet_by_payment_as(name, request, include_all_facets)` | +Execution requires `comment(...)`, `purpose(...)`, and exactly one `context`. + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `query`. + +- Use generated projection, relation, predicate, sort, and aggregate APIs; never + concatenate application SQL or silently ignore an unsupported filter. +- Rows, record count, facets, and aggregates must share the same active filter. +- Add negative tests for missing purpose/comment and forbidden dynamic fields. + +### Optional per-parent Top-N optimization + +- Configure `top_n_probe_parent_threshold(threshold)` on the nested child request before its intent/execution + stage. Use the field-specific relation Assist for the exact generated selector. +- Give the child a per-parent limit and deterministic ordering with an ID tie-breaker; + bound the parent query separately. A single-parent detail query is not batch Top-N. +- Server providers default to a window query (`threshold = 0`). A positive threshold + permits bounded probes only when the already-loaded parent count is at or below it; + above it, use the window plan. SQLite uses its internal AlwaysProbe policy by default. +- Measure before opting in. Do not infer a threshold from table size or execute COUNT + or statistics queries to choose a plan. Keep an index on foreign key + sort + ID. +- Inspect selected-plan, parent-count, per-parent-limit and probe-count telemetry. + Runtime-managed probes are not application-owned N+1 loops; neither strategy changes + authorization, loaded-state semantics or the required comment/purpose. diff --git a/examples/trace-chain/evidence/assist/python-assist-query/payment_attempt.reference_code.md b/examples/trace-chain/evidence/assist/python-assist-query/payment_attempt.reference_code.md new file mode 100644 index 0000000..b754893 --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-query/payment_attempt.reference_code.md @@ -0,0 +1,70 @@ + + +# Python Query Field Assist — `payment_attempt.reference_code` + +KSML entity: `Payment Attempt` +KSML field: `reference_code` +Type: `string` + +| Capability | Generated API | +| --- | --- | +| Select | `select_reference_code()` | +| Equality/set | `with_reference_code_is(value)`, `with_reference_code_is_not(value)`, `with_reference_code_in(values)`, `with_reference_code_not_in(values)` | +| Comparison | `with_reference_code_greater_than(value)`, `with_reference_code_greater_than_or_equal_to(value)`, `with_reference_code_less_than(value)`, `with_reference_code_less_than_or_equal_to(value)`, `with_reference_code_between(lower, upper)` | +| Null state | `with_reference_code_is_known()`, `with_reference_code_is_unknown()` | +| String | `with_reference_code_containing(value)`, `with_reference_code_not_containing(value)`, `with_reference_code_starting_with(value)`, `with_reference_code_ending_with(value)`, `with_reference_code_sounding_like(value)` | +| Order | `order_by_reference_code_ascending()`, `order_by_reference_code_descending()` | +| Group | `group_by_reference_code()`, `group_by_reference_code_as(alias)` | +Execution requires `comment(...)`, `purpose(...)`, and exactly one `context`. + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `query`. + +- Use generated projection, relation, predicate, sort, and aggregate APIs; never + concatenate application SQL or silently ignore an unsupported filter. +- Rows, record count, facets, and aggregates must share the same active filter. +- Add negative tests for missing purpose/comment and forbidden dynamic fields. + +### Optional per-parent Top-N optimization + +- Configure `top_n_probe_parent_threshold(threshold)` on the nested child request before its intent/execution + stage. Use the field-specific relation Assist for the exact generated selector. +- Give the child a per-parent limit and deterministic ordering with an ID tie-breaker; + bound the parent query separately. A single-parent detail query is not batch Top-N. +- Server providers default to a window query (`threshold = 0`). A positive threshold + permits bounded probes only when the already-loaded parent count is at or below it; + above it, use the window plan. SQLite uses its internal AlwaysProbe policy by default. +- Measure before opting in. Do not infer a threshold from table size or execute COUNT + or statistics queries to choose a plan. Keep an index on foreign key + sort + ID. +- Inspect selected-plan, parent-count, per-parent-limit and probe-count telemetry. + Runtime-managed probes are not application-owned N+1 loops; neither strategy changes + authorization, loaded-state semantics or the required comment/purpose. diff --git a/examples/trace-chain/evidence/assist/python-assist-query/platform.md b/examples/trace-chain/evidence/assist/python-assist-query/platform.md new file mode 100644 index 0000000..f0b27b5 --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-query/platform.md @@ -0,0 +1,98 @@ + + +# Python Assist — Query `Platform` + +Use the exact generated `Q.platforms_minimal()` entry +point. Trusted runtime and policy state comes only from the context. + +```python +from Q import Q +from models.platform import Platform +from teaql.core.list import SmartList + + +async def query_platform_by_id(context, entity_id: int) -> SmartList[Platform]: + return await ( + Q.platforms_minimal() + .with_id_is(entity_id) + .order_by_id_ascending() + .limit(20) + .comment("what: load the requested Platform row") + .purpose("why: serve the authorized Platform view") + .execute_for_list(context) + ) +``` + +Compile and execute this source unchanged. Use only generated projection, +predicate, ordering, and relation-selection APIs. Missing or blank intent, +unknown fields, unbounded reads, and trusted-context inputs must fail. Use generated +relation loading rather than handwritten child-query loops; never guess a plural. + +## Field-specific Query Assist + +Use the canonical KSML field name from this list. Do not substitute a language member name, JSON name, or database column. + +| KSML field | Type | Field help | +| --- | --- | --- | +| `id` | `id` | `python-assist-query/platform.id` | +| `name` | `string` | `python-assist-query/platform.name` | +| `version` | `version` | `python-assist-query/platform.version` | + +Generated reverse relations are derived from referencing KSML fields. Use their exact generated location for relation selection and access: + +| Generated reverse relation | Child entity | Field help | +| --- | --- | --- | +| `customer_order_list` | `customer_order` | `python-assist-query/platform.customer_order_list` | + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `query`. + +- Use generated projection, relation, predicate, sort, and aggregate APIs; never + concatenate application SQL or silently ignore an unsupported filter. +- Rows, record count, facets, and aggregates must share the same active filter. +- Add negative tests for missing purpose/comment and forbidden dynamic fields. + +### Optional per-parent Top-N optimization + +- Configure `top_n_probe_parent_threshold(threshold)` on the nested child request before its intent/execution + stage. Use the field-specific relation Assist for the exact generated selector. +- Give the child a per-parent limit and deterministic ordering with an ID tie-breaker; + bound the parent query separately. A single-parent detail query is not batch Top-N. +- Server providers default to a window query (`threshold = 0`). A positive threshold + permits bounded probes only when the already-loaded parent count is at or below it; + above it, use the window plan. SQLite uses its internal AlwaysProbe policy by default. +- Measure before opting in. Do not infer a threshold from table size or execute COUNT + or statistics queries to choose a plan. Keep an index on foreign key + sort + ID. +- Inspect selected-plan, parent-count, per-parent-limit and probe-count telemetry. + Runtime-managed probes are not application-owned N+1 loops; neither strategy changes + authorization, loaded-state semantics or the required comment/purpose. diff --git a/examples/trace-chain/evidence/assist/python-assist-query/shipment.md b/examples/trace-chain/evidence/assist/python-assist-query/shipment.md new file mode 100644 index 0000000..43eac2c --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-query/shipment.md @@ -0,0 +1,93 @@ + + +# Python Assist — Query `Shipment` + +Use the exact generated `Q.shipments_minimal()` entry +point. Trusted runtime and policy state comes only from the context. + +```python +from Q import Q +from models.shipment import Shipment +from teaql.core.list import SmartList + + +async def query_shipment_by_id(context, entity_id: int) -> SmartList[Shipment]: + return await ( + Q.shipments_minimal() + .with_id_is(entity_id) + .order_by_id_ascending() + .limit(20) + .comment("what: load the requested Shipment row") + .purpose("why: serve the authorized Shipment view") + .execute_for_list(context) + ) +``` + +Compile and execute this source unchanged. Use only generated projection, +predicate, ordering, and relation-selection APIs. Missing or blank intent, +unknown fields, unbounded reads, and trusted-context inputs must fail. Use generated +relation loading rather than handwritten child-query loops; never guess a plural. + +## Field-specific Query Assist + +Use the canonical KSML field name from this list. Do not substitute a language member name, JSON name, or database column. + +| KSML field | Type | Field help | +| --- | --- | --- | +| `id` | `id` | `python-assist-query/shipment.id` | +| `customer_order` | `relation` | `python-assist-query/shipment.customer_order` | +| `reference_code` | `string` | `python-assist-query/shipment.reference_code` | +| `version` | `version` | `python-assist-query/shipment.version` | + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `query`. + +- Use generated projection, relation, predicate, sort, and aggregate APIs; never + concatenate application SQL or silently ignore an unsupported filter. +- Rows, record count, facets, and aggregates must share the same active filter. +- Add negative tests for missing purpose/comment and forbidden dynamic fields. + +### Optional per-parent Top-N optimization + +- Configure `top_n_probe_parent_threshold(threshold)` on the nested child request before its intent/execution + stage. Use the field-specific relation Assist for the exact generated selector. +- Give the child a per-parent limit and deterministic ordering with an ID tie-breaker; + bound the parent query separately. A single-parent detail query is not batch Top-N. +- Server providers default to a window query (`threshold = 0`). A positive threshold + permits bounded probes only when the already-loaded parent count is at or below it; + above it, use the window plan. SQLite uses its internal AlwaysProbe policy by default. +- Measure before opting in. Do not infer a threshold from table size or execute COUNT + or statistics queries to choose a plan. Keep an index on foreign key + sort + ID. +- Inspect selected-plan, parent-count, per-parent-limit and probe-count telemetry. + Runtime-managed probes are not application-owned N+1 loops; neither strategy changes + authorization, loaded-state semantics or the required comment/purpose. diff --git a/examples/trace-chain/evidence/assist/python-assist-update/customer_order.md b/examples/trace-chain/evidence/assist/python-assist-update/customer_order.md new file mode 100644 index 0000000..8c833ab --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-update/customer_order.md @@ -0,0 +1,82 @@ + + +# Python Assist — Update `Customer Order` + +Load the current generated entity so its stored ID and original version +participate in optimistic locking. Never reconstruct it from untrusted JSON. + +An entity update is not a partial DTO patch. Load every scalar field before +modification so checker rules can validate the complete business state. Never +use the `_minimal()` request or a reduced projection for an entity that will be saved. + +```python +from Q import Q + +async def update_customer_order( + context, + entity_id, + order_number, + description, + throw_if_missing=False): + entity = await (Q.customer_orders() + .with_id_is(entity_id) + + .comment("what: load current Customer Order for update") + .purpose("why: preserve original version for optimistic locking") + .execute_for_one(context)) + if entity is None: + if throw_if_missing: + raise LookupError("Customer Order not found: " + str(entity_id)) + return None + + entity.update_order_number(order_number) + entity.update_description(description) + + await entity.audit_as( + "Update Customer Order for the requested business operation").save(context) + return entity +``` + +Compile and execute this source unchanged. Prove persistence and query-back, a +conflict from an independently loaded stale copy, None and throwing not-found +behavior, missing/blank audit rejection, and failure for unknown or trusted +fields. Constant and relation changes require an explicitly selected generated +method; do not guess one. + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `update`. + +- Load the tenant-scoped current entity first so its original version participates + in optimistic locking; do not reconstruct versioned state from untrusted JSON. +- Allow-list writable fields, attach the generated audit-reason API, and save with + the same UserContext. Add a stale-version rejection test. diff --git a/examples/trace-chain/evidence/assist/python-assist-update/order_item.md b/examples/trace-chain/evidence/assist/python-assist-update/order_item.md new file mode 100644 index 0000000..3f4b572 --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-update/order_item.md @@ -0,0 +1,80 @@ + + +# Python Assist — Update `Order Item` + +Load the current generated entity so its stored ID and original version +participate in optimistic locking. Never reconstruct it from untrusted JSON. + +An entity update is not a partial DTO patch. Load every scalar field before +modification so checker rules can validate the complete business state. Never +use the `_minimal()` request or a reduced projection for an entity that will be saved. + +```python +from Q import Q + +async def update_order_item( + context, + entity_id, + name, + throw_if_missing=False): + entity = await (Q.order_items() + .with_id_is(entity_id) + + .comment("what: load current Order Item for update") + .purpose("why: preserve original version for optimistic locking") + .execute_for_one(context)) + if entity is None: + if throw_if_missing: + raise LookupError("Order Item not found: " + str(entity_id)) + return None + + entity.update_name(name) + + await entity.audit_as( + "Update Order Item for the requested business operation").save(context) + return entity +``` + +Compile and execute this source unchanged. Prove persistence and query-back, a +conflict from an independently loaded stale copy, None and throwing not-found +behavior, missing/blank audit rejection, and failure for unknown or trusted +fields. Constant and relation changes require an explicitly selected generated +method; do not guess one. + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `update`. + +- Load the tenant-scoped current entity first so its original version participates + in optimistic locking; do not reconstruct versioned state from untrusted JSON. +- Allow-list writable fields, attach the generated audit-reason API, and save with + the same UserContext. Add a stale-version rejection test. diff --git a/examples/trace-chain/evidence/assist/python-assist-update/payment.md b/examples/trace-chain/evidence/assist/python-assist-update/payment.md new file mode 100644 index 0000000..4efb0be --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-update/payment.md @@ -0,0 +1,80 @@ + + +# Python Assist — Update `Payment` + +Load the current generated entity so its stored ID and original version +participate in optimistic locking. Never reconstruct it from untrusted JSON. + +An entity update is not a partial DTO patch. Load every scalar field before +modification so checker rules can validate the complete business state. Never +use the `_minimal()` request or a reduced projection for an entity that will be saved. + +```python +from Q import Q + +async def update_payment( + context, + entity_id, + reference_code, + throw_if_missing=False): + entity = await (Q.payments() + .with_id_is(entity_id) + + .comment("what: load current Payment for update") + .purpose("why: preserve original version for optimistic locking") + .execute_for_one(context)) + if entity is None: + if throw_if_missing: + raise LookupError("Payment not found: " + str(entity_id)) + return None + + entity.update_reference_code(reference_code) + + await entity.audit_as( + "Update Payment for the requested business operation").save(context) + return entity +``` + +Compile and execute this source unchanged. Prove persistence and query-back, a +conflict from an independently loaded stale copy, None and throwing not-found +behavior, missing/blank audit rejection, and failure for unknown or trusted +fields. Constant and relation changes require an explicitly selected generated +method; do not guess one. + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `update`. + +- Load the tenant-scoped current entity first so its original version participates + in optimistic locking; do not reconstruct versioned state from untrusted JSON. +- Allow-list writable fields, attach the generated audit-reason API, and save with + the same UserContext. Add a stale-version rejection test. diff --git a/examples/trace-chain/evidence/assist/python-assist-update/payment_attempt.md b/examples/trace-chain/evidence/assist/python-assist-update/payment_attempt.md new file mode 100644 index 0000000..3e63d6e --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-update/payment_attempt.md @@ -0,0 +1,80 @@ + + +# Python Assist — Update `Payment Attempt` + +Load the current generated entity so its stored ID and original version +participate in optimistic locking. Never reconstruct it from untrusted JSON. + +An entity update is not a partial DTO patch. Load every scalar field before +modification so checker rules can validate the complete business state. Never +use the `_minimal()` request or a reduced projection for an entity that will be saved. + +```python +from Q import Q + +async def update_payment_attempt( + context, + entity_id, + reference_code, + throw_if_missing=False): + entity = await (Q.payment_attempts() + .with_id_is(entity_id) + + .comment("what: load current Payment Attempt for update") + .purpose("why: preserve original version for optimistic locking") + .execute_for_one(context)) + if entity is None: + if throw_if_missing: + raise LookupError("Payment Attempt not found: " + str(entity_id)) + return None + + entity.update_reference_code(reference_code) + + await entity.audit_as( + "Update Payment Attempt for the requested business operation").save(context) + return entity +``` + +Compile and execute this source unchanged. Prove persistence and query-back, a +conflict from an independently loaded stale copy, None and throwing not-found +behavior, missing/blank audit rejection, and failure for unknown or trusted +fields. Constant and relation changes require an explicitly selected generated +method; do not guess one. + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `update`. + +- Load the tenant-scoped current entity first so its original version participates + in optimistic locking; do not reconstruct versioned state from untrusted JSON. +- Allow-list writable fields, attach the generated audit-reason API, and save with + the same UserContext. Add a stale-version rejection test. diff --git a/examples/trace-chain/evidence/assist/python-assist-update/platform.md b/examples/trace-chain/evidence/assist/python-assist-update/platform.md new file mode 100644 index 0000000..b4c2e3f --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-update/platform.md @@ -0,0 +1,80 @@ + + +# Python Assist — Update `Platform` + +Load the current generated entity so its stored ID and original version +participate in optimistic locking. Never reconstruct it from untrusted JSON. + +An entity update is not a partial DTO patch. Load every scalar field before +modification so checker rules can validate the complete business state. Never +use the `_minimal()` request or a reduced projection for an entity that will be saved. + +```python +from Q import Q + +async def update_platform( + context, + entity_id, + name, + throw_if_missing=False): + entity = await (Q.platforms() + .with_id_is(entity_id) + + .comment("what: load current Platform for update") + .purpose("why: preserve original version for optimistic locking") + .execute_for_one(context)) + if entity is None: + if throw_if_missing: + raise LookupError("Platform not found: " + str(entity_id)) + return None + + entity.update_name(name) + + await entity.audit_as( + "Update Platform for the requested business operation").save(context) + return entity +``` + +Compile and execute this source unchanged. Prove persistence and query-back, a +conflict from an independently loaded stale copy, None and throwing not-found +behavior, missing/blank audit rejection, and failure for unknown or trusted +fields. Constant and relation changes require an explicitly selected generated +method; do not guess one. + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `update`. + +- Load the tenant-scoped current entity first so its original version participates + in optimistic locking; do not reconstruct versioned state from untrusted JSON. +- Allow-list writable fields, attach the generated audit-reason API, and save with + the same UserContext. Add a stale-version rejection test. diff --git a/examples/trace-chain/evidence/assist/python-assist-update/shipment.md b/examples/trace-chain/evidence/assist/python-assist-update/shipment.md new file mode 100644 index 0000000..9e360b3 --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-update/shipment.md @@ -0,0 +1,80 @@ + + +# Python Assist — Update `Shipment` + +Load the current generated entity so its stored ID and original version +participate in optimistic locking. Never reconstruct it from untrusted JSON. + +An entity update is not a partial DTO patch. Load every scalar field before +modification so checker rules can validate the complete business state. Never +use the `_minimal()` request or a reduced projection for an entity that will be saved. + +```python +from Q import Q + +async def update_shipment( + context, + entity_id, + reference_code, + throw_if_missing=False): + entity = await (Q.shipments() + .with_id_is(entity_id) + + .comment("what: load current Shipment for update") + .purpose("why: preserve original version for optimistic locking") + .execute_for_one(context)) + if entity is None: + if throw_if_missing: + raise LookupError("Shipment not found: " + str(entity_id)) + return None + + entity.update_reference_code(reference_code) + + await entity.audit_as( + "Update Shipment for the requested business operation").save(context) + return entity +``` + +Compile and execute this source unchanged. Prove persistence and query-back, a +conflict from an independently loaded stale copy, None and throwing not-found +behavior, missing/blank audit rejection, and failure for unknown or trusted +fields. Constant and relation changes require an explicitly selected generated +method; do not guess one. + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `update`. + +- Load the tenant-scoped current entity first so its original version participates + in optimistic locking; do not reconstruct versioned state from untrusted JSON. +- Allow-list writable fields, attach the generated audit-reason API, and save with + the same UserContext. Add a stale-version rejection test. diff --git a/examples/trace-chain/evidence/evaluation.md b/examples/trace-chain/evidence/evaluation.md new file mode 100644 index 0000000..1342e1e --- /dev/null +++ b/examples/trace-chain/evidence/evaluation.md @@ -0,0 +1,30 @@ + +# KSML Evaluation Report +## 📊 Summary +- **Errors**: 0 +- **Warnings**: 0 +- **Suggestions**: 0 +- **Solids**: 17 + +## ✅ Solids + +| Rule ID | Target | Source | Message | +|---------|--------|--------|---------| +| `KSML-UPLOAD-001` | `` | — | The upload contains 1 files. | +| `KSML-UPLOAD-005` | `` | `model.xml:2` | Resolved entrypoint: model.xml | +| `KSML-XML-002` | `` | `model.xml:2` | The XML document model.xml was parsed successfully. | +| `KSML-ROOT-003` | `` | `model.xml:2` | Root name 'trace-chain-service' is well-formed. | +| `KSML-ROOT-008` | `` | `model.xml:2` | External JSON business fields use the default camelCase profile. | +| `KSML-OBJECT-001` | `platform` | `model.xml:4` | Object 'platform' defines display name, module, and module key metadata. | +| `KSML-OBJECT-001` | `customer_order` | `model.xml:6` | Object 'customer_order' defines display name, module, and module key metadata. | +| `KSML-OBJECT-001` | `order_item` | `model.xml:9` | Object 'order_item' defines display name, module, and module key metadata. | +| `KSML-OBJECT-001` | `payment` | `model.xml:11` | Object 'payment' defines display name, module, and module key metadata. | +| `KSML-OBJECT-001` | `payment_attempt` | `model.xml:13` | Object 'payment_attempt' defines display name, module, and module key metadata. | +| `KSML-OBJECT-001` | `shipment` | `model.xml:15` | Object 'shipment' defines display name, module, and module key metadata. | +| `KSML-REFERENCE-003` | `customer_order.platform` | `model.xml:7` | Reference 'platform' in 'customer_order' successfully resolves to target object 'platform'. | +| `KSML-REFERENCE-003` | `order_item.customer_order` | `model.xml:10` | Reference 'customer_order' in 'order_item' successfully resolves to target object 'customer_order'. | +| `KSML-REFERENCE-003` | `payment.customer_order` | `model.xml:12` | Reference 'customer_order' in 'payment' successfully resolves to target object 'customer_order'. | +| `KSML-REFERENCE-003` | `payment_attempt.payment` | `model.xml:14` | Reference 'payment' in 'payment_attempt' successfully resolves to target object 'payment'. | +| `KSML-REFERENCE-003` | `shipment.customer_order` | `model.xml:16` | Reference 'customer_order' in 'shipment' successfully resolves to target object 'customer_order'. | +| `KSML-DOMAIN-ROOT-003` | `` | `model.xml:2` | Exactly one domain root candidate 'platform' was found. | + diff --git a/examples/trace-chain/evidence/library-sha256.txt b/examples/trace-chain/evidence/library-sha256.txt new file mode 100644 index 0000000..85a0ef5 --- /dev/null +++ b/examples/trace-chain/evidence/library-sha256.txt @@ -0,0 +1,19 @@ +3dd62d0762032c4d90b64583a0eff75115a848b374a0ee2978bc6f7684380a6b ./E.py +555a60a9561499fb3ce7a6a3e40e7004d0a79b8ea01f2d80686c999db4873352 ./Q.py +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ./models/__init__.py +09f4072731d5ac72a2f73ccdd78b23451629bbbf7b75e0912b2a577cdb87d3c6 ./models/customer_order.py +d24b15d953b5234c9442d6b4cbc5d748f31df1c0c2acb44bfe76cbd4428176b3 ./models/order_item.py +1ab512f57a22560047d868925b442e3c877c6f980d4490eaadad3f8f97c43c4f ./models/payment.py +5c6b1fecd541479f166fffd85d672f960383762e64174ff870ad8e5e04e9caef ./models/payment_attempt.py +e619ec2fe1471681f8870882329f832cb9988e149f41e7d2be40ab502028e23e ./models/platform.py +018738070ffbb9b209436a38fcb34e96859986ed9f434823983de0f3027a4124 ./models/shipment.py +f5820a2cbf1c4a29053e53e4d12e8304aee78eb52b6c1cda987daea3f22eff95 ./pyproject.toml +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ./requests/__init__.py +dc0c2d4ff7c17c494fa76f3adee3094b2b27264c33116bb2b6f8483027868bc0 ./requests/customer_order_request.py +33dd1fb4b506f5dacc13ca60f85633da2b2a735e7d6c337f6046e41065bad4a2 ./requests/order_item_request.py +336ea012de43681dcca78443b633139ba63cfff349a5e17db7d8be177728011a ./requests/payment_attempt_request.py +cb8e22723f61f8f5c9e3b645044716fa09eb4d5c7fb67b9bbeed5cec69aa9131 ./requests/payment_request.py +9148ef76be0c6b176e2cb05a501c7d8b579e210aae617c2c76971ad6f64cb4a8 ./requests/platform_request.py +88bc9932f546e57328b323d1e421d357658dbf5e7fae2af17ae972ec59bb53fd ./requests/shipment_request.py +adf7af0e37f2d7b25d2fc709353bdfca207a2db3fe53989b1dbe37e0e2f2e63b ./runtime_module.py +5ff4a2f49563da43619a3bde0da4d3b73105e87824aef4851ba050b0509272d3 ./teaql-i18n.json diff --git a/examples/trace-chain/lib/E.py b/examples/trace-chain/lib/E.py new file mode 100644 index 0000000..b9d6821 --- /dev/null +++ b/examples/trace-chain/lib/E.py @@ -0,0 +1,245 @@ +class TeaQLNotLoadedError(RuntimeError): + def __init__(self, root, access_path, break_point): + self.root = root + self.access_path = access_path + self.break_point = break_point + super().__init__( + f"TeaQLNotLoadedError: root={root} access_path={access_path} " + f"break_point={break_point} suggested_fix=select_{break_point}(...)" + ) + + +class ValueExpression: + def __init__(self, value=None, error=None): + self._value = value + self._error = error + + def eval(self): + if self._error is not None: + raise self._error + return self._value + + def or_if_null(self, fallback): + value = self.eval() + return fallback if value is None else value + + +class EntityExpression: + def __init__(self, value, root=None, path="", error=None): + self._value = value + self._root = root or f"{type(value).__name__ if value is not None else 'Entity'}(null)" + self._path = path + self._error = error + + def eval(self): + if self._error is not None: + raise self._error + return self._value + + def _path_for(self, field): + return f"{self._path}.{field}" if self._path else field + + def _not_loaded(self, field): + path = self._path_for(field) + return TeaQLNotLoadedError(self._root, path, field) + + def _scalar(self, field, relation_id=False): + if self._error is not None: + return ValueExpression(error=self._error) + if self._value is None: + return ValueExpression(None) + if field not in getattr(self._value, "_loaded_fields", set()): + return ValueExpression(error=self._not_loaded(field)) + value = getattr(self._value, field) + if relation_id and value is not None and not isinstance(value, (int, str)): + value = getattr(value, "id", None) + return ValueExpression(value) + + def _relation(self, field, expression_type): + path = self._path_for(field) + if self._error is not None: + return expression_type(None, self._root, path, self._error) + if self._value is None: + return expression_type(None, self._root, path) + if field not in getattr(self._value, "_loaded_fields", set()): + return expression_type(None, self._root, path, self._not_loaded(field)) + value = getattr(self._value, field) + if value is not None and isinstance(value, (int, str)): + return expression_type(None, self._root, path, self._not_loaded(field)) + return expression_type(value, self._root, path) + + +class ListExpression: + def __init__(self, values, root, path, item_expression, error=None): + self._values = values + self._root = root + self._path = path + self._item_expression = item_expression + self._error = error + + def size(self): + return ValueExpression(error=self._error) if self._error else ValueExpression(len(self._values)) + + def first(self): + return self.get(0) + + def get(self, index): + path = f"{self._path}.get({index})" + if self._error is not None: + return self._item_expression(None, self._root, path, self._error) + value = self._values[index] if 0 <= index < len(self._values) else None + return self._item_expression(value, self._root, path) + + +class PlatformExpression(EntityExpression): + def id(self): + return self._scalar("id") + def name(self): + return self._scalar("name") + def version(self): + return self._scalar("version") + def customer_order_list(self): + path = self._path_for("customer_order_list") + if self._error is not None: + return ListExpression([], self._root, path, CustomerOrderExpression, self._error) + if self._value is None: + return ListExpression([], self._root, path, CustomerOrderExpression) + if "customer_order_list" not in getattr(self._value, "_loaded_fields", set()): + return ListExpression([], self._root, path, CustomerOrderExpression, self._not_loaded("customer_order_list")) + return ListExpression(getattr(self._value, "_customer_order_list"), self._root, path, CustomerOrderExpression) + pass + +class CustomerOrderExpression(EntityExpression): + def id(self): + return self._scalar("id") + def order_number(self): + return self._scalar("orderNumber") + def description(self): + return self._scalar("description") + def version(self): + return self._scalar("version") + def platform_id(self): + return self._scalar("platform", relation_id=True) + + def platform(self): + return self._relation("platform", PlatformExpression) + def order_item_list(self): + path = self._path_for("order_item_list") + if self._error is not None: + return ListExpression([], self._root, path, OrderItemExpression, self._error) + if self._value is None: + return ListExpression([], self._root, path, OrderItemExpression) + if "order_item_list" not in getattr(self._value, "_loaded_fields", set()): + return ListExpression([], self._root, path, OrderItemExpression, self._not_loaded("order_item_list")) + return ListExpression(getattr(self._value, "_order_item_list"), self._root, path, OrderItemExpression) + def payment_list(self): + path = self._path_for("payment_list") + if self._error is not None: + return ListExpression([], self._root, path, PaymentExpression, self._error) + if self._value is None: + return ListExpression([], self._root, path, PaymentExpression) + if "payment_list" not in getattr(self._value, "_loaded_fields", set()): + return ListExpression([], self._root, path, PaymentExpression, self._not_loaded("payment_list")) + return ListExpression(getattr(self._value, "_payment_list"), self._root, path, PaymentExpression) + def shipment_list(self): + path = self._path_for("shipment_list") + if self._error is not None: + return ListExpression([], self._root, path, ShipmentExpression, self._error) + if self._value is None: + return ListExpression([], self._root, path, ShipmentExpression) + if "shipment_list" not in getattr(self._value, "_loaded_fields", set()): + return ListExpression([], self._root, path, ShipmentExpression, self._not_loaded("shipment_list")) + return ListExpression(getattr(self._value, "_shipment_list"), self._root, path, ShipmentExpression) + pass + +class OrderItemExpression(EntityExpression): + def id(self): + return self._scalar("id") + def name(self): + return self._scalar("name") + def version(self): + return self._scalar("version") + def customer_order_id(self): + return self._scalar("customerOrder", relation_id=True) + + def customer_order(self): + return self._relation("customerOrder", CustomerOrderExpression) + pass + +class PaymentExpression(EntityExpression): + def id(self): + return self._scalar("id") + def reference_code(self): + return self._scalar("referenceCode") + def version(self): + return self._scalar("version") + def customer_order_id(self): + return self._scalar("customerOrder", relation_id=True) + + def customer_order(self): + return self._relation("customerOrder", CustomerOrderExpression) + def payment_attempt_list(self): + path = self._path_for("payment_attempt_list") + if self._error is not None: + return ListExpression([], self._root, path, PaymentAttemptExpression, self._error) + if self._value is None: + return ListExpression([], self._root, path, PaymentAttemptExpression) + if "payment_attempt_list" not in getattr(self._value, "_loaded_fields", set()): + return ListExpression([], self._root, path, PaymentAttemptExpression, self._not_loaded("payment_attempt_list")) + return ListExpression(getattr(self._value, "_payment_attempt_list"), self._root, path, PaymentAttemptExpression) + pass + +class PaymentAttemptExpression(EntityExpression): + def id(self): + return self._scalar("id") + def reference_code(self): + return self._scalar("referenceCode") + def version(self): + return self._scalar("version") + def payment_id(self): + return self._scalar("payment", relation_id=True) + + def payment(self): + return self._relation("payment", PaymentExpression) + pass + +class ShipmentExpression(EntityExpression): + def id(self): + return self._scalar("id") + def reference_code(self): + return self._scalar("referenceCode") + def version(self): + return self._scalar("version") + def customer_order_id(self): + return self._scalar("customerOrder", relation_id=True) + + def customer_order(self): + return self._relation("customerOrder", CustomerOrderExpression) + pass + +class E: + @staticmethod + def platform(value): + entity_id = getattr(value, "id", None) + return PlatformExpression(value, "Platform(id={})".format(entity_id)) + @staticmethod + def customer_order(value): + entity_id = getattr(value, "id", None) + return CustomerOrderExpression(value, "CustomerOrder(id={})".format(entity_id)) + @staticmethod + def order_item(value): + entity_id = getattr(value, "id", None) + return OrderItemExpression(value, "OrderItem(id={})".format(entity_id)) + @staticmethod + def payment(value): + entity_id = getattr(value, "id", None) + return PaymentExpression(value, "Payment(id={})".format(entity_id)) + @staticmethod + def payment_attempt(value): + entity_id = getattr(value, "id", None) + return PaymentAttemptExpression(value, "PaymentAttempt(id={})".format(entity_id)) + @staticmethod + def shipment(value): + entity_id = getattr(value, "id", None) + return ShipmentExpression(value, "Shipment(id={})".format(entity_id)) + pass \ No newline at end of file diff --git a/examples/trace-chain/lib/Q.py b/examples/trace-chain/lib/Q.py new file mode 100644 index 0000000..29b065e --- /dev/null +++ b/examples/trace-chain/lib/Q.py @@ -0,0 +1,58 @@ +# Generated by teaql-code-gen +from requests.platform_request import PlatformRequest +from requests.customer_order_request import CustomerOrderRequest +from requests.order_item_request import OrderItemRequest +from requests.payment_request import PaymentRequest +from requests.payment_attempt_request import PaymentAttemptRequest +from requests.shipment_request import ShipmentRequest + +class Q: + @staticmethod + def platforms() -> PlatformRequest: + return PlatformRequest(minimal=False) + + @staticmethod + def platforms_minimal() -> PlatformRequest: + return PlatformRequest(minimal=True) + + @staticmethod + def customer_orders() -> CustomerOrderRequest: + return CustomerOrderRequest(minimal=False) + + @staticmethod + def customer_orders_minimal() -> CustomerOrderRequest: + return CustomerOrderRequest(minimal=True) + + @staticmethod + def order_items() -> OrderItemRequest: + return OrderItemRequest(minimal=False) + + @staticmethod + def order_items_minimal() -> OrderItemRequest: + return OrderItemRequest(minimal=True) + + @staticmethod + def payments() -> PaymentRequest: + return PaymentRequest(minimal=False) + + @staticmethod + def payments_minimal() -> PaymentRequest: + return PaymentRequest(minimal=True) + + @staticmethod + def payment_attempts() -> PaymentAttemptRequest: + return PaymentAttemptRequest(minimal=False) + + @staticmethod + def payment_attempts_minimal() -> PaymentAttemptRequest: + return PaymentAttemptRequest(minimal=True) + + @staticmethod + def shipments() -> ShipmentRequest: + return ShipmentRequest(minimal=False) + + @staticmethod + def shipments_minimal() -> ShipmentRequest: + return ShipmentRequest(minimal=True) + + diff --git a/examples/trace-chain/lib/models/__init__.py b/examples/trace-chain/lib/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/trace-chain/lib/models/customer_order.py b/examples/trace-chain/lib/models/customer_order.py new file mode 100644 index 0000000..6c88fd4 --- /dev/null +++ b/examples/trace-chain/lib/models/customer_order.py @@ -0,0 +1,397 @@ +from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core import MutationIntent +from teaql.core.value import Value +from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation +import itertools +from models.platform import Platform + + +class CustomerOrder: + _teaql_temporary_ids = itertools.count(1) + @classmethod + def refer(cls, entity_id): + return cls(id=entity_id) + + @classmethod + def _teaql_new_with_fixed_id(cls, entity_id): + """Generated bootstrap capability; application code must not call it.""" + return cls(id=entity_id)._teaql_force_create() + + def _teaql_force_create(self): + self._action = "Create" + self._entity_root.mark_as_new(self._teaql_entity_key()) + return self + + def __init__(self, **kwargs): + self._entity_root = kwargs.pop("_entity_root", None) or EntityRoot() + if "id" in kwargs and "id" not in kwargs: + kwargs["id"] = kwargs.pop("id") + if "platform" in kwargs and "platform" not in kwargs: + kwargs["platform"] = kwargs.pop("platform") + if "order_number" in kwargs and "orderNumber" not in kwargs: + kwargs["orderNumber"] = kwargs.pop("order_number") + if "description" in kwargs and "description" not in kwargs: + kwargs["description"] = kwargs.pop("description") + if "version" in kwargs and "version" not in kwargs: + kwargs["version"] = kwargs.pop("version") + self._action = "Update" if kwargs.get("id") else "Create" + self._comment = None + self._loaded_fields = set(kwargs.keys()) + self.id = kwargs.get("id") + + self.platform = kwargs.get("platform") + + self.orderNumber = kwargs.get("orderNumber") + + self.description = kwargs.get("description") + + self.version = kwargs.get("version") + + if isinstance(self.platform, dict): + self.platform = Platform(**self.platform) + self._order_item_list = kwargs.get("order_item_list", []) + if "order_item_list" in kwargs or kwargs.get("id") is None: + self._loaded_fields.add("order_item_list") + self._payment_list = kwargs.get("payment_list", []) + if "payment_list" in kwargs or kwargs.get("id") is None: + self._loaded_fields.add("payment_list") + self._shipment_list = kwargs.get("shipment_list", []) + if "shipment_list" in kwargs or kwargs.get("id") is None: + self._loaded_fields.add("shipment_list") + if self._order_item_list: + from models.order_item import OrderItem + self._order_item_list = [ + item if isinstance(item, OrderItem) else OrderItem(**item) + for item in self._order_item_list + ] + if self._payment_list: + from models.payment import Payment + self._payment_list = [ + item if isinstance(item, Payment) else Payment(**item) + for item in self._payment_list + ] + if self._shipment_list: + from models.shipment import Shipment + self._shipment_list = [ + item if isinstance(item, Shipment) else Shipment(**item) + for item in self._shipment_list + ] + self._ledger_id = getattr(self, "id", None) + if self._ledger_id is None: + self._ledger_id = -next(self._teaql_temporary_ids) + key = self._teaql_entity_key() + if self._action == "Create": + self._entity_root.mark_as_new(key) + elif getattr(self, "version", None) is not None: + self._entity_root.set_original_version(key, int(self.version)) + + def _teaql_entity_key(self): + return EntityKey("CustomerOrder", self._ledger_id) + + def _teaql_attach_root(self, root): + if self._entity_root is not root: + root.merge_from(self._entity_root) + self._entity_root = root + for child in self._order_item_list: + child._teaql_attach_root(root) + for child in self._payment_list: + child._teaql_attach_root(root) + for child in self._shipment_list: + child._teaql_attach_root(root) + return self + def mark_for_deletion(self): + self._action = "Delete" + self._entity_root.mark_as_deleted(self._teaql_entity_key()) + return self + + def audit_as(self, comment: str): + MutationIntent(comment) + self._comment = comment + return self + + async def save(self, context): + intent = MutationIntent(self._comment) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) + + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return + + async def _teaql_reserve_graph_ids(self, graph, visited): + context = graph.context + object_identity = id(self) + if object_identity in visited: + return + visited.add(object_identity) + if self._action == "Create" and getattr(self, "id", None) is None: + service = context.require_resource("dataService") + allocator = getattr(service, "next_id", None) + if not callable(allocator): + raise RuntimeError( + "Configured dataService does not support stable ID reservation" + ) + old_key = self._teaql_entity_key() + old_loaded = set(self._loaded_fields) + self.id = int(await allocator("CustomerOrder")) + self._loaded_fields.add("id") + self._ledger_id = self.id + new_key = self._teaql_entity_key() + self._entity_root.rekey(old_key, new_key) + self._entity_root.set(new_key, "id", Value.I64(self.id)) + def rollback_reservation(): + self.id = None + self._ledger_id = old_key.id + self._loaded_fields = old_loaded + self._entity_root.rekey(new_key, old_key) + self._entity_root.set(old_key, "id", Value.from_any(None)) + graph.after_rollback(rollback_reservation) + await self._teaql_ensure_business_ids(context) + for child in self._order_item_list: + child._teaql_attach_root(self._entity_root) + await child._teaql_reserve_graph_ids(graph, visited) + for child in self._payment_list: + child._teaql_attach_root(self._entity_root) + await child._teaql_reserve_graph_ids(graph, visited) + for child in self._shipment_list: + child._teaql_attach_root(self._entity_root) + await child._teaql_reserve_graph_ids(graph, visited) + + async def _teaql_preflight_and_save(self, graph): + await self._teaql_reserve_graph_ids(graph, set()) + self._teaql_preflight_graph(graph) + return await self._teaql_save_within_graph(graph) + + def _teaql_build_command(self): + payload = {} + if "id" in self._loaded_fields: + payload["id"] = Value.I64(self.id) + + if "platform" in self._loaded_fields: + reference = self.platform + reference_id = getattr(reference, "id", reference) + payload["platform"] = (Value.Object(reference) + if reference is not None and hasattr(reference, "id") and reference_id is None + else Value.I64(reference_id)) + + if "orderNumber" in self._loaded_fields: + payload["order_number"] = Value.Text(self.orderNumber) + + if "description" in self._loaded_fields: + payload["description"] = Value.Text(self.description) + + if "version" in self._loaded_fields: + payload["version"] = Value.I64(self.version) + + action = self._action + if action == "Update": + ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) + payload = {field: value for field, value in ledger.items() if field not in ("id", "version")} + if action == "Create": + cmd = InsertCommand("CustomerOrder", payload) + elif action == "Update": + cmd = UpdateCommand("CustomerOrder", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + for key, value in payload.items(): + if key not in ("id", "version"): cmd.value(key, value) + else: + cmd = DeleteCommand("CustomerOrder", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + return action, cmd + + def _teaql_preflight_graph(self, graph): + context = graph.context + if self._action == "Update": + if "id" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) + if "platform" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("platform"), message="Mutation requires a fully loaded entity")]) + if "orderNumber" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("order_number"), message="Mutation requires a fully loaded entity")]) + if "description" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("description"), message="Mutation requires a fully loaded entity")]) + if "version" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) + _action, cmd = self._teaql_build_command() + try: + context.preflight_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) + for index, child in enumerate(self._order_item_list): + child._teaql_attach_root(self._entity_root) + child.update_customer_order(self) + try: + child._teaql_preflight_graph(graph) + except CheckException as error: + prefix = ObjectLocation().property("order_item_list").index(index) + raise CheckException([ + CheckResult(v.rule_id, v.location.prefixed_by(prefix), v.input_value, v.system_value, v.message) + for v in error.violations + ]) from error + for index, child in enumerate(self._payment_list): + child._teaql_attach_root(self._entity_root) + child.update_customer_order(self) + try: + child._teaql_preflight_graph(graph) + except CheckException as error: + prefix = ObjectLocation().property("payment_list").index(index) + raise CheckException([ + CheckResult(v.rule_id, v.location.prefixed_by(prefix), v.input_value, v.system_value, v.message) + for v in error.violations + ]) from error + for index, child in enumerate(self._shipment_list): + child._teaql_attach_root(self._entity_root) + child.update_customer_order(self) + try: + child._teaql_preflight_graph(graph) + except CheckException as error: + prefix = ObjectLocation().property("shipment_list").index(index) + raise CheckException([ + CheckResult(v.rule_id, v.location.prefixed_by(prefix), v.input_value, v.system_value, v.message) + for v in error.violations + ]) from error + + async def _teaql_save_within_graph(self, graph, parent_scope=None): + context = graph.context + scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) + + self._teaql_attach_root(self._entity_root) + action, cmd = self._teaql_build_command() + + + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) + + try: + context.check_and_fix_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) + context.mark_mutation_checked(cmd) + service = context.require_resource("dataService") + result = await service.mutate(context, req) + persisted = result.persisted_record + if persisted is None: + raise RuntimeError( + "Mutation provider did not return authoritative persisted state for CustomerOrder" + ) + rollback_payload = {field: getattr(self, field, None) for field in self._loaded_fields | {"id", "version"}} + rollback_ledger_id = self._ledger_id + rollback_action = self._action + rollback_loaded_fields = set(self._loaded_fields) + old_key = self._teaql_entity_key() + if "id" in persisted: + self.id = persisted["id"] + self._loaded_fields.add("id") + elif "id" in persisted: + self.id = persisted["id"] + self._loaded_fields.add("id") + if "platform" in persisted: + self.platform = persisted["platform"] + self._loaded_fields.add("platform") + elif "platform" in persisted: + self.platform = persisted["platform"] + self._loaded_fields.add("platform") + if "order_number" in persisted: + self.orderNumber = persisted["order_number"] + self._loaded_fields.add("orderNumber") + elif "orderNumber" in persisted: + self.orderNumber = persisted["orderNumber"] + self._loaded_fields.add("orderNumber") + if "description" in persisted: + self.description = persisted["description"] + self._loaded_fields.add("description") + elif "description" in persisted: + self.description = persisted["description"] + self._loaded_fields.add("description") + if "version" in persisted: + self.version = persisted["version"] + self._loaded_fields.add("version") + elif "version" in persisted: + self.version = persisted["version"] + self._loaded_fields.add("version") + self._ledger_id = getattr(self, "id", self._ledger_id) + new_key = self._teaql_entity_key() + if old_key != new_key: + self._entity_root.rekey(old_key, new_key) + def rollback_entity(): + for field, value in rollback_payload.items(): + setattr(self, field, value) + self._ledger_id = rollback_ledger_id + self._action = rollback_action + self._loaded_fields = rollback_loaded_fields + if old_key != new_key: + self._entity_root.rekey(new_key, old_key) + graph.after_rollback(rollback_entity) + if action != "Delete": + self._action = "Update" + + cascade_relations = [] + cascade_relations.append(("order_item_list", self._order_item_list, "update_customer_order")) + cascade_relations.append(("payment_list", self._payment_list, "update_customer_order")) + cascade_relations.append(("shipment_list", self._shipment_list, "update_customer_order")) + if action != "Delete": + for relation_name, children, updater in cascade_relations: + for index, child in enumerate(children): + child._teaql_attach_root(self._entity_root) + getattr(child, updater)(self) + try: + await child._teaql_save_within_graph(graph, scope) + except CheckException as error: + prefix = ObjectLocation().property(relation_name).index(index) + raise CheckException([ + CheckResult( + violation.rule_id, + violation.location.prefixed_by(prefix), + violation.input_value, + violation.system_value, + violation.message, + ) + for violation in error.violations + ]) from error + def commit_entity(): + self._entity_root.clear_entity(new_key) + if getattr(self, "version", None) is not None: + self._entity_root.set_original_version(new_key, int(self.version)) + graph.after_commit(commit_entity) + return self + + def update_id(self, value): + self.id = value + self._loaded_fields.add("id") + self._entity_root.set(self._teaql_entity_key(), "id", Value.from_any(value)) + return self + + def update_order_number(self, value): + self.orderNumber = value + self._loaded_fields.add("orderNumber") + self._entity_root.set(self._teaql_entity_key(), "order_number", Value.from_any(value)) + return self + + def update_description(self, value): + self.description = value + self._loaded_fields.add("description") + self._entity_root.set(self._teaql_entity_key(), "description", Value.from_any(value)) + return self + + def update_version(self, value): + self.version = value + self._loaded_fields.add("version") + self._entity_root.set(self._teaql_entity_key(), "version", Value.from_any(value)) + return self + def update_platform(self, value): + self.platform = getattr(value, "id", value) if value else None + self._loaded_fields.add("platform") + self._entity_root.set(self._teaql_entity_key(), "platform", Value.from_any(self.platform)) + return self + + def order_item_list(self) -> list: + self._loaded_fields.add("order_item_list") + return self._order_item_list + + def payment_list(self) -> list: + self._loaded_fields.add("payment_list") + return self._payment_list + + def shipment_list(self) -> list: + self._loaded_fields.add("shipment_list") + return self._shipment_list diff --git a/examples/trace-chain/lib/models/order_item.py b/examples/trace-chain/lib/models/order_item.py new file mode 100644 index 0000000..02f382c --- /dev/null +++ b/examples/trace-chain/lib/models/order_item.py @@ -0,0 +1,286 @@ +from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core import MutationIntent +from teaql.core.value import Value +from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation +import itertools +from models.customer_order import CustomerOrder + + +class OrderItem: + _teaql_temporary_ids = itertools.count(1) + @classmethod + def refer(cls, entity_id): + return cls(id=entity_id) + + @classmethod + def _teaql_new_with_fixed_id(cls, entity_id): + """Generated bootstrap capability; application code must not call it.""" + return cls(id=entity_id)._teaql_force_create() + + def _teaql_force_create(self): + self._action = "Create" + self._entity_root.mark_as_new(self._teaql_entity_key()) + return self + + def __init__(self, **kwargs): + self._entity_root = kwargs.pop("_entity_root", None) or EntityRoot() + if "id" in kwargs and "id" not in kwargs: + kwargs["id"] = kwargs.pop("id") + if "customer_order" in kwargs and "customerOrder" not in kwargs: + kwargs["customerOrder"] = kwargs.pop("customer_order") + if "name" in kwargs and "name" not in kwargs: + kwargs["name"] = kwargs.pop("name") + if "version" in kwargs and "version" not in kwargs: + kwargs["version"] = kwargs.pop("version") + self._action = "Update" if kwargs.get("id") else "Create" + self._comment = None + self._loaded_fields = set(kwargs.keys()) + self.id = kwargs.get("id") + + self.customerOrder = kwargs.get("customerOrder") + + self.name = kwargs.get("name") + + self.version = kwargs.get("version") + + if isinstance(self.customerOrder, dict): + self.customerOrder = CustomerOrder(**self.customerOrder) + self._ledger_id = getattr(self, "id", None) + if self._ledger_id is None: + self._ledger_id = -next(self._teaql_temporary_ids) + key = self._teaql_entity_key() + if self._action == "Create": + self._entity_root.mark_as_new(key) + elif getattr(self, "version", None) is not None: + self._entity_root.set_original_version(key, int(self.version)) + + def _teaql_entity_key(self): + return EntityKey("OrderItem", self._ledger_id) + + def _teaql_attach_root(self, root): + if self._entity_root is not root: + root.merge_from(self._entity_root) + self._entity_root = root + return self + def mark_for_deletion(self): + self._action = "Delete" + self._entity_root.mark_as_deleted(self._teaql_entity_key()) + return self + + def audit_as(self, comment: str): + MutationIntent(comment) + self._comment = comment + return self + + async def save(self, context): + intent = MutationIntent(self._comment) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) + + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return + + async def _teaql_reserve_graph_ids(self, graph, visited): + context = graph.context + object_identity = id(self) + if object_identity in visited: + return + visited.add(object_identity) + if self._action == "Create" and getattr(self, "id", None) is None: + service = context.require_resource("dataService") + allocator = getattr(service, "next_id", None) + if not callable(allocator): + raise RuntimeError( + "Configured dataService does not support stable ID reservation" + ) + old_key = self._teaql_entity_key() + old_loaded = set(self._loaded_fields) + self.id = int(await allocator("OrderItem")) + self._loaded_fields.add("id") + self._ledger_id = self.id + new_key = self._teaql_entity_key() + self._entity_root.rekey(old_key, new_key) + self._entity_root.set(new_key, "id", Value.I64(self.id)) + def rollback_reservation(): + self.id = None + self._ledger_id = old_key.id + self._loaded_fields = old_loaded + self._entity_root.rekey(new_key, old_key) + self._entity_root.set(old_key, "id", Value.from_any(None)) + graph.after_rollback(rollback_reservation) + await self._teaql_ensure_business_ids(context) + + async def _teaql_preflight_and_save(self, graph): + await self._teaql_reserve_graph_ids(graph, set()) + self._teaql_preflight_graph(graph) + return await self._teaql_save_within_graph(graph) + + def _teaql_build_command(self): + payload = {} + if "id" in self._loaded_fields: + payload["id"] = Value.I64(self.id) + + if "customerOrder" in self._loaded_fields: + reference = self.customerOrder + reference_id = getattr(reference, "id", reference) + payload["customer_order"] = (Value.Object(reference) + if reference is not None and hasattr(reference, "id") and reference_id is None + else Value.I64(reference_id)) + + if "name" in self._loaded_fields: + payload["name"] = Value.Text(self.name) + + if "version" in self._loaded_fields: + payload["version"] = Value.I64(self.version) + + action = self._action + if action == "Update": + ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) + payload = {field: value for field, value in ledger.items() if field not in ("id", "version")} + if action == "Create": + cmd = InsertCommand("OrderItem", payload) + elif action == "Update": + cmd = UpdateCommand("OrderItem", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + for key, value in payload.items(): + if key not in ("id", "version"): cmd.value(key, value) + else: + cmd = DeleteCommand("OrderItem", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + return action, cmd + + def _teaql_preflight_graph(self, graph): + context = graph.context + if self._action == "Update": + if "id" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) + if "customerOrder" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("customer_order"), message="Mutation requires a fully loaded entity")]) + if "name" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("name"), message="Mutation requires a fully loaded entity")]) + if "version" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) + _action, cmd = self._teaql_build_command() + try: + context.preflight_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) + + async def _teaql_save_within_graph(self, graph, parent_scope=None): + context = graph.context + scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) + + self._teaql_attach_root(self._entity_root) + action, cmd = self._teaql_build_command() + + + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) + + try: + context.check_and_fix_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) + context.mark_mutation_checked(cmd) + service = context.require_resource("dataService") + result = await service.mutate(context, req) + persisted = result.persisted_record + if persisted is None: + raise RuntimeError( + "Mutation provider did not return authoritative persisted state for OrderItem" + ) + rollback_payload = {field: getattr(self, field, None) for field in self._loaded_fields | {"id", "version"}} + rollback_ledger_id = self._ledger_id + rollback_action = self._action + rollback_loaded_fields = set(self._loaded_fields) + old_key = self._teaql_entity_key() + if "id" in persisted: + self.id = persisted["id"] + self._loaded_fields.add("id") + elif "id" in persisted: + self.id = persisted["id"] + self._loaded_fields.add("id") + if "customer_order" in persisted: + self.customerOrder = persisted["customer_order"] + self._loaded_fields.add("customerOrder") + elif "customerOrder" in persisted: + self.customerOrder = persisted["customerOrder"] + self._loaded_fields.add("customerOrder") + if "name" in persisted: + self.name = persisted["name"] + self._loaded_fields.add("name") + elif "name" in persisted: + self.name = persisted["name"] + self._loaded_fields.add("name") + if "version" in persisted: + self.version = persisted["version"] + self._loaded_fields.add("version") + elif "version" in persisted: + self.version = persisted["version"] + self._loaded_fields.add("version") + self._ledger_id = getattr(self, "id", self._ledger_id) + new_key = self._teaql_entity_key() + if old_key != new_key: + self._entity_root.rekey(old_key, new_key) + def rollback_entity(): + for field, value in rollback_payload.items(): + setattr(self, field, value) + self._ledger_id = rollback_ledger_id + self._action = rollback_action + self._loaded_fields = rollback_loaded_fields + if old_key != new_key: + self._entity_root.rekey(new_key, old_key) + graph.after_rollback(rollback_entity) + if action != "Delete": + self._action = "Update" + + cascade_relations = [] + if action != "Delete": + for relation_name, children, updater in cascade_relations: + for index, child in enumerate(children): + child._teaql_attach_root(self._entity_root) + getattr(child, updater)(self) + try: + await child._teaql_save_within_graph(graph, scope) + except CheckException as error: + prefix = ObjectLocation().property(relation_name).index(index) + raise CheckException([ + CheckResult( + violation.rule_id, + violation.location.prefixed_by(prefix), + violation.input_value, + violation.system_value, + violation.message, + ) + for violation in error.violations + ]) from error + def commit_entity(): + self._entity_root.clear_entity(new_key) + if getattr(self, "version", None) is not None: + self._entity_root.set_original_version(new_key, int(self.version)) + graph.after_commit(commit_entity) + return self + + def update_id(self, value): + self.id = value + self._loaded_fields.add("id") + self._entity_root.set(self._teaql_entity_key(), "id", Value.from_any(value)) + return self + + def update_name(self, value): + self.name = value + self._loaded_fields.add("name") + self._entity_root.set(self._teaql_entity_key(), "name", Value.from_any(value)) + return self + + def update_version(self, value): + self.version = value + self._loaded_fields.add("version") + self._entity_root.set(self._teaql_entity_key(), "version", Value.from_any(value)) + return self + def update_customer_order(self, value): + self.customerOrder = getattr(value, "id", value) if value else None + self._loaded_fields.add("customerOrder") + self._entity_root.set(self._teaql_entity_key(), "customer_order", Value.from_any(self.customerOrder)) + return self diff --git a/examples/trace-chain/lib/models/payment.py b/examples/trace-chain/lib/models/payment.py new file mode 100644 index 0000000..eee897b --- /dev/null +++ b/examples/trace-chain/lib/models/payment.py @@ -0,0 +1,316 @@ +from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core import MutationIntent +from teaql.core.value import Value +from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation +import itertools +from models.customer_order import CustomerOrder + + +class Payment: + _teaql_temporary_ids = itertools.count(1) + @classmethod + def refer(cls, entity_id): + return cls(id=entity_id) + + @classmethod + def _teaql_new_with_fixed_id(cls, entity_id): + """Generated bootstrap capability; application code must not call it.""" + return cls(id=entity_id)._teaql_force_create() + + def _teaql_force_create(self): + self._action = "Create" + self._entity_root.mark_as_new(self._teaql_entity_key()) + return self + + def __init__(self, **kwargs): + self._entity_root = kwargs.pop("_entity_root", None) or EntityRoot() + if "id" in kwargs and "id" not in kwargs: + kwargs["id"] = kwargs.pop("id") + if "customer_order" in kwargs and "customerOrder" not in kwargs: + kwargs["customerOrder"] = kwargs.pop("customer_order") + if "reference_code" in kwargs and "referenceCode" not in kwargs: + kwargs["referenceCode"] = kwargs.pop("reference_code") + if "version" in kwargs and "version" not in kwargs: + kwargs["version"] = kwargs.pop("version") + self._action = "Update" if kwargs.get("id") else "Create" + self._comment = None + self._loaded_fields = set(kwargs.keys()) + self.id = kwargs.get("id") + + self.customerOrder = kwargs.get("customerOrder") + + self.referenceCode = kwargs.get("referenceCode") + + self.version = kwargs.get("version") + + if isinstance(self.customerOrder, dict): + self.customerOrder = CustomerOrder(**self.customerOrder) + self._payment_attempt_list = kwargs.get("payment_attempt_list", []) + if "payment_attempt_list" in kwargs or kwargs.get("id") is None: + self._loaded_fields.add("payment_attempt_list") + if self._payment_attempt_list: + from models.payment_attempt import PaymentAttempt + self._payment_attempt_list = [ + item if isinstance(item, PaymentAttempt) else PaymentAttempt(**item) + for item in self._payment_attempt_list + ] + self._ledger_id = getattr(self, "id", None) + if self._ledger_id is None: + self._ledger_id = -next(self._teaql_temporary_ids) + key = self._teaql_entity_key() + if self._action == "Create": + self._entity_root.mark_as_new(key) + elif getattr(self, "version", None) is not None: + self._entity_root.set_original_version(key, int(self.version)) + + def _teaql_entity_key(self): + return EntityKey("Payment", self._ledger_id) + + def _teaql_attach_root(self, root): + if self._entity_root is not root: + root.merge_from(self._entity_root) + self._entity_root = root + for child in self._payment_attempt_list: + child._teaql_attach_root(root) + return self + def mark_for_deletion(self): + self._action = "Delete" + self._entity_root.mark_as_deleted(self._teaql_entity_key()) + return self + + def audit_as(self, comment: str): + MutationIntent(comment) + self._comment = comment + return self + + async def save(self, context): + intent = MutationIntent(self._comment) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) + + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return + + async def _teaql_reserve_graph_ids(self, graph, visited): + context = graph.context + object_identity = id(self) + if object_identity in visited: + return + visited.add(object_identity) + if self._action == "Create" and getattr(self, "id", None) is None: + service = context.require_resource("dataService") + allocator = getattr(service, "next_id", None) + if not callable(allocator): + raise RuntimeError( + "Configured dataService does not support stable ID reservation" + ) + old_key = self._teaql_entity_key() + old_loaded = set(self._loaded_fields) + self.id = int(await allocator("Payment")) + self._loaded_fields.add("id") + self._ledger_id = self.id + new_key = self._teaql_entity_key() + self._entity_root.rekey(old_key, new_key) + self._entity_root.set(new_key, "id", Value.I64(self.id)) + def rollback_reservation(): + self.id = None + self._ledger_id = old_key.id + self._loaded_fields = old_loaded + self._entity_root.rekey(new_key, old_key) + self._entity_root.set(old_key, "id", Value.from_any(None)) + graph.after_rollback(rollback_reservation) + await self._teaql_ensure_business_ids(context) + for child in self._payment_attempt_list: + child._teaql_attach_root(self._entity_root) + await child._teaql_reserve_graph_ids(graph, visited) + + async def _teaql_preflight_and_save(self, graph): + await self._teaql_reserve_graph_ids(graph, set()) + self._teaql_preflight_graph(graph) + return await self._teaql_save_within_graph(graph) + + def _teaql_build_command(self): + payload = {} + if "id" in self._loaded_fields: + payload["id"] = Value.I64(self.id) + + if "customerOrder" in self._loaded_fields: + reference = self.customerOrder + reference_id = getattr(reference, "id", reference) + payload["customer_order"] = (Value.Object(reference) + if reference is not None and hasattr(reference, "id") and reference_id is None + else Value.I64(reference_id)) + + if "referenceCode" in self._loaded_fields: + payload["reference_code"] = Value.Text(self.referenceCode) + + if "version" in self._loaded_fields: + payload["version"] = Value.I64(self.version) + + action = self._action + if action == "Update": + ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) + payload = {field: value for field, value in ledger.items() if field not in ("id", "version")} + if action == "Create": + cmd = InsertCommand("Payment", payload) + elif action == "Update": + cmd = UpdateCommand("Payment", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + for key, value in payload.items(): + if key not in ("id", "version"): cmd.value(key, value) + else: + cmd = DeleteCommand("Payment", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + return action, cmd + + def _teaql_preflight_graph(self, graph): + context = graph.context + if self._action == "Update": + if "id" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) + if "customerOrder" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("customer_order"), message="Mutation requires a fully loaded entity")]) + if "referenceCode" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("reference_code"), message="Mutation requires a fully loaded entity")]) + if "version" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) + _action, cmd = self._teaql_build_command() + try: + context.preflight_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) + for index, child in enumerate(self._payment_attempt_list): + child._teaql_attach_root(self._entity_root) + child.update_payment(self) + try: + child._teaql_preflight_graph(graph) + except CheckException as error: + prefix = ObjectLocation().property("payment_attempt_list").index(index) + raise CheckException([ + CheckResult(v.rule_id, v.location.prefixed_by(prefix), v.input_value, v.system_value, v.message) + for v in error.violations + ]) from error + + async def _teaql_save_within_graph(self, graph, parent_scope=None): + context = graph.context + scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) + + self._teaql_attach_root(self._entity_root) + action, cmd = self._teaql_build_command() + + + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) + + try: + context.check_and_fix_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) + context.mark_mutation_checked(cmd) + service = context.require_resource("dataService") + result = await service.mutate(context, req) + persisted = result.persisted_record + if persisted is None: + raise RuntimeError( + "Mutation provider did not return authoritative persisted state for Payment" + ) + rollback_payload = {field: getattr(self, field, None) for field in self._loaded_fields | {"id", "version"}} + rollback_ledger_id = self._ledger_id + rollback_action = self._action + rollback_loaded_fields = set(self._loaded_fields) + old_key = self._teaql_entity_key() + if "id" in persisted: + self.id = persisted["id"] + self._loaded_fields.add("id") + elif "id" in persisted: + self.id = persisted["id"] + self._loaded_fields.add("id") + if "customer_order" in persisted: + self.customerOrder = persisted["customer_order"] + self._loaded_fields.add("customerOrder") + elif "customerOrder" in persisted: + self.customerOrder = persisted["customerOrder"] + self._loaded_fields.add("customerOrder") + if "reference_code" in persisted: + self.referenceCode = persisted["reference_code"] + self._loaded_fields.add("referenceCode") + elif "referenceCode" in persisted: + self.referenceCode = persisted["referenceCode"] + self._loaded_fields.add("referenceCode") + if "version" in persisted: + self.version = persisted["version"] + self._loaded_fields.add("version") + elif "version" in persisted: + self.version = persisted["version"] + self._loaded_fields.add("version") + self._ledger_id = getattr(self, "id", self._ledger_id) + new_key = self._teaql_entity_key() + if old_key != new_key: + self._entity_root.rekey(old_key, new_key) + def rollback_entity(): + for field, value in rollback_payload.items(): + setattr(self, field, value) + self._ledger_id = rollback_ledger_id + self._action = rollback_action + self._loaded_fields = rollback_loaded_fields + if old_key != new_key: + self._entity_root.rekey(new_key, old_key) + graph.after_rollback(rollback_entity) + if action != "Delete": + self._action = "Update" + + cascade_relations = [] + cascade_relations.append(("payment_attempt_list", self._payment_attempt_list, "update_payment")) + if action != "Delete": + for relation_name, children, updater in cascade_relations: + for index, child in enumerate(children): + child._teaql_attach_root(self._entity_root) + getattr(child, updater)(self) + try: + await child._teaql_save_within_graph(graph, scope) + except CheckException as error: + prefix = ObjectLocation().property(relation_name).index(index) + raise CheckException([ + CheckResult( + violation.rule_id, + violation.location.prefixed_by(prefix), + violation.input_value, + violation.system_value, + violation.message, + ) + for violation in error.violations + ]) from error + def commit_entity(): + self._entity_root.clear_entity(new_key) + if getattr(self, "version", None) is not None: + self._entity_root.set_original_version(new_key, int(self.version)) + graph.after_commit(commit_entity) + return self + + def update_id(self, value): + self.id = value + self._loaded_fields.add("id") + self._entity_root.set(self._teaql_entity_key(), "id", Value.from_any(value)) + return self + + def update_reference_code(self, value): + self.referenceCode = value + self._loaded_fields.add("referenceCode") + self._entity_root.set(self._teaql_entity_key(), "reference_code", Value.from_any(value)) + return self + + def update_version(self, value): + self.version = value + self._loaded_fields.add("version") + self._entity_root.set(self._teaql_entity_key(), "version", Value.from_any(value)) + return self + def update_customer_order(self, value): + self.customerOrder = getattr(value, "id", value) if value else None + self._loaded_fields.add("customerOrder") + self._entity_root.set(self._teaql_entity_key(), "customer_order", Value.from_any(self.customerOrder)) + return self + + def payment_attempt_list(self) -> list: + self._loaded_fields.add("payment_attempt_list") + return self._payment_attempt_list diff --git a/examples/trace-chain/lib/models/payment_attempt.py b/examples/trace-chain/lib/models/payment_attempt.py new file mode 100644 index 0000000..075c96c --- /dev/null +++ b/examples/trace-chain/lib/models/payment_attempt.py @@ -0,0 +1,286 @@ +from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core import MutationIntent +from teaql.core.value import Value +from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation +import itertools +from models.payment import Payment + + +class PaymentAttempt: + _teaql_temporary_ids = itertools.count(1) + @classmethod + def refer(cls, entity_id): + return cls(id=entity_id) + + @classmethod + def _teaql_new_with_fixed_id(cls, entity_id): + """Generated bootstrap capability; application code must not call it.""" + return cls(id=entity_id)._teaql_force_create() + + def _teaql_force_create(self): + self._action = "Create" + self._entity_root.mark_as_new(self._teaql_entity_key()) + return self + + def __init__(self, **kwargs): + self._entity_root = kwargs.pop("_entity_root", None) or EntityRoot() + if "id" in kwargs and "id" not in kwargs: + kwargs["id"] = kwargs.pop("id") + if "payment" in kwargs and "payment" not in kwargs: + kwargs["payment"] = kwargs.pop("payment") + if "reference_code" in kwargs and "referenceCode" not in kwargs: + kwargs["referenceCode"] = kwargs.pop("reference_code") + if "version" in kwargs and "version" not in kwargs: + kwargs["version"] = kwargs.pop("version") + self._action = "Update" if kwargs.get("id") else "Create" + self._comment = None + self._loaded_fields = set(kwargs.keys()) + self.id = kwargs.get("id") + + self.payment = kwargs.get("payment") + + self.referenceCode = kwargs.get("referenceCode") + + self.version = kwargs.get("version") + + if isinstance(self.payment, dict): + self.payment = Payment(**self.payment) + self._ledger_id = getattr(self, "id", None) + if self._ledger_id is None: + self._ledger_id = -next(self._teaql_temporary_ids) + key = self._teaql_entity_key() + if self._action == "Create": + self._entity_root.mark_as_new(key) + elif getattr(self, "version", None) is not None: + self._entity_root.set_original_version(key, int(self.version)) + + def _teaql_entity_key(self): + return EntityKey("PaymentAttempt", self._ledger_id) + + def _teaql_attach_root(self, root): + if self._entity_root is not root: + root.merge_from(self._entity_root) + self._entity_root = root + return self + def mark_for_deletion(self): + self._action = "Delete" + self._entity_root.mark_as_deleted(self._teaql_entity_key()) + return self + + def audit_as(self, comment: str): + MutationIntent(comment) + self._comment = comment + return self + + async def save(self, context): + intent = MutationIntent(self._comment) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) + + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return + + async def _teaql_reserve_graph_ids(self, graph, visited): + context = graph.context + object_identity = id(self) + if object_identity in visited: + return + visited.add(object_identity) + if self._action == "Create" and getattr(self, "id", None) is None: + service = context.require_resource("dataService") + allocator = getattr(service, "next_id", None) + if not callable(allocator): + raise RuntimeError( + "Configured dataService does not support stable ID reservation" + ) + old_key = self._teaql_entity_key() + old_loaded = set(self._loaded_fields) + self.id = int(await allocator("PaymentAttempt")) + self._loaded_fields.add("id") + self._ledger_id = self.id + new_key = self._teaql_entity_key() + self._entity_root.rekey(old_key, new_key) + self._entity_root.set(new_key, "id", Value.I64(self.id)) + def rollback_reservation(): + self.id = None + self._ledger_id = old_key.id + self._loaded_fields = old_loaded + self._entity_root.rekey(new_key, old_key) + self._entity_root.set(old_key, "id", Value.from_any(None)) + graph.after_rollback(rollback_reservation) + await self._teaql_ensure_business_ids(context) + + async def _teaql_preflight_and_save(self, graph): + await self._teaql_reserve_graph_ids(graph, set()) + self._teaql_preflight_graph(graph) + return await self._teaql_save_within_graph(graph) + + def _teaql_build_command(self): + payload = {} + if "id" in self._loaded_fields: + payload["id"] = Value.I64(self.id) + + if "payment" in self._loaded_fields: + reference = self.payment + reference_id = getattr(reference, "id", reference) + payload["payment"] = (Value.Object(reference) + if reference is not None and hasattr(reference, "id") and reference_id is None + else Value.I64(reference_id)) + + if "referenceCode" in self._loaded_fields: + payload["reference_code"] = Value.Text(self.referenceCode) + + if "version" in self._loaded_fields: + payload["version"] = Value.I64(self.version) + + action = self._action + if action == "Update": + ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) + payload = {field: value for field, value in ledger.items() if field not in ("id", "version")} + if action == "Create": + cmd = InsertCommand("PaymentAttempt", payload) + elif action == "Update": + cmd = UpdateCommand("PaymentAttempt", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + for key, value in payload.items(): + if key not in ("id", "version"): cmd.value(key, value) + else: + cmd = DeleteCommand("PaymentAttempt", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + return action, cmd + + def _teaql_preflight_graph(self, graph): + context = graph.context + if self._action == "Update": + if "id" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) + if "payment" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("payment"), message="Mutation requires a fully loaded entity")]) + if "referenceCode" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("reference_code"), message="Mutation requires a fully loaded entity")]) + if "version" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) + _action, cmd = self._teaql_build_command() + try: + context.preflight_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) + + async def _teaql_save_within_graph(self, graph, parent_scope=None): + context = graph.context + scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) + + self._teaql_attach_root(self._entity_root) + action, cmd = self._teaql_build_command() + + + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) + + try: + context.check_and_fix_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) + context.mark_mutation_checked(cmd) + service = context.require_resource("dataService") + result = await service.mutate(context, req) + persisted = result.persisted_record + if persisted is None: + raise RuntimeError( + "Mutation provider did not return authoritative persisted state for PaymentAttempt" + ) + rollback_payload = {field: getattr(self, field, None) for field in self._loaded_fields | {"id", "version"}} + rollback_ledger_id = self._ledger_id + rollback_action = self._action + rollback_loaded_fields = set(self._loaded_fields) + old_key = self._teaql_entity_key() + if "id" in persisted: + self.id = persisted["id"] + self._loaded_fields.add("id") + elif "id" in persisted: + self.id = persisted["id"] + self._loaded_fields.add("id") + if "payment" in persisted: + self.payment = persisted["payment"] + self._loaded_fields.add("payment") + elif "payment" in persisted: + self.payment = persisted["payment"] + self._loaded_fields.add("payment") + if "reference_code" in persisted: + self.referenceCode = persisted["reference_code"] + self._loaded_fields.add("referenceCode") + elif "referenceCode" in persisted: + self.referenceCode = persisted["referenceCode"] + self._loaded_fields.add("referenceCode") + if "version" in persisted: + self.version = persisted["version"] + self._loaded_fields.add("version") + elif "version" in persisted: + self.version = persisted["version"] + self._loaded_fields.add("version") + self._ledger_id = getattr(self, "id", self._ledger_id) + new_key = self._teaql_entity_key() + if old_key != new_key: + self._entity_root.rekey(old_key, new_key) + def rollback_entity(): + for field, value in rollback_payload.items(): + setattr(self, field, value) + self._ledger_id = rollback_ledger_id + self._action = rollback_action + self._loaded_fields = rollback_loaded_fields + if old_key != new_key: + self._entity_root.rekey(new_key, old_key) + graph.after_rollback(rollback_entity) + if action != "Delete": + self._action = "Update" + + cascade_relations = [] + if action != "Delete": + for relation_name, children, updater in cascade_relations: + for index, child in enumerate(children): + child._teaql_attach_root(self._entity_root) + getattr(child, updater)(self) + try: + await child._teaql_save_within_graph(graph, scope) + except CheckException as error: + prefix = ObjectLocation().property(relation_name).index(index) + raise CheckException([ + CheckResult( + violation.rule_id, + violation.location.prefixed_by(prefix), + violation.input_value, + violation.system_value, + violation.message, + ) + for violation in error.violations + ]) from error + def commit_entity(): + self._entity_root.clear_entity(new_key) + if getattr(self, "version", None) is not None: + self._entity_root.set_original_version(new_key, int(self.version)) + graph.after_commit(commit_entity) + return self + + def update_id(self, value): + self.id = value + self._loaded_fields.add("id") + self._entity_root.set(self._teaql_entity_key(), "id", Value.from_any(value)) + return self + + def update_reference_code(self, value): + self.referenceCode = value + self._loaded_fields.add("referenceCode") + self._entity_root.set(self._teaql_entity_key(), "reference_code", Value.from_any(value)) + return self + + def update_version(self, value): + self.version = value + self._loaded_fields.add("version") + self._entity_root.set(self._teaql_entity_key(), "version", Value.from_any(value)) + return self + def update_payment(self, value): + self.payment = getattr(value, "id", value) if value else None + self._loaded_fields.add("payment") + self._entity_root.set(self._teaql_entity_key(), "payment", Value.from_any(self.payment)) + return self diff --git a/examples/trace-chain/lib/models/platform.py b/examples/trace-chain/lib/models/platform.py new file mode 100644 index 0000000..17da379 --- /dev/null +++ b/examples/trace-chain/lib/models/platform.py @@ -0,0 +1,288 @@ +from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core import MutationIntent +from teaql.core.value import Value +from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation +import itertools + + +class Platform: + _teaql_temporary_ids = itertools.count(1) + @classmethod + def refer(cls, entity_id): + return cls(id=entity_id) + + @classmethod + def _teaql_new_with_fixed_id(cls, entity_id): + """Generated bootstrap capability; application code must not call it.""" + return cls(id=entity_id)._teaql_force_create() + + def _teaql_force_create(self): + self._action = "Create" + self._entity_root.mark_as_new(self._teaql_entity_key()) + return self + + def __init__(self, **kwargs): + self._entity_root = kwargs.pop("_entity_root", None) or EntityRoot() + if "id" in kwargs and "id" not in kwargs: + kwargs["id"] = kwargs.pop("id") + if "name" in kwargs and "name" not in kwargs: + kwargs["name"] = kwargs.pop("name") + if "version" in kwargs and "version" not in kwargs: + kwargs["version"] = kwargs.pop("version") + self._action = "Update" if kwargs.get("id") else "Create" + self._comment = None + self._loaded_fields = set(kwargs.keys()) + self.id = kwargs.get("id") + + self.name = kwargs.get("name") + + self.version = kwargs.get("version") + + self._customer_order_list = kwargs.get("customer_order_list", []) + if "customer_order_list" in kwargs or kwargs.get("id") is None: + self._loaded_fields.add("customer_order_list") + if self._customer_order_list: + from models.customer_order import CustomerOrder + self._customer_order_list = [ + item if isinstance(item, CustomerOrder) else CustomerOrder(**item) + for item in self._customer_order_list + ] + self._ledger_id = getattr(self, "id", None) + if self._ledger_id is None: + self._ledger_id = -next(self._teaql_temporary_ids) + key = self._teaql_entity_key() + if self._action == "Create": + self._entity_root.mark_as_new(key) + elif getattr(self, "version", None) is not None: + self._entity_root.set_original_version(key, int(self.version)) + + def _teaql_entity_key(self): + return EntityKey("Platform", self._ledger_id) + + def _teaql_attach_root(self, root): + if self._entity_root is not root: + root.merge_from(self._entity_root) + self._entity_root = root + for child in self._customer_order_list: + child._teaql_attach_root(root) + return self + def mark_for_deletion(self): + self._action = "Delete" + self._entity_root.mark_as_deleted(self._teaql_entity_key()) + return self + + def audit_as(self, comment: str): + MutationIntent(comment) + self._comment = comment + return self + + async def save(self, context): + intent = MutationIntent(self._comment) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) + + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return + + async def _teaql_reserve_graph_ids(self, graph, visited): + context = graph.context + object_identity = id(self) + if object_identity in visited: + return + visited.add(object_identity) + if self._action == "Create" and getattr(self, "id", None) is None: + service = context.require_resource("dataService") + allocator = getattr(service, "next_id", None) + if not callable(allocator): + raise RuntimeError( + "Configured dataService does not support stable ID reservation" + ) + old_key = self._teaql_entity_key() + old_loaded = set(self._loaded_fields) + self.id = int(await allocator("Platform")) + self._loaded_fields.add("id") + self._ledger_id = self.id + new_key = self._teaql_entity_key() + self._entity_root.rekey(old_key, new_key) + self._entity_root.set(new_key, "id", Value.I64(self.id)) + def rollback_reservation(): + self.id = None + self._ledger_id = old_key.id + self._loaded_fields = old_loaded + self._entity_root.rekey(new_key, old_key) + self._entity_root.set(old_key, "id", Value.from_any(None)) + graph.after_rollback(rollback_reservation) + await self._teaql_ensure_business_ids(context) + for child in self._customer_order_list: + child._teaql_attach_root(self._entity_root) + await child._teaql_reserve_graph_ids(graph, visited) + + async def _teaql_preflight_and_save(self, graph): + await self._teaql_reserve_graph_ids(graph, set()) + self._teaql_preflight_graph(graph) + return await self._teaql_save_within_graph(graph) + + def _teaql_build_command(self): + payload = {} + if "id" in self._loaded_fields: + payload["id"] = Value.I64(self.id) + + if "name" in self._loaded_fields: + payload["name"] = Value.Text(self.name) + + if "version" in self._loaded_fields: + payload["version"] = Value.I64(self.version) + + action = self._action + if action == "Update": + ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) + payload = {field: value for field, value in ledger.items() if field not in ("id", "version")} + if action == "Create": + cmd = InsertCommand("Platform", payload) + elif action == "Update": + cmd = UpdateCommand("Platform", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + for key, value in payload.items(): + if key not in ("id", "version"): cmd.value(key, value) + else: + cmd = DeleteCommand("Platform", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + return action, cmd + + def _teaql_preflight_graph(self, graph): + context = graph.context + if self._action == "Update": + if "id" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) + if "name" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("name"), message="Mutation requires a fully loaded entity")]) + if "version" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) + _action, cmd = self._teaql_build_command() + try: + context.preflight_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) + for index, child in enumerate(self._customer_order_list): + child._teaql_attach_root(self._entity_root) + child.update_platform(self) + try: + child._teaql_preflight_graph(graph) + except CheckException as error: + prefix = ObjectLocation().property("customer_order_list").index(index) + raise CheckException([ + CheckResult(v.rule_id, v.location.prefixed_by(prefix), v.input_value, v.system_value, v.message) + for v in error.violations + ]) from error + + async def _teaql_save_within_graph(self, graph, parent_scope=None): + context = graph.context + scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) + + self._teaql_attach_root(self._entity_root) + action, cmd = self._teaql_build_command() + + + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) + + try: + context.check_and_fix_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) + context.mark_mutation_checked(cmd) + service = context.require_resource("dataService") + result = await service.mutate(context, req) + persisted = result.persisted_record + if persisted is None: + raise RuntimeError( + "Mutation provider did not return authoritative persisted state for Platform" + ) + rollback_payload = {field: getattr(self, field, None) for field in self._loaded_fields | {"id", "version"}} + rollback_ledger_id = self._ledger_id + rollback_action = self._action + rollback_loaded_fields = set(self._loaded_fields) + old_key = self._teaql_entity_key() + if "id" in persisted: + self.id = persisted["id"] + self._loaded_fields.add("id") + elif "id" in persisted: + self.id = persisted["id"] + self._loaded_fields.add("id") + if "name" in persisted: + self.name = persisted["name"] + self._loaded_fields.add("name") + elif "name" in persisted: + self.name = persisted["name"] + self._loaded_fields.add("name") + if "version" in persisted: + self.version = persisted["version"] + self._loaded_fields.add("version") + elif "version" in persisted: + self.version = persisted["version"] + self._loaded_fields.add("version") + self._ledger_id = getattr(self, "id", self._ledger_id) + new_key = self._teaql_entity_key() + if old_key != new_key: + self._entity_root.rekey(old_key, new_key) + def rollback_entity(): + for field, value in rollback_payload.items(): + setattr(self, field, value) + self._ledger_id = rollback_ledger_id + self._action = rollback_action + self._loaded_fields = rollback_loaded_fields + if old_key != new_key: + self._entity_root.rekey(new_key, old_key) + graph.after_rollback(rollback_entity) + if action != "Delete": + self._action = "Update" + + cascade_relations = [] + cascade_relations.append(("customer_order_list", self._customer_order_list, "update_platform")) + if action != "Delete": + for relation_name, children, updater in cascade_relations: + for index, child in enumerate(children): + child._teaql_attach_root(self._entity_root) + getattr(child, updater)(self) + try: + await child._teaql_save_within_graph(graph, scope) + except CheckException as error: + prefix = ObjectLocation().property(relation_name).index(index) + raise CheckException([ + CheckResult( + violation.rule_id, + violation.location.prefixed_by(prefix), + violation.input_value, + violation.system_value, + violation.message, + ) + for violation in error.violations + ]) from error + def commit_entity(): + self._entity_root.clear_entity(new_key) + if getattr(self, "version", None) is not None: + self._entity_root.set_original_version(new_key, int(self.version)) + graph.after_commit(commit_entity) + return self + + def update_id(self, value): + self.id = value + self._loaded_fields.add("id") + self._entity_root.set(self._teaql_entity_key(), "id", Value.from_any(value)) + return self + + def update_name(self, value): + self.name = value + self._loaded_fields.add("name") + self._entity_root.set(self._teaql_entity_key(), "name", Value.from_any(value)) + return self + + def update_version(self, value): + self.version = value + self._loaded_fields.add("version") + self._entity_root.set(self._teaql_entity_key(), "version", Value.from_any(value)) + return self + def customer_order_list(self) -> list: + self._loaded_fields.add("customer_order_list") + return self._customer_order_list diff --git a/examples/trace-chain/lib/models/shipment.py b/examples/trace-chain/lib/models/shipment.py new file mode 100644 index 0000000..64e3fab --- /dev/null +++ b/examples/trace-chain/lib/models/shipment.py @@ -0,0 +1,286 @@ +from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core import MutationIntent +from teaql.core.value import Value +from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation +import itertools +from models.customer_order import CustomerOrder + + +class Shipment: + _teaql_temporary_ids = itertools.count(1) + @classmethod + def refer(cls, entity_id): + return cls(id=entity_id) + + @classmethod + def _teaql_new_with_fixed_id(cls, entity_id): + """Generated bootstrap capability; application code must not call it.""" + return cls(id=entity_id)._teaql_force_create() + + def _teaql_force_create(self): + self._action = "Create" + self._entity_root.mark_as_new(self._teaql_entity_key()) + return self + + def __init__(self, **kwargs): + self._entity_root = kwargs.pop("_entity_root", None) or EntityRoot() + if "id" in kwargs and "id" not in kwargs: + kwargs["id"] = kwargs.pop("id") + if "customer_order" in kwargs and "customerOrder" not in kwargs: + kwargs["customerOrder"] = kwargs.pop("customer_order") + if "reference_code" in kwargs and "referenceCode" not in kwargs: + kwargs["referenceCode"] = kwargs.pop("reference_code") + if "version" in kwargs and "version" not in kwargs: + kwargs["version"] = kwargs.pop("version") + self._action = "Update" if kwargs.get("id") else "Create" + self._comment = None + self._loaded_fields = set(kwargs.keys()) + self.id = kwargs.get("id") + + self.customerOrder = kwargs.get("customerOrder") + + self.referenceCode = kwargs.get("referenceCode") + + self.version = kwargs.get("version") + + if isinstance(self.customerOrder, dict): + self.customerOrder = CustomerOrder(**self.customerOrder) + self._ledger_id = getattr(self, "id", None) + if self._ledger_id is None: + self._ledger_id = -next(self._teaql_temporary_ids) + key = self._teaql_entity_key() + if self._action == "Create": + self._entity_root.mark_as_new(key) + elif getattr(self, "version", None) is not None: + self._entity_root.set_original_version(key, int(self.version)) + + def _teaql_entity_key(self): + return EntityKey("Shipment", self._ledger_id) + + def _teaql_attach_root(self, root): + if self._entity_root is not root: + root.merge_from(self._entity_root) + self._entity_root = root + return self + def mark_for_deletion(self): + self._action = "Delete" + self._entity_root.mark_as_deleted(self._teaql_entity_key()) + return self + + def audit_as(self, comment: str): + MutationIntent(comment) + self._comment = comment + return self + + async def save(self, context): + intent = MutationIntent(self._comment) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) + + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return + + async def _teaql_reserve_graph_ids(self, graph, visited): + context = graph.context + object_identity = id(self) + if object_identity in visited: + return + visited.add(object_identity) + if self._action == "Create" and getattr(self, "id", None) is None: + service = context.require_resource("dataService") + allocator = getattr(service, "next_id", None) + if not callable(allocator): + raise RuntimeError( + "Configured dataService does not support stable ID reservation" + ) + old_key = self._teaql_entity_key() + old_loaded = set(self._loaded_fields) + self.id = int(await allocator("Shipment")) + self._loaded_fields.add("id") + self._ledger_id = self.id + new_key = self._teaql_entity_key() + self._entity_root.rekey(old_key, new_key) + self._entity_root.set(new_key, "id", Value.I64(self.id)) + def rollback_reservation(): + self.id = None + self._ledger_id = old_key.id + self._loaded_fields = old_loaded + self._entity_root.rekey(new_key, old_key) + self._entity_root.set(old_key, "id", Value.from_any(None)) + graph.after_rollback(rollback_reservation) + await self._teaql_ensure_business_ids(context) + + async def _teaql_preflight_and_save(self, graph): + await self._teaql_reserve_graph_ids(graph, set()) + self._teaql_preflight_graph(graph) + return await self._teaql_save_within_graph(graph) + + def _teaql_build_command(self): + payload = {} + if "id" in self._loaded_fields: + payload["id"] = Value.I64(self.id) + + if "customerOrder" in self._loaded_fields: + reference = self.customerOrder + reference_id = getattr(reference, "id", reference) + payload["customer_order"] = (Value.Object(reference) + if reference is not None and hasattr(reference, "id") and reference_id is None + else Value.I64(reference_id)) + + if "referenceCode" in self._loaded_fields: + payload["reference_code"] = Value.Text(self.referenceCode) + + if "version" in self._loaded_fields: + payload["version"] = Value.I64(self.version) + + action = self._action + if action == "Update": + ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) + payload = {field: value for field, value in ledger.items() if field not in ("id", "version")} + if action == "Create": + cmd = InsertCommand("Shipment", payload) + elif action == "Update": + cmd = UpdateCommand("Shipment", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + for key, value in payload.items(): + if key not in ("id", "version"): cmd.value(key, value) + else: + cmd = DeleteCommand("Shipment", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + return action, cmd + + def _teaql_preflight_graph(self, graph): + context = graph.context + if self._action == "Update": + if "id" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) + if "customerOrder" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("customer_order"), message="Mutation requires a fully loaded entity")]) + if "referenceCode" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("reference_code"), message="Mutation requires a fully loaded entity")]) + if "version" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) + _action, cmd = self._teaql_build_command() + try: + context.preflight_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) + + async def _teaql_save_within_graph(self, graph, parent_scope=None): + context = graph.context + scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) + + self._teaql_attach_root(self._entity_root) + action, cmd = self._teaql_build_command() + + + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) + + try: + context.check_and_fix_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) + context.mark_mutation_checked(cmd) + service = context.require_resource("dataService") + result = await service.mutate(context, req) + persisted = result.persisted_record + if persisted is None: + raise RuntimeError( + "Mutation provider did not return authoritative persisted state for Shipment" + ) + rollback_payload = {field: getattr(self, field, None) for field in self._loaded_fields | {"id", "version"}} + rollback_ledger_id = self._ledger_id + rollback_action = self._action + rollback_loaded_fields = set(self._loaded_fields) + old_key = self._teaql_entity_key() + if "id" in persisted: + self.id = persisted["id"] + self._loaded_fields.add("id") + elif "id" in persisted: + self.id = persisted["id"] + self._loaded_fields.add("id") + if "customer_order" in persisted: + self.customerOrder = persisted["customer_order"] + self._loaded_fields.add("customerOrder") + elif "customerOrder" in persisted: + self.customerOrder = persisted["customerOrder"] + self._loaded_fields.add("customerOrder") + if "reference_code" in persisted: + self.referenceCode = persisted["reference_code"] + self._loaded_fields.add("referenceCode") + elif "referenceCode" in persisted: + self.referenceCode = persisted["referenceCode"] + self._loaded_fields.add("referenceCode") + if "version" in persisted: + self.version = persisted["version"] + self._loaded_fields.add("version") + elif "version" in persisted: + self.version = persisted["version"] + self._loaded_fields.add("version") + self._ledger_id = getattr(self, "id", self._ledger_id) + new_key = self._teaql_entity_key() + if old_key != new_key: + self._entity_root.rekey(old_key, new_key) + def rollback_entity(): + for field, value in rollback_payload.items(): + setattr(self, field, value) + self._ledger_id = rollback_ledger_id + self._action = rollback_action + self._loaded_fields = rollback_loaded_fields + if old_key != new_key: + self._entity_root.rekey(new_key, old_key) + graph.after_rollback(rollback_entity) + if action != "Delete": + self._action = "Update" + + cascade_relations = [] + if action != "Delete": + for relation_name, children, updater in cascade_relations: + for index, child in enumerate(children): + child._teaql_attach_root(self._entity_root) + getattr(child, updater)(self) + try: + await child._teaql_save_within_graph(graph, scope) + except CheckException as error: + prefix = ObjectLocation().property(relation_name).index(index) + raise CheckException([ + CheckResult( + violation.rule_id, + violation.location.prefixed_by(prefix), + violation.input_value, + violation.system_value, + violation.message, + ) + for violation in error.violations + ]) from error + def commit_entity(): + self._entity_root.clear_entity(new_key) + if getattr(self, "version", None) is not None: + self._entity_root.set_original_version(new_key, int(self.version)) + graph.after_commit(commit_entity) + return self + + def update_id(self, value): + self.id = value + self._loaded_fields.add("id") + self._entity_root.set(self._teaql_entity_key(), "id", Value.from_any(value)) + return self + + def update_reference_code(self, value): + self.referenceCode = value + self._loaded_fields.add("referenceCode") + self._entity_root.set(self._teaql_entity_key(), "reference_code", Value.from_any(value)) + return self + + def update_version(self, value): + self.version = value + self._loaded_fields.add("version") + self._entity_root.set(self._teaql_entity_key(), "version", Value.from_any(value)) + return self + def update_customer_order(self, value): + self.customerOrder = getattr(value, "id", value) if value else None + self._loaded_fields.add("customerOrder") + self._entity_root.set(self._teaql_entity_key(), "customer_order", Value.from_any(self.customerOrder)) + return self diff --git a/examples/trace-chain/lib/pyproject.toml b/examples/trace-chain/lib/pyproject.toml new file mode 100644 index 0000000..9539c37 --- /dev/null +++ b/examples/trace-chain/lib/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "trace-chain-service-lib" +version = "1.0.0" +description = "Generated python library" +dependencies = ["teaql==0.2.9", "aiosqlite>=0.22.1"] + +[tool.setuptools] +py-modules = ["Q", "E"] + +[tool.setuptools.packages.find] +where = ["."] +include = ["models*", "requests*"] + +[build-system] +requires = ["setuptools>=42"] +build-backend = "setuptools.build_meta" \ No newline at end of file diff --git a/examples/trace-chain/lib/requests/__init__.py b/examples/trace-chain/lib/requests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/trace-chain/lib/requests/customer_order_request.py b/examples/trace-chain/lib/requests/customer_order_request.py new file mode 100644 index 0000000..d9ef15e --- /dev/null +++ b/examples/trace-chain/lib/requests/customer_order_request.py @@ -0,0 +1,632 @@ +from teaql.core.query import RelationAggregate, SelectQuery +from teaql.core.list import SmartList, TeaQLPage +from teaql.runtime import EntityRoot +from teaql.data_service import QueryRequest +from teaql.core import QueryIntent +from copy import deepcopy +from teaql.core.expr import ( + begin_with, between, column, contain, end_with, eq, gt, gte, + in_list, in_subquery, is_not_null, is_null, lt, lte, ne, not_begin_with, + not_contain, not_end_with, not_in_list, not_in_subquery, value, + sound_like, +) +from models.customer_order import CustomerOrder +from typing import Protocol + +class QuerySelection(Protocol): + query: SelectQuery + +class CustomerOrderRequest: + def __init__(self, minimal=False): + self.query = SelectQuery("CustomerOrder") + self._purpose = None + self._comment = None + self.query.and_filter(gte("version", 1)) + if minimal: + self.select_id() + self.select_version() + else: + self.select_self_fields() + + def comment(self, c: str): + self.query.comment(c) + self._comment = c + return self + + def purpose(self, p: str): + self.query.purpose(p) + self._purpose = p + return ExecutableCustomerOrderRequest(self) + + def optimize_for_continuous_page_fetch(self): + self.query.optimize_for_continuous_page_fetch() + return self + + def optimize_for_continuous_page_fetch_with(self, namespace: str, ttl_seconds: int): + self.query.optimize_for_continuous_page_fetch_with(namespace, ttl_seconds) + return self + + def optimize_pagination_with_id_set(self): + self.query.optimize_pagination_with_id_set() + return self + + def optimize_pagination_with_id_set_config(self, namespace: str, ttl_seconds: int, max_ids: int): + self.query.optimize_pagination_with_id_set_config(namespace, ttl_seconds, max_ids) + return self + + def top_n_probe_parent_threshold(self, threshold: int): + self.query.top_n_probe_parent_threshold(threshold) + return self + + def limit(self, n: int): + self.query.limit(n) + return self + + def offset(self, n: int): + self.query.offset(n) + return self + + def with_deleted_rows(self): + self.query.with_deleted_rows() + return self + + def deleted_rows_only(self): + self.query.deleted_rows_only() + return self + + def select_self_fields(self): + self.query.project("id", "platform", "order_number", "description", "version") + return self + + def select_id(self): + self.query.project("id") + return self + + + def select_order_number(self): + self.query.project("order_number") + return self + + def select_description(self): + self.query.project("description") + return self + + def select_version(self): + self.query.project("version") + return self + + def select_platform_with(self, child_request): + self.query.project("platform") + self.query.relation_query("platform", child_request.query) + return self + def with_platform_matching(self, child_request): + child_request.query.projection = ["id"] + self.query.and_filter(in_subquery(column("platform"), "Platform", child_request.query)) + return self + + def without_platform_matching(self, child_request): + child_request.query.projection = ["id"] + self.query.and_filter(not_in_subquery(column("platform"), "Platform", child_request.query)) + return self + + def have_platform(self): + self.query.and_filter(is_not_null(column("platform"))) + return self + + def have_no_platform(self): + self.query.and_filter(is_null(column("platform"))) + return self + + def with_id_is(self, val): + self.query.and_filter(eq("id", val)) + return self + + def with_id_is_not(self, val): + self.query.and_filter(ne("id", val)) + return self + + def with_id_in(self, *vals): + self.query.and_filter(in_list("id", list(vals))) + return self + + def with_id_not_in(self, *vals): + self.query.and_filter(not_in_list("id", list(vals))) + return self + + def with_id_greater_than(self, val): + self.query.and_filter(gt("id", val)) + return self + + def with_id_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("id", val)) + return self + + def with_id_less_than(self, val): + self.query.and_filter(lt("id", val)) + return self + + def with_id_less_than_or_equal_to(self, val): + self.query.and_filter(lte("id", val)) + return self + + def with_id_between(self, lower, upper): + self.query.and_filter(between(column("id"), value(lower), value(upper))) + return self + + def with_id_is_known(self): + self.query.and_filter(is_not_null(column("id"))) + return self + + def with_id_is_unknown(self): + self.query.and_filter(is_null(column("id"))) + return self + + def filter_by_platform(self, val): + self.query.and_filter(eq("platform", val)) + return self + + def with_order_number_containing(self, val: str): + self.query.and_filter(contain("order_number", val)) + return self + + def with_order_number_not_containing(self, val: str): + self.query.and_filter(not_contain("order_number", val)) + return self + + def with_order_number_starting_with(self, val: str): + self.query.and_filter(begin_with("order_number", val)) + return self + + def with_order_number_not_starting_with(self, val: str): + self.query.and_filter(not_begin_with("order_number", val)) + return self + + def with_order_number_ending_with(self, val: str): + self.query.and_filter(end_with("order_number", val)) + return self + + def with_order_number_not_ending_with(self, val: str): + self.query.and_filter(not_end_with("order_number", val)) + return self + + def with_order_number_sounding_like(self, val: str): + self.query.and_filter(sound_like("order_number", val)) + return self + + def with_order_number_is(self, val: str): + self.query.and_filter(eq("order_number", val)) + return self + def with_order_number_is_not(self, val): + self.query.and_filter(ne("order_number", val)) + return self + + def with_order_number_in(self, *vals): + self.query.and_filter(in_list("order_number", list(vals))) + return self + + def with_order_number_not_in(self, *vals): + self.query.and_filter(not_in_list("order_number", list(vals))) + return self + + def with_order_number_greater_than(self, val): + self.query.and_filter(gt("order_number", val)) + return self + + def with_order_number_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("order_number", val)) + return self + + def with_order_number_less_than(self, val): + self.query.and_filter(lt("order_number", val)) + return self + + def with_order_number_less_than_or_equal_to(self, val): + self.query.and_filter(lte("order_number", val)) + return self + + def with_order_number_between(self, lower, upper): + self.query.and_filter(between(column("order_number"), value(lower), value(upper))) + return self + + def with_order_number_is_known(self): + self.query.and_filter(is_not_null(column("order_number"))) + return self + + def with_order_number_is_unknown(self): + self.query.and_filter(is_null(column("order_number"))) + return self + + def with_description_containing(self, val: str): + self.query.and_filter(contain("description", val)) + return self + + def with_description_not_containing(self, val: str): + self.query.and_filter(not_contain("description", val)) + return self + + def with_description_starting_with(self, val: str): + self.query.and_filter(begin_with("description", val)) + return self + + def with_description_not_starting_with(self, val: str): + self.query.and_filter(not_begin_with("description", val)) + return self + + def with_description_ending_with(self, val: str): + self.query.and_filter(end_with("description", val)) + return self + + def with_description_not_ending_with(self, val: str): + self.query.and_filter(not_end_with("description", val)) + return self + + def with_description_sounding_like(self, val: str): + self.query.and_filter(sound_like("description", val)) + return self + + def with_description_is(self, val: str): + self.query.and_filter(eq("description", val)) + return self + def with_description_is_not(self, val): + self.query.and_filter(ne("description", val)) + return self + + def with_description_in(self, *vals): + self.query.and_filter(in_list("description", list(vals))) + return self + + def with_description_not_in(self, *vals): + self.query.and_filter(not_in_list("description", list(vals))) + return self + + def with_description_greater_than(self, val): + self.query.and_filter(gt("description", val)) + return self + + def with_description_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("description", val)) + return self + + def with_description_less_than(self, val): + self.query.and_filter(lt("description", val)) + return self + + def with_description_less_than_or_equal_to(self, val): + self.query.and_filter(lte("description", val)) + return self + + def with_description_between(self, lower, upper): + self.query.and_filter(between(column("description"), value(lower), value(upper))) + return self + + def with_description_is_known(self): + self.query.and_filter(is_not_null(column("description"))) + return self + + def with_description_is_unknown(self): + self.query.and_filter(is_null(column("description"))) + return self + + def with_version_is(self, val): + self.query.and_filter(eq("version", val)) + return self + + def with_version_is_not(self, val): + self.query.and_filter(ne("version", val)) + return self + + def with_version_in(self, *vals): + self.query.and_filter(in_list("version", list(vals))) + return self + + def with_version_not_in(self, *vals): + self.query.and_filter(not_in_list("version", list(vals))) + return self + + def with_version_greater_than(self, val): + self.query.and_filter(gt("version", val)) + return self + + def with_version_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("version", val)) + return self + + def with_version_less_than(self, val): + self.query.and_filter(lt("version", val)) + return self + + def with_version_less_than_or_equal_to(self, val): + self.query.and_filter(lte("version", val)) + return self + + def with_version_between(self, lower, upper): + self.query.and_filter(between(column("version"), value(lower), value(upper))) + return self + + def with_version_is_known(self): + self.query.and_filter(is_not_null(column("version"))) + return self + + def with_version_is_unknown(self): + self.query.and_filter(is_null(column("version"))) + return self + + def order_by_id_ascending(self): + self.query.order_by("id", "asc") + return self + + def order_by_id_descending(self): + self.query.order_by("id", "desc") + return self + + + def order_by_order_number_ascending(self): + self.query.order_by("order_number", "asc") + return self + + def order_by_order_number_descending(self): + self.query.order_by("order_number", "desc") + return self + + def order_by_description_ascending(self): + self.query.order_by("description", "asc") + return self + + def order_by_description_descending(self): + self.query.order_by("description", "desc") + return self + + def order_by_version_ascending(self): + self.query.order_by("version", "asc") + return self + + def order_by_version_descending(self): + self.query.order_by("version", "desc") + return self + + + def count(self): + self.query.count_field("id", "count") + return self + + def count_as(self, ret_name: str): + self.query.count_field("id", ret_name) + return self + + def group_by_id(self): + self.query.group_by("id") + return self + + def group_by_id_as(self, ret_name: str): + self.query.group_by("id") + return self + def group_by_platform(self): + self.query.group_by("platform") + return self + + def group_by_platform_as(self, ret_name: str): + self.query.group_by("platform") + return self + def group_by_order_number(self): + self.query.group_by("order_number") + return self + + def group_by_order_number_as(self, ret_name: str): + self.query.group_by("order_number") + return self + def group_by_description(self): + self.query.group_by("description") + return self + + def group_by_description_as(self, ret_name: str): + self.query.group_by("description") + return self + def group_by_version(self): + self.query.group_by("version") + return self + + def group_by_version_as(self, ret_name: str): + self.query.group_by("version") + return self + def select_order_item_list(self): + from requests.order_item_request import OrderItemRequest + return self.select_order_item_list_with(OrderItemRequest()) + + def select_order_item_list_with(self, child_request): + self.query.relation_query("order_item_list", child_request.query) + return self + def select_payment_list(self): + from requests.payment_request import PaymentRequest + return self.select_payment_list_with(PaymentRequest()) + + def select_payment_list_with(self, child_request): + self.query.relation_query("payment_list", child_request.query) + return self + def select_shipment_list(self): + from requests.shipment_request import ShipmentRequest + return self.select_shipment_list_with(ShipmentRequest()) + + def select_shipment_list_with(self, child_request): + self.query.relation_query("shipment_list", child_request.query) + return self + def have_order_items(self): + from requests.order_item_request import OrderItemRequest + return self.with_order_item_list_matching(OrderItemRequest()) + + def have_no_order_items(self): + from requests.order_item_request import OrderItemRequest + return self.without_order_item_list_matching(OrderItemRequest()) + + def with_order_item_list_matching(self, child_request): + child_request.query.projection = ["customer_order"] + self.query.and_filter(in_subquery(column("id"), "OrderItem", child_request.query)) + return self + + def without_order_item_list_matching(self, child_request): + child_request.query.projection = ["customer_order"] + self.query.and_filter(not_in_subquery(column("id"), "OrderItem", child_request.query)) + return self + def have_payments(self): + from requests.payment_request import PaymentRequest + return self.with_payment_list_matching(PaymentRequest()) + + def have_no_payments(self): + from requests.payment_request import PaymentRequest + return self.without_payment_list_matching(PaymentRequest()) + + def with_payment_list_matching(self, child_request): + child_request.query.projection = ["customer_order"] + self.query.and_filter(in_subquery(column("id"), "Payment", child_request.query)) + return self + + def without_payment_list_matching(self, child_request): + child_request.query.projection = ["customer_order"] + self.query.and_filter(not_in_subquery(column("id"), "Payment", child_request.query)) + return self + def have_shipments(self): + from requests.shipment_request import ShipmentRequest + return self.with_shipment_list_matching(ShipmentRequest()) + + def have_no_shipments(self): + from requests.shipment_request import ShipmentRequest + return self.without_shipment_list_matching(ShipmentRequest()) + + def with_shipment_list_matching(self, child_request): + child_request.query.projection = ["customer_order"] + self.query.and_filter(in_subquery(column("id"), "Shipment", child_request.query)) + return self + + def without_shipment_list_matching(self, child_request): + child_request.query.projection = ["customer_order"] + self.query.and_filter(not_in_subquery(column("id"), "Shipment", child_request.query)) + return self + def count_order_items(self): + return self.count_order_items_as("count_order_items") + + def count_order_items_as(self, alias: str): + from requests.order_item_request import OrderItemRequest + return self.count_order_items_with(alias, OrderItemRequest()) + + def count_order_items_with(self, alias: str, child_request): + child_request.query.count_field("id", alias) + self.query.relation_aggregates.append( + RelationAggregate("order_item_list", alias, child_request.query, True) + ) + return self + + + def count_payments(self): + return self.count_payments_as("count_payments") + + def count_payments_as(self, alias: str): + from requests.payment_request import PaymentRequest + return self.count_payments_with(alias, PaymentRequest()) + + def count_payments_with(self, alias: str, child_request): + child_request.query.count_field("id", alias) + self.query.relation_aggregates.append( + RelationAggregate("payment_list", alias, child_request.query, True) + ) + return self + + + def count_shipments(self): + return self.count_shipments_as("count_shipments") + + def count_shipments_as(self, alias: str): + from requests.shipment_request import ShipmentRequest + return self.count_shipments_with(alias, ShipmentRequest()) + + def count_shipments_with(self, alias: str, child_request): + child_request.query.count_field("id", alias) + self.query.relation_aggregates.append( + RelationAggregate("shipment_list", alias, child_request.query, True) + ) + return self + + + def facet_by_platform_as(self, name: str, request: QuerySelection, + include_all_facets: bool = True): + self.query.facet_by(name, "platform", request.query, include_all_facets) + return self + + +class ExecutableCustomerOrderRequest: + def __init__(self, request): + self._request = request + + def comment(self, c: str): + self._request.comment(c) + return self + + def new_entity(self, context) -> CustomerOrder: + request = self._request + QueryIntent(request._comment, request._purpose) + entity = context.initialize_entity("CustomerOrder", CustomerOrder()) + if not isinstance(entity, CustomerOrder): + raise TypeError("entity initializer returned an incompatible CustomerOrder") + return entity + + async def execute_for_result(self, context): + self = self._request + req = QueryRequest(self.query, _comment=self._comment, _purpose=self._purpose) + req = context.prepare_query_request(req) + service = context.require_resource("dataService") + return await service.query(context, req) + + async def execute_for_rows(self, context): + return (await self.execute_for_result(context)).rows + + async def execute_for_list(self, context) -> SmartList[CustomerOrder]: + result = await self.execute_for_result(context) + query_root = EntityRoot() + return SmartList( + (CustomerOrder(_entity_root=query_root, **row) for row in result.rows), + facets=result.facets) + + async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[CustomerOrder]: + request = self._request + intent = QueryIntent(request._comment, request._purpose) + query = deepcopy(request.query) + query.offset(offset).limit(limit) + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query + service = context.require_resource("dataService") + alias = "__teaql_total" + if authorized.id_set_pagination is not None: + row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + retained_count, accuracy = context.id_set_count() + if accuracy == "EXACT": + total_count = retained_count + else: + count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): + raise RuntimeError("dataService did not return an exact page count") + total_count = int(count_result.rows[0][alias]) + else: + count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): + raise RuntimeError("dataService did not return an exact page count") + total_count = int(count_result.rows[0][alias]) + row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + query_root = EntityRoot() + data = SmartList(CustomerOrder(_entity_root=query_root, **row) for row in row_result.rows) + return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) + + async def execute_for_one(self, context): + request = deepcopy(self._request) + request.limit(1) + entities = await ExecutableCustomerOrderRequest(request).execute_for_list(context) + return entities[0] if entities else None + + async def execute_for_stream(self, context, chunk_size: int = 1000): + """Yield entity chunks lazily from the provider cursor.""" + request = self._request + req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) + req = context.prepare_query_request(req) + service = context.require_resource("dataService") + if not hasattr(service, "query_stream"): + raise RuntimeError("dataService does not implement query_stream") + query_root = EntityRoot() + async for chunk in service.query_stream(context, req, chunk_size): + for row in chunk.rows: + yield CustomerOrder(_entity_root=query_root, **row) diff --git a/examples/trace-chain/lib/requests/order_item_request.py b/examples/trace-chain/lib/requests/order_item_request.py new file mode 100644 index 0000000..284142e --- /dev/null +++ b/examples/trace-chain/lib/requests/order_item_request.py @@ -0,0 +1,425 @@ +from teaql.core.query import RelationAggregate, SelectQuery +from teaql.core.list import SmartList, TeaQLPage +from teaql.runtime import EntityRoot +from teaql.data_service import QueryRequest +from teaql.core import QueryIntent +from copy import deepcopy +from teaql.core.expr import ( + begin_with, between, column, contain, end_with, eq, gt, gte, + in_list, in_subquery, is_not_null, is_null, lt, lte, ne, not_begin_with, + not_contain, not_end_with, not_in_list, not_in_subquery, value, + sound_like, +) +from models.order_item import OrderItem +from typing import Protocol + +class QuerySelection(Protocol): + query: SelectQuery + +class OrderItemRequest: + def __init__(self, minimal=False): + self.query = SelectQuery("OrderItem") + self._purpose = None + self._comment = None + self.query.and_filter(gte("version", 1)) + if minimal: + self.select_id() + self.select_version() + else: + self.select_self_fields() + + def comment(self, c: str): + self.query.comment(c) + self._comment = c + return self + + def purpose(self, p: str): + self.query.purpose(p) + self._purpose = p + return ExecutableOrderItemRequest(self) + + def optimize_for_continuous_page_fetch(self): + self.query.optimize_for_continuous_page_fetch() + return self + + def optimize_for_continuous_page_fetch_with(self, namespace: str, ttl_seconds: int): + self.query.optimize_for_continuous_page_fetch_with(namespace, ttl_seconds) + return self + + def optimize_pagination_with_id_set(self): + self.query.optimize_pagination_with_id_set() + return self + + def optimize_pagination_with_id_set_config(self, namespace: str, ttl_seconds: int, max_ids: int): + self.query.optimize_pagination_with_id_set_config(namespace, ttl_seconds, max_ids) + return self + + def top_n_probe_parent_threshold(self, threshold: int): + self.query.top_n_probe_parent_threshold(threshold) + return self + + def limit(self, n: int): + self.query.limit(n) + return self + + def offset(self, n: int): + self.query.offset(n) + return self + + def with_deleted_rows(self): + self.query.with_deleted_rows() + return self + + def deleted_rows_only(self): + self.query.deleted_rows_only() + return self + + def select_self_fields(self): + self.query.project("id", "customer_order", "name", "version") + return self + + def select_id(self): + self.query.project("id") + return self + + + def select_name(self): + self.query.project("name") + return self + + def select_version(self): + self.query.project("version") + return self + + def select_customer_order_with(self, child_request): + self.query.project("customer_order") + self.query.relation_query("customer_order", child_request.query) + return self + def with_customer_order_matching(self, child_request): + child_request.query.projection = ["id"] + self.query.and_filter(in_subquery(column("customer_order"), "CustomerOrder", child_request.query)) + return self + + def without_customer_order_matching(self, child_request): + child_request.query.projection = ["id"] + self.query.and_filter(not_in_subquery(column("customer_order"), "CustomerOrder", child_request.query)) + return self + + def have_customer_order(self): + self.query.and_filter(is_not_null(column("customer_order"))) + return self + + def have_no_customer_order(self): + self.query.and_filter(is_null(column("customer_order"))) + return self + + def with_id_is(self, val): + self.query.and_filter(eq("id", val)) + return self + + def with_id_is_not(self, val): + self.query.and_filter(ne("id", val)) + return self + + def with_id_in(self, *vals): + self.query.and_filter(in_list("id", list(vals))) + return self + + def with_id_not_in(self, *vals): + self.query.and_filter(not_in_list("id", list(vals))) + return self + + def with_id_greater_than(self, val): + self.query.and_filter(gt("id", val)) + return self + + def with_id_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("id", val)) + return self + + def with_id_less_than(self, val): + self.query.and_filter(lt("id", val)) + return self + + def with_id_less_than_or_equal_to(self, val): + self.query.and_filter(lte("id", val)) + return self + + def with_id_between(self, lower, upper): + self.query.and_filter(between(column("id"), value(lower), value(upper))) + return self + + def with_id_is_known(self): + self.query.and_filter(is_not_null(column("id"))) + return self + + def with_id_is_unknown(self): + self.query.and_filter(is_null(column("id"))) + return self + + def filter_by_customer_order(self, val): + self.query.and_filter(eq("customer_order", val)) + return self + + def with_name_containing(self, val: str): + self.query.and_filter(contain("name", val)) + return self + + def with_name_not_containing(self, val: str): + self.query.and_filter(not_contain("name", val)) + return self + + def with_name_starting_with(self, val: str): + self.query.and_filter(begin_with("name", val)) + return self + + def with_name_not_starting_with(self, val: str): + self.query.and_filter(not_begin_with("name", val)) + return self + + def with_name_ending_with(self, val: str): + self.query.and_filter(end_with("name", val)) + return self + + def with_name_not_ending_with(self, val: str): + self.query.and_filter(not_end_with("name", val)) + return self + + def with_name_sounding_like(self, val: str): + self.query.and_filter(sound_like("name", val)) + return self + + def with_name_is(self, val: str): + self.query.and_filter(eq("name", val)) + return self + def with_name_is_not(self, val): + self.query.and_filter(ne("name", val)) + return self + + def with_name_in(self, *vals): + self.query.and_filter(in_list("name", list(vals))) + return self + + def with_name_not_in(self, *vals): + self.query.and_filter(not_in_list("name", list(vals))) + return self + + def with_name_greater_than(self, val): + self.query.and_filter(gt("name", val)) + return self + + def with_name_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("name", val)) + return self + + def with_name_less_than(self, val): + self.query.and_filter(lt("name", val)) + return self + + def with_name_less_than_or_equal_to(self, val): + self.query.and_filter(lte("name", val)) + return self + + def with_name_between(self, lower, upper): + self.query.and_filter(between(column("name"), value(lower), value(upper))) + return self + + def with_name_is_known(self): + self.query.and_filter(is_not_null(column("name"))) + return self + + def with_name_is_unknown(self): + self.query.and_filter(is_null(column("name"))) + return self + + def with_version_is(self, val): + self.query.and_filter(eq("version", val)) + return self + + def with_version_is_not(self, val): + self.query.and_filter(ne("version", val)) + return self + + def with_version_in(self, *vals): + self.query.and_filter(in_list("version", list(vals))) + return self + + def with_version_not_in(self, *vals): + self.query.and_filter(not_in_list("version", list(vals))) + return self + + def with_version_greater_than(self, val): + self.query.and_filter(gt("version", val)) + return self + + def with_version_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("version", val)) + return self + + def with_version_less_than(self, val): + self.query.and_filter(lt("version", val)) + return self + + def with_version_less_than_or_equal_to(self, val): + self.query.and_filter(lte("version", val)) + return self + + def with_version_between(self, lower, upper): + self.query.and_filter(between(column("version"), value(lower), value(upper))) + return self + + def with_version_is_known(self): + self.query.and_filter(is_not_null(column("version"))) + return self + + def with_version_is_unknown(self): + self.query.and_filter(is_null(column("version"))) + return self + + def order_by_id_ascending(self): + self.query.order_by("id", "asc") + return self + + def order_by_id_descending(self): + self.query.order_by("id", "desc") + return self + + + def order_by_name_ascending(self): + self.query.order_by("name", "asc") + return self + + def order_by_name_descending(self): + self.query.order_by("name", "desc") + return self + + def order_by_version_ascending(self): + self.query.order_by("version", "asc") + return self + + def order_by_version_descending(self): + self.query.order_by("version", "desc") + return self + + + def count(self): + self.query.count_field("id", "count") + return self + + def count_as(self, ret_name: str): + self.query.count_field("id", ret_name) + return self + + def group_by_id(self): + self.query.group_by("id") + return self + + def group_by_id_as(self, ret_name: str): + self.query.group_by("id") + return self + def group_by_customer_order(self): + self.query.group_by("customer_order") + return self + + def group_by_customer_order_as(self, ret_name: str): + self.query.group_by("customer_order") + return self + def group_by_name(self): + self.query.group_by("name") + return self + + def group_by_name_as(self, ret_name: str): + self.query.group_by("name") + return self + def group_by_version(self): + self.query.group_by("version") + return self + + def group_by_version_as(self, ret_name: str): + self.query.group_by("version") + return self + def facet_by_customer_order_as(self, name: str, request: QuerySelection, + include_all_facets: bool = True): + self.query.facet_by(name, "customer_order", request.query, include_all_facets) + return self + + +class ExecutableOrderItemRequest: + def __init__(self, request): + self._request = request + + def comment(self, c: str): + self._request.comment(c) + return self + + def new_entity(self, context) -> OrderItem: + request = self._request + QueryIntent(request._comment, request._purpose) + entity = context.initialize_entity("OrderItem", OrderItem()) + if not isinstance(entity, OrderItem): + raise TypeError("entity initializer returned an incompatible OrderItem") + return entity + + async def execute_for_result(self, context): + self = self._request + req = QueryRequest(self.query, _comment=self._comment, _purpose=self._purpose) + req = context.prepare_query_request(req) + service = context.require_resource("dataService") + return await service.query(context, req) + + async def execute_for_rows(self, context): + return (await self.execute_for_result(context)).rows + + async def execute_for_list(self, context) -> SmartList[OrderItem]: + result = await self.execute_for_result(context) + query_root = EntityRoot() + return SmartList( + (OrderItem(_entity_root=query_root, **row) for row in result.rows), + facets=result.facets) + + async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[OrderItem]: + request = self._request + intent = QueryIntent(request._comment, request._purpose) + query = deepcopy(request.query) + query.offset(offset).limit(limit) + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query + service = context.require_resource("dataService") + alias = "__teaql_total" + if authorized.id_set_pagination is not None: + row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + retained_count, accuracy = context.id_set_count() + if accuracy == "EXACT": + total_count = retained_count + else: + count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): + raise RuntimeError("dataService did not return an exact page count") + total_count = int(count_result.rows[0][alias]) + else: + count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): + raise RuntimeError("dataService did not return an exact page count") + total_count = int(count_result.rows[0][alias]) + row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + query_root = EntityRoot() + data = SmartList(OrderItem(_entity_root=query_root, **row) for row in row_result.rows) + return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) + + async def execute_for_one(self, context): + request = deepcopy(self._request) + request.limit(1) + entities = await ExecutableOrderItemRequest(request).execute_for_list(context) + return entities[0] if entities else None + + async def execute_for_stream(self, context, chunk_size: int = 1000): + """Yield entity chunks lazily from the provider cursor.""" + request = self._request + req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) + req = context.prepare_query_request(req) + service = context.require_resource("dataService") + if not hasattr(service, "query_stream"): + raise RuntimeError("dataService does not implement query_stream") + query_root = EntityRoot() + async for chunk in service.query_stream(context, req, chunk_size): + for row in chunk.rows: + yield OrderItem(_entity_root=query_root, **row) diff --git a/examples/trace-chain/lib/requests/payment_attempt_request.py b/examples/trace-chain/lib/requests/payment_attempt_request.py new file mode 100644 index 0000000..3982605 --- /dev/null +++ b/examples/trace-chain/lib/requests/payment_attempt_request.py @@ -0,0 +1,425 @@ +from teaql.core.query import RelationAggregate, SelectQuery +from teaql.core.list import SmartList, TeaQLPage +from teaql.runtime import EntityRoot +from teaql.data_service import QueryRequest +from teaql.core import QueryIntent +from copy import deepcopy +from teaql.core.expr import ( + begin_with, between, column, contain, end_with, eq, gt, gte, + in_list, in_subquery, is_not_null, is_null, lt, lte, ne, not_begin_with, + not_contain, not_end_with, not_in_list, not_in_subquery, value, + sound_like, +) +from models.payment_attempt import PaymentAttempt +from typing import Protocol + +class QuerySelection(Protocol): + query: SelectQuery + +class PaymentAttemptRequest: + def __init__(self, minimal=False): + self.query = SelectQuery("PaymentAttempt") + self._purpose = None + self._comment = None + self.query.and_filter(gte("version", 1)) + if minimal: + self.select_id() + self.select_version() + else: + self.select_self_fields() + + def comment(self, c: str): + self.query.comment(c) + self._comment = c + return self + + def purpose(self, p: str): + self.query.purpose(p) + self._purpose = p + return ExecutablePaymentAttemptRequest(self) + + def optimize_for_continuous_page_fetch(self): + self.query.optimize_for_continuous_page_fetch() + return self + + def optimize_for_continuous_page_fetch_with(self, namespace: str, ttl_seconds: int): + self.query.optimize_for_continuous_page_fetch_with(namespace, ttl_seconds) + return self + + def optimize_pagination_with_id_set(self): + self.query.optimize_pagination_with_id_set() + return self + + def optimize_pagination_with_id_set_config(self, namespace: str, ttl_seconds: int, max_ids: int): + self.query.optimize_pagination_with_id_set_config(namespace, ttl_seconds, max_ids) + return self + + def top_n_probe_parent_threshold(self, threshold: int): + self.query.top_n_probe_parent_threshold(threshold) + return self + + def limit(self, n: int): + self.query.limit(n) + return self + + def offset(self, n: int): + self.query.offset(n) + return self + + def with_deleted_rows(self): + self.query.with_deleted_rows() + return self + + def deleted_rows_only(self): + self.query.deleted_rows_only() + return self + + def select_self_fields(self): + self.query.project("id", "payment", "reference_code", "version") + return self + + def select_id(self): + self.query.project("id") + return self + + + def select_reference_code(self): + self.query.project("reference_code") + return self + + def select_version(self): + self.query.project("version") + return self + + def select_payment_with(self, child_request): + self.query.project("payment") + self.query.relation_query("payment", child_request.query) + return self + def with_payment_matching(self, child_request): + child_request.query.projection = ["id"] + self.query.and_filter(in_subquery(column("payment"), "Payment", child_request.query)) + return self + + def without_payment_matching(self, child_request): + child_request.query.projection = ["id"] + self.query.and_filter(not_in_subquery(column("payment"), "Payment", child_request.query)) + return self + + def have_payment(self): + self.query.and_filter(is_not_null(column("payment"))) + return self + + def have_no_payment(self): + self.query.and_filter(is_null(column("payment"))) + return self + + def with_id_is(self, val): + self.query.and_filter(eq("id", val)) + return self + + def with_id_is_not(self, val): + self.query.and_filter(ne("id", val)) + return self + + def with_id_in(self, *vals): + self.query.and_filter(in_list("id", list(vals))) + return self + + def with_id_not_in(self, *vals): + self.query.and_filter(not_in_list("id", list(vals))) + return self + + def with_id_greater_than(self, val): + self.query.and_filter(gt("id", val)) + return self + + def with_id_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("id", val)) + return self + + def with_id_less_than(self, val): + self.query.and_filter(lt("id", val)) + return self + + def with_id_less_than_or_equal_to(self, val): + self.query.and_filter(lte("id", val)) + return self + + def with_id_between(self, lower, upper): + self.query.and_filter(between(column("id"), value(lower), value(upper))) + return self + + def with_id_is_known(self): + self.query.and_filter(is_not_null(column("id"))) + return self + + def with_id_is_unknown(self): + self.query.and_filter(is_null(column("id"))) + return self + + def filter_by_payment(self, val): + self.query.and_filter(eq("payment", val)) + return self + + def with_reference_code_containing(self, val: str): + self.query.and_filter(contain("reference_code", val)) + return self + + def with_reference_code_not_containing(self, val: str): + self.query.and_filter(not_contain("reference_code", val)) + return self + + def with_reference_code_starting_with(self, val: str): + self.query.and_filter(begin_with("reference_code", val)) + return self + + def with_reference_code_not_starting_with(self, val: str): + self.query.and_filter(not_begin_with("reference_code", val)) + return self + + def with_reference_code_ending_with(self, val: str): + self.query.and_filter(end_with("reference_code", val)) + return self + + def with_reference_code_not_ending_with(self, val: str): + self.query.and_filter(not_end_with("reference_code", val)) + return self + + def with_reference_code_sounding_like(self, val: str): + self.query.and_filter(sound_like("reference_code", val)) + return self + + def with_reference_code_is(self, val: str): + self.query.and_filter(eq("reference_code", val)) + return self + def with_reference_code_is_not(self, val): + self.query.and_filter(ne("reference_code", val)) + return self + + def with_reference_code_in(self, *vals): + self.query.and_filter(in_list("reference_code", list(vals))) + return self + + def with_reference_code_not_in(self, *vals): + self.query.and_filter(not_in_list("reference_code", list(vals))) + return self + + def with_reference_code_greater_than(self, val): + self.query.and_filter(gt("reference_code", val)) + return self + + def with_reference_code_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("reference_code", val)) + return self + + def with_reference_code_less_than(self, val): + self.query.and_filter(lt("reference_code", val)) + return self + + def with_reference_code_less_than_or_equal_to(self, val): + self.query.and_filter(lte("reference_code", val)) + return self + + def with_reference_code_between(self, lower, upper): + self.query.and_filter(between(column("reference_code"), value(lower), value(upper))) + return self + + def with_reference_code_is_known(self): + self.query.and_filter(is_not_null(column("reference_code"))) + return self + + def with_reference_code_is_unknown(self): + self.query.and_filter(is_null(column("reference_code"))) + return self + + def with_version_is(self, val): + self.query.and_filter(eq("version", val)) + return self + + def with_version_is_not(self, val): + self.query.and_filter(ne("version", val)) + return self + + def with_version_in(self, *vals): + self.query.and_filter(in_list("version", list(vals))) + return self + + def with_version_not_in(self, *vals): + self.query.and_filter(not_in_list("version", list(vals))) + return self + + def with_version_greater_than(self, val): + self.query.and_filter(gt("version", val)) + return self + + def with_version_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("version", val)) + return self + + def with_version_less_than(self, val): + self.query.and_filter(lt("version", val)) + return self + + def with_version_less_than_or_equal_to(self, val): + self.query.and_filter(lte("version", val)) + return self + + def with_version_between(self, lower, upper): + self.query.and_filter(between(column("version"), value(lower), value(upper))) + return self + + def with_version_is_known(self): + self.query.and_filter(is_not_null(column("version"))) + return self + + def with_version_is_unknown(self): + self.query.and_filter(is_null(column("version"))) + return self + + def order_by_id_ascending(self): + self.query.order_by("id", "asc") + return self + + def order_by_id_descending(self): + self.query.order_by("id", "desc") + return self + + + def order_by_reference_code_ascending(self): + self.query.order_by("reference_code", "asc") + return self + + def order_by_reference_code_descending(self): + self.query.order_by("reference_code", "desc") + return self + + def order_by_version_ascending(self): + self.query.order_by("version", "asc") + return self + + def order_by_version_descending(self): + self.query.order_by("version", "desc") + return self + + + def count(self): + self.query.count_field("id", "count") + return self + + def count_as(self, ret_name: str): + self.query.count_field("id", ret_name) + return self + + def group_by_id(self): + self.query.group_by("id") + return self + + def group_by_id_as(self, ret_name: str): + self.query.group_by("id") + return self + def group_by_payment(self): + self.query.group_by("payment") + return self + + def group_by_payment_as(self, ret_name: str): + self.query.group_by("payment") + return self + def group_by_reference_code(self): + self.query.group_by("reference_code") + return self + + def group_by_reference_code_as(self, ret_name: str): + self.query.group_by("reference_code") + return self + def group_by_version(self): + self.query.group_by("version") + return self + + def group_by_version_as(self, ret_name: str): + self.query.group_by("version") + return self + def facet_by_payment_as(self, name: str, request: QuerySelection, + include_all_facets: bool = True): + self.query.facet_by(name, "payment", request.query, include_all_facets) + return self + + +class ExecutablePaymentAttemptRequest: + def __init__(self, request): + self._request = request + + def comment(self, c: str): + self._request.comment(c) + return self + + def new_entity(self, context) -> PaymentAttempt: + request = self._request + QueryIntent(request._comment, request._purpose) + entity = context.initialize_entity("PaymentAttempt", PaymentAttempt()) + if not isinstance(entity, PaymentAttempt): + raise TypeError("entity initializer returned an incompatible PaymentAttempt") + return entity + + async def execute_for_result(self, context): + self = self._request + req = QueryRequest(self.query, _comment=self._comment, _purpose=self._purpose) + req = context.prepare_query_request(req) + service = context.require_resource("dataService") + return await service.query(context, req) + + async def execute_for_rows(self, context): + return (await self.execute_for_result(context)).rows + + async def execute_for_list(self, context) -> SmartList[PaymentAttempt]: + result = await self.execute_for_result(context) + query_root = EntityRoot() + return SmartList( + (PaymentAttempt(_entity_root=query_root, **row) for row in result.rows), + facets=result.facets) + + async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[PaymentAttempt]: + request = self._request + intent = QueryIntent(request._comment, request._purpose) + query = deepcopy(request.query) + query.offset(offset).limit(limit) + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query + service = context.require_resource("dataService") + alias = "__teaql_total" + if authorized.id_set_pagination is not None: + row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + retained_count, accuracy = context.id_set_count() + if accuracy == "EXACT": + total_count = retained_count + else: + count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): + raise RuntimeError("dataService did not return an exact page count") + total_count = int(count_result.rows[0][alias]) + else: + count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): + raise RuntimeError("dataService did not return an exact page count") + total_count = int(count_result.rows[0][alias]) + row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + query_root = EntityRoot() + data = SmartList(PaymentAttempt(_entity_root=query_root, **row) for row in row_result.rows) + return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) + + async def execute_for_one(self, context): + request = deepcopy(self._request) + request.limit(1) + entities = await ExecutablePaymentAttemptRequest(request).execute_for_list(context) + return entities[0] if entities else None + + async def execute_for_stream(self, context, chunk_size: int = 1000): + """Yield entity chunks lazily from the provider cursor.""" + request = self._request + req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) + req = context.prepare_query_request(req) + service = context.require_resource("dataService") + if not hasattr(service, "query_stream"): + raise RuntimeError("dataService does not implement query_stream") + query_root = EntityRoot() + async for chunk in service.query_stream(context, req, chunk_size): + for row in chunk.rows: + yield PaymentAttempt(_entity_root=query_root, **row) diff --git a/examples/trace-chain/lib/requests/payment_request.py b/examples/trace-chain/lib/requests/payment_request.py new file mode 100644 index 0000000..229cf52 --- /dev/null +++ b/examples/trace-chain/lib/requests/payment_request.py @@ -0,0 +1,464 @@ +from teaql.core.query import RelationAggregate, SelectQuery +from teaql.core.list import SmartList, TeaQLPage +from teaql.runtime import EntityRoot +from teaql.data_service import QueryRequest +from teaql.core import QueryIntent +from copy import deepcopy +from teaql.core.expr import ( + begin_with, between, column, contain, end_with, eq, gt, gte, + in_list, in_subquery, is_not_null, is_null, lt, lte, ne, not_begin_with, + not_contain, not_end_with, not_in_list, not_in_subquery, value, + sound_like, +) +from models.payment import Payment +from typing import Protocol + +class QuerySelection(Protocol): + query: SelectQuery + +class PaymentRequest: + def __init__(self, minimal=False): + self.query = SelectQuery("Payment") + self._purpose = None + self._comment = None + self.query.and_filter(gte("version", 1)) + if minimal: + self.select_id() + self.select_version() + else: + self.select_self_fields() + + def comment(self, c: str): + self.query.comment(c) + self._comment = c + return self + + def purpose(self, p: str): + self.query.purpose(p) + self._purpose = p + return ExecutablePaymentRequest(self) + + def optimize_for_continuous_page_fetch(self): + self.query.optimize_for_continuous_page_fetch() + return self + + def optimize_for_continuous_page_fetch_with(self, namespace: str, ttl_seconds: int): + self.query.optimize_for_continuous_page_fetch_with(namespace, ttl_seconds) + return self + + def optimize_pagination_with_id_set(self): + self.query.optimize_pagination_with_id_set() + return self + + def optimize_pagination_with_id_set_config(self, namespace: str, ttl_seconds: int, max_ids: int): + self.query.optimize_pagination_with_id_set_config(namespace, ttl_seconds, max_ids) + return self + + def top_n_probe_parent_threshold(self, threshold: int): + self.query.top_n_probe_parent_threshold(threshold) + return self + + def limit(self, n: int): + self.query.limit(n) + return self + + def offset(self, n: int): + self.query.offset(n) + return self + + def with_deleted_rows(self): + self.query.with_deleted_rows() + return self + + def deleted_rows_only(self): + self.query.deleted_rows_only() + return self + + def select_self_fields(self): + self.query.project("id", "customer_order", "reference_code", "version") + return self + + def select_id(self): + self.query.project("id") + return self + + + def select_reference_code(self): + self.query.project("reference_code") + return self + + def select_version(self): + self.query.project("version") + return self + + def select_customer_order_with(self, child_request): + self.query.project("customer_order") + self.query.relation_query("customer_order", child_request.query) + return self + def with_customer_order_matching(self, child_request): + child_request.query.projection = ["id"] + self.query.and_filter(in_subquery(column("customer_order"), "CustomerOrder", child_request.query)) + return self + + def without_customer_order_matching(self, child_request): + child_request.query.projection = ["id"] + self.query.and_filter(not_in_subquery(column("customer_order"), "CustomerOrder", child_request.query)) + return self + + def have_customer_order(self): + self.query.and_filter(is_not_null(column("customer_order"))) + return self + + def have_no_customer_order(self): + self.query.and_filter(is_null(column("customer_order"))) + return self + + def with_id_is(self, val): + self.query.and_filter(eq("id", val)) + return self + + def with_id_is_not(self, val): + self.query.and_filter(ne("id", val)) + return self + + def with_id_in(self, *vals): + self.query.and_filter(in_list("id", list(vals))) + return self + + def with_id_not_in(self, *vals): + self.query.and_filter(not_in_list("id", list(vals))) + return self + + def with_id_greater_than(self, val): + self.query.and_filter(gt("id", val)) + return self + + def with_id_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("id", val)) + return self + + def with_id_less_than(self, val): + self.query.and_filter(lt("id", val)) + return self + + def with_id_less_than_or_equal_to(self, val): + self.query.and_filter(lte("id", val)) + return self + + def with_id_between(self, lower, upper): + self.query.and_filter(between(column("id"), value(lower), value(upper))) + return self + + def with_id_is_known(self): + self.query.and_filter(is_not_null(column("id"))) + return self + + def with_id_is_unknown(self): + self.query.and_filter(is_null(column("id"))) + return self + + def filter_by_customer_order(self, val): + self.query.and_filter(eq("customer_order", val)) + return self + + def with_reference_code_containing(self, val: str): + self.query.and_filter(contain("reference_code", val)) + return self + + def with_reference_code_not_containing(self, val: str): + self.query.and_filter(not_contain("reference_code", val)) + return self + + def with_reference_code_starting_with(self, val: str): + self.query.and_filter(begin_with("reference_code", val)) + return self + + def with_reference_code_not_starting_with(self, val: str): + self.query.and_filter(not_begin_with("reference_code", val)) + return self + + def with_reference_code_ending_with(self, val: str): + self.query.and_filter(end_with("reference_code", val)) + return self + + def with_reference_code_not_ending_with(self, val: str): + self.query.and_filter(not_end_with("reference_code", val)) + return self + + def with_reference_code_sounding_like(self, val: str): + self.query.and_filter(sound_like("reference_code", val)) + return self + + def with_reference_code_is(self, val: str): + self.query.and_filter(eq("reference_code", val)) + return self + def with_reference_code_is_not(self, val): + self.query.and_filter(ne("reference_code", val)) + return self + + def with_reference_code_in(self, *vals): + self.query.and_filter(in_list("reference_code", list(vals))) + return self + + def with_reference_code_not_in(self, *vals): + self.query.and_filter(not_in_list("reference_code", list(vals))) + return self + + def with_reference_code_greater_than(self, val): + self.query.and_filter(gt("reference_code", val)) + return self + + def with_reference_code_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("reference_code", val)) + return self + + def with_reference_code_less_than(self, val): + self.query.and_filter(lt("reference_code", val)) + return self + + def with_reference_code_less_than_or_equal_to(self, val): + self.query.and_filter(lte("reference_code", val)) + return self + + def with_reference_code_between(self, lower, upper): + self.query.and_filter(between(column("reference_code"), value(lower), value(upper))) + return self + + def with_reference_code_is_known(self): + self.query.and_filter(is_not_null(column("reference_code"))) + return self + + def with_reference_code_is_unknown(self): + self.query.and_filter(is_null(column("reference_code"))) + return self + + def with_version_is(self, val): + self.query.and_filter(eq("version", val)) + return self + + def with_version_is_not(self, val): + self.query.and_filter(ne("version", val)) + return self + + def with_version_in(self, *vals): + self.query.and_filter(in_list("version", list(vals))) + return self + + def with_version_not_in(self, *vals): + self.query.and_filter(not_in_list("version", list(vals))) + return self + + def with_version_greater_than(self, val): + self.query.and_filter(gt("version", val)) + return self + + def with_version_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("version", val)) + return self + + def with_version_less_than(self, val): + self.query.and_filter(lt("version", val)) + return self + + def with_version_less_than_or_equal_to(self, val): + self.query.and_filter(lte("version", val)) + return self + + def with_version_between(self, lower, upper): + self.query.and_filter(between(column("version"), value(lower), value(upper))) + return self + + def with_version_is_known(self): + self.query.and_filter(is_not_null(column("version"))) + return self + + def with_version_is_unknown(self): + self.query.and_filter(is_null(column("version"))) + return self + + def order_by_id_ascending(self): + self.query.order_by("id", "asc") + return self + + def order_by_id_descending(self): + self.query.order_by("id", "desc") + return self + + + def order_by_reference_code_ascending(self): + self.query.order_by("reference_code", "asc") + return self + + def order_by_reference_code_descending(self): + self.query.order_by("reference_code", "desc") + return self + + def order_by_version_ascending(self): + self.query.order_by("version", "asc") + return self + + def order_by_version_descending(self): + self.query.order_by("version", "desc") + return self + + + def count(self): + self.query.count_field("id", "count") + return self + + def count_as(self, ret_name: str): + self.query.count_field("id", ret_name) + return self + + def group_by_id(self): + self.query.group_by("id") + return self + + def group_by_id_as(self, ret_name: str): + self.query.group_by("id") + return self + def group_by_customer_order(self): + self.query.group_by("customer_order") + return self + + def group_by_customer_order_as(self, ret_name: str): + self.query.group_by("customer_order") + return self + def group_by_reference_code(self): + self.query.group_by("reference_code") + return self + + def group_by_reference_code_as(self, ret_name: str): + self.query.group_by("reference_code") + return self + def group_by_version(self): + self.query.group_by("version") + return self + + def group_by_version_as(self, ret_name: str): + self.query.group_by("version") + return self + def select_payment_attempt_list(self): + from requests.payment_attempt_request import PaymentAttemptRequest + return self.select_payment_attempt_list_with(PaymentAttemptRequest()) + + def select_payment_attempt_list_with(self, child_request): + self.query.relation_query("payment_attempt_list", child_request.query) + return self + def have_payment_attempts(self): + from requests.payment_attempt_request import PaymentAttemptRequest + return self.with_payment_attempt_list_matching(PaymentAttemptRequest()) + + def have_no_payment_attempts(self): + from requests.payment_attempt_request import PaymentAttemptRequest + return self.without_payment_attempt_list_matching(PaymentAttemptRequest()) + + def with_payment_attempt_list_matching(self, child_request): + child_request.query.projection = ["payment"] + self.query.and_filter(in_subquery(column("id"), "PaymentAttempt", child_request.query)) + return self + + def without_payment_attempt_list_matching(self, child_request): + child_request.query.projection = ["payment"] + self.query.and_filter(not_in_subquery(column("id"), "PaymentAttempt", child_request.query)) + return self + def count_payment_attempts(self): + return self.count_payment_attempts_as("count_payment_attempts") + + def count_payment_attempts_as(self, alias: str): + from requests.payment_attempt_request import PaymentAttemptRequest + return self.count_payment_attempts_with(alias, PaymentAttemptRequest()) + + def count_payment_attempts_with(self, alias: str, child_request): + child_request.query.count_field("id", alias) + self.query.relation_aggregates.append( + RelationAggregate("payment_attempt_list", alias, child_request.query, True) + ) + return self + + + def facet_by_customer_order_as(self, name: str, request: QuerySelection, + include_all_facets: bool = True): + self.query.facet_by(name, "customer_order", request.query, include_all_facets) + return self + + +class ExecutablePaymentRequest: + def __init__(self, request): + self._request = request + + def comment(self, c: str): + self._request.comment(c) + return self + + def new_entity(self, context) -> Payment: + request = self._request + QueryIntent(request._comment, request._purpose) + entity = context.initialize_entity("Payment", Payment()) + if not isinstance(entity, Payment): + raise TypeError("entity initializer returned an incompatible Payment") + return entity + + async def execute_for_result(self, context): + self = self._request + req = QueryRequest(self.query, _comment=self._comment, _purpose=self._purpose) + req = context.prepare_query_request(req) + service = context.require_resource("dataService") + return await service.query(context, req) + + async def execute_for_rows(self, context): + return (await self.execute_for_result(context)).rows + + async def execute_for_list(self, context) -> SmartList[Payment]: + result = await self.execute_for_result(context) + query_root = EntityRoot() + return SmartList( + (Payment(_entity_root=query_root, **row) for row in result.rows), + facets=result.facets) + + async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[Payment]: + request = self._request + intent = QueryIntent(request._comment, request._purpose) + query = deepcopy(request.query) + query.offset(offset).limit(limit) + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query + service = context.require_resource("dataService") + alias = "__teaql_total" + if authorized.id_set_pagination is not None: + row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + retained_count, accuracy = context.id_set_count() + if accuracy == "EXACT": + total_count = retained_count + else: + count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): + raise RuntimeError("dataService did not return an exact page count") + total_count = int(count_result.rows[0][alias]) + else: + count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): + raise RuntimeError("dataService did not return an exact page count") + total_count = int(count_result.rows[0][alias]) + row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + query_root = EntityRoot() + data = SmartList(Payment(_entity_root=query_root, **row) for row in row_result.rows) + return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) + + async def execute_for_one(self, context): + request = deepcopy(self._request) + request.limit(1) + entities = await ExecutablePaymentRequest(request).execute_for_list(context) + return entities[0] if entities else None + + async def execute_for_stream(self, context, chunk_size: int = 1000): + """Yield entity chunks lazily from the provider cursor.""" + request = self._request + req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) + req = context.prepare_query_request(req) + service = context.require_resource("dataService") + if not hasattr(service, "query_stream"): + raise RuntimeError("dataService does not implement query_stream") + query_root = EntityRoot() + async for chunk in service.query_stream(context, req, chunk_size): + for row in chunk.rows: + yield Payment(_entity_root=query_root, **row) diff --git a/examples/trace-chain/lib/requests/platform_request.py b/examples/trace-chain/lib/requests/platform_request.py new file mode 100644 index 0000000..9e10715 --- /dev/null +++ b/examples/trace-chain/lib/requests/platform_request.py @@ -0,0 +1,425 @@ +from teaql.core.query import RelationAggregate, SelectQuery +from teaql.core.list import SmartList, TeaQLPage +from teaql.runtime import EntityRoot +from teaql.data_service import QueryRequest +from teaql.core import QueryIntent +from copy import deepcopy +from teaql.core.expr import ( + begin_with, between, column, contain, end_with, eq, gt, gte, + in_list, in_subquery, is_not_null, is_null, lt, lte, ne, not_begin_with, + not_contain, not_end_with, not_in_list, not_in_subquery, value, + sound_like, +) +from models.platform import Platform +from typing import Protocol + +class QuerySelection(Protocol): + query: SelectQuery + +class PlatformRequest: + def __init__(self, minimal=False): + self.query = SelectQuery("Platform") + self._purpose = None + self._comment = None + self.query.and_filter(gte("version", 1)) + if minimal: + self.select_id() + self.select_version() + else: + self.select_self_fields() + + def comment(self, c: str): + self.query.comment(c) + self._comment = c + return self + + def purpose(self, p: str): + self.query.purpose(p) + self._purpose = p + return ExecutablePlatformRequest(self) + + def optimize_for_continuous_page_fetch(self): + self.query.optimize_for_continuous_page_fetch() + return self + + def optimize_for_continuous_page_fetch_with(self, namespace: str, ttl_seconds: int): + self.query.optimize_for_continuous_page_fetch_with(namespace, ttl_seconds) + return self + + def optimize_pagination_with_id_set(self): + self.query.optimize_pagination_with_id_set() + return self + + def optimize_pagination_with_id_set_config(self, namespace: str, ttl_seconds: int, max_ids: int): + self.query.optimize_pagination_with_id_set_config(namespace, ttl_seconds, max_ids) + return self + + def top_n_probe_parent_threshold(self, threshold: int): + self.query.top_n_probe_parent_threshold(threshold) + return self + + def limit(self, n: int): + self.query.limit(n) + return self + + def offset(self, n: int): + self.query.offset(n) + return self + + def with_deleted_rows(self): + self.query.with_deleted_rows() + return self + + def deleted_rows_only(self): + self.query.deleted_rows_only() + return self + + def select_self_fields(self): + self.query.project("id", "name", "version") + return self + + def select_id(self): + self.query.project("id") + return self + + def select_name(self): + self.query.project("name") + return self + + def select_version(self): + self.query.project("version") + return self + + + def with_id_is(self, val): + self.query.and_filter(eq("id", val)) + return self + + def with_id_is_not(self, val): + self.query.and_filter(ne("id", val)) + return self + + def with_id_in(self, *vals): + self.query.and_filter(in_list("id", list(vals))) + return self + + def with_id_not_in(self, *vals): + self.query.and_filter(not_in_list("id", list(vals))) + return self + + def with_id_greater_than(self, val): + self.query.and_filter(gt("id", val)) + return self + + def with_id_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("id", val)) + return self + + def with_id_less_than(self, val): + self.query.and_filter(lt("id", val)) + return self + + def with_id_less_than_or_equal_to(self, val): + self.query.and_filter(lte("id", val)) + return self + + def with_id_between(self, lower, upper): + self.query.and_filter(between(column("id"), value(lower), value(upper))) + return self + + def with_id_is_known(self): + self.query.and_filter(is_not_null(column("id"))) + return self + + def with_id_is_unknown(self): + self.query.and_filter(is_null(column("id"))) + return self + + def with_name_containing(self, val: str): + self.query.and_filter(contain("name", val)) + return self + + def with_name_not_containing(self, val: str): + self.query.and_filter(not_contain("name", val)) + return self + + def with_name_starting_with(self, val: str): + self.query.and_filter(begin_with("name", val)) + return self + + def with_name_not_starting_with(self, val: str): + self.query.and_filter(not_begin_with("name", val)) + return self + + def with_name_ending_with(self, val: str): + self.query.and_filter(end_with("name", val)) + return self + + def with_name_not_ending_with(self, val: str): + self.query.and_filter(not_end_with("name", val)) + return self + + def with_name_sounding_like(self, val: str): + self.query.and_filter(sound_like("name", val)) + return self + + def with_name_is(self, val: str): + self.query.and_filter(eq("name", val)) + return self + def with_name_is_not(self, val): + self.query.and_filter(ne("name", val)) + return self + + def with_name_in(self, *vals): + self.query.and_filter(in_list("name", list(vals))) + return self + + def with_name_not_in(self, *vals): + self.query.and_filter(not_in_list("name", list(vals))) + return self + + def with_name_greater_than(self, val): + self.query.and_filter(gt("name", val)) + return self + + def with_name_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("name", val)) + return self + + def with_name_less_than(self, val): + self.query.and_filter(lt("name", val)) + return self + + def with_name_less_than_or_equal_to(self, val): + self.query.and_filter(lte("name", val)) + return self + + def with_name_between(self, lower, upper): + self.query.and_filter(between(column("name"), value(lower), value(upper))) + return self + + def with_name_is_known(self): + self.query.and_filter(is_not_null(column("name"))) + return self + + def with_name_is_unknown(self): + self.query.and_filter(is_null(column("name"))) + return self + + def with_version_is(self, val): + self.query.and_filter(eq("version", val)) + return self + + def with_version_is_not(self, val): + self.query.and_filter(ne("version", val)) + return self + + def with_version_in(self, *vals): + self.query.and_filter(in_list("version", list(vals))) + return self + + def with_version_not_in(self, *vals): + self.query.and_filter(not_in_list("version", list(vals))) + return self + + def with_version_greater_than(self, val): + self.query.and_filter(gt("version", val)) + return self + + def with_version_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("version", val)) + return self + + def with_version_less_than(self, val): + self.query.and_filter(lt("version", val)) + return self + + def with_version_less_than_or_equal_to(self, val): + self.query.and_filter(lte("version", val)) + return self + + def with_version_between(self, lower, upper): + self.query.and_filter(between(column("version"), value(lower), value(upper))) + return self + + def with_version_is_known(self): + self.query.and_filter(is_not_null(column("version"))) + return self + + def with_version_is_unknown(self): + self.query.and_filter(is_null(column("version"))) + return self + + def order_by_id_ascending(self): + self.query.order_by("id", "asc") + return self + + def order_by_id_descending(self): + self.query.order_by("id", "desc") + return self + + def order_by_name_ascending(self): + self.query.order_by("name", "asc") + return self + + def order_by_name_descending(self): + self.query.order_by("name", "desc") + return self + + def order_by_version_ascending(self): + self.query.order_by("version", "asc") + return self + + def order_by_version_descending(self): + self.query.order_by("version", "desc") + return self + + + def count(self): + self.query.count_field("id", "count") + return self + + def count_as(self, ret_name: str): + self.query.count_field("id", ret_name) + return self + + def group_by_id(self): + self.query.group_by("id") + return self + + def group_by_id_as(self, ret_name: str): + self.query.group_by("id") + return self + def group_by_name(self): + self.query.group_by("name") + return self + + def group_by_name_as(self, ret_name: str): + self.query.group_by("name") + return self + def group_by_version(self): + self.query.group_by("version") + return self + + def group_by_version_as(self, ret_name: str): + self.query.group_by("version") + return self + def select_customer_order_list(self): + from requests.customer_order_request import CustomerOrderRequest + return self.select_customer_order_list_with(CustomerOrderRequest()) + + def select_customer_order_list_with(self, child_request): + self.query.relation_query("customer_order_list", child_request.query) + return self + def have_customer_orders(self): + from requests.customer_order_request import CustomerOrderRequest + return self.with_customer_order_list_matching(CustomerOrderRequest()) + + def have_no_customer_orders(self): + from requests.customer_order_request import CustomerOrderRequest + return self.without_customer_order_list_matching(CustomerOrderRequest()) + + def with_customer_order_list_matching(self, child_request): + child_request.query.projection = ["platform"] + self.query.and_filter(in_subquery(column("id"), "CustomerOrder", child_request.query)) + return self + + def without_customer_order_list_matching(self, child_request): + child_request.query.projection = ["platform"] + self.query.and_filter(not_in_subquery(column("id"), "CustomerOrder", child_request.query)) + return self + def count_customer_orders(self): + return self.count_customer_orders_as("count_customer_orders") + + def count_customer_orders_as(self, alias: str): + from requests.customer_order_request import CustomerOrderRequest + return self.count_customer_orders_with(alias, CustomerOrderRequest()) + + def count_customer_orders_with(self, alias: str, child_request): + child_request.query.count_field("id", alias) + self.query.relation_aggregates.append( + RelationAggregate("customer_order_list", alias, child_request.query, True) + ) + return self + + + +class ExecutablePlatformRequest: + def __init__(self, request): + self._request = request + + def comment(self, c: str): + self._request.comment(c) + return self + + def new_entity(self, context) -> Platform: + request = self._request + QueryIntent(request._comment, request._purpose) + entity = context.initialize_entity("Platform", Platform()) + if not isinstance(entity, Platform): + raise TypeError("entity initializer returned an incompatible Platform") + return entity + + async def execute_for_result(self, context): + self = self._request + req = QueryRequest(self.query, _comment=self._comment, _purpose=self._purpose) + req = context.prepare_query_request(req) + service = context.require_resource("dataService") + return await service.query(context, req) + + async def execute_for_rows(self, context): + return (await self.execute_for_result(context)).rows + + async def execute_for_list(self, context) -> SmartList[Platform]: + result = await self.execute_for_result(context) + query_root = EntityRoot() + return SmartList( + (Platform(_entity_root=query_root, **row) for row in result.rows), + facets=result.facets) + + async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[Platform]: + request = self._request + intent = QueryIntent(request._comment, request._purpose) + query = deepcopy(request.query) + query.offset(offset).limit(limit) + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query + service = context.require_resource("dataService") + alias = "__teaql_total" + if authorized.id_set_pagination is not None: + row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + retained_count, accuracy = context.id_set_count() + if accuracy == "EXACT": + total_count = retained_count + else: + count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): + raise RuntimeError("dataService did not return an exact page count") + total_count = int(count_result.rows[0][alias]) + else: + count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): + raise RuntimeError("dataService did not return an exact page count") + total_count = int(count_result.rows[0][alias]) + row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + query_root = EntityRoot() + data = SmartList(Platform(_entity_root=query_root, **row) for row in row_result.rows) + return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) + + async def execute_for_one(self, context): + request = deepcopy(self._request) + request.limit(1) + entities = await ExecutablePlatformRequest(request).execute_for_list(context) + return entities[0] if entities else None + + async def execute_for_stream(self, context, chunk_size: int = 1000): + """Yield entity chunks lazily from the provider cursor.""" + request = self._request + req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) + req = context.prepare_query_request(req) + service = context.require_resource("dataService") + if not hasattr(service, "query_stream"): + raise RuntimeError("dataService does not implement query_stream") + query_root = EntityRoot() + async for chunk in service.query_stream(context, req, chunk_size): + for row in chunk.rows: + yield Platform(_entity_root=query_root, **row) diff --git a/examples/trace-chain/lib/requests/shipment_request.py b/examples/trace-chain/lib/requests/shipment_request.py new file mode 100644 index 0000000..89565fd --- /dev/null +++ b/examples/trace-chain/lib/requests/shipment_request.py @@ -0,0 +1,425 @@ +from teaql.core.query import RelationAggregate, SelectQuery +from teaql.core.list import SmartList, TeaQLPage +from teaql.runtime import EntityRoot +from teaql.data_service import QueryRequest +from teaql.core import QueryIntent +from copy import deepcopy +from teaql.core.expr import ( + begin_with, between, column, contain, end_with, eq, gt, gte, + in_list, in_subquery, is_not_null, is_null, lt, lte, ne, not_begin_with, + not_contain, not_end_with, not_in_list, not_in_subquery, value, + sound_like, +) +from models.shipment import Shipment +from typing import Protocol + +class QuerySelection(Protocol): + query: SelectQuery + +class ShipmentRequest: + def __init__(self, minimal=False): + self.query = SelectQuery("Shipment") + self._purpose = None + self._comment = None + self.query.and_filter(gte("version", 1)) + if minimal: + self.select_id() + self.select_version() + else: + self.select_self_fields() + + def comment(self, c: str): + self.query.comment(c) + self._comment = c + return self + + def purpose(self, p: str): + self.query.purpose(p) + self._purpose = p + return ExecutableShipmentRequest(self) + + def optimize_for_continuous_page_fetch(self): + self.query.optimize_for_continuous_page_fetch() + return self + + def optimize_for_continuous_page_fetch_with(self, namespace: str, ttl_seconds: int): + self.query.optimize_for_continuous_page_fetch_with(namespace, ttl_seconds) + return self + + def optimize_pagination_with_id_set(self): + self.query.optimize_pagination_with_id_set() + return self + + def optimize_pagination_with_id_set_config(self, namespace: str, ttl_seconds: int, max_ids: int): + self.query.optimize_pagination_with_id_set_config(namespace, ttl_seconds, max_ids) + return self + + def top_n_probe_parent_threshold(self, threshold: int): + self.query.top_n_probe_parent_threshold(threshold) + return self + + def limit(self, n: int): + self.query.limit(n) + return self + + def offset(self, n: int): + self.query.offset(n) + return self + + def with_deleted_rows(self): + self.query.with_deleted_rows() + return self + + def deleted_rows_only(self): + self.query.deleted_rows_only() + return self + + def select_self_fields(self): + self.query.project("id", "customer_order", "reference_code", "version") + return self + + def select_id(self): + self.query.project("id") + return self + + + def select_reference_code(self): + self.query.project("reference_code") + return self + + def select_version(self): + self.query.project("version") + return self + + def select_customer_order_with(self, child_request): + self.query.project("customer_order") + self.query.relation_query("customer_order", child_request.query) + return self + def with_customer_order_matching(self, child_request): + child_request.query.projection = ["id"] + self.query.and_filter(in_subquery(column("customer_order"), "CustomerOrder", child_request.query)) + return self + + def without_customer_order_matching(self, child_request): + child_request.query.projection = ["id"] + self.query.and_filter(not_in_subquery(column("customer_order"), "CustomerOrder", child_request.query)) + return self + + def have_customer_order(self): + self.query.and_filter(is_not_null(column("customer_order"))) + return self + + def have_no_customer_order(self): + self.query.and_filter(is_null(column("customer_order"))) + return self + + def with_id_is(self, val): + self.query.and_filter(eq("id", val)) + return self + + def with_id_is_not(self, val): + self.query.and_filter(ne("id", val)) + return self + + def with_id_in(self, *vals): + self.query.and_filter(in_list("id", list(vals))) + return self + + def with_id_not_in(self, *vals): + self.query.and_filter(not_in_list("id", list(vals))) + return self + + def with_id_greater_than(self, val): + self.query.and_filter(gt("id", val)) + return self + + def with_id_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("id", val)) + return self + + def with_id_less_than(self, val): + self.query.and_filter(lt("id", val)) + return self + + def with_id_less_than_or_equal_to(self, val): + self.query.and_filter(lte("id", val)) + return self + + def with_id_between(self, lower, upper): + self.query.and_filter(between(column("id"), value(lower), value(upper))) + return self + + def with_id_is_known(self): + self.query.and_filter(is_not_null(column("id"))) + return self + + def with_id_is_unknown(self): + self.query.and_filter(is_null(column("id"))) + return self + + def filter_by_customer_order(self, val): + self.query.and_filter(eq("customer_order", val)) + return self + + def with_reference_code_containing(self, val: str): + self.query.and_filter(contain("reference_code", val)) + return self + + def with_reference_code_not_containing(self, val: str): + self.query.and_filter(not_contain("reference_code", val)) + return self + + def with_reference_code_starting_with(self, val: str): + self.query.and_filter(begin_with("reference_code", val)) + return self + + def with_reference_code_not_starting_with(self, val: str): + self.query.and_filter(not_begin_with("reference_code", val)) + return self + + def with_reference_code_ending_with(self, val: str): + self.query.and_filter(end_with("reference_code", val)) + return self + + def with_reference_code_not_ending_with(self, val: str): + self.query.and_filter(not_end_with("reference_code", val)) + return self + + def with_reference_code_sounding_like(self, val: str): + self.query.and_filter(sound_like("reference_code", val)) + return self + + def with_reference_code_is(self, val: str): + self.query.and_filter(eq("reference_code", val)) + return self + def with_reference_code_is_not(self, val): + self.query.and_filter(ne("reference_code", val)) + return self + + def with_reference_code_in(self, *vals): + self.query.and_filter(in_list("reference_code", list(vals))) + return self + + def with_reference_code_not_in(self, *vals): + self.query.and_filter(not_in_list("reference_code", list(vals))) + return self + + def with_reference_code_greater_than(self, val): + self.query.and_filter(gt("reference_code", val)) + return self + + def with_reference_code_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("reference_code", val)) + return self + + def with_reference_code_less_than(self, val): + self.query.and_filter(lt("reference_code", val)) + return self + + def with_reference_code_less_than_or_equal_to(self, val): + self.query.and_filter(lte("reference_code", val)) + return self + + def with_reference_code_between(self, lower, upper): + self.query.and_filter(between(column("reference_code"), value(lower), value(upper))) + return self + + def with_reference_code_is_known(self): + self.query.and_filter(is_not_null(column("reference_code"))) + return self + + def with_reference_code_is_unknown(self): + self.query.and_filter(is_null(column("reference_code"))) + return self + + def with_version_is(self, val): + self.query.and_filter(eq("version", val)) + return self + + def with_version_is_not(self, val): + self.query.and_filter(ne("version", val)) + return self + + def with_version_in(self, *vals): + self.query.and_filter(in_list("version", list(vals))) + return self + + def with_version_not_in(self, *vals): + self.query.and_filter(not_in_list("version", list(vals))) + return self + + def with_version_greater_than(self, val): + self.query.and_filter(gt("version", val)) + return self + + def with_version_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("version", val)) + return self + + def with_version_less_than(self, val): + self.query.and_filter(lt("version", val)) + return self + + def with_version_less_than_or_equal_to(self, val): + self.query.and_filter(lte("version", val)) + return self + + def with_version_between(self, lower, upper): + self.query.and_filter(between(column("version"), value(lower), value(upper))) + return self + + def with_version_is_known(self): + self.query.and_filter(is_not_null(column("version"))) + return self + + def with_version_is_unknown(self): + self.query.and_filter(is_null(column("version"))) + return self + + def order_by_id_ascending(self): + self.query.order_by("id", "asc") + return self + + def order_by_id_descending(self): + self.query.order_by("id", "desc") + return self + + + def order_by_reference_code_ascending(self): + self.query.order_by("reference_code", "asc") + return self + + def order_by_reference_code_descending(self): + self.query.order_by("reference_code", "desc") + return self + + def order_by_version_ascending(self): + self.query.order_by("version", "asc") + return self + + def order_by_version_descending(self): + self.query.order_by("version", "desc") + return self + + + def count(self): + self.query.count_field("id", "count") + return self + + def count_as(self, ret_name: str): + self.query.count_field("id", ret_name) + return self + + def group_by_id(self): + self.query.group_by("id") + return self + + def group_by_id_as(self, ret_name: str): + self.query.group_by("id") + return self + def group_by_customer_order(self): + self.query.group_by("customer_order") + return self + + def group_by_customer_order_as(self, ret_name: str): + self.query.group_by("customer_order") + return self + def group_by_reference_code(self): + self.query.group_by("reference_code") + return self + + def group_by_reference_code_as(self, ret_name: str): + self.query.group_by("reference_code") + return self + def group_by_version(self): + self.query.group_by("version") + return self + + def group_by_version_as(self, ret_name: str): + self.query.group_by("version") + return self + def facet_by_customer_order_as(self, name: str, request: QuerySelection, + include_all_facets: bool = True): + self.query.facet_by(name, "customer_order", request.query, include_all_facets) + return self + + +class ExecutableShipmentRequest: + def __init__(self, request): + self._request = request + + def comment(self, c: str): + self._request.comment(c) + return self + + def new_entity(self, context) -> Shipment: + request = self._request + QueryIntent(request._comment, request._purpose) + entity = context.initialize_entity("Shipment", Shipment()) + if not isinstance(entity, Shipment): + raise TypeError("entity initializer returned an incompatible Shipment") + return entity + + async def execute_for_result(self, context): + self = self._request + req = QueryRequest(self.query, _comment=self._comment, _purpose=self._purpose) + req = context.prepare_query_request(req) + service = context.require_resource("dataService") + return await service.query(context, req) + + async def execute_for_rows(self, context): + return (await self.execute_for_result(context)).rows + + async def execute_for_list(self, context) -> SmartList[Shipment]: + result = await self.execute_for_result(context) + query_root = EntityRoot() + return SmartList( + (Shipment(_entity_root=query_root, **row) for row in result.rows), + facets=result.facets) + + async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[Shipment]: + request = self._request + intent = QueryIntent(request._comment, request._purpose) + query = deepcopy(request.query) + query.offset(offset).limit(limit) + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query + service = context.require_resource("dataService") + alias = "__teaql_total" + if authorized.id_set_pagination is not None: + row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + retained_count, accuracy = context.id_set_count() + if accuracy == "EXACT": + total_count = retained_count + else: + count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): + raise RuntimeError("dataService did not return an exact page count") + total_count = int(count_result.rows[0][alias]) + else: + count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): + raise RuntimeError("dataService did not return an exact page count") + total_count = int(count_result.rows[0][alias]) + row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + query_root = EntityRoot() + data = SmartList(Shipment(_entity_root=query_root, **row) for row in row_result.rows) + return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) + + async def execute_for_one(self, context): + request = deepcopy(self._request) + request.limit(1) + entities = await ExecutableShipmentRequest(request).execute_for_list(context) + return entities[0] if entities else None + + async def execute_for_stream(self, context, chunk_size: int = 1000): + """Yield entity chunks lazily from the provider cursor.""" + request = self._request + req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) + req = context.prepare_query_request(req) + service = context.require_resource("dataService") + if not hasattr(service, "query_stream"): + raise RuntimeError("dataService does not implement query_stream") + query_root = EntityRoot() + async for chunk in service.query_stream(context, req, chunk_size): + for row in chunk.rows: + yield Shipment(_entity_root=query_root, **row) diff --git a/examples/trace-chain/lib/runtime_module.py b/examples/trace-chain/lib/runtime_module.py new file mode 100644 index 0000000..66c8d7c --- /dev/null +++ b/examples/trace-chain/lib/runtime_module.py @@ -0,0 +1,214 @@ +import asyncio +from datetime import datetime, timezone +from teaql.runtime import CheckResult, ContextEntityRef, JsonFieldNamingProfile, ObjectLocation, RuntimeModule, create_wire_entity_metadata +from teaql.core.meta import EntityDescriptor, PropertyDescriptor, RelationDescriptor +from teaql.core.value import DataType +from Q import Q +from teaql.core.value import Value +try: + from teaql.core.graph import GraphNode +except ImportError: + class GraphNode: + def __init__(self, entity): + self.entity, self.fields = entity, {} + def set(self, field, value): + self.fields[field] = value + return self +from models.platform import Platform +from models.customer_order import CustomerOrder +from models.order_item import OrderItem +from models.payment import Payment +from models.payment_attempt import PaymentAttempt +from models.shipment import Shipment + +def _teaql_is_null(value): + return value.is_null() if hasattr(value, "is_null") else value is None + +def _teaql_raw(value): + return value.val if hasattr(value, "val") else value + +def _teaql_entity_id(value): + value = _teaql_raw(value) + if hasattr(value, "id"): + return value.id + if isinstance(value, dict): + return value.get("id") + return value + +class _PlatformChecker: + def check_and_fix(self, context, record, location, results): + operation = context.get_resource("fix_operation") + now = context.get_resource("fix_time") + if (operation == "insert" and "name" not in record) or ("name" in record and _teaql_is_null(record["name"])): + results.append(CheckResult("required", ObjectLocation().property("name"))) + if "name" in record and _teaql_raw(record["name"]) is not None and len(_teaql_raw(record["name"])) > 100: + results.append(CheckResult("max_length", ObjectLocation().property("name"), _teaql_raw(record["name"]), 100)) + + + +class _CustomerOrderChecker: + def check_and_fix(self, context, record, location, results): + operation = context.get_resource("fix_operation") + now = context.get_resource("fix_time") + if (operation == "insert" and "platform" not in record) or ("platform" in record and _teaql_is_null(record["platform"])): + results.append(CheckResult("required", ObjectLocation().property("platform"))) + + if (operation == "insert" and "order_number" not in record) or ("order_number" in record and _teaql_is_null(record["order_number"])): + results.append(CheckResult("required", ObjectLocation().property("order_number"))) + if "order_number" in record and _teaql_raw(record["order_number"]) is not None and len(_teaql_raw(record["order_number"])) > 100: + results.append(CheckResult("max_length", ObjectLocation().property("order_number"), _teaql_raw(record["order_number"]), 100)) + + if (operation == "insert" and "description" not in record) or ("description" in record and _teaql_is_null(record["description"])): + results.append(CheckResult("required", ObjectLocation().property("description"))) + if "description" in record and _teaql_raw(record["description"]) is not None and len(_teaql_raw(record["description"])) > 100: + results.append(CheckResult("max_length", ObjectLocation().property("description"), _teaql_raw(record["description"]), 100)) + + + +class _OrderItemChecker: + def check_and_fix(self, context, record, location, results): + operation = context.get_resource("fix_operation") + now = context.get_resource("fix_time") + if (operation == "insert" and "customer_order" not in record) or ("customer_order" in record and _teaql_is_null(record["customer_order"])): + results.append(CheckResult("required", ObjectLocation().property("customer_order"))) + + if (operation == "insert" and "name" not in record) or ("name" in record and _teaql_is_null(record["name"])): + results.append(CheckResult("required", ObjectLocation().property("name"))) + if "name" in record and _teaql_raw(record["name"]) is not None and len(_teaql_raw(record["name"])) > 100: + results.append(CheckResult("max_length", ObjectLocation().property("name"), _teaql_raw(record["name"]), 100)) + + + +class _PaymentChecker: + def check_and_fix(self, context, record, location, results): + operation = context.get_resource("fix_operation") + now = context.get_resource("fix_time") + if (operation == "insert" and "customer_order" not in record) or ("customer_order" in record and _teaql_is_null(record["customer_order"])): + results.append(CheckResult("required", ObjectLocation().property("customer_order"))) + + if (operation == "insert" and "reference_code" not in record) or ("reference_code" in record and _teaql_is_null(record["reference_code"])): + results.append(CheckResult("required", ObjectLocation().property("reference_code"))) + if "reference_code" in record and _teaql_raw(record["reference_code"]) is not None and len(_teaql_raw(record["reference_code"])) > 100: + results.append(CheckResult("max_length", ObjectLocation().property("reference_code"), _teaql_raw(record["reference_code"]), 100)) + + + +class _PaymentAttemptChecker: + def check_and_fix(self, context, record, location, results): + operation = context.get_resource("fix_operation") + now = context.get_resource("fix_time") + if (operation == "insert" and "payment" not in record) or ("payment" in record and _teaql_is_null(record["payment"])): + results.append(CheckResult("required", ObjectLocation().property("payment"))) + + if (operation == "insert" and "reference_code" not in record) or ("reference_code" in record and _teaql_is_null(record["reference_code"])): + results.append(CheckResult("required", ObjectLocation().property("reference_code"))) + if "reference_code" in record and _teaql_raw(record["reference_code"]) is not None and len(_teaql_raw(record["reference_code"])) > 100: + results.append(CheckResult("max_length", ObjectLocation().property("reference_code"), _teaql_raw(record["reference_code"]), 100)) + + + +class _ShipmentChecker: + def check_and_fix(self, context, record, location, results): + operation = context.get_resource("fix_operation") + now = context.get_resource("fix_time") + if (operation == "insert" and "customer_order" not in record) or ("customer_order" in record and _teaql_is_null(record["customer_order"])): + results.append(CheckResult("required", ObjectLocation().property("customer_order"))) + + if (operation == "insert" and "reference_code" not in record) or ("reference_code" in record and _teaql_is_null(record["reference_code"])): + results.append(CheckResult("required", ObjectLocation().property("reference_code"))) + if "reference_code" in record and _teaql_raw(record["reference_code"]) is not None and len(_teaql_raw(record["reference_code"])) > 100: + results.append(CheckResult("max_length", ObjectLocation().property("reference_code"), _teaql_raw(record["reference_code"]), 100)) + + + +_Platform_DESCRIPTOR = (EntityDescriptor("Platform") + .audit_mask_fields([]) + .table_name("platform_data").property(PropertyDescriptor("id", DataType.I64).column_name("id").log_policy("plain").is_id().required()).property(PropertyDescriptor("name", DataType.Text).column_name("name").log_policy("plain").required()).property(PropertyDescriptor("version", DataType.I64).column_name("version").log_policy("plain").is_version().required()).relation(RelationDescriptor("customer_order_list", "CustomerOrder").local("id").foreign("platform").many()) +) + +_CustomerOrder_DESCRIPTOR = (EntityDescriptor("CustomerOrder") + .audit_mask_fields([]) + .table_name("customer_order_data").property(PropertyDescriptor("id", DataType.I64).column_name("id").log_policy("plain").is_id().required()).property(PropertyDescriptor("platform", DataType.I64).column_name("platform").log_policy("plain").required()).property(PropertyDescriptor("order_number", DataType.Text).column_name("order_number").log_policy("plain").required()).property(PropertyDescriptor("description", DataType.Text).column_name("description").log_policy("plain").required()).property(PropertyDescriptor("version", DataType.I64).column_name("version").log_policy("plain").is_version().required()).relation(RelationDescriptor("platform", "Platform").local("platform").foreign("id")).relation(RelationDescriptor("order_item_list", "OrderItem").local("id").foreign("customer_order").many()).relation(RelationDescriptor("payment_list", "Payment").local("id").foreign("customer_order").many()).relation(RelationDescriptor("shipment_list", "Shipment").local("id").foreign("customer_order").many()) +) + +_OrderItem_DESCRIPTOR = (EntityDescriptor("OrderItem") + .audit_mask_fields([]) + .table_name("order_item_data").property(PropertyDescriptor("id", DataType.I64).column_name("id").log_policy("plain").is_id().required()).property(PropertyDescriptor("customer_order", DataType.I64).column_name("customer_order").log_policy("plain").required()).property(PropertyDescriptor("name", DataType.Text).column_name("name").log_policy("plain").required()).property(PropertyDescriptor("version", DataType.I64).column_name("version").log_policy("plain").is_version().required()).relation(RelationDescriptor("customer_order", "CustomerOrder").local("customer_order").foreign("id")) +) + +_Payment_DESCRIPTOR = (EntityDescriptor("Payment") + .audit_mask_fields([]) + .table_name("payment_data").property(PropertyDescriptor("id", DataType.I64).column_name("id").log_policy("plain").is_id().required()).property(PropertyDescriptor("customer_order", DataType.I64).column_name("customer_order").log_policy("plain").required()).property(PropertyDescriptor("reference_code", DataType.Text).column_name("reference_code").log_policy("plain").required()).property(PropertyDescriptor("version", DataType.I64).column_name("version").log_policy("plain").is_version().required()).relation(RelationDescriptor("customer_order", "CustomerOrder").local("customer_order").foreign("id")).relation(RelationDescriptor("payment_attempt_list", "PaymentAttempt").local("id").foreign("payment").many()) +) + +_PaymentAttempt_DESCRIPTOR = (EntityDescriptor("PaymentAttempt") + .audit_mask_fields([]) + .table_name("payment_attempt_data").property(PropertyDescriptor("id", DataType.I64).column_name("id").log_policy("plain").is_id().required()).property(PropertyDescriptor("payment", DataType.I64).column_name("payment").log_policy("plain").required()).property(PropertyDescriptor("reference_code", DataType.Text).column_name("reference_code").log_policy("plain").required()).property(PropertyDescriptor("version", DataType.I64).column_name("version").log_policy("plain").is_version().required()).relation(RelationDescriptor("payment", "Payment").local("payment").foreign("id")) +) + +_Shipment_DESCRIPTOR = (EntityDescriptor("Shipment") + .audit_mask_fields([]) + .table_name("shipment_data").property(PropertyDescriptor("id", DataType.I64).column_name("id").log_policy("plain").is_id().required()).property(PropertyDescriptor("customer_order", DataType.I64).column_name("customer_order").log_policy("plain").required()).property(PropertyDescriptor("reference_code", DataType.Text).column_name("reference_code").log_policy("plain").required()).property(PropertyDescriptor("version", DataType.I64).column_name("version").log_policy("plain").is_version().required()).relation(RelationDescriptor("customer_order", "CustomerOrder").local("customer_order").foreign("id")) +) + +async def _ensure_generated_bootstrap_once(context): + previous_actor = context.user_identifier() if hasattr(context, 'user_identifier') else None + previous_category = context.get_resource('bootstrapCategory') + if hasattr(context, 'set_user_identifier'): + context.set_user_identifier('teaql-generated-bootstrap') + context.insert_resource('bootstrapCategory', 'runtime-bootstrap') + try: + platform_1 = await (Q.platforms().with_id_is(1).comment('what: locate generated bootstrap entity').purpose('why: idempotent runtime bootstrap').execute_for_one(context)) + if platform_1 is None: + platform_1 = Platform._teaql_new_with_fixed_id(1) + platform_1.update_name("Trace Chain Verification") + try: + await platform_1.audit_as('create model root Platform(1)').save(context) + except Exception as _teaql_create_error: + for _teaql_attempt in range(5): + platform_1 = await (Q.platforms().with_id_is(1).comment('what: recover concurrent bootstrap').purpose('why: make generated bootstrap idempotent').execute_for_one(context)) + if platform_1 is not None: + break + if _teaql_attempt < 4: + await asyncio.sleep((_teaql_attempt + 1) * 0.01) + if platform_1 is None: + raise _teaql_create_error + context.with_active_root(ContextEntityRef("Platform", 1)) + finally: + if hasattr(context, 'set_user_identifier'): + context.set_user_identifier(previous_actor) + context.insert_resource('bootstrapCategory', previous_category) + +async def _ensure_generated_bootstrap(context): + for _teaql_attempt in range(5): + try: + await _ensure_generated_bootstrap_once(context) + return + except Exception: + if _teaql_attempt == 4: + raise + await asyncio.sleep((_teaql_attempt + 1) * 0.01) + + +# Passive generated manifest. Call ensure_schema() separately and explicitly. +GENERATED_RUNTIME_MODULE = (RuntimeModule().entity(Platform) + .schema_entity(_Platform_DESCRIPTOR) + .checker("Platform", _PlatformChecker()) + .wire_metadata("Platform", create_wire_entity_metadata("Platform", ["id", "name", "version"], JsonFieldNamingProfile.CAMEL_CASE, {"id": ["id"], "name": ["name"], "version": ["version"]})).entity(CustomerOrder) + .schema_entity(_CustomerOrder_DESCRIPTOR) + .checker("CustomerOrder", _CustomerOrderChecker()) + .wire_metadata("CustomerOrder", create_wire_entity_metadata("CustomerOrder", ["id", "platform", "order_number", "description", "version"], JsonFieldNamingProfile.CAMEL_CASE, {"id": ["id"], "platform": ["platform"], "order_number": ["order_number"], "description": ["description"], "version": ["version"]})).entity(OrderItem) + .schema_entity(_OrderItem_DESCRIPTOR) + .checker("OrderItem", _OrderItemChecker()) + .wire_metadata("OrderItem", create_wire_entity_metadata("OrderItem", ["id", "customer_order", "name", "version"], JsonFieldNamingProfile.CAMEL_CASE, {"id": ["id"], "customer_order": ["customer_order"], "name": ["name"], "version": ["version"]})).entity(Payment) + .schema_entity(_Payment_DESCRIPTOR) + .checker("Payment", _PaymentChecker()) + .wire_metadata("Payment", create_wire_entity_metadata("Payment", ["id", "customer_order", "reference_code", "version"], JsonFieldNamingProfile.CAMEL_CASE, {"id": ["id"], "customer_order": ["customer_order"], "reference_code": ["reference_code"], "version": ["version"]})).entity(PaymentAttempt) + .schema_entity(_PaymentAttempt_DESCRIPTOR) + .checker("PaymentAttempt", _PaymentAttemptChecker()) + .wire_metadata("PaymentAttempt", create_wire_entity_metadata("PaymentAttempt", ["id", "payment", "reference_code", "version"], JsonFieldNamingProfile.CAMEL_CASE, {"id": ["id"], "payment": ["payment"], "reference_code": ["reference_code"], "version": ["version"]})).entity(Shipment) + .schema_entity(_Shipment_DESCRIPTOR) + .checker("Shipment", _ShipmentChecker()) + .wire_metadata("Shipment", create_wire_entity_metadata("Shipment", ["id", "customer_order", "reference_code", "version"], JsonFieldNamingProfile.CAMEL_CASE, {"id": ["id"], "customer_order": ["customer_order"], "reference_code": ["reference_code"], "version": ["version"]})) + .generated_bootstrap(_ensure_generated_bootstrap) +) \ No newline at end of file diff --git a/examples/trace-chain/lib/teaql-i18n.json b/examples/trace-chain/lib/teaql-i18n.json new file mode 100644 index 0000000..f63a000 --- /dev/null +++ b/examples/trace-chain/lib/teaql-i18n.json @@ -0,0 +1,126 @@ +{ + "schema": "teaql.i18n/v1", + "defaultLocale": "en", + "locales": { + "de": { + "vocabulary": { + }, + "messages": { + } + }, + "ko": { + "vocabulary": { + }, + "messages": { + } + }, + "pt": { + "vocabulary": { + }, + "messages": { + } + }, + "zh-TW": { + "vocabulary": { + }, + "messages": { + } + }, + "fil": { + "vocabulary": { + }, + "messages": { + } + }, + "en": { + "vocabulary": { + "property.platform.name": "Name", + "property.shipment.version": "Version", + "property.customerOrder.orderNumber": "Order Number", + "property.orderItem.id": "Id", + "property.shipment.referenceCode": "Reference Code", + "property.customerOrder.description": "Description", + "entity.orderItem": "Order Item", + "property.orderItem.customerOrder": "Customer Order", + "property.orderItem.name": "Name", + "entity.customerOrder": "Customer Order", + "property.payment.customerOrder": "Customer Order", + "property.customerOrder.id": "Id", + "property.paymentAttempt.version": "Version", + "property.payment.version": "Version", + "property.shipment.customerOrder": "Customer Order", + "property.paymentAttempt.id": "Id", + "property.customerOrder.version": "Version", + "property.payment.id": "Id", + "entity.platform": "Platform", + "property.platform.id": "Id", + "property.customerOrder.platform": "Platform", + "entity.payment": "Payment", + "property.shipment.id": "Id", + "property.payment.referenceCode": "Reference Code", + "property.paymentAttempt.referenceCode": "Reference Code", + "entity.shipment": "Shipment", + "property.orderItem.version": "Version", + "property.platform.version": "Version", + "property.paymentAttempt.payment": "Payment", + "entity.paymentAttempt": "Payment Attempt" + }, + "messages": { + } + }, + "fr": { + "vocabulary": { + }, + "messages": { + } + }, + "zh-CN": { + "vocabulary": { + }, + "messages": { + } + }, + "es": { + "vocabulary": { + }, + "messages": { + } + }, + "ar": { + "vocabulary": { + }, + "messages": { + } + }, + "vi": { + "vocabulary": { + }, + "messages": { + } + }, + "th": { + "vocabulary": { + }, + "messages": { + } + }, + "uk": { + "vocabulary": { + }, + "messages": { + } + }, + "ja": { + "vocabulary": { + }, + "messages": { + } + }, + "id": { + "vocabulary": { + }, + "messages": { + } + } + } +} \ No newline at end of file diff --git a/examples/trace-chain/main.py b/examples/trace-chain/main.py new file mode 100644 index 0000000..e227453 --- /dev/null +++ b/examples/trace-chain/main.py @@ -0,0 +1,255 @@ +"""Generated Q/E/Mutation traversal observed at three real SQLite boundaries.""" +import asyncio +import os +import uuid + +from E import E +from Q import Q +from runtime_module import GENERATED_RUNTIME_MODULE +from teaql.provider.sqlite import create_sqlite_service +from teaql.runtime import UserContext, DelegatingMutationPolicyRegistry +from teaql.core.request_intent import RequestIntentError +from teaql.sql.executor import TransportError + + +def chain(nodes): + return [(node.name or node.entity_type, node.entity_id, node.comment) for node in nodes] + + +def scalar(value): + return getattr(value, 'val', value) + + +class ObservedService: + """Transparent observer, not a replacement planner or provider.""" + def __init__(self, service): + self.service = service + self.requests = [] + self.results = [] + self.finishes = [] + self.begins = 0 + self.fault = None + + def __getattr__(self, name): + return getattr(self.service, name) + + async def begin(self, context): + self.begins += 1 + transaction = await self.service.begin(context) + return ObservedTransaction(self, transaction) + + +class ObservedTransaction: + def __init__(self, owner, transaction): + self.owner, self.transaction = owner, transaction + for method_name in ('execute_sql', 'fetch_all_sql'): + original = getattr(transaction.transport, method_name) + async def intercepted(compiled, original=original, method_name=method_name): + if self.owner.fault == method_name and 'payment_attempt_data' in compiled.sql: + raise RuntimeError('synthetic payment attempt failure') + return await original(compiled) + setattr(transaction.transport, method_name, intercepted) + + def __getattr__(self, name): + return getattr(self.transaction, name) + + async def mutate(self, context, request): + command = request._data + identity = scalar(getattr(command, 'id', None) or command.values.get('id')) + self.owner.requests.append((command.entity, identity, chain(request.mutation_lineage))) + result = await self.transaction.mutate(context, request) + self.owner.results.append(result.metadata) + return result + + async def commit(self, context): + await self.transaction.commit(context) + self.owner.finishes.append('commit') + + async def rollback(self, context): + await self.transaction.rollback(context) + self.owner.finishes.append('rollback') + + +class AuditSink: + def __init__(self, service): + self.service, self.events = service, [] + + def on_safe_event(self, context, event): + assert self.service.finishes[-1] == 'commit', 'audit escaped before database commit' + self.events.append(event) + + +def new(request, context): + return request.comment('what: construct a trace example entity').purpose( + 'why: verify generated graph behavior').new_entity(context) + + +def make_graph(context, platform, label): + order = new(Q.customer_orders(), context).update_platform(platform) + order.update_order_number(label).update_description('Trace example draft') + available = new(Q.order_items(), context).update_customer_order(order).update_name('Available item') + removed = new(Q.order_items(), context).update_customer_order(order).update_name('Unavailable item') + payment = new(Q.payments(), context).update_customer_order(order).update_reference_code(label + '-payment') + attempt = new(Q.payment_attempts(), context).update_payment(payment).update_reference_code(label + '-attempt') + shipment = new(Q.shipments(), context).update_customer_order(order).update_reference_code(label + '-shipment') + payment.payment_attempt_list().append(attempt) + order.order_item_list().extend([available, removed]) + order.payment_list().append(payment) + order.shipment_list().append(shipment) + return order, available, payment, attempt, shipment, removed + + +def expected_graph(graph, reason): + order, item, payment, attempt, shipment, removed = graph + root = ('CustomerOrder', E.customer_order(order).id().eval(), reason) + payment_node = ('Payment', E.payment(payment).id().eval(), 'authorize payment') + shipment_node = ('Shipment', E.shipment(shipment).id().eval(), 'dispatch shipment') + deleted_node = ('OrderItem', E.order_item(removed).id().eval(), 'remove unavailable item') + return { + ('CustomerOrder', root[1]): [root], + ('OrderItem', E.order_item(item).id().eval()): [root], + ('Payment', payment_node[1]): [root, payment_node], + ('PaymentAttempt', E.payment_attempt(attempt).id().eval()): [root, payment_node], + ('Shipment', shipment_node[1]): [root, shipment_node], + ('OrderItem', deleted_node[1]): [root, deleted_node], + } + + +def reset(service, sink, context): + service.requests.clear() + service.results.clear() + service.finishes.clear() + service.begins = 0 + sink.events.clear() + context.clear_sql_logs() + + +async def main(): + path = os.environ['TEAQL_TRACE_CHAIN_DB'] + label = 'TRACE-' + uuid.uuid4().hex[:16] + context = UserContext.new().install(GENERATED_RUNTIME_MODULE) + policy_calls = [] + def resolve_policy(request_key): + policy_calls.append(request_key) + return None # Observe the configured resolver; retain generated-default governance. + context.with_mutation_policy_registry(DelegatingMutationPolicyRegistry(resolve_policy)) + service = ObservedService(create_sqlite_service(path)) + sink = AuditSink(service) + context.insert_resource('dataService', service).with_app_audit_event_sink(sink) + context.set_diagnostic_sql_log_sink(type('Silent', (), {'write': lambda self, entry: None})()) + await context.ensure_schema() + platforms = await Q.platforms().with_id_is(1).limit(1).comment( + 'what: reuse bootstrap root').purpose('why: attach trace example').execute_for_list(context) + assert len(platforms) == 1 + platform = platforms[0] + + rejected = make_graph(context, platform, label + '-rejected') + rejected[2].audit_as('valid child reason cannot substitute for root') + reset(service, sink, context) + policy_calls.clear() + for reason in (None, '', ' \t'): + try: + if reason is None: + await rejected[0].save(context) + else: + await rejected[0].audit_as(reason).save(context) + except RequestIntentError as error: + assert error.code == 'REQUEST_COMMENT_REQUIRED' + else: + raise AssertionError('missing root reason was accepted') + assert service.begins == 0 and service.requests == [] and sink.events == [] and policy_calls == [] + print('PASS: missing/empty/blank root intent rejected before any transaction, despite valid child intent') + + graph = make_graph(context, platform, label) + order, item, payment, attempt, shipment, removed = graph + await order.audit_as('prepare complete graph').save(context) + assert E.payment(payment).id().eval() == E.customer_order(order).id().eval(), 'fixture must exercise equal IDs of different types' + order.update_description('Submitted trace example') + payment.audit_as('authorize payment') + shipment.audit_as('dispatch shipment') + removed.mark_for_deletion().audit_as('remove unavailable item') + reset(service, sink, context) + await order.audit_as('submit order').save(context) + expected = expected_graph(graph, 'submit order') + commands = {(entity, identity): nodes for entity, identity, nodes in service.requests} + metadata = {(entity, identity): chain(item.mutation_lineage) + for (entity, identity, _), item in zip(service.requests, service.results)} + audits = {(event.entity, scalar(event.entity_id)): chain(event.trace_chain) for event in sink.events} + assert len(service.requests) == len(service.results) == len(sink.events) == 6 + assert commands == metadata == audits == expected + for record in service.results: + assert record.trace_chain[0].name == 'CustomerOrder' + assert [node.kind for node in record.trace_chain] == ['operation', 'entity', 'provider', 'sql'] + assert all(node.kind == 'auditReason' for node in record.mutation_lineage) + assert len([entry for entry in context.sql_logs() if entry.mutation_lineage]) == 6 + print('PASS: normative six items; assigned typed identity; branch/deletion reasons; request/SQL/audit boundaries') + + loaded = await (Q.customer_orders().with_id_is(E.customer_order(order).id().eval()) + .select_order_item_list_with(Q.order_items().limit(10)) + .select_payment_list_with(Q.payments().limit(10) + .select_payment_attempt_list_with(Q.payment_attempts().limit(10))) + .select_shipment_list_with(Q.shipments().limit(10)).limit(1) + .comment('what: reload committed trace graph').purpose('why: verify generated Q and loaded E').execute_for_one(context)) + assert E.customer_order(loaded).order_number().eval() == label + assert E.customer_order(loaded).order_item_list().size().eval() == 1 + assert E.customer_order(loaded).payment_list().size().eval() == 1 + deleted = await Q.order_items().with_id_is(E.order_item(removed).id().eval()).deleted_rows_only().limit(1).comment( + 'what: verify retained deletion').purpose('why: test graph save soft deletion').execute_for_one(context) + assert E.order_item(deleted).version().eval() < 0 + print('PASS: generated Q graph reload; loaded E; deleted row retained and hidden') + + reset(service, sink, context) + deep = await (Q.payment_attempts().with_id_is(E.payment_attempt(attempt).id().eval()) + .select_payment_with(Q.payments().limit(1).select_customer_order_with( + Q.customer_orders().limit(1).select_platform_with(Q.platforms().limit(1)))) + .limit(1).comment('what: inspect three relation levels').purpose('why: retain root query intent').execute_for_one(context)) + assert E.payment_attempt(deep).reference_code().eval() == label + '-attempt' + entries = context.sql_logs() + assert len(entries) == 4 + for depth, entry in enumerate(entries): + assert entry.comment == 'what: inspect three relation levels' + assert entry.purpose == 'why: retain root query intent' + assert entry.trace_path[0].name == 'PaymentAttempt' + assert [node.name for node in entry.trace_path[2:-2]] == ['payment', 'customer_order', 'platform'][:depth] + print('PASS: generated query traverses three actual relations with inherited intent') + + graphs = [make_graph(context, platform, label + '-' + name) for name in ('first', 'second')] + reset(service, sink, context) + await asyncio.gather(*(g[0].audit_as('independent ' + name).save(context) + for g, name in zip(graphs, ('first', 'second')))) + assert service.finishes == ['commit', 'commit'] and len(sink.events) == 12 + roots = {E.customer_order(g[0]).id().eval(): 'independent ' + name + for g, name in zip(graphs, ('first', 'second'))} + assert all(chain(event.trace_chain) == [('CustomerOrder', chain(event.trace_chain)[0][1], + roots[chain(event.trace_chain)[0][1]])] for event in sink.events) + assert context.require_resource('dataService') is service + print('PASS: two concurrent generated graph saves in one real Context remain isolated') + + for failure in ('execute_sql', 'fetch_all_sql'): + failed = make_graph(context, platform, label + '-' + failure) + failed[2].audit_as('authorize failing payment') + reset(service, sink, context) + service.fault = failure + try: + await failed[0].audit_as('attempt failing graph').save(context) + except (TransportError, RuntimeError) as error: + actual = error.error if isinstance(error, TransportError) else error + assert str(actual) == 'synthetic payment attempt failure' + else: + raise AssertionError('injected failure did not interrupt graph') + finally: + service.fault = None + assert service.finishes == ['rollback'] and sink.events == [] + evidence = context.sql_logs() + assert evidence[-1].execution_outcome == 'failure' + assert evidence[-1].mutation_lineage[-1].comment == 'authorize failing payment' + if failure == 'fetch_all_sql': + assert evidence[-2].execution_outcome == 'success' + assert evidence[-2].trace_path[-1].name == 'insert' + assert evidence[-1].trace_path[-1].name == 'select' + print('PASS: ' + failure + ' failure retains lineage, rolls back and emits zero committed audit') + print('PASS: generated library unchanged; all trace example checks passed') + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/examples/trace-chain/model.xml b/examples/trace-chain/model.xml new file mode 100644 index 0000000..aecdef5 --- /dev/null +++ b/examples/trace-chain/model.xml @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/examples/trace-chain/verify.sh b/examples/trace-chain/verify.sh new file mode 100644 index 0000000..8b89e37 --- /dev/null +++ b/examples/trace-chain/verify.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail +example="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo="$(cd "$example/../.." && pwd)" +if [[ -z "${TEAQL_TRACE_CHAIN_DB:-}" ]]; then + trace_test_directory="$(mktemp -d)" + export TEAQL_TRACE_CHAIN_DB="$trace_test_directory/trace-chain.sqlite" +fi +export PYTHONDONTWRITEBYTECODE=1 +export PYTHONPATH="$example/lib:$repo/src${PYTHONPATH:+:$PYTHONPATH}" +snapshot="$(mktemp)" +(cd "$example/lib" && find . -type f ! -path '*/__pycache__/*' -print0 | sort -z | xargs -0 sha256sum) > "$snapshot" +for attempt in first second; do + echo "Run $attempt on $TEAQL_TRACE_CHAIN_DB without cleanup" + python "$example/main.py" +done +(cd "$example/lib" && sha256sum --check "$snapshot") +echo "PASS: two runs on the same database; generated library hashes unchanged" diff --git a/scripts/verify-examples.sh b/scripts/verify-examples.sh index 9c5ae48..0238f2d 100755 --- a/scripts/verify-examples.sh +++ b/scripts/verify-examples.sh @@ -2,7 +2,7 @@ set -euo pipefail repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -expected=(business-clock business-id conformance mutation-policy opaque-entity-reference order-management query-policy school-management task_board) +expected=(business-clock business-id conformance mutation-policy opaque-entity-reference order-management query-policy school-management task_board trace-chain) mapfile -t actual < <(find "$repo/examples" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort) if [[ "${actual[*]}" != "${expected[*]}" ]]; then echo "example inventory changed; update scripts/verify-examples.sh: ${actual[*]}" >&2 @@ -38,4 +38,5 @@ TEAQL_ORDER_MANAGEMENT_DB="$order_management_tmp/order.db" \ PYTHONPATH="$repo/src" python -m unittest discover -s "$repo/examples/order-management" -p 'test_sql_log_intent.py' -v TEAQL_TASK_BOARD_DB="$task_board_tmp/task_board.db" PYTHONPATH="$repo/examples/task_board:$repo/src" python "$repo/examples/task_board/main.py" PYTHONPATH="$repo/src" python -m unittest discover -s "$repo/examples/task_board" -p 'test_task_board.py' -v +bash "$repo/examples/trace-chain/verify.sh" echo "PASS: all Python examples" diff --git a/src/teaql/core/entity.py b/src/teaql/core/entity.py index 7514ecd..9e1db95 100644 --- a/src/teaql/core/entity.py +++ b/src/teaql/core/entity.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from copy import deepcopy from threading import RLock from typing import Dict, Optional, Any, Iterable, Mapping, Set, Tuple from .value import Value @@ -57,6 +58,20 @@ def __init__(self): self._original_versions: Dict[EntityKey, int] = {} self._new_keys: Set[EntityKey] = set() self._deleted_keys: Set[EntityKey] = set() + self._trace_chains: Dict[EntityKey, tuple] = {} + + def set_trace_chain(self, key: EntityKey, chain) -> None: + """Store a complete per-entity replacement, not an appended fragment.""" + from .mutation import TraceNode + nodes = tuple(chain) + if any(not isinstance(node, TraceNode) or node.kind != 'auditReason' for node in nodes): + raise TypeError('ledger lineage must contain typed AuditReason nodes') + with self._lock: + self._trace_chains[key] = deepcopy(nodes) + + def trace_chain(self, key: EntityKey) -> tuple: + with self._lock: + return deepcopy(self._trace_chains.get(key, ())) def push_change_set(self) -> None: with self._lock: @@ -115,6 +130,7 @@ def clear_committed(self) -> None: self._change_sets[-1] = EntityChangeSet() self._new_keys.clear() self._deleted_keys.clear() + self._trace_chains.clear() def merge_from(self, other: 'EntityRoot') -> None: if other is self: @@ -124,6 +140,7 @@ def merge_from(self, other: 'EntityRoot') -> None: self._original_versions.update(other._original_versions) self._new_keys.update(other._new_keys) self._deleted_keys.update(other._deleted_keys) + self._trace_chains.update(deepcopy(other._trace_chains)) def rekey(self, old_key: EntityKey, new_key: EntityKey) -> None: with self._lock: @@ -137,6 +154,12 @@ def rekey(self, old_key: EntityKey, new_key: EntityKey) -> None: if old_key in self._deleted_keys: self._deleted_keys.remove(old_key) self._deleted_keys.add(new_key) + if old_key in self._trace_chains: + self._trace_chains[new_key] = self._trace_chains.pop(old_key) + for chain in self._trace_chains.values(): + for node in chain: + if (node.name or node.entity_type, node.entity_id) == (old_key.entity, old_key.id): + node.entity_id = new_key.id def clear_entity(self, key: EntityKey) -> None: with self._lock: @@ -144,6 +167,7 @@ def clear_entity(self, key: EntityKey) -> None: change_set.clear_entity(key) self._new_keys.discard(key) self._deleted_keys.discard(key) + self._trace_chains.pop(key, None) class BaseEntityData: def __init__(self, id: int = 0, version: int = 0, dynamic: Optional[Dict[str, Value]] = None): diff --git a/src/teaql/core/mutation.py b/src/teaql/core/mutation.py index 90f9ea1..a599e91 100644 --- a/src/teaql/core/mutation.py +++ b/src/teaql/core/mutation.py @@ -1,6 +1,7 @@ from enum import Enum, auto from typing import Dict, Any, List, Optional from dataclasses import dataclass, field +from copy import deepcopy from .value import Value from .request_intent import MutationIntent @@ -114,11 +115,25 @@ class MutationKind(Enum): BATCH = auto() class MutationRequest: - __slots__ = ('_data', '__intent') + __slots__ = ('_data', '__intent', '__mutation_lineage') def __init__(self, data: Any, comment: Optional[str] = None): self.__intent = MutationIntent(comment) self._data = data + self.__mutation_lineage = () + + @property + def mutation_lineage(self) -> tuple[TraceNode, ...]: + """Detached business lineage, never the physical SQL trace path.""" + return deepcopy(self.__mutation_lineage) + + def with_mutation_lineage(self, chain) -> 'MutationRequest': + nodes = tuple(chain) + if any(not isinstance(node, TraceNode) or node.kind != 'auditReason' for node in nodes): + raise TypeError('mutation lineage must contain typed AuditReason nodes') + result = MutationRequest(self._data, comment=self.intent.comment) + result.__mutation_lineage = deepcopy(nodes) + return result @property def intent(self) -> MutationIntent: @@ -130,7 +145,7 @@ def validate(self) -> None: raise TypeError('MutationRequest trace must contain typed TraceNode values') def with_root_intent(self, intent: MutationIntent) -> 'MutationRequest': - return MutationRequest(self._data, comment=intent.comment) + return MutationRequest(self._data, comment=intent.comment).with_mutation_lineage(self.mutation_lineage) def trace_chain(self) -> List[TraceNode]: if isinstance(self._data, list): diff --git a/src/teaql/core/trace_scope.py b/src/teaql/core/trace_scope.py new file mode 100644 index 0000000..3f40bc7 --- /dev/null +++ b/src/teaql/core/trace_scope.py @@ -0,0 +1,55 @@ +"""Persistent, invocation-owned graph lineage, independent of SQL routes.""" + +from dataclasses import dataclass +from typing import Optional + +from .entity import EntityKey +from .mutation import TraceNode + + +@dataclass(frozen=True) +class _AuditReason: + entity: str + entity_id: int + reason: str + + +@dataclass(frozen=True) +class TraceScope: + """O(1) immutable branch token; recover owned nodes only at a boundary.""" + + _owner: object + _node: _AuditReason + _parent: Optional['TraceScope'] = None + + @classmethod + def root(cls, owner: object, key: EntityKey, reason: str) -> 'TraceScope': + from .request_intent import MutationIntent + intent = MutationIntent(reason) + cls._require_assigned(key) + return cls(owner, _AuditReason(key.entity, key.id, intent.comment)) + + def child(self, key: EntityKey, reason: Optional[str]) -> 'TraceScope': + self._require_assigned(key) + if reason is None or (isinstance(reason, str) and not reason.strip()): + return self + from .request_intent import MutationIntent + intent = MutationIntent(reason) + return TraceScope(self._owner, _AuditReason(key.entity, key.id, intent.comment), self) + + def recover(self) -> tuple[TraceNode, ...]: + nodes = [] + scope = self + while scope is not None: + node = scope._node + nodes.append(TraceNode(entity_type=node.entity, entity_id=node.entity_id, + comment=node.reason, kind='auditReason', name=node.entity)) + scope = scope._parent + nodes.reverse() + return tuple(nodes) + + @staticmethod + def _require_assigned(key: EntityKey) -> None: + if not isinstance(key, EntityKey) or not isinstance(key.id, int) \ + or isinstance(key.id, bool) or key.id <= 0: + raise ValueError('graph trace scope requires an assigned typed entity ID') diff --git a/src/teaql/data_service/__init__.py b/src/teaql/data_service/__init__.py index e63cd86..a60cf0f 100644 --- a/src/teaql/data_service/__init__.py +++ b/src/teaql/data_service/__init__.py @@ -101,6 +101,7 @@ class ExecutionMetadata: affected_rows: Optional[int] = None result_count: Optional[int] = None trace_chain: List[TraceNode] = field(default_factory=list) + mutation_lineage: tuple[TraceNode, ...] = field(default_factory=tuple) comment: Optional[str] = None purpose: Optional[str] = None audit_reason: Optional[str] = None diff --git a/src/teaql/runtime/__init__.py b/src/teaql/runtime/__init__.py index 44f2f21..c6d127e 100644 --- a/src/teaql/runtime/__init__.py +++ b/src/teaql/runtime/__init__.py @@ -8,6 +8,7 @@ Tools, ToolToken, ToolUnavailableError, ) from .module import RuntimeModule, DefaultEntityDataServiceBehavior +from .graph_session import GraphMutationSession, GraphCommittedError from .store import DataStore from .audit import RawAuditEvent, SafeAuditEvent, MutationAuditKind from .business_clock import BusinessClock, FixedBusinessClock, SystemBusinessClock @@ -59,6 +60,7 @@ __all__ = ["WireFieldMetadata", "WireEntityMetadata", "NormalizedWireInput", "WireInputError", "create_wire_entity_metadata", "normalize_wire_input", "encode_wire_output", "retain_submitted_paths", "EntityKey", "EntityChangeSet", "EntityRoot", "ContextEntityRef", "ContextRootError", "CheckException", "CheckResult", "I18nCatalog", "JsonFieldNamingProfile", "Locale", "ObjectLocation", "UnsupportedLocaleError", "UserContext", "TeaqlRuntime", "SqlLogEntry", "SqlLogOperation", "DiagnosticSqlLogSink", "TextDiagnosticSqlLogSink", "ServiceRuntimeFromEnv", "RuntimeModule", "DataStore", "RawAuditEvent", "SafeAuditEvent", "MutationAuditKind", "BusinessClock", "FixedBusinessClock", "SystemBusinessClock", "AeadEntityReferenceCodec", "EntityReferenceClaims", "EntityReferenceCodec", "EntityReferenceTokenError", "ENTITY_REFERENCE_AAD", "UNSAFE_RAW_ENTITY_REFERENCES_ACKNOWLEDGEMENT", "UNSAFE_RAW_ENTITY_REFERENCES_ENVIRONMENT", "BUSINESS_ID_PERMUTATION_V1_ALPHABET", "BUSINESS_ID_PERMUTATION_V1_DOMAIN_SIZE", "BUSINESS_ID_PERMUTATION_V1_MAX_SEQUENCE", "BUSINESS_ID_PERMUTATION_V1_WIDTH", "DefaultBusinessIdProfileFactory", "DefaultBusinessIdService", "InMemoryBusinessIdAllocator", "PermutedDailyBusinessIdProfile", "StaticBusinessIdKeyProvider", "encode_business_id_permutation_v1", "ContextTools", "ExecutableHttpTool", "HTTP_TOOL", "HttpIntentPhase", "HttpTool", "HttpToolProvider", "ToolDeniedError", "ToolError", "ToolPolicy", "ToolRisk", "Tools", "ToolToken", "ToolUnavailableError"] __all__ += [ + "GraphMutationSession", "GraphCommittedError", "MISSING_APPROVAL", "MISSING_POLICY", "DelegatingMutationGovernanceSink", "DelegatingMutationPolicyApprovalProvider", diff --git a/src/teaql/runtime/audit.py b/src/teaql/runtime/audit.py index 1097a9a..5e4d1ab 100644 --- a/src/teaql/runtime/audit.py +++ b/src/teaql/runtime/audit.py @@ -88,3 +88,43 @@ async def deliver(sink: Any, method: str, context: Any, event: Any) -> None: result = callback(context, event) if isawaitable(result): await result + + +class _CommittedAuditJournal: + """Private journal owned by one SQL transaction, never by shared Context.""" + + def __init__(self, context): + from copy import copy + self._events = [] + self._active = True + view = copy(context) + context._resources.setdefault('sql_logs', []) + view._resources = dict(context._resources) + view._audit_journal = self + self.context = view + + def require_active(self): + if not self._active: + raise RuntimeError('SQL audit transaction is no longer writable') + + def queue(self, event, safe_event): + from copy import deepcopy + self.require_active() + self._events.append((deepcopy(event), deepcopy(safe_event))) + + def discard(self): + self._active = False + self._events.clear() + + async def committed(self): + self._active = False + failures = [] + for event, safe in self._events: + try: + await self.context._deliver_audit_event(event, safe) + except BaseException as error: + failures.append(error) + self._events.clear() + if failures: + from .graph_session import GraphCommittedError + raise GraphCommittedError(failures) diff --git a/src/teaql/runtime/context.py b/src/teaql/runtime/context.py index e5df73c..156da02 100644 --- a/src/teaql/runtime/context.py +++ b/src/teaql/runtime/context.py @@ -2,6 +2,7 @@ import asyncio import contextvars from copy import deepcopy +from inspect import signature from dataclasses import dataclass from array import array from datetime import date, datetime, timedelta, timezone @@ -109,13 +110,14 @@ def __init__(self): self._id_set_plan = "ID_SET_DISABLED" self._id_set_count = 0 self._id_set_count_accuracy = "UNKNOWN" - self._graph_save_active = False self._graph_save_lock = asyncio.Lock() - self._graph_save_owner = contextvars.ContextVar( - f"teaql_graph_save_owner_{id(self)}", default=None + # This variable is a fail-fast re-entry guard, not an ambient session + # or a trace stack. Children receive the session explicitly. + self._graph_save_guard = contextvars.ContextVar( + f"teaql_graph_save_guard_{id(self)}", default=None ) - self._graph_commit_actions: List[Any] = [] - self._graph_rollback_actions: List[Any] = [] + self._graph_session = None + self._audit_journal = None self._fix_evidence_current: List[FixEvidence] = [] self._fix_evidence_last: List[FixEvidence] = [] self._checked_mutations = set() @@ -208,77 +210,58 @@ def decode_entity_reference( return claims async def execute_graph_save(self, work, *, comment=None): - """Run one generated entity graph in one provider transaction.""" + """Run ``work(session)``; never implicitly join another root save.""" from teaql.core.request_intent import MutationIntent + from .graph_session import GraphMutationSession intent = MutationIntent(comment) - if self._graph_save_owner.get() is not None: - return await work() + guard = self._graph_save_guard.get() + if self._graph_session is not None or (guard is not None and guard['active']): + raise RuntimeError('independent graph save cannot re-enter an active graph; compose children explicitly') async with self._graph_save_lock: provider = self.require_resource("dataService") begin = getattr(provider, "begin", None) if not callable(begin): raise RuntimeError("Configured dataService does not support graph transactions") + transaction = await self._call_transaction_method(begin, self) + guard = {'active': True} + guard_token = self._graph_save_guard.set(guard) + session = None try: - transaction = await begin(self) - except TypeError: - transaction = await begin() - owner_token = self._graph_save_owner.set(object()) - self._graph_save_active = True - self._mutation_policy.begin_graph(intent.comment) - self._graph_commit_actions = [] - self._graph_rollback_actions = [] - self.insert_resource("fix_time", self.business_time()) - self.begin_fix_evidence() - self.insert_resource("dataService", transaction) - try: - result = await work() + session = GraphMutationSession(self, transaction, intent) + result = await work(session) + session.context._mutation_policy.ensure_graph_complete() + await self._finish_graph_transaction(transaction, "commit", session.context) except BaseException: try: - await self._finish_graph_transaction(transaction, "rollback") - finally: - for action in reversed(self._graph_rollback_actions): - action() + await self._finish_graph_transaction(transaction, "rollback", session.context if session else self) + except BaseException: + pass # Preserve the original operation/commit failure. + if session is not None: + await session.rolled_back() raise else: - try: - self._mutation_policy.ensure_graph_complete() - await self._finish_graph_transaction(transaction, "commit") - except BaseException: - try: - await self._finish_graph_transaction(transaction, "rollback") - finally: - for action in reversed(self._graph_rollback_actions): - action() - raise - for action in self._graph_commit_actions: - action() + await session.committed() return result finally: - self.insert_resource("dataService", provider) - self._graph_save_active = False - self._mutation_policy.end_graph() - self._graph_commit_actions = [] - self._graph_rollback_actions = [] - self._resources.pop("fix_time", None) - self.finish_fix_evidence() - self._graph_save_owner.reset(owner_token) - - async def _finish_graph_transaction(self, transaction, operation: str) -> None: - finish = getattr(transaction, operation) + if session is not None: + session.close() + self._fix_evidence_last = list(session.context.last_fix_evidence()) + guard['active'] = False + self._graph_save_guard.reset(guard_token) + + @staticmethod + async def _call_transaction_method(method, context): + # Select the signature before invocation. A TypeError thrown *inside* + # a provider must not trigger a second begin/commit/rollback call. try: - await finish(self) + signature(method).bind(context) except TypeError: - await finish() + return await method() + return await method(context) - def after_graph_commit(self, work) -> None: - if not self._graph_save_active: - raise RuntimeError("No graph save is active") - self._graph_commit_actions.append(work) - - def after_graph_rollback(self, work) -> None: - if not self._graph_save_active: - raise RuntimeError("No graph save is active") - self._graph_rollback_actions.append(work) + async def _finish_graph_transaction(self, transaction, operation: str, context) -> None: + finish = getattr(transaction, operation) + await self._call_transaction_method(finish, context) async def get_in_store(self, key: str) -> Optional[Any]: store = self.get_resource("data_store") @@ -752,6 +735,19 @@ def translate_check_results(self, results: Any): return results async def send_audit_event(self, event: Any): + descriptor = self.entity(event.entity) + mask_fields = getattr(descriptor, "audit_mask_fields_val", []) if descriptor else [] + max_len = getattr(descriptor, "audit_value_max_len_val", None) if descriptor else None + safe_event = event.safe(mask_fields, max_len) + if self._graph_session is not None: + self._graph_session.queue_audit(event, safe_event) + return + if self._audit_journal is not None: + self._audit_journal.queue(event, safe_event) + return + await self._deliver_audit_event(event, safe_event) + + async def _deliver_audit_event(self, event: Any, safe_event: Any): from .audit import deliver from .telemetry import RuntimeOperation, start_runtime_operation scope = start_runtime_operation(self._runtime_telemetry, RuntimeOperation( @@ -762,13 +758,19 @@ async def send_audit_event(self, event: Any): }, )) try: + failures = [] if self._standard_audit_sink is not None: - await deliver(self._standard_audit_sink, "on_event", self, event) + try: + await deliver(self._standard_audit_sink, "on_event", self, event) + except BaseException as error: + failures.append(error) if self._app_audit_sink is not None: - descriptor = self.entity(event.entity) - mask_fields = getattr(descriptor, "audit_mask_fields_val", []) if descriptor else [] - max_len = getattr(descriptor, "audit_value_max_len_val", None) if descriptor else None - await deliver(self._app_audit_sink, "on_safe_event", self, event.safe(mask_fields, max_len)) + try: + await deliver(self._app_audit_sink, "on_safe_event", self, safe_event) + except BaseException as error: + failures.append(error) + if failures: + raise failures[0] scope.success() except BaseException as error: scope.failure(error) @@ -814,8 +816,19 @@ def preflight_mutation(self, mutation: Any) -> None: def mutation_policy_execution(self, request: Any): """Provider boundary scope; entered after validation and before mutation.""" + self._require_mutation_invocation() return self._mutation_policy.enter_mutation(self, request) + def _require_mutation_invocation(self): + if self._audit_journal is not None: + self._audit_journal.require_active() + if self._graph_session is not None: + self._graph_session._require_active() + return + guard = self._graph_save_guard.get() + if guard is not None and guard['active']: + raise RuntimeError('mutation within an active graph requires its explicit session context') + def with_sql_log_options(self, options: 'SqlLogOptions') -> 'UserContext': self.insert_resource("sql_log_options", options) return self @@ -903,6 +916,7 @@ def _record_metadata_log(self, metadata: Any, *, intent_source=None, intent_valu purpose=getattr(metadata, 'purpose', None), audit_reason=getattr(metadata, 'audit_reason', None), trace_path=list(getattr(metadata, 'trace_chain', [])), + mutation_lineage=tuple(getattr(metadata, 'mutation_lineage', ())), sql=getattr(metadata, 'parameterized_sql', ''), params=list(getattr(metadata, 'parameters', [])), debug_sql=getattr(metadata, 'debug_query', '') or '', @@ -1238,6 +1252,7 @@ class SqlLogEntry: log_mode: Optional[str] = None omission_reason: Optional[str] = None execution_outcome: Optional[str] = None + mutation_lineage: tuple[Any, ...] = () class DiagnosticSqlLogSink: """Policy-projected SQL destination; the text sink is installed by default.""" diff --git a/src/teaql/runtime/graph_session.py b/src/teaql/runtime/graph_session.py new file mode 100644 index 0000000..be1c312 --- /dev/null +++ b/src/teaql/runtime/graph_session.py @@ -0,0 +1,117 @@ +"""One explicit graph-save session. Context supplies configuration, not lineage.""" + +from copy import copy, deepcopy +from inspect import isawaitable + +from teaql.core.mutation import MutationRequest +from teaql.core.request_intent import MutationIntent +from teaql.core.trace_scope import TraceScope + + +class GraphCommittedError(RuntimeError): + """Database commit succeeded; delivery/cleanup failed. Never retry the write.""" + + committed = True + + def __init__(self, failures): + self.failures = tuple(failures) + super().__init__('graph committed; one or more completion actions failed') + + +class GraphMutationSession: + def __init__(self, context, transaction, intent: MutationIntent): + self.intent = intent + self.transaction = transaction + self._owner = object() + self._active = True + self._committed = False + self._commit_actions = [] + self._rollback_actions = [] + self._audits = [] + # Preserve customer UserContext overrides and configured services. Only + # mutable invocation state is detached; diagnostic destinations are shared. + view = copy(context) + context._resources.setdefault('sql_logs', []) + view._resources = dict(context._resources) + view._resources['dataService'] = transaction + view._resources['fix_time'] = context.business_time() + view._mutation_policy = context._mutation_policy.for_invocation() + view._mutation_policy.begin_graph(intent.comment) + view._checked_mutations = set() + view._fix_evidence_current = [] + view._fix_evidence_last = [] + view._graph_session = self + self.context = view + + def _require_active(self): + if not self._active or self._committed: + raise RuntimeError('graph mutation session is no longer writable') + + def scope(self, key, parent=None, local_reason=None): + self._require_active() + if parent is None: + return TraceScope.root(self._owner, key, self.intent.comment) + if not isinstance(parent, TraceScope) or parent._owner is not self._owner: + raise ValueError('graph trace scope belongs to another invocation') + return parent.child(key, local_reason) + + def request(self, command, scope, ledger=None, key=None): + self._require_active() + if not isinstance(scope, TraceScope) or scope._owner is not self._owner: + raise ValueError('graph trace scope belongs to another invocation') + specific = ledger.trace_chain(key) if ledger is not None and key is not None else () + return MutationRequest(command, comment=self.intent.comment).with_mutation_lineage( + specific or scope.recover()) + + def after_commit(self, action): + self._require_active() + self._commit_actions.append(action) + + def after_rollback(self, action): + self._require_active() + self._rollback_actions.append(action) + + def queue_audit(self, event, safe_event): + self._require_active() + self._audits.append((deepcopy(event), deepcopy(safe_event))) + + async def committed(self): + self._committed = True + failures = [] + # A failed sink must not prevent subsequent events or ledger cleanup. + for event, safe_event in self._audits: + try: + await self.context._deliver_audit_event(event, safe_event) + except BaseException as error: + failures.append(error) + for action in self._commit_actions: + try: + result = action() + if isawaitable(result): + await result + except BaseException as error: + failures.append(error) + if failures: + raise GraphCommittedError(failures) + + async def rolled_back(self): + # Rollback evidence must never escape as a committed audit event. + self._audits.clear() + failures = [] + for action in reversed(self._rollback_actions): + try: + result = action() + if isawaitable(result): + await result + except BaseException as error: + failures.append(error) + return tuple(failures) + + def close(self): + self._active = False + self.context._mutation_policy.end_graph() + self.context.finish_fix_evidence() + self.context._resources.pop('fix_time', None) + self._audits.clear() + self._commit_actions.clear() + self._rollback_actions.clear() diff --git a/src/teaql/runtime/log_privacy.py b/src/teaql/runtime/log_privacy.py index eb02fdb..bfc9f31 100644 --- a/src/teaql/runtime/log_privacy.py +++ b/src/teaql/runtime/log_privacy.py @@ -191,5 +191,6 @@ def literal(index): result_summary=(f'{entry.result_count} rows returned' if entry.result_count is not None else f'{entry.affected_rows} rows affected' if entry.affected_rows is not None else scrub(entry.result_summary, secrets, hide_all=unknown_debug_intent)), - trace_path=intent(entry.trace_path)) + trace_path=intent(entry.trace_path), + mutation_lineage=intent(entry.mutation_lineage)) return projected diff --git a/src/teaql/runtime/mutation_policy.py b/src/teaql/runtime/mutation_policy.py index 5d8d252..0409460 100644 --- a/src/teaql/runtime/mutation_policy.py +++ b/src/teaql/runtime/mutation_policy.py @@ -244,6 +244,16 @@ def __init__(self) -> None: def current(self) -> Optional[MutationGovernanceSnapshot]: return self._active.get() + def for_invocation(self) -> 'MutationPolicyRuntimeState': + """Share configuration/warning dedup, never a graph's mutable plan.""" + state = MutationPolicyRuntimeState() + state.registry = self.registry + state.approval_provider = self.approval_provider + state.warning_sink = self.warning_sink + state._emitted_warnings = self._emitted_warnings + state._warning_lock = self._warning_lock + return state + def begin_graph(self, audit_reason: str) -> None: from teaql.core.request_intent import MutationIntent intent = MutationIntent(audit_reason) diff --git a/src/teaql/sql/executor.py b/src/teaql/sql/executor.py index 234345b..d74ba9c 100644 --- a/src/teaql/sql/executor.py +++ b/src/teaql/sql/executor.py @@ -252,10 +252,14 @@ def _record_statement(self, context, request, compiled, started_at, operation, TraceNode(kind='purpose', name=request.origin_entity, comment=request._purpose), *request.trace_chain] else: - chain = request.trace_chain() + chain = [*request.mutation_lineage, *request.trace_chain()] root = next((trace_name(node) for node in chain if trace_name(node).strip()), entity) + target_id = getattr(request._data, 'id', None) + if target_id is None: + target_id = getattr(request._data, 'values', {}).get('id') + target_id = getattr(target_id, 'val', target_id) trace_source = [TraceNode(kind='auditReason', name=root, comment=comment), - *chain, TraceNode(kind='entity', name=entity)] + *chain, TraceNode(kind='entity', name=entity, entity_id=target_id)] metadata = ExecutionMetadata( backend=provider, operation=operation, started_at=started_at, ended_at=datetime.now(), execution_outcome=outcome, @@ -266,6 +270,7 @@ def _record_statement(self, context, request, compiled, started_at, operation, comment=comment, purpose=request._purpose if query else None, audit_reason=None if query else comment, trace_chain=canonical_sql_trace_path(trace_source, provider, sql_operation), + mutation_lineage=() if query else request.mutation_lineage, ) if context is not None: try: @@ -728,6 +733,8 @@ def _attach_empty_relation_aggregate(self, parents, aggregate, query): async def mutate(self, context: 'UserContext', request: MutationRequest) -> MutationResult: request.validate() + if context is not None: + context._require_mutation_invocation() self._sync_generated_schema(context) entity = getattr(request._data, "entity", "unknown") kind = type(request._data).__name__.replace("Command", "").lower() @@ -762,11 +769,14 @@ async def _mutate(self, context: 'UserContext', request: MutationRequest) -> Mut if isinstance(self.transport, SqlTransactionTransport): transaction = await self.transport.begin_sql() executor = SqlDataServiceExecutor(self.dialect, transaction, self.schema_provider) + from teaql.runtime.audit import _CommittedAuditJournal + journal = _CommittedAuditJournal(context) if context is not None else None try: - result = await executor._mutate(context, request) + result = await executor._mutate(journal.context if journal else context, request) await transaction.commit_sql() - return result except BaseException: + if journal: + journal.discard() # CancelledError is not an Exception. Release the transaction # on cancellation too, without replacing the original failure. try: @@ -774,6 +784,10 @@ async def _mutate(self, context: 'UserContext', request: MutationRequest) -> Mut except BaseException: pass raise + else: + if journal: + await journal.committed() + return result req_data = request._data if isinstance(req_data, list): @@ -821,6 +835,14 @@ async def _mutate(self, context: 'UserContext', request: MutationRequest) -> Mut ), None) if version_prop is not None and version_prop.name not in req_data.values: req_data.values[version_prop.name] = Value.from_any(1) + + if not request.mutation_lineage: + target_id = getattr(req_data, 'id', None) + if target_id is None and id_prop is not None: + target_id = getattr(req_data, 'values', {}).get(id_prop.name) + request = request.with_mutation_lineage((TraceNode( + kind='auditReason', name=req_data.entity, entity_type=req_data.entity, + entity_id=getattr(target_id, 'val', target_id), comment=request.comment()),)) try: if isinstance(req_data, InsertCommand): @@ -942,7 +964,7 @@ async def _mutate(self, context: 'UserContext', request: MutationRequest) -> Mut req_data.entity, entity_id, changes, - tuple(request.trace_chain()), + request.mutation_lineage, context.user_identifier(), context.get_resource("bootstrapCategory"), context.current_mutation_governance(), @@ -1127,6 +1149,9 @@ def __init__(self, dialect: SqlDialect, transport: SqlTransactionTransportTx, sc self.dialect = dialect self.transport = transport self.schema_provider = schema_provider + self._audit_journal = None + self._audit_owner = None + self._completed = False def capabilities(self) -> DataServiceCapabilities: return DataServiceCapabilities( @@ -1144,6 +1169,21 @@ async def query(self, context: 'UserContext', request: QueryRequest) -> QueryRes return await executor.query(context, request) async def mutate(self, context: 'UserContext', request: MutationRequest) -> MutationResult: + request.validate() + if self._completed: + raise RuntimeError('SQL transaction is already completed') + if context is not None: + context._require_mutation_invocation() + if context._graph_session is None: + if self._audit_journal is None: + from teaql.runtime.audit import _CommittedAuditJournal + self._audit_journal = _CommittedAuditJournal(context) + self._audit_owner = context + self._audit_journal.context._mutation_policy = context._mutation_policy.for_invocation() + self._audit_journal.context._checked_mutations = set() + elif self._audit_owner is not context: + raise RuntimeError('SQL transaction audit owner cannot change') + context = self._audit_journal.context executor = SqlDataServiceExecutor(self.dialect, self.transport, self.schema_provider) return await executor.mutate(context, request) @@ -1156,7 +1196,15 @@ async def ensure_id_floor(self, entity: str, floor: int) -> None: await executor.ensure_id_floor(entity, floor) async def commit(self, context: 'UserContext') -> None: + if self._completed: + raise RuntimeError('SQL transaction is already completed') await self.transport.commit_sql() + self._completed = True + if self._audit_journal: + await self._audit_journal.committed() async def rollback(self, context: 'UserContext') -> None: + if self._audit_journal: + self._audit_journal.discard() + self._completed = True await self.transport.rollback_sql() diff --git a/tests/core/test_trace_scope.py b/tests/core/test_trace_scope.py new file mode 100644 index 0000000..13415cd --- /dev/null +++ b/tests/core/test_trace_scope.py @@ -0,0 +1,74 @@ +"""Shared graph fixture/token evidence; not generated traversal proof.""" +import json +from dataclasses import FrozenInstanceError +from pathlib import Path + +import pytest + +from teaql.core.entity import EntityKey, EntityRoot +from teaql.core.mutation import InsertCommand, MutationRequest, TraceNode +from teaql.core.trace_scope import TraceScope + + +CASES = json.loads((Path(__file__).parents[1] / 'fixtures' / 'graph-mutation-lineage-v1.json').read_text())['cases'] + + +def logical(node): + return {'kind': 'AuditReason', 'name': node.name or node.entity_type, + 'entityId': node.entity_id, 'detail': node.comment} + + +@pytest.mark.parametrize('case', CASES, ids=[case['id'] for case in CASES]) +def test_shared_graph_scope_recovery(case): + owner, scopes, recovered = object(), {}, {} + ledger = EntityRoot() + for node in case['nodes']: + key = EntityKey(node['entityType'], node.get('assignedEntityId') or node['entityId']) + scope = (TraceScope.root(owner, key, case['requestComment']) if node['parent'] is None + else scopes[node['parent']].child(key, node['localComment'])) + scopes[node['nodeId']] = scope + if 'ledgerLineage' in node: + ledger.set_trace_chain(key, [TraceNode(kind='auditReason', name=item['name'], + entity_id=item['entityId'], comment=item['detail']) for item in node['ledgerLineage']]) + request = MutationRequest(InsertCommand(key.entity), comment=case['requestComment']).with_mutation_lineage( + ledger.trace_chain(key) or scope.recover()) + recovered[node['nodeId']] = [logical(item) for item in request.mutation_lineage] + copied = request.mutation_lineage + copied[0].comment = 'consumer changed the recovered snapshot' + assert [logical(item) for item in request.mutation_lineage] == recovered[node['nodeId']] + assert recovered == {item['nodeId']: item['lineage'] for item in case['expected']} + + +def test_persistent_scope_is_immutable_and_blank_child_shares_parent(): + root = TraceScope.root(object(), EntityKey('CustomerOrder', 1), 'submit order') + assert root.child(EntityKey('OrderItem', 2), ' \t') is root + with pytest.raises(FrozenInstanceError): + root._node.reason = 'changed ancestor' + with pytest.raises(FrozenInstanceError): + root._parent = root + child = root.child(EntityKey('Payment', 1), 'authorize payment') + assert child._parent is root + assert child.recover()[0].entity_type == 'CustomerOrder' + + +def test_ledger_trace_is_typed_owned_and_rekeys_matching_ancestors_only(): + ledger = EntityRoot() + old, new, payment = EntityKey('CustomerOrder', -1), EntityKey('CustomerOrder', 100), EntityKey('Payment', -1) + source = [TraceNode(kind='auditReason', name='CustomerOrder', entity_id=-1, comment='submit order'), + TraceNode(kind='auditReason', name='Payment', entity_id=-1, comment='authorize payment')] + ledger.set_trace_chain(payment, source) + source[0].comment = 'caller changed source' + ledger.rekey(old, new) + chain = ledger.trace_chain(payment) + assert [(node.name, node.entity_id) for node in chain] == [('CustomerOrder', 100), ('Payment', -1)] + assert chain[0].comment == 'submit order' + chain[0].entity_id = 999 + assert ledger.trace_chain(payment)[0].entity_id == 100 + merged = EntityRoot() + merged.merge_from(ledger) + ledger.clear_entity(payment) + assert len(merged.trace_chain(payment)) == 2 + merged.clear_committed() + assert merged.trace_chain(payment) == () + with pytest.raises(TypeError, match='typed AuditReason'): + ledger.set_trace_chain(payment, ['opaque sensitive input']) diff --git a/tests/fixtures/graph-mutation-lineage-v1.json b/tests/fixtures/graph-mutation-lineage-v1.json new file mode 100644 index 0000000..3ee6702 --- /dev/null +++ b/tests/fixtures/graph-mutation-lineage-v1.json @@ -0,0 +1,37 @@ +{ + "contract": "teaql.graph-mutation-lineage.v1", + "owningIssue": "https://github.com/teaql/teaql-conformance/issues/99", + "evidenceLevel": "normative-fixture-not-runtime-proof", + "notes": [ + "nodeId is a fixture-local handle only. Runtime identity remains (entityType, assignedEntityId).", + "The root comment is the Mutation Request comment and must appear once, not once for the request plus once for the root entity.", + "For unit token recovery, parentage and final assigned IDs are supplied by the fixture. Only real planner/provider/audit integration proves capture, allocation, deletion and commit ordering.", + "Compare keyed per-entity observations and ordered nodes within each lineage. Do not impose this fixture's sibling order on a runtime's SQL batch scheduler.", + "localComment=null or blank describes defensive graph state, not a permitted blank public audited-save request." + ], + "cases": [ + {"id":"normative-order","coverage":["TC-MUT-01","TC-MUT-02","TC-MUT-03","TC-MUT-04","TC-MUT-05","TC-MUT-06","TC-MUT-09"],"requestComment":"submit order","nodes":[{"nodeId":"order","entityType":"Order","entityId":100,"parent":null,"localComment":"submit order","operation":"save"},{"nodeId":"item","entityType":"OrderItem","entityId":201,"parent":"order","localComment":null,"operation":"save"},{"nodeId":"payment","entityType":"Payment","entityId":301,"parent":"order","localComment":"authorize payment","operation":"save"},{"nodeId":"attempt","entityType":"PaymentAttempt","entityId":401,"parent":"payment","localComment":null,"operation":"save"},{"nodeId":"shipment","entityType":"Shipment","entityId":501,"parent":"order","localComment":"dispatch shipment","operation":"save"},{"nodeId":"deleted-item","entityType":"OrderItem","entityId":202,"parent":"order","localComment":"remove unavailable item","operation":"delete"}],"expected":[{"nodeId":"order","lineage":[{"kind":"AuditReason","name":"Order","entityId":100,"detail":"submit order"}]},{"nodeId":"item","lineage":[{"kind":"AuditReason","name":"Order","entityId":100,"detail":"submit order"}]},{"nodeId":"payment","lineage":[{"kind":"AuditReason","name":"Order","entityId":100,"detail":"submit order"},{"kind":"AuditReason","name":"Payment","entityId":301,"detail":"authorize payment"}]},{"nodeId":"attempt","lineage":[{"kind":"AuditReason","name":"Order","entityId":100,"detail":"submit order"},{"kind":"AuditReason","name":"Payment","entityId":301,"detail":"authorize payment"}]},{"nodeId":"shipment","lineage":[{"kind":"AuditReason","name":"Order","entityId":100,"detail":"submit order"},{"kind":"AuditReason","name":"Shipment","entityId":501,"detail":"dispatch shipment"}]},{"nodeId":"deleted-item","lineage":[{"kind":"AuditReason","name":"Order","entityId":100,"detail":"submit order"},{"kind":"AuditReason","name":"OrderItem","entityId":202,"detail":"remove unavailable item"}]}]}, + {"id":"same-id-different-types","coverage":["TC-MUT-08"],"requestComment":"submit order","nodes":[{"nodeId":"order","entityType":"Order","entityId":1,"parent":null,"localComment":"submit order","operation":"save"},{"nodeId":"payment","entityType":"Payment","entityId":1,"parent":"order","localComment":"authorize payment","operation":"save"}],"expected":[{"nodeId":"order","lineage":[{"kind":"AuditReason","name":"Order","entityId":1,"detail":"submit order"}]},{"nodeId":"payment","lineage":[{"kind":"AuditReason","name":"Order","entityId":1,"detail":"submit order"},{"kind":"AuditReason","name":"Payment","entityId":1,"detail":"authorize payment"}]}]}, + {"id":"complete-ledger-chain-replaces-fallback","coverage":["TC-MUT-10"],"requestComment":"submit order","nodes":[{"nodeId":"order","entityType":"Order","entityId":100,"parent":null,"localComment":"submit order","operation":"save"},{"nodeId":"payment","entityType":"Payment","entityId":301,"parent":"order","localComment":"authorize payment","operation":"save","ledgerLineage":[{"kind":"AuditReason","name":"Order","entityId":100,"detail":"submit order"},{"kind":"AuditReason","name":"Payment","entityId":301,"detail":"special approval"}]},{"nodeId":"item","entityType":"OrderItem","entityId":201,"parent":"order","localComment":null,"operation":"save","ledgerLineage":[]}],"expected":[{"nodeId":"order","lineage":[{"kind":"AuditReason","name":"Order","entityId":100,"detail":"submit order"}]},{"nodeId":"payment","lineage":[{"kind":"AuditReason","name":"Order","entityId":100,"detail":"submit order"},{"kind":"AuditReason","name":"Payment","entityId":301,"detail":"special approval"}]},{"nodeId":"item","lineage":[{"kind":"AuditReason","name":"Order","entityId":100,"detail":"submit order"}]}]}, + {"id":"blank-local-defensive-inheritance","coverage":["TC-MUT-11"],"requestComment":"submit order","nodes":[{"nodeId":"order","entityType":"Order","entityId":100,"parent":null,"localComment":"submit order","operation":"save"},{"nodeId":"item","entityType":"OrderItem","entityId":201,"parent":"order","localComment":" \t ","operation":"save"}],"expected":[{"nodeId":"order","lineage":[{"kind":"AuditReason","name":"Order","entityId":100,"detail":"submit order"}]},{"nodeId":"item","lineage":[{"kind":"AuditReason","name":"Order","entityId":100,"detail":"submit order"}]}]}, + {"id":"late-assigned-id","coverage":["TC-MUT-07"],"requestComment":"submit order","nodes":[{"nodeId":"order","entityType":"Order","entityId":100,"parent":null,"localComment":"submit order","operation":"save"},{"nodeId":"payment","entityType":"Payment","entityId":null,"parent":"order","localComment":"authorize payment","operation":"save","assignedEntityId":601}],"expected":[{"nodeId":"order","lineage":[{"kind":"AuditReason","name":"Order","entityId":100,"detail":"submit order"}]},{"nodeId":"payment","lineage":[{"kind":"AuditReason","name":"Order","entityId":100,"detail":"submit order"},{"kind":"AuditReason","name":"Payment","entityId":601,"detail":"authorize payment"}]}]} + ], + "requiredIntegrationCases": [ + { + "id": "TC-MUT-12", + "requirement": "Execute two independent graphs concurrently in one real Context; observe isolated emitted lineages." + }, + { + "id": "TC-MUT-13", + "requirement": "Fail the provider after planning; retain failed SQL lineage and emit no committed audit event." + }, + { + "id": "TC-MUT-14", + "requirement": "Commit the write and fail readback; retain successful write and independently traced readback failure." + }, + { + "id": "TC-MUT-15", + "requirement": "Drive normative-order with unchanged generated Mutation/Q/E APIs; assert emitted commands, SQL metadata and safe committed audit sinks; run twice without cleanup." + } + ] +} diff --git a/tests/provider/sqlite/test_sqlite.py b/tests/provider/sqlite/test_sqlite.py index 442486d..6ee24d6 100644 --- a/tests/provider/sqlite/test_sqlite.py +++ b/tests/provider/sqlite/test_sqlite.py @@ -777,7 +777,11 @@ async def on_safe_event(self, context, event): self.events.append(event) assert result.affected_rows == 1 assert len(raw.events) == 1 and raw.events[0].changes[1].new_value.val == "Alice Example" - assert raw.events[0].trace_chain[0].comment == "approved change" + assert raw.events[0].trace_chain[0].comment == "what: runtime regression fixture" + assert len(raw.events[0].trace_chain) == 1 + assert raw.events[0].trace_chain[0].entity_id == 1 + # Legacy command trace text is not the request-owned root audit reason. + assert command.trace_chain[0].comment == "approved change" assert len(app.events) == 1 name_field = next(field for field in app.events[0].fields if field.field == "name") assert name_field.masked and name_field.value != "Alice Example" diff --git a/tests/provider/sqlite/test_trace_chain.py b/tests/provider/sqlite/test_trace_chain.py index 611975e..f16b0ef 100644 --- a/tests/provider/sqlite/test_trace_chain.py +++ b/tests/provider/sqlite/test_trace_chain.py @@ -168,3 +168,64 @@ async def test_unexecuted_aggregate_provenance_does_not_modify_query_builders(tm assert result.rows == [] and len(entries) == len(transport.reads) == 1 assert request.query == before and child.slice is None assert child.relations[0].query.slice is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize('rollback', [False, True]) +async def test_explicit_transaction_audit_waits_for_commit_and_rollback_discards(tmp_path, rollback): + context, service, transport, entries = await fixture(tmp_path) + audits = [] + context.with_app_audit_event_sink(SimpleNamespace(on_safe_event=lambda context, event: audits.append(event))) + transaction = await service.begin(context) + command = InsertCommand.new('Payment').value('name', 'new payment').value('parent_id', 1) + await transaction.mutate(context, MutationRequest(command, comment='authorize transaction payment')) + assert audits == [] + if rollback: + await transaction.rollback(context) + else: + await transaction.commit(context) + assert len(audits) == 1 + assert audits[0].trace_chain[0].comment == 'authorize transaction payment' + assert audits[0].trace_chain[0].entity_id == 2 + async with aiosqlite.connect(transport.db_path) as database: + cursor = await database.execute('SELECT COUNT(*) FROM payment_data') + assert (await cursor.fetchone())[0] == (1 if rollback else 2) + assert context._audit_journal is None + + +@pytest.mark.asyncio +async def test_automatic_batch_readback_failure_discards_prior_successful_audits(tmp_path): + context, service, transport, entries = await fixture(tmp_path) + audits = [] + context.with_app_audit_event_sink(SimpleNamespace(on_safe_event=lambda context, event: audits.append(event))) + transport.fail_table = 'paymentattempt_data' + first = InsertCommand.new('Payment').value('name', 'first payment').value('parent_id', 1) + second = InsertCommand.new('PaymentAttempt').value('name', 'second attempt').value('parent_id', 1) + batch = MutationRequest.Batch([first, second], comment='authorize atomic payment batch') + with pytest.raises(RuntimeError) as caught: + await service.mutate(context, batch) + assert caught.value is transport.failure + assert audits == [] + assert [entry.execution_outcome for entry in entries] == ['success', 'success', 'failure'] + async with aiosqlite.connect(transport.db_path) as database: + for table in ('payment_data', 'paymentattempt_data'): + cursor = await database.execute(f'SELECT COUNT(*) FROM {table}') + assert (await cursor.fetchone())[0] == 1 + assert context._audit_journal is None + + +@pytest.mark.asyncio +async def test_automatic_transaction_sink_failure_reports_committed_and_does_not_rollback(tmp_path): + context, service, transport, entries = await fixture(tmp_path) + calls = [] + def unavailable(context, event): + calls.append(event) + raise RuntimeError('postcommit audit unavailable') + context.with_app_audit_event_sink(SimpleNamespace(on_safe_event=unavailable)) + command = InsertCommand.new('Payment').value('name', 'committed payment').value('parent_id', 1) + with pytest.raises(RuntimeError) as caught: + await service.mutate(context, MutationRequest(command, comment='create committed payment')) + assert caught.value.committed is True and len(calls) == 1 + async with aiosqlite.connect(transport.db_path) as database: + cursor = await database.execute('SELECT COUNT(*) FROM payment_data') + assert (await cursor.fetchone())[0] == 2 diff --git a/tests/runtime/test_graph_audit.py b/tests/runtime/test_graph_audit.py new file mode 100644 index 0000000..77a9824 --- /dev/null +++ b/tests/runtime/test_graph_audit.py @@ -0,0 +1,141 @@ +"""Transaction lifecycle assertions, independent of generated library fixtures.""" +import pytest + +from teaql.runtime import UserContext +from teaql.runtime.audit import MutationAuditKind, RawAuditEvent +from teaql.core.entity import EntityKey + + +class Provider: + def __init__(self, events): + self.events = events + + async def begin(self, context): + self.events.append('begin') + return self + + async def commit(self, context): + self.events.append('commit') + + async def rollback(self, context): + self.events.append('rollback') + + +def fixture(): + events = [] + provider = Provider(events) + context = UserContext().insert_resource('dataService', provider) + class Sink: + def on_safe_event(self, context, event): + events.append('audit') + context.with_app_audit_event_sink(Sink()) + return context, provider, events + + +@pytest.mark.asyncio +@pytest.mark.parametrize('failure', [False, True]) +async def test_graph_audit_waits_for_commit_and_rollback_discards(failure): + context, provider, events = fixture() + async def work(graph): + await graph.context.send_audit_event(RawAuditEvent(MutationAuditKind.CREATED, 'CustomerOrder', 1, ())) + assert events == ['begin'] + if failure: + raise RuntimeError('failed graph') + if failure: + with pytest.raises(RuntimeError, match='failed graph'): + await context.execute_graph_save(work, comment='create order') + assert events == ['begin', 'rollback'] + else: + await context.execute_graph_save(work, comment='create order') + assert events == ['begin', 'commit', 'audit'] + + +@pytest.mark.asyncio +async def test_postcommit_failure_still_attempts_remaining_completion_actions(): + context, provider, events = fixture() + def broken(): + events.append('broken completion') + raise RuntimeError('completion unavailable') + async def work(graph): + graph.after_commit(broken) + graph.after_commit(lambda: events.append('ledger clear')) + with pytest.raises(RuntimeError) as caught: + await context.execute_graph_save(work, comment='complete order') + assert getattr(caught.value, 'committed', False) is True + assert events == ['begin', 'commit', 'broken completion', 'ledger clear'] + + +@pytest.mark.asyncio +async def test_reentrant_root_fails_before_begin_and_explicit_child_uses_one_session(): + context, provider, events = fixture() + async def root(graph): + assert context.require_resource('dataService') is provider + with pytest.raises(RuntimeError, match='re-enter'): + await context.execute_graph_save(root, comment='another independent root') + async def spawned(): + with pytest.raises(RuntimeError, match='re-enter'): + await context.execute_graph_save(root, comment='spawned independent root') + await __import__('asyncio').create_task(spawned()) + with pytest.raises(RuntimeError, match='explicit session context'): + context._require_mutation_invocation() + graph.context._require_mutation_invocation() + return graph + graph = await context.execute_graph_save(root, comment='explicit root') + assert events == ['begin', 'commit'] + with pytest.raises(RuntimeError, match='no longer writable'): + graph.scope(EntityKey('CustomerOrder', 1)) + with pytest.raises(RuntimeError, match='no longer writable'): + graph.context._require_mutation_invocation() + + +@pytest.mark.asyncio +async def test_foreign_scope_rejected_and_next_invocation_can_reuse_context(): + context, provider, events = fixture() + async def first(graph): + return graph.scope(EntityKey('CustomerOrder', 1)) + previous = await context.execute_graph_save(first, comment='first graph') + async def second(graph): + with pytest.raises(ValueError, match='another invocation'): + graph.scope(EntityKey('Payment', 1), previous, 'authorize payment') + return graph.scope(EntityKey('CustomerOrder', 1)).recover() + nodes = await context.execute_graph_save(second, comment='second graph') + assert [node.comment for node in nodes] == ['second graph'] + assert events == ['begin', 'commit', 'begin', 'commit'] + + +@pytest.mark.asyncio +async def test_audit_snapshot_is_owned_and_failing_sink_does_not_skip_other_sink_or_cleanup(): + context, provider, events = fixture() + raw = [] + class Broken: + def on_event(self, context, event): + raw.append(event) + events.append('raw attempt') + raise RuntimeError('sink failure') + context._set_standard_audit_sink(Broken()) + async def work(graph): + from teaql.core.mutation import TraceNode + source = TraceNode(kind='auditReason', name='CustomerOrder', entity_id=1, comment='create order') + await graph.context.send_audit_event(RawAuditEvent( + MutationAuditKind.CREATED, 'CustomerOrder', 1, (), (source,))) + source.comment = 'changed after queue' + graph.after_commit(lambda: events.append('ledger clear')) + with pytest.raises(RuntimeError) as caught: + await context.execute_graph_save(work, comment='create order') + assert caught.value.committed is True + assert raw[0].trace_chain[0].comment == 'create order' + assert events == ['begin', 'commit', 'raw attempt', 'audit', 'ledger clear'] + + +@pytest.mark.asyncio +async def test_provider_type_error_does_not_retry_transaction_begin(): + context, provider, events = fixture() + async def broken(context): + events.append('broken begin') + raise TypeError('provider implementation error') + provider.begin = broken + async def work(graph): + raise AssertionError('work must not run') + with pytest.raises(TypeError, match='provider implementation error'): + await context.execute_graph_save(work, comment='create order') + assert events == ['broken begin'] diff --git a/tests/runtime/test_mutation_policy.py b/tests/runtime/test_mutation_policy.py index 93843db..4bf635e 100644 --- a/tests/runtime/test_mutation_policy.py +++ b/tests/runtime/test_mutation_policy.py @@ -179,16 +179,17 @@ def review(plan): order = _insert("Order", 42, "DRAFT") line = _insert("OrderLine", 99, "LINE") - async def save_graph(): - context.preflight_mutation(order) - context.preflight_mutation(line) + async def save_graph(graph): + local = graph.context + local.preflight_mutation(order) + local.preflight_mutation(line) order.values["name"] = Value.Text("APPROVED") # The policy still receives DRAFT, and an execution may only consume the # original reviewed operation rather than the changed command. - transaction = context.require_resource("dataService") + transaction = local.require_resource("dataService") await transaction.mutate( - context, MutationRequest(_insert("Order", 42, "DRAFT"), comment='what: runtime regression fixture')) - return await transaction.mutate(context, MutationRequest(line, comment='what: runtime regression fixture')) + local, MutationRequest(_insert("Order", 42, "DRAFT"), comment='what: runtime regression fixture')) + return await transaction.mutate(local, MutationRequest(line, comment='what: runtime regression fixture')) await context.execute_graph_save(save_graph, comment='what: runtime regression fixture') assert observed == {"count": 2, "name": "DRAFT"} @@ -218,11 +219,11 @@ async def test_denial_and_missing_preflight_leave_zero_provider_mutations(): order = _insert("Order", 43, "DENIED") line = _insert("OrderLine", 100, "DENIED-LINE") - async def denied_graph(): - denied.preflight_mutation(order) - denied.preflight_mutation(line) - return await denied.require_resource("dataService").mutate( - denied, MutationRequest(order, comment='what: runtime regression fixture')) + async def denied_graph(graph): + graph.context.preflight_mutation(order) + graph.context.preflight_mutation(line) + return await graph.transaction.mutate( + graph.context, MutationRequest(order, comment='what: runtime regression fixture')) with pytest.raises(MutationPolicyError, match="ORDER_DENIED"): await denied.execute_graph_save(denied_graph, comment='what: runtime regression fixture') @@ -237,8 +238,8 @@ async def denied_graph(): ) with pytest.raises(MutationPolicyError, match="complete graph preflight"): await missing.execute_graph_save( - lambda: missing.require_resource("dataService").mutate( - missing, MutationRequest(_insert("Order", 44, "MISSING"), comment='what: runtime regression fixture')), comment='what: runtime regression fixture') + lambda graph: graph.transaction.mutate( + graph.context, MutationRequest(_insert("Order", 44, "MISSING"), comment='what: runtime regression fixture')), comment='what: runtime regression fixture') assert missing_provider.mutations == 0 assert missing_provider.rollbacks == 1 @@ -251,12 +252,12 @@ async def test_generated_default_allows_legacy_graph_without_complete_preflight( .insert_resource("dataService", provider) .with_mutation_governance_sink(warnings)) - async def legacy_graph(): - transaction = context.require_resource("dataService") + async def legacy_graph(graph): + transaction = graph.transaction await transaction.mutate( - context, MutationRequest(_insert("Order", 47, "FIRST"), comment='what: runtime regression fixture')) + graph.context, MutationRequest(_insert("Order", 47, "FIRST"), comment='what: runtime regression fixture')) await transaction.mutate( - context, MutationRequest(_insert("OrderLine", 102, "SECOND"), comment='what: runtime regression fixture')) + graph.context, MutationRequest(_insert("OrderLine", 102, "SECOND"), comment='what: runtime regression fixture')) await context.execute_graph_save(legacy_graph, comment='what: runtime regression fixture') assert provider.mutations == 2 @@ -274,10 +275,10 @@ async def test_unplanned_and_incomplete_operations_fail_closed(): TestPolicy(identity, lambda plan: MutationDecision.allowed()), ) - async def unplanned_graph(): - unplanned.preflight_mutation(_insert("Order", 45, "PLANNED")) - return await unplanned.require_resource("dataService").mutate( - unplanned, MutationRequest(_insert("Order", 45, "DIFFERENT"), comment='what: runtime regression fixture')) + async def unplanned_graph(graph): + graph.context.preflight_mutation(_insert("Order", 45, "PLANNED")) + return await graph.transaction.mutate( + graph.context, MutationRequest(_insert("Order", 45, "DIFFERENT"), comment='what: runtime regression fixture')) with pytest.raises(MutationPolicyError, match="not present"): await unplanned.execute_graph_save(unplanned_graph, comment='what: runtime regression fixture') @@ -292,11 +293,11 @@ async def unplanned_graph(): first = _insert("Order", 46, "FIRST") second = _insert("OrderLine", 101, "SECOND") - async def incomplete_graph(): - incomplete.preflight_mutation(first) - incomplete.preflight_mutation(second) - return await incomplete.require_resource("dataService").mutate( - incomplete, MutationRequest(first, comment='what: runtime regression fixture')) + async def incomplete_graph(graph): + graph.context.preflight_mutation(first) + graph.context.preflight_mutation(second) + return await graph.transaction.mutate( + graph.context, MutationRequest(first, comment='what: runtime regression fixture')) with pytest.raises(MutationPolicyError, match="were not executed"): await incomplete.execute_graph_save(incomplete_graph, comment='what: runtime regression fixture') @@ -317,10 +318,10 @@ async def test_warning_sink_failure_is_fail_open(): ) order = _insert("Order", 47, "ALLOWED") - async def graph(): - context.preflight_mutation(order) - return await context.require_resource("dataService").mutate( - context, MutationRequest(order, comment='what: runtime regression fixture')) + async def graph(session): + session.context.preflight_mutation(order) + return await session.transaction.mutate( + session.context, MutationRequest(order, comment='what: runtime regression fixture')) await context.execute_graph_save(graph, comment='what: runtime regression fixture') assert warnings.events[0].warning_code == MISSING_APPROVAL diff --git a/tests/runtime/test_runtime.py b/tests/runtime/test_runtime.py index 40bf040..1a38968 100644 --- a/tests/runtime/test_runtime.py +++ b/tests/runtime/test_runtime.py @@ -143,14 +143,15 @@ async def test_graph_save_uses_one_transaction_and_retains_rollback_order(): provider = RecordingTransactionProvider(events) context = UserContext.new().insert_resource("dataService", provider) - async def failing_graph(): - assert context.require_resource("dataService") is provider.transaction - context.after_graph_rollback(lambda: events.append("parent rollback")) + async def failing_graph(graph): + assert context.require_resource("dataService") is provider + assert graph.context.require_resource("dataService") is provider.transaction + graph.after_rollback(lambda: events.append("parent rollback")) - async def nested_save(): - context.after_graph_rollback(lambda: events.append("child rollback")) + async def child_work(graph): + graph.after_rollback(lambda: events.append("child rollback")) - await context.execute_graph_save(nested_save, comment='what: runtime regression fixture') + await child_work(graph) raise RuntimeError("injected graph failure") with pytest.raises(RuntimeError, match="injected graph failure"): @@ -166,8 +167,8 @@ async def test_graph_save_runs_commit_actions_only_after_provider_commit(): provider = RecordingTransactionProvider(events) context = UserContext.new().insert_resource("dataService", provider) - async def successful_graph(): - context.after_graph_commit(lambda: events.append("ledger clear")) + async def successful_graph(graph): + graph.after_commit(lambda: events.append("ledger clear")) return "saved" assert await context.execute_graph_save(successful_graph, comment='what: runtime regression fixture') == "saved" @@ -191,10 +192,10 @@ def check_and_fix(self, context, record, location, results): .insert_resource("dataService", provider) .with_checker_registry(DummyCheckerRegistry(ClockChecker()))) - async def graph(): - context.check_and_fix_mutation(InsertCommand.new("Dummy")) + async def graph(session): + session.context.check_and_fix_mutation(InsertCommand.new("Dummy")) await __import__("asyncio").sleep(0.005) - context.check_and_fix_mutation(InsertCommand.new("Dummy")) + session.context.check_and_fix_mutation(InsertCommand.new("Dummy")) await context.execute_graph_save(graph, comment='what: runtime regression fixture') assert len(observed) == 2 and observed[0] is observed[1] @@ -226,7 +227,7 @@ async def test_independent_concurrent_graph_saves_do_not_join_transaction(): context = UserContext.new().insert_resource("dataService", provider) async def save(name, delay): - async def graph(): + async def graph(session): events.append(f"{name}:start") await __import__("asyncio").sleep(delay) events.append(f"{name}:end") diff --git a/tests/runtime/test_sql_mask_lifecycle.py b/tests/runtime/test_sql_mask_lifecycle.py index 06adf8d..40c144a 100644 --- a/tests/runtime/test_sql_mask_lifecycle.py +++ b/tests/runtime/test_sql_mask_lifecycle.py @@ -518,12 +518,13 @@ async def begin_sql(self): return tx context.insert_resource('dataService', SqlDataServiceExecutor(SqliteDialect(), Transport(), provider)) failure = RuntimeError('SECOND-READBACK-FAILURE') - async def work(): - service = context.require_resource('dataService') - await service.mutate(context, readback_request()) + async def work(graph): + local = graph.context + service = local.require_resource('dataService') + await service.mutate(local, readback_request()) tx.failure = failure - await service.mutate(context, readback_request()) - await service.mutate(context, readback_request()) + await service.mutate(local, readback_request()) + await service.mutate(local, readback_request()) with pytest.raises(RuntimeError) as caught: await context.execute_graph_save(work, comment='what: runtime regression fixture') assert caught.value is failure From a2c5edf44eae85b43a3ba897e0ec4f3ff162e214 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sat, 3 Oct 2026 05:12:35 +0800 Subject: [PATCH 04/40] fix(trace): isolate Python graph ownership and reject mixed versions (#43) Signed-off-by: Philip Z --- examples/conformance/models/platform.py | 93 ++++--- examples/conformance/models/work_item.py | 93 ++++--- .../conformance/requests/platform_request.py | 10 +- .../conformance/requests/work_item_request.py | 10 +- .../models/commerce_platform.py | 141 ++++++---- .../python-lib-core/models/customer.py | 109 ++++---- .../python-lib-core/models/customer_order.py | 121 ++++---- .../python-lib-core/models/order_line.py | 109 ++++---- .../models/order_search_preset.py | 109 ++++---- .../python-lib-core/models/order_status.py | 109 ++++---- .../python-lib-core/models/product.py | 113 ++++---- .../requests/commerce_platform_request.py | 10 +- .../requests/customer_order_request.py | 10 +- .../requests/customer_request.py | 10 +- .../requests/order_line_request.py | 10 +- .../requests/order_search_preset_request.py | 10 +- .../requests/order_status_request.py | 10 +- .../requests/product_request.py | 10 +- examples/school-management/models/platform.py | 113 ++++---- examples/school-management/models/school.py | 117 ++++---- .../school-management/models/school_type.py | 105 +++---- .../requests/platform_request.py | 10 +- .../requests/school_request.py | 10 +- .../requests/school_type_request.py | 10 +- .../task_board/generated/models/platform.py | 109 ++++---- examples/task_board/generated/models/task.py | 101 ++++--- .../generated/models/task_execution_log.py | 93 ++++--- .../generated/models/task_status.py | 113 ++++---- .../generated/requests/platform_request.py | 10 +- .../requests/task_execution_log_request.py | 10 +- .../generated/requests/task_request.py | 10 +- .../generated/requests/task_status_request.py | 10 +- examples/trace-chain/README.md | 20 +- .../python-assist-delete/customer_order.md | 4 +- .../assist/python-assist-delete/order_item.md | 4 +- .../assist/python-assist-delete/payment.md | 4 +- .../python-assist-delete/payment_attempt.md | 4 +- .../assist/python-assist-delete/platform.md | 4 +- .../assist/python-assist-delete/shipment.md | 4 +- .../python-assist-query/customer_order.id.md | 72 +++++ .../customer_order.order_number.md | 73 +++++ .../payment.reference_code.md | 5 +- .../payment_attempt.reference_code.md | 5 +- .../trace-chain/lib/models/customer_order.py | 117 ++++---- examples/trace-chain/lib/models/order_item.py | 89 +++--- examples/trace-chain/lib/models/payment.py | 97 ++++--- .../trace-chain/lib/models/payment_attempt.py | 89 +++--- examples/trace-chain/lib/models/platform.py | 93 ++++--- examples/trace-chain/lib/models/shipment.py | 89 +++--- .../lib/requests/customer_order_request.py | 10 +- .../lib/requests/order_item_request.py | 10 +- .../lib/requests/payment_attempt_request.py | 10 +- .../lib/requests/payment_request.py | 10 +- .../lib/requests/platform_request.py | 10 +- .../lib/requests/shipment_request.py | 10 +- examples/trace-chain/main.py | 8 +- examples/trace-chain/shared_reference.py | 258 ++++++++++++++++++ examples/trace-chain/verify.sh | 15 +- src/teaql/core/entity.py | 66 ++++- tests/core/test_entity_root_ownership.py | 121 ++++++++ 60 files changed, 1988 insertions(+), 1221 deletions(-) create mode 100644 examples/trace-chain/evidence/assist/python-assist-query/customer_order.id.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-query/customer_order.order_number.md create mode 100644 examples/trace-chain/shared_reference.py create mode 100644 tests/core/test_entity_root_ownership.py diff --git a/examples/conformance/models/platform.py b/examples/conformance/models/platform.py index 7f6ed23..d75a5d6 100644 --- a/examples/conformance/models/platform.py +++ b/examples/conformance/models/platform.py @@ -44,7 +44,7 @@ def __init__(self, **kwargs): if self._work_item_list: from models.work_item import WorkItem self._work_item_list = [ - item if isinstance(item, WorkItem) else WorkItem(**item) + item if isinstance(item, WorkItem) else WorkItem(_entity_root=self._entity_root, **item) for item in self._work_item_list ] self._ledger_id = getattr(self, "id", None) @@ -60,8 +60,9 @@ def _teaql_entity_key(self): return EntityKey("Platform", self._ledger_id) def _teaql_attach_root(self, root): - if self._entity_root is not root: - root.merge_from(self._entity_root) + key = self._teaql_entity_key() + if self._entity_root is not root and self._entity_root.has_pending(key): + root.merge_entity_from(self._entity_root, key) self._entity_root = root for child in self._work_item_list: child._teaql_attach_root(root) @@ -140,32 +141,39 @@ def _teaql_build_command(self): if action == "Create": cmd = InsertCommand("Platform", payload) elif action == "Update": - cmd = UpdateCommand("Platform", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = UpdateCommand("Platform", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) for key, value in payload.items(): if key not in ("id", "version"): cmd.value(key, value) else: - cmd = DeleteCommand("Platform", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = DeleteCommand("Platform", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) return action, cmd def _teaql_preflight_graph(self, graph): context = graph.context - if self._action == "Update": - if "id" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) - if "name" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("name"), message="Mutation requires a fully loaded entity")]) - if "version" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) - _action, cmd = self._teaql_build_command() - try: - context.preflight_mutation(cmd) - finally: - for field, value in getattr(cmd, "values", {}).items(): - if field not in ("id", "version"): - self._entity_root.set(self._teaql_entity_key(), field, value) + if self._action != "Update" or self._entity_root.has_pending(self._teaql_entity_key()): + if self._action == "Update": + if "id" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) + if "name" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("name"), message="Mutation requires a fully loaded entity")]) + if "version" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) + _action, cmd = self._teaql_build_command() + try: + context.preflight_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) for index, child in enumerate(self._work_item_list): child._teaql_attach_root(self._entity_root) - child.update_platform(self) + current = getattr(child, "platform", None) + if getattr(current, "id", current) != self.id: + child.update_platform(self) try: child._teaql_preflight_graph(graph) except CheckException as error: @@ -179,6 +187,10 @@ async def _teaql_save_within_graph(self, graph, parent_scope=None): context = graph.context scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) + if self._action == "Update" and not self._entity_root.has_pending(self._teaql_entity_key()): + await self._teaql_save_children(graph, scope) + return self + self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() @@ -238,34 +250,33 @@ def rollback_entity(): if action != "Delete": self._action = "Update" - cascade_relations = [] - cascade_relations.append(("work_item_list", self._work_item_list, "update_platform")) if action != "Delete": - for relation_name, children, updater in cascade_relations: - for index, child in enumerate(children): - child._teaql_attach_root(self._entity_root) - getattr(child, updater)(self) - try: - await child._teaql_save_within_graph(graph, scope) - except CheckException as error: - prefix = ObjectLocation().property(relation_name).index(index) - raise CheckException([ - CheckResult( - violation.rule_id, - violation.location.prefixed_by(prefix), - violation.input_value, - violation.system_value, - violation.message, - ) - for violation in error.violations - ]) from error + await self._teaql_save_children(graph, scope) def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: - self._entity_root.set_original_version(new_key, int(self.version)) + self._entity_root.accept_committed_version(new_key, int(self.version)) graph.after_commit(commit_entity) return self + async def _teaql_save_children(self, graph, scope): + cascade_relations = [] + cascade_relations.append(("work_item_list", self._work_item_list, "update_platform", "platform")) + for relation_name, children, updater, member in cascade_relations: + for index, child in enumerate(children): + child._teaql_attach_root(self._entity_root) + current = getattr(child, member, None) + if getattr(current, "id", current) != self.id: + getattr(child, updater)(self) + try: + await child._teaql_save_within_graph(graph, scope) + except CheckException as error: + prefix = ObjectLocation().property(relation_name).index(index) + raise CheckException([ + CheckResult(v.rule_id, v.location.prefixed_by(prefix), v.input_value, v.system_value, v.message) + for v in error.violations + ]) from error + def update_id(self, value): self.id = value self._loaded_fields.add("id") diff --git a/examples/conformance/models/work_item.py b/examples/conformance/models/work_item.py index 6ed24d2..d977869 100644 --- a/examples/conformance/models/work_item.py +++ b/examples/conformance/models/work_item.py @@ -62,8 +62,9 @@ def _teaql_entity_key(self): return EntityKey("WorkItem", self._ledger_id) def _teaql_attach_root(self, root): - if self._entity_root is not root: - root.merge_from(self._entity_root) + key = self._teaql_entity_key() + if self._entity_root is not root and self._entity_root.has_pending(key): + root.merge_entity_from(self._entity_root, key) self._entity_root = root return self def mark_for_deletion(self): @@ -147,38 +148,47 @@ def _teaql_build_command(self): if action == "Create": cmd = InsertCommand("WorkItem", payload) elif action == "Update": - cmd = UpdateCommand("WorkItem", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = UpdateCommand("WorkItem", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) for key, value in payload.items(): if key not in ("id", "version"): cmd.value(key, value) else: - cmd = DeleteCommand("WorkItem", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = DeleteCommand("WorkItem", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) return action, cmd def _teaql_preflight_graph(self, graph): context = graph.context - if self._action == "Update": - if "id" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) - if "title" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("title"), message="Mutation requires a fully loaded entity")]) - if "description" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("description"), message="Mutation requires a fully loaded entity")]) - if "platform" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("platform"), message="Mutation requires a fully loaded entity")]) - if "version" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) - _action, cmd = self._teaql_build_command() - try: - context.preflight_mutation(cmd) - finally: - for field, value in getattr(cmd, "values", {}).items(): - if field not in ("id", "version"): - self._entity_root.set(self._teaql_entity_key(), field, value) + if self._action != "Update" or self._entity_root.has_pending(self._teaql_entity_key()): + if self._action == "Update": + if "id" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) + if "title" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("title"), message="Mutation requires a fully loaded entity")]) + if "description" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("description"), message="Mutation requires a fully loaded entity")]) + if "platform" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("platform"), message="Mutation requires a fully loaded entity")]) + if "version" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) + _action, cmd = self._teaql_build_command() + try: + context.preflight_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) async def _teaql_save_within_graph(self, graph, parent_scope=None): context = graph.context scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) + if self._action == "Update" and not self._entity_root.has_pending(self._teaql_entity_key()): + await self._teaql_save_children(graph, scope) + return self + self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() @@ -250,33 +260,32 @@ def rollback_entity(): if action != "Delete": self._action = "Update" - cascade_relations = [] if action != "Delete": - for relation_name, children, updater in cascade_relations: - for index, child in enumerate(children): - child._teaql_attach_root(self._entity_root) - getattr(child, updater)(self) - try: - await child._teaql_save_within_graph(graph, scope) - except CheckException as error: - prefix = ObjectLocation().property(relation_name).index(index) - raise CheckException([ - CheckResult( - violation.rule_id, - violation.location.prefixed_by(prefix), - violation.input_value, - violation.system_value, - violation.message, - ) - for violation in error.violations - ]) from error + await self._teaql_save_children(graph, scope) def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: - self._entity_root.set_original_version(new_key, int(self.version)) + self._entity_root.accept_committed_version(new_key, int(self.version)) graph.after_commit(commit_entity) return self + async def _teaql_save_children(self, graph, scope): + cascade_relations = [] + for relation_name, children, updater, member in cascade_relations: + for index, child in enumerate(children): + child._teaql_attach_root(self._entity_root) + current = getattr(child, member, None) + if getattr(current, "id", current) != self.id: + getattr(child, updater)(self) + try: + await child._teaql_save_within_graph(graph, scope) + except CheckException as error: + prefix = ObjectLocation().property(relation_name).index(index) + raise CheckException([ + CheckResult(v.rule_id, v.location.prefixed_by(prefix), v.input_value, v.system_value, v.message) + for v in error.violations + ]) from error + def update_id(self, value): self.id = value self._loaded_fields.add("id") diff --git a/examples/conformance/requests/platform_request.py b/examples/conformance/requests/platform_request.py index 4fa77f9..0c4413e 100644 --- a/examples/conformance/requests/platform_request.py +++ b/examples/conformance/requests/platform_request.py @@ -1,6 +1,5 @@ from teaql.core.query import RelationAggregate, SelectQuery from teaql.core.list import SmartList, TeaQLPage -from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest from teaql.core import QueryIntent from copy import deepcopy @@ -372,9 +371,8 @@ async def execute_for_rows(self, context): async def execute_for_list(self, context) -> SmartList[Platform]: result = await self.execute_for_result(context) - query_root = EntityRoot() return SmartList( - (Platform(_entity_root=query_root, **row) for row in result.rows), + (Platform(**row) for row in result.rows), facets=result.facets) async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[Platform]: @@ -401,8 +399,7 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) - query_root = EntityRoot() - data = SmartList(Platform(_entity_root=query_root, **row) for row in row_result.rows) + data = SmartList(Platform(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): @@ -419,7 +416,6 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - query_root = EntityRoot() async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: - yield Platform(_entity_root=query_root, **row) + yield Platform(**row) diff --git a/examples/conformance/requests/work_item_request.py b/examples/conformance/requests/work_item_request.py index fd1f5e1..9940d7d 100644 --- a/examples/conformance/requests/work_item_request.py +++ b/examples/conformance/requests/work_item_request.py @@ -1,6 +1,5 @@ from teaql.core.query import RelationAggregate, SelectQuery from teaql.core.list import SmartList, TeaQLPage -from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest from teaql.core import QueryIntent from copy import deepcopy @@ -462,9 +461,8 @@ async def execute_for_rows(self, context): async def execute_for_list(self, context) -> SmartList[WorkItem]: result = await self.execute_for_result(context) - query_root = EntityRoot() return SmartList( - (WorkItem(_entity_root=query_root, **row) for row in result.rows), + (WorkItem(**row) for row in result.rows), facets=result.facets) async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[WorkItem]: @@ -491,8 +489,7 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) - query_root = EntityRoot() - data = SmartList(WorkItem(_entity_root=query_root, **row) for row in row_result.rows) + data = SmartList(WorkItem(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): @@ -509,7 +506,6 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - query_root = EntityRoot() async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: - yield WorkItem(_entity_root=query_root, **row) + yield WorkItem(**row) diff --git a/examples/order-management/python-lib-core/models/commerce_platform.py b/examples/order-management/python-lib-core/models/commerce_platform.py index 61147e8..d44909b 100644 --- a/examples/order-management/python-lib-core/models/commerce_platform.py +++ b/examples/order-management/python-lib-core/models/commerce_platform.py @@ -67,37 +67,37 @@ def __init__(self, **kwargs): if self._customer_list: from models.customer import Customer self._customer_list = [ - item if isinstance(item, Customer) else Customer(**item) + item if isinstance(item, Customer) else Customer(_entity_root=self._entity_root, **item) for item in self._customer_list ] if self._order_status_list: from models.order_status import OrderStatus self._order_status_list = [ - item if isinstance(item, OrderStatus) else OrderStatus(**item) + item if isinstance(item, OrderStatus) else OrderStatus(_entity_root=self._entity_root, **item) for item in self._order_status_list ] if self._customer_order_list: from models.customer_order import CustomerOrder self._customer_order_list = [ - item if isinstance(item, CustomerOrder) else CustomerOrder(**item) + item if isinstance(item, CustomerOrder) else CustomerOrder(_entity_root=self._entity_root, **item) for item in self._customer_order_list ] if self._product_list: from models.product import Product self._product_list = [ - item if isinstance(item, Product) else Product(**item) + item if isinstance(item, Product) else Product(_entity_root=self._entity_root, **item) for item in self._product_list ] if self._order_line_list: from models.order_line import OrderLine self._order_line_list = [ - item if isinstance(item, OrderLine) else OrderLine(**item) + item if isinstance(item, OrderLine) else OrderLine(_entity_root=self._entity_root, **item) for item in self._order_line_list ] if self._order_search_preset_list: from models.order_search_preset import OrderSearchPreset self._order_search_preset_list = [ - item if isinstance(item, OrderSearchPreset) else OrderSearchPreset(**item) + item if isinstance(item, OrderSearchPreset) else OrderSearchPreset(_entity_root=self._entity_root, **item) for item in self._order_search_preset_list ] self._ledger_id = getattr(self, "id", None) @@ -113,8 +113,9 @@ def _teaql_entity_key(self): return EntityKey("CommercePlatform", self._ledger_id) def _teaql_attach_root(self, root): - if self._entity_root is not root: - root.merge_from(self._entity_root) + key = self._teaql_entity_key() + if self._entity_root is not root and self._entity_root.has_pending(key): + root.merge_entity_from(self._entity_root, key) self._entity_root = root for child in self._customer_list: child._teaql_attach_root(root) @@ -224,36 +225,43 @@ def _teaql_build_command(self): if action == "Create": cmd = InsertCommand("CommercePlatform", payload) elif action == "Update": - cmd = UpdateCommand("CommercePlatform", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = UpdateCommand("CommercePlatform", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) for key, value in payload.items(): if key not in ("id", "version"): cmd.value(key, value) else: - cmd = DeleteCommand("CommercePlatform", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = DeleteCommand("CommercePlatform", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) return action, cmd def _teaql_preflight_graph(self, graph): context = graph.context - if self._action == "Update": - if "id" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) - if "name" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("name"), message="Mutation requires a fully loaded entity")]) - if "createTime" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("create_time"), message="Mutation requires a fully loaded entity")]) - if "updateTime" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("update_time"), message="Mutation requires a fully loaded entity")]) - if "version" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) - _action, cmd = self._teaql_build_command() - try: - context.preflight_mutation(cmd) - finally: - for field, value in getattr(cmd, "values", {}).items(): - if field not in ("id", "version"): - self._entity_root.set(self._teaql_entity_key(), field, value) + if self._action != "Update" or self._entity_root.has_pending(self._teaql_entity_key()): + if self._action == "Update": + if "id" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) + if "name" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("name"), message="Mutation requires a fully loaded entity")]) + if "createTime" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("create_time"), message="Mutation requires a fully loaded entity")]) + if "updateTime" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("update_time"), message="Mutation requires a fully loaded entity")]) + if "version" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) + _action, cmd = self._teaql_build_command() + try: + context.preflight_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) for index, child in enumerate(self._customer_list): child._teaql_attach_root(self._entity_root) - child.update_commerce_platform(self) + current = getattr(child, "commercePlatform", None) + if getattr(current, "id", current) != self.id: + child.update_commerce_platform(self) try: child._teaql_preflight_graph(graph) except CheckException as error: @@ -264,7 +272,9 @@ def _teaql_preflight_graph(self, graph): ]) from error for index, child in enumerate(self._order_status_list): child._teaql_attach_root(self._entity_root) - child.update_commerce_platform(self) + current = getattr(child, "commercePlatform", None) + if getattr(current, "id", current) != self.id: + child.update_commerce_platform(self) try: child._teaql_preflight_graph(graph) except CheckException as error: @@ -275,7 +285,9 @@ def _teaql_preflight_graph(self, graph): ]) from error for index, child in enumerate(self._customer_order_list): child._teaql_attach_root(self._entity_root) - child.update_commerce_platform(self) + current = getattr(child, "commercePlatform", None) + if getattr(current, "id", current) != self.id: + child.update_commerce_platform(self) try: child._teaql_preflight_graph(graph) except CheckException as error: @@ -286,7 +298,9 @@ def _teaql_preflight_graph(self, graph): ]) from error for index, child in enumerate(self._product_list): child._teaql_attach_root(self._entity_root) - child.update_commerce_platform(self) + current = getattr(child, "commercePlatform", None) + if getattr(current, "id", current) != self.id: + child.update_commerce_platform(self) try: child._teaql_preflight_graph(graph) except CheckException as error: @@ -297,7 +311,9 @@ def _teaql_preflight_graph(self, graph): ]) from error for index, child in enumerate(self._order_line_list): child._teaql_attach_root(self._entity_root) - child.update_commerce_platform(self) + current = getattr(child, "commercePlatform", None) + if getattr(current, "id", current) != self.id: + child.update_commerce_platform(self) try: child._teaql_preflight_graph(graph) except CheckException as error: @@ -308,7 +324,9 @@ def _teaql_preflight_graph(self, graph): ]) from error for index, child in enumerate(self._order_search_preset_list): child._teaql_attach_root(self._entity_root) - child.update_commerce_platform(self) + current = getattr(child, "commercePlatform", None) + if getattr(current, "id", current) != self.id: + child.update_commerce_platform(self) try: child._teaql_preflight_graph(graph) except CheckException as error: @@ -322,6 +340,10 @@ async def _teaql_save_within_graph(self, graph, parent_scope=None): context = graph.context scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) + if self._action == "Update" and not self._entity_root.has_pending(self._teaql_entity_key()): + await self._teaql_save_children(graph, scope) + return self + self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() @@ -393,39 +415,38 @@ def rollback_entity(): if action != "Delete": self._action = "Update" - cascade_relations = [] - cascade_relations.append(("customer_list", self._customer_list, "update_commerce_platform")) - cascade_relations.append(("order_status_list", self._order_status_list, "update_commerce_platform")) - cascade_relations.append(("customer_order_list", self._customer_order_list, "update_commerce_platform")) - cascade_relations.append(("product_list", self._product_list, "update_commerce_platform")) - cascade_relations.append(("order_line_list", self._order_line_list, "update_commerce_platform")) - cascade_relations.append(("order_search_preset_list", self._order_search_preset_list, "update_commerce_platform")) if action != "Delete": - for relation_name, children, updater in cascade_relations: - for index, child in enumerate(children): - child._teaql_attach_root(self._entity_root) - getattr(child, updater)(self) - try: - await child._teaql_save_within_graph(graph, scope) - except CheckException as error: - prefix = ObjectLocation().property(relation_name).index(index) - raise CheckException([ - CheckResult( - violation.rule_id, - violation.location.prefixed_by(prefix), - violation.input_value, - violation.system_value, - violation.message, - ) - for violation in error.violations - ]) from error + await self._teaql_save_children(graph, scope) def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: - self._entity_root.set_original_version(new_key, int(self.version)) + self._entity_root.accept_committed_version(new_key, int(self.version)) graph.after_commit(commit_entity) return self + async def _teaql_save_children(self, graph, scope): + cascade_relations = [] + cascade_relations.append(("customer_list", self._customer_list, "update_commerce_platform", "commercePlatform")) + cascade_relations.append(("order_status_list", self._order_status_list, "update_commerce_platform", "commercePlatform")) + cascade_relations.append(("customer_order_list", self._customer_order_list, "update_commerce_platform", "commercePlatform")) + cascade_relations.append(("product_list", self._product_list, "update_commerce_platform", "commercePlatform")) + cascade_relations.append(("order_line_list", self._order_line_list, "update_commerce_platform", "commercePlatform")) + cascade_relations.append(("order_search_preset_list", self._order_search_preset_list, "update_commerce_platform", "commercePlatform")) + for relation_name, children, updater, member in cascade_relations: + for index, child in enumerate(children): + child._teaql_attach_root(self._entity_root) + current = getattr(child, member, None) + if getattr(current, "id", current) != self.id: + getattr(child, updater)(self) + try: + await child._teaql_save_within_graph(graph, scope) + except CheckException as error: + prefix = ObjectLocation().property(relation_name).index(index) + raise CheckException([ + CheckResult(v.rule_id, v.location.prefixed_by(prefix), v.input_value, v.system_value, v.message) + for v in error.violations + ]) from error + def update_id(self, value): self.id = value self._loaded_fields.add("id") diff --git a/examples/order-management/python-lib-core/models/customer.py b/examples/order-management/python-lib-core/models/customer.py index d9742dc..9b4d7bf 100644 --- a/examples/order-management/python-lib-core/models/customer.py +++ b/examples/order-management/python-lib-core/models/customer.py @@ -63,7 +63,7 @@ def __init__(self, **kwargs): if self._customer_order_list: from models.customer_order import CustomerOrder self._customer_order_list = [ - item if isinstance(item, CustomerOrder) else CustomerOrder(**item) + item if isinstance(item, CustomerOrder) else CustomerOrder(_entity_root=self._entity_root, **item) for item in self._customer_order_list ] self._ledger_id = getattr(self, "id", None) @@ -79,8 +79,9 @@ def _teaql_entity_key(self): return EntityKey("Customer", self._ledger_id) def _teaql_attach_root(self, root): - if self._entity_root is not root: - root.merge_from(self._entity_root) + key = self._teaql_entity_key() + if self._entity_root is not root and self._entity_root.has_pending(key): + root.merge_entity_from(self._entity_root, key) self._entity_root = root for child in self._customer_order_list: child._teaql_attach_root(root) @@ -175,40 +176,47 @@ def _teaql_build_command(self): if action == "Create": cmd = InsertCommand("Customer", payload) elif action == "Update": - cmd = UpdateCommand("Customer", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = UpdateCommand("Customer", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) for key, value in payload.items(): if key not in ("id", "version"): cmd.value(key, value) else: - cmd = DeleteCommand("Customer", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = DeleteCommand("Customer", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) return action, cmd def _teaql_preflight_graph(self, graph): context = graph.context - if self._action == "Update": - if "id" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) - if "name" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("name"), message="Mutation requires a fully loaded entity")]) - if "email" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("email"), message="Mutation requires a fully loaded entity")]) - if "commercePlatform" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("commerce_platform"), message="Mutation requires a fully loaded entity")]) - if "createTime" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("create_time"), message="Mutation requires a fully loaded entity")]) - if "updateTime" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("update_time"), message="Mutation requires a fully loaded entity")]) - if "version" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) - _action, cmd = self._teaql_build_command() - try: - context.preflight_mutation(cmd) - finally: - for field, value in getattr(cmd, "values", {}).items(): - if field not in ("id", "version"): - self._entity_root.set(self._teaql_entity_key(), field, value) + if self._action != "Update" or self._entity_root.has_pending(self._teaql_entity_key()): + if self._action == "Update": + if "id" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) + if "name" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("name"), message="Mutation requires a fully loaded entity")]) + if "email" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("email"), message="Mutation requires a fully loaded entity")]) + if "commercePlatform" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("commerce_platform"), message="Mutation requires a fully loaded entity")]) + if "createTime" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("create_time"), message="Mutation requires a fully loaded entity")]) + if "updateTime" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("update_time"), message="Mutation requires a fully loaded entity")]) + if "version" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) + _action, cmd = self._teaql_build_command() + try: + context.preflight_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) for index, child in enumerate(self._customer_order_list): child._teaql_attach_root(self._entity_root) - child.update_customer(self) + current = getattr(child, "customer", None) + if getattr(current, "id", current) != self.id: + child.update_customer(self) try: child._teaql_preflight_graph(graph) except CheckException as error: @@ -222,6 +230,10 @@ async def _teaql_save_within_graph(self, graph, parent_scope=None): context = graph.context scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) + if self._action == "Update" and not self._entity_root.has_pending(self._teaql_entity_key()): + await self._teaql_save_children(graph, scope) + return self + self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() @@ -305,34 +317,33 @@ def rollback_entity(): if action != "Delete": self._action = "Update" - cascade_relations = [] - cascade_relations.append(("customer_order_list", self._customer_order_list, "update_customer")) if action != "Delete": - for relation_name, children, updater in cascade_relations: - for index, child in enumerate(children): - child._teaql_attach_root(self._entity_root) - getattr(child, updater)(self) - try: - await child._teaql_save_within_graph(graph, scope) - except CheckException as error: - prefix = ObjectLocation().property(relation_name).index(index) - raise CheckException([ - CheckResult( - violation.rule_id, - violation.location.prefixed_by(prefix), - violation.input_value, - violation.system_value, - violation.message, - ) - for violation in error.violations - ]) from error + await self._teaql_save_children(graph, scope) def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: - self._entity_root.set_original_version(new_key, int(self.version)) + self._entity_root.accept_committed_version(new_key, int(self.version)) graph.after_commit(commit_entity) return self + async def _teaql_save_children(self, graph, scope): + cascade_relations = [] + cascade_relations.append(("customer_order_list", self._customer_order_list, "update_customer", "customer")) + for relation_name, children, updater, member in cascade_relations: + for index, child in enumerate(children): + child._teaql_attach_root(self._entity_root) + current = getattr(child, member, None) + if getattr(current, "id", current) != self.id: + getattr(child, updater)(self) + try: + await child._teaql_save_within_graph(graph, scope) + except CheckException as error: + prefix = ObjectLocation().property(relation_name).index(index) + raise CheckException([ + CheckResult(v.rule_id, v.location.prefixed_by(prefix), v.input_value, v.system_value, v.message) + for v in error.violations + ]) from error + def update_id(self, value): self.id = value self._loaded_fields.add("id") diff --git a/examples/order-management/python-lib-core/models/customer_order.py b/examples/order-management/python-lib-core/models/customer_order.py index 4b4474f..0e6a67f 100644 --- a/examples/order-management/python-lib-core/models/customer_order.py +++ b/examples/order-management/python-lib-core/models/customer_order.py @@ -81,7 +81,7 @@ def __init__(self, **kwargs): if self._order_line_list: from models.order_line import OrderLine self._order_line_list = [ - item if isinstance(item, OrderLine) else OrderLine(**item) + item if isinstance(item, OrderLine) else OrderLine(_entity_root=self._entity_root, **item) for item in self._order_line_list ] self._ledger_id = getattr(self, "id", None) @@ -97,8 +97,9 @@ def _teaql_entity_key(self): return EntityKey("CustomerOrder", self._ledger_id) def _teaql_attach_root(self, root): - if self._entity_root is not root: - root.merge_from(self._entity_root) + key = self._teaql_entity_key() + if self._entity_root is not root and self._entity_root.has_pending(key): + root.merge_entity_from(self._entity_root, key) self._entity_root = root for child in self._order_line_list: child._teaql_attach_root(root) @@ -210,46 +211,53 @@ def _teaql_build_command(self): if action == "Create": cmd = InsertCommand("CustomerOrder", payload) elif action == "Update": - cmd = UpdateCommand("CustomerOrder", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = UpdateCommand("CustomerOrder", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) for key, value in payload.items(): if key not in ("id", "version"): cmd.value(key, value) else: - cmd = DeleteCommand("CustomerOrder", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = DeleteCommand("CustomerOrder", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) return action, cmd def _teaql_preflight_graph(self, graph): context = graph.context - if self._action == "Update": - if "id" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) - if "orderNumber" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("order_number"), message="Mutation requires a fully loaded entity")]) - if "orderDate" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("order_date"), message="Mutation requires a fully loaded entity")]) - if "totalAmount" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("total_amount"), message="Mutation requires a fully loaded entity")]) - if "status" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("status"), message="Mutation requires a fully loaded entity")]) - if "customer" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("customer"), message="Mutation requires a fully loaded entity")]) - if "commercePlatform" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("commerce_platform"), message="Mutation requires a fully loaded entity")]) - if "createTime" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("create_time"), message="Mutation requires a fully loaded entity")]) - if "updateTime" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("update_time"), message="Mutation requires a fully loaded entity")]) - if "version" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) - _action, cmd = self._teaql_build_command() - try: - context.preflight_mutation(cmd) - finally: - for field, value in getattr(cmd, "values", {}).items(): - if field not in ("id", "version"): - self._entity_root.set(self._teaql_entity_key(), field, value) + if self._action != "Update" or self._entity_root.has_pending(self._teaql_entity_key()): + if self._action == "Update": + if "id" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) + if "orderNumber" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("order_number"), message="Mutation requires a fully loaded entity")]) + if "orderDate" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("order_date"), message="Mutation requires a fully loaded entity")]) + if "totalAmount" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("total_amount"), message="Mutation requires a fully loaded entity")]) + if "status" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("status"), message="Mutation requires a fully loaded entity")]) + if "customer" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("customer"), message="Mutation requires a fully loaded entity")]) + if "commercePlatform" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("commerce_platform"), message="Mutation requires a fully loaded entity")]) + if "createTime" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("create_time"), message="Mutation requires a fully loaded entity")]) + if "updateTime" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("update_time"), message="Mutation requires a fully loaded entity")]) + if "version" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) + _action, cmd = self._teaql_build_command() + try: + context.preflight_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) for index, child in enumerate(self._order_line_list): child._teaql_attach_root(self._entity_root) - child.update_customer_order(self) + current = getattr(child, "customerOrder", None) + if getattr(current, "id", current) != self.id: + child.update_customer_order(self) try: child._teaql_preflight_graph(graph) except CheckException as error: @@ -263,6 +271,10 @@ async def _teaql_save_within_graph(self, graph, parent_scope=None): context = graph.context scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) + if self._action == "Update" and not self._entity_root.has_pending(self._teaql_entity_key()): + await self._teaql_save_children(graph, scope) + return self + self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() @@ -364,34 +376,33 @@ def rollback_entity(): if action != "Delete": self._action = "Update" - cascade_relations = [] - cascade_relations.append(("order_line_list", self._order_line_list, "update_customer_order")) if action != "Delete": - for relation_name, children, updater in cascade_relations: - for index, child in enumerate(children): - child._teaql_attach_root(self._entity_root) - getattr(child, updater)(self) - try: - await child._teaql_save_within_graph(graph, scope) - except CheckException as error: - prefix = ObjectLocation().property(relation_name).index(index) - raise CheckException([ - CheckResult( - violation.rule_id, - violation.location.prefixed_by(prefix), - violation.input_value, - violation.system_value, - violation.message, - ) - for violation in error.violations - ]) from error + await self._teaql_save_children(graph, scope) def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: - self._entity_root.set_original_version(new_key, int(self.version)) + self._entity_root.accept_committed_version(new_key, int(self.version)) graph.after_commit(commit_entity) return self + async def _teaql_save_children(self, graph, scope): + cascade_relations = [] + cascade_relations.append(("order_line_list", self._order_line_list, "update_customer_order", "customerOrder")) + for relation_name, children, updater, member in cascade_relations: + for index, child in enumerate(children): + child._teaql_attach_root(self._entity_root) + current = getattr(child, member, None) + if getattr(current, "id", current) != self.id: + getattr(child, updater)(self) + try: + await child._teaql_save_within_graph(graph, scope) + except CheckException as error: + prefix = ObjectLocation().property(relation_name).index(index) + raise CheckException([ + CheckResult(v.rule_id, v.location.prefixed_by(prefix), v.input_value, v.system_value, v.message) + for v in error.violations + ]) from error + def update_id(self, value): self.id = value self._loaded_fields.add("id") diff --git a/examples/order-management/python-lib-core/models/order_line.py b/examples/order-management/python-lib-core/models/order_line.py index 6f87ee0..f2b9624 100644 --- a/examples/order-management/python-lib-core/models/order_line.py +++ b/examples/order-management/python-lib-core/models/order_line.py @@ -84,8 +84,9 @@ def _teaql_entity_key(self): return EntityKey("OrderLine", self._ledger_id) def _teaql_attach_root(self, root): - if self._entity_root is not root: - root.merge_from(self._entity_root) + key = self._teaql_entity_key() + if self._entity_root is not root and self._entity_root.has_pending(key): + root.merge_entity_from(self._entity_root, key) self._entity_root = root return self def mark_for_deletion(self): @@ -189,46 +190,55 @@ def _teaql_build_command(self): if action == "Create": cmd = InsertCommand("OrderLine", payload) elif action == "Update": - cmd = UpdateCommand("OrderLine", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = UpdateCommand("OrderLine", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) for key, value in payload.items(): if key not in ("id", "version"): cmd.value(key, value) else: - cmd = DeleteCommand("OrderLine", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = DeleteCommand("OrderLine", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) return action, cmd def _teaql_preflight_graph(self, graph): context = graph.context - if self._action == "Update": - if "id" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) - if "customerOrder" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("customer_order"), message="Mutation requires a fully loaded entity")]) - if "product" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("product"), message="Mutation requires a fully loaded entity")]) - if "productName" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("product_name"), message="Mutation requires a fully loaded entity")]) - if "sku" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("sku"), message="Mutation requires a fully loaded entity")]) - if "quantity" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("quantity"), message="Mutation requires a fully loaded entity")]) - if "commercePlatform" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("commerce_platform"), message="Mutation requires a fully loaded entity")]) - if "createTime" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("create_time"), message="Mutation requires a fully loaded entity")]) - if "version" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) - _action, cmd = self._teaql_build_command() - try: - context.preflight_mutation(cmd) - finally: - for field, value in getattr(cmd, "values", {}).items(): - if field not in ("id", "version"): - self._entity_root.set(self._teaql_entity_key(), field, value) + if self._action != "Update" or self._entity_root.has_pending(self._teaql_entity_key()): + if self._action == "Update": + if "id" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) + if "customerOrder" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("customer_order"), message="Mutation requires a fully loaded entity")]) + if "product" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("product"), message="Mutation requires a fully loaded entity")]) + if "productName" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("product_name"), message="Mutation requires a fully loaded entity")]) + if "sku" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("sku"), message="Mutation requires a fully loaded entity")]) + if "quantity" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("quantity"), message="Mutation requires a fully loaded entity")]) + if "commercePlatform" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("commerce_platform"), message="Mutation requires a fully loaded entity")]) + if "createTime" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("create_time"), message="Mutation requires a fully loaded entity")]) + if "version" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) + _action, cmd = self._teaql_build_command() + try: + context.preflight_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) async def _teaql_save_within_graph(self, graph, parent_scope=None): context = graph.context scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) + if self._action == "Update" and not self._entity_root.has_pending(self._teaql_entity_key()): + await self._teaql_save_children(graph, scope) + return self + self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() @@ -324,33 +334,32 @@ def rollback_entity(): if action != "Delete": self._action = "Update" - cascade_relations = [] if action != "Delete": - for relation_name, children, updater in cascade_relations: - for index, child in enumerate(children): - child._teaql_attach_root(self._entity_root) - getattr(child, updater)(self) - try: - await child._teaql_save_within_graph(graph, scope) - except CheckException as error: - prefix = ObjectLocation().property(relation_name).index(index) - raise CheckException([ - CheckResult( - violation.rule_id, - violation.location.prefixed_by(prefix), - violation.input_value, - violation.system_value, - violation.message, - ) - for violation in error.violations - ]) from error + await self._teaql_save_children(graph, scope) def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: - self._entity_root.set_original_version(new_key, int(self.version)) + self._entity_root.accept_committed_version(new_key, int(self.version)) graph.after_commit(commit_entity) return self + async def _teaql_save_children(self, graph, scope): + cascade_relations = [] + for relation_name, children, updater, member in cascade_relations: + for index, child in enumerate(children): + child._teaql_attach_root(self._entity_root) + current = getattr(child, member, None) + if getattr(current, "id", current) != self.id: + getattr(child, updater)(self) + try: + await child._teaql_save_within_graph(graph, scope) + except CheckException as error: + prefix = ObjectLocation().property(relation_name).index(index) + raise CheckException([ + CheckResult(v.rule_id, v.location.prefixed_by(prefix), v.input_value, v.system_value, v.message) + for v in error.violations + ]) from error + def update_id(self, value): self.id = value self._loaded_fields.add("id") diff --git a/examples/order-management/python-lib-core/models/order_search_preset.py b/examples/order-management/python-lib-core/models/order_search_preset.py index 8d3b5fe..62bba0b 100644 --- a/examples/order-management/python-lib-core/models/order_search_preset.py +++ b/examples/order-management/python-lib-core/models/order_search_preset.py @@ -78,8 +78,9 @@ def _teaql_entity_key(self): return EntityKey("OrderSearchPreset", self._ledger_id) def _teaql_attach_root(self, root): - if self._entity_root is not root: - root.merge_from(self._entity_root) + key = self._teaql_entity_key() + if self._entity_root is not root and self._entity_root.has_pending(key): + root.merge_entity_from(self._entity_root, key) self._entity_root = root return self def mark_for_deletion(self): @@ -175,46 +176,55 @@ def _teaql_build_command(self): if action == "Create": cmd = InsertCommand("OrderSearchPreset", payload) elif action == "Update": - cmd = UpdateCommand("OrderSearchPreset", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = UpdateCommand("OrderSearchPreset", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) for key, value in payload.items(): if key not in ("id", "version"): cmd.value(key, value) else: - cmd = DeleteCommand("OrderSearchPreset", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = DeleteCommand("OrderSearchPreset", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) return action, cmd def _teaql_preflight_graph(self, graph): context = graph.context - if self._action == "Update": - if "id" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) - if "name" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("name"), message="Mutation requires a fully loaded entity")]) - if "filterJson" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("filter_json"), message="Mutation requires a fully loaded entity")]) - if "requestId" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("request_id"), message="Mutation requires a fully loaded entity")]) - if "ownerUserId" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("owner_user_id"), message="Mutation requires a fully loaded entity")]) - if "commercePlatform" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("commerce_platform"), message="Mutation requires a fully loaded entity")]) - if "createTime" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("create_time"), message="Mutation requires a fully loaded entity")]) - if "updateTime" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("update_time"), message="Mutation requires a fully loaded entity")]) - if "version" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) - _action, cmd = self._teaql_build_command() - try: - context.preflight_mutation(cmd) - finally: - for field, value in getattr(cmd, "values", {}).items(): - if field not in ("id", "version"): - self._entity_root.set(self._teaql_entity_key(), field, value) + if self._action != "Update" or self._entity_root.has_pending(self._teaql_entity_key()): + if self._action == "Update": + if "id" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) + if "name" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("name"), message="Mutation requires a fully loaded entity")]) + if "filterJson" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("filter_json"), message="Mutation requires a fully loaded entity")]) + if "requestId" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("request_id"), message="Mutation requires a fully loaded entity")]) + if "ownerUserId" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("owner_user_id"), message="Mutation requires a fully loaded entity")]) + if "commercePlatform" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("commerce_platform"), message="Mutation requires a fully loaded entity")]) + if "createTime" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("create_time"), message="Mutation requires a fully loaded entity")]) + if "updateTime" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("update_time"), message="Mutation requires a fully loaded entity")]) + if "version" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) + _action, cmd = self._teaql_build_command() + try: + context.preflight_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) async def _teaql_save_within_graph(self, graph, parent_scope=None): context = graph.context scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) + if self._action == "Update" and not self._entity_root.has_pending(self._teaql_entity_key()): + await self._teaql_save_children(graph, scope) + return self + self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() @@ -310,33 +320,32 @@ def rollback_entity(): if action != "Delete": self._action = "Update" - cascade_relations = [] if action != "Delete": - for relation_name, children, updater in cascade_relations: - for index, child in enumerate(children): - child._teaql_attach_root(self._entity_root) - getattr(child, updater)(self) - try: - await child._teaql_save_within_graph(graph, scope) - except CheckException as error: - prefix = ObjectLocation().property(relation_name).index(index) - raise CheckException([ - CheckResult( - violation.rule_id, - violation.location.prefixed_by(prefix), - violation.input_value, - violation.system_value, - violation.message, - ) - for violation in error.violations - ]) from error + await self._teaql_save_children(graph, scope) def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: - self._entity_root.set_original_version(new_key, int(self.version)) + self._entity_root.accept_committed_version(new_key, int(self.version)) graph.after_commit(commit_entity) return self + async def _teaql_save_children(self, graph, scope): + cascade_relations = [] + for relation_name, children, updater, member in cascade_relations: + for index, child in enumerate(children): + child._teaql_attach_root(self._entity_root) + current = getattr(child, member, None) + if getattr(current, "id", current) != self.id: + getattr(child, updater)(self) + try: + await child._teaql_save_within_graph(graph, scope) + except CheckException as error: + prefix = ObjectLocation().property(relation_name).index(index) + raise CheckException([ + CheckResult(v.rule_id, v.location.prefixed_by(prefix), v.input_value, v.system_value, v.message) + for v in error.violations + ]) from error + def update_id(self, value): self.id = value self._loaded_fields.add("id") diff --git a/examples/order-management/python-lib-core/models/order_status.py b/examples/order-management/python-lib-core/models/order_status.py index 8b6a744..0703e32 100644 --- a/examples/order-management/python-lib-core/models/order_status.py +++ b/examples/order-management/python-lib-core/models/order_status.py @@ -63,7 +63,7 @@ def __init__(self, **kwargs): if self._customer_order_list: from models.customer_order import CustomerOrder self._customer_order_list = [ - item if isinstance(item, CustomerOrder) else CustomerOrder(**item) + item if isinstance(item, CustomerOrder) else CustomerOrder(_entity_root=self._entity_root, **item) for item in self._customer_order_list ] self._ledger_id = getattr(self, "id", None) @@ -79,8 +79,9 @@ def _teaql_entity_key(self): return EntityKey("OrderStatus", self._ledger_id) def _teaql_attach_root(self, root): - if self._entity_root is not root: - root.merge_from(self._entity_root) + key = self._teaql_entity_key() + if self._entity_root is not root and self._entity_root.has_pending(key): + root.merge_entity_from(self._entity_root, key) self._entity_root = root for child in self._customer_order_list: child._teaql_attach_root(root) @@ -175,40 +176,47 @@ def _teaql_build_command(self): if action == "Create": cmd = InsertCommand("OrderStatus", payload) elif action == "Update": - cmd = UpdateCommand("OrderStatus", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = UpdateCommand("OrderStatus", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) for key, value in payload.items(): if key not in ("id", "version"): cmd.value(key, value) else: - cmd = DeleteCommand("OrderStatus", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = DeleteCommand("OrderStatus", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) return action, cmd def _teaql_preflight_graph(self, graph): context = graph.context - if self._action == "Update": - if "id" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) - if "name" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("name"), message="Mutation requires a fully loaded entity")]) - if "code" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("code"), message="Mutation requires a fully loaded entity")]) - if "color" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("color"), message="Mutation requires a fully loaded entity")]) - if "displayOrder" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("display_order"), message="Mutation requires a fully loaded entity")]) - if "commercePlatform" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("commerce_platform"), message="Mutation requires a fully loaded entity")]) - if "version" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) - _action, cmd = self._teaql_build_command() - try: - context.preflight_mutation(cmd) - finally: - for field, value in getattr(cmd, "values", {}).items(): - if field not in ("id", "version"): - self._entity_root.set(self._teaql_entity_key(), field, value) + if self._action != "Update" or self._entity_root.has_pending(self._teaql_entity_key()): + if self._action == "Update": + if "id" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) + if "name" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("name"), message="Mutation requires a fully loaded entity")]) + if "code" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("code"), message="Mutation requires a fully loaded entity")]) + if "color" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("color"), message="Mutation requires a fully loaded entity")]) + if "displayOrder" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("display_order"), message="Mutation requires a fully loaded entity")]) + if "commercePlatform" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("commerce_platform"), message="Mutation requires a fully loaded entity")]) + if "version" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) + _action, cmd = self._teaql_build_command() + try: + context.preflight_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) for index, child in enumerate(self._customer_order_list): child._teaql_attach_root(self._entity_root) - child.update_status(self) + current = getattr(child, "status", None) + if getattr(current, "id", current) != self.id: + child.update_status(self) try: child._teaql_preflight_graph(graph) except CheckException as error: @@ -222,6 +230,10 @@ async def _teaql_save_within_graph(self, graph, parent_scope=None): context = graph.context scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) + if self._action == "Update" and not self._entity_root.has_pending(self._teaql_entity_key()): + await self._teaql_save_children(graph, scope) + return self + self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() @@ -305,34 +317,33 @@ def rollback_entity(): if action != "Delete": self._action = "Update" - cascade_relations = [] - cascade_relations.append(("customer_order_list", self._customer_order_list, "update_status")) if action != "Delete": - for relation_name, children, updater in cascade_relations: - for index, child in enumerate(children): - child._teaql_attach_root(self._entity_root) - getattr(child, updater)(self) - try: - await child._teaql_save_within_graph(graph, scope) - except CheckException as error: - prefix = ObjectLocation().property(relation_name).index(index) - raise CheckException([ - CheckResult( - violation.rule_id, - violation.location.prefixed_by(prefix), - violation.input_value, - violation.system_value, - violation.message, - ) - for violation in error.violations - ]) from error + await self._teaql_save_children(graph, scope) def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: - self._entity_root.set_original_version(new_key, int(self.version)) + self._entity_root.accept_committed_version(new_key, int(self.version)) graph.after_commit(commit_entity) return self + async def _teaql_save_children(self, graph, scope): + cascade_relations = [] + cascade_relations.append(("customer_order_list", self._customer_order_list, "update_status", "status")) + for relation_name, children, updater, member in cascade_relations: + for index, child in enumerate(children): + child._teaql_attach_root(self._entity_root) + current = getattr(child, member, None) + if getattr(current, "id", current) != self.id: + getattr(child, updater)(self) + try: + await child._teaql_save_within_graph(graph, scope) + except CheckException as error: + prefix = ObjectLocation().property(relation_name).index(index) + raise CheckException([ + CheckResult(v.rule_id, v.location.prefixed_by(prefix), v.input_value, v.system_value, v.message) + for v in error.violations + ]) from error + def update_id(self, value): self.id = value self._loaded_fields.add("id") diff --git a/examples/order-management/python-lib-core/models/product.py b/examples/order-management/python-lib-core/models/product.py index 08e50e2..8623ebb 100644 --- a/examples/order-management/python-lib-core/models/product.py +++ b/examples/order-management/python-lib-core/models/product.py @@ -67,7 +67,7 @@ def __init__(self, **kwargs): if self._order_line_list: from models.order_line import OrderLine self._order_line_list = [ - item if isinstance(item, OrderLine) else OrderLine(**item) + item if isinstance(item, OrderLine) else OrderLine(_entity_root=self._entity_root, **item) for item in self._order_line_list ] self._ledger_id = getattr(self, "id", None) @@ -83,8 +83,9 @@ def _teaql_entity_key(self): return EntityKey("Product", self._ledger_id) def _teaql_attach_root(self, root): - if self._entity_root is not root: - root.merge_from(self._entity_root) + key = self._teaql_entity_key() + if self._entity_root is not root and self._entity_root.has_pending(key): + root.merge_entity_from(self._entity_root, key) self._entity_root = root for child in self._order_line_list: child._teaql_attach_root(root) @@ -182,42 +183,49 @@ def _teaql_build_command(self): if action == "Create": cmd = InsertCommand("Product", payload) elif action == "Update": - cmd = UpdateCommand("Product", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = UpdateCommand("Product", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) for key, value in payload.items(): if key not in ("id", "version"): cmd.value(key, value) else: - cmd = DeleteCommand("Product", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = DeleteCommand("Product", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) return action, cmd def _teaql_preflight_graph(self, graph): context = graph.context - if self._action == "Update": - if "id" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) - if "name" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("name"), message="Mutation requires a fully loaded entity")]) - if "sku" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("sku"), message="Mutation requires a fully loaded entity")]) - if "imageUrl" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("image_url"), message="Mutation requires a fully loaded entity")]) - if "commercePlatform" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("commerce_platform"), message="Mutation requires a fully loaded entity")]) - if "createTime" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("create_time"), message="Mutation requires a fully loaded entity")]) - if "updateTime" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("update_time"), message="Mutation requires a fully loaded entity")]) - if "version" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) - _action, cmd = self._teaql_build_command() - try: - context.preflight_mutation(cmd) - finally: - for field, value in getattr(cmd, "values", {}).items(): - if field not in ("id", "version"): - self._entity_root.set(self._teaql_entity_key(), field, value) + if self._action != "Update" or self._entity_root.has_pending(self._teaql_entity_key()): + if self._action == "Update": + if "id" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) + if "name" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("name"), message="Mutation requires a fully loaded entity")]) + if "sku" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("sku"), message="Mutation requires a fully loaded entity")]) + if "imageUrl" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("image_url"), message="Mutation requires a fully loaded entity")]) + if "commercePlatform" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("commerce_platform"), message="Mutation requires a fully loaded entity")]) + if "createTime" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("create_time"), message="Mutation requires a fully loaded entity")]) + if "updateTime" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("update_time"), message="Mutation requires a fully loaded entity")]) + if "version" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) + _action, cmd = self._teaql_build_command() + try: + context.preflight_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) for index, child in enumerate(self._order_line_list): child._teaql_attach_root(self._entity_root) - child.update_product(self) + current = getattr(child, "product", None) + if getattr(current, "id", current) != self.id: + child.update_product(self) try: child._teaql_preflight_graph(graph) except CheckException as error: @@ -231,6 +239,10 @@ async def _teaql_save_within_graph(self, graph, parent_scope=None): context = graph.context scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) + if self._action == "Update" and not self._entity_root.has_pending(self._teaql_entity_key()): + await self._teaql_save_children(graph, scope) + return self + self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() @@ -320,34 +332,33 @@ def rollback_entity(): if action != "Delete": self._action = "Update" - cascade_relations = [] - cascade_relations.append(("order_line_list", self._order_line_list, "update_product")) if action != "Delete": - for relation_name, children, updater in cascade_relations: - for index, child in enumerate(children): - child._teaql_attach_root(self._entity_root) - getattr(child, updater)(self) - try: - await child._teaql_save_within_graph(graph, scope) - except CheckException as error: - prefix = ObjectLocation().property(relation_name).index(index) - raise CheckException([ - CheckResult( - violation.rule_id, - violation.location.prefixed_by(prefix), - violation.input_value, - violation.system_value, - violation.message, - ) - for violation in error.violations - ]) from error + await self._teaql_save_children(graph, scope) def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: - self._entity_root.set_original_version(new_key, int(self.version)) + self._entity_root.accept_committed_version(new_key, int(self.version)) graph.after_commit(commit_entity) return self + async def _teaql_save_children(self, graph, scope): + cascade_relations = [] + cascade_relations.append(("order_line_list", self._order_line_list, "update_product", "product")) + for relation_name, children, updater, member in cascade_relations: + for index, child in enumerate(children): + child._teaql_attach_root(self._entity_root) + current = getattr(child, member, None) + if getattr(current, "id", current) != self.id: + getattr(child, updater)(self) + try: + await child._teaql_save_within_graph(graph, scope) + except CheckException as error: + prefix = ObjectLocation().property(relation_name).index(index) + raise CheckException([ + CheckResult(v.rule_id, v.location.prefixed_by(prefix), v.input_value, v.system_value, v.message) + for v in error.violations + ]) from error + def update_id(self, value): self.id = value self._loaded_fields.add("id") diff --git a/examples/order-management/python-lib-core/requests/commerce_platform_request.py b/examples/order-management/python-lib-core/requests/commerce_platform_request.py index aa70b5d..2c3557a 100644 --- a/examples/order-management/python-lib-core/requests/commerce_platform_request.py +++ b/examples/order-management/python-lib-core/requests/commerce_platform_request.py @@ -1,6 +1,5 @@ from teaql.core.query import RelationAggregate, SelectQuery from teaql.core.list import SmartList, TeaQLPage -from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest from teaql.core import QueryIntent from copy import deepcopy @@ -954,9 +953,8 @@ async def execute_for_rows(self, context): async def execute_for_list(self, context) -> SmartList[CommercePlatform]: result = await self.execute_for_result(context) - query_root = EntityRoot() return SmartList( - (CommercePlatform(_entity_root=query_root, **row) for row in result.rows), + (CommercePlatform(**row) for row in result.rows), facets=result.facets) async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[CommercePlatform]: @@ -983,8 +981,7 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) - query_root = EntityRoot() - data = SmartList(CommercePlatform(_entity_root=query_root, **row) for row in row_result.rows) + data = SmartList(CommercePlatform(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): @@ -1001,7 +998,6 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - query_root = EntityRoot() async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: - yield CommercePlatform(_entity_root=query_root, **row) + yield CommercePlatform(**row) diff --git a/examples/order-management/python-lib-core/requests/customer_order_request.py b/examples/order-management/python-lib-core/requests/customer_order_request.py index 9b86b82..dbc5fd3 100644 --- a/examples/order-management/python-lib-core/requests/customer_order_request.py +++ b/examples/order-management/python-lib-core/requests/customer_order_request.py @@ -1,6 +1,5 @@ from teaql.core.query import RelationAggregate, SelectQuery from teaql.core.list import SmartList, TeaQLPage -from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest from teaql.core import QueryIntent from copy import deepcopy @@ -882,9 +881,8 @@ async def execute_for_rows(self, context): async def execute_for_list(self, context) -> SmartList[CustomerOrder]: result = await self.execute_for_result(context) - query_root = EntityRoot() return SmartList( - (CustomerOrder(_entity_root=query_root, **row) for row in result.rows), + (CustomerOrder(**row) for row in result.rows), facets=result.facets) async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[CustomerOrder]: @@ -911,8 +909,7 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) - query_root = EntityRoot() - data = SmartList(CustomerOrder(_entity_root=query_root, **row) for row in row_result.rows) + data = SmartList(CustomerOrder(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): @@ -929,7 +926,6 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - query_root = EntityRoot() async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: - yield CustomerOrder(_entity_root=query_root, **row) + yield CustomerOrder(**row) diff --git a/examples/order-management/python-lib-core/requests/customer_request.py b/examples/order-management/python-lib-core/requests/customer_request.py index cb78d46..2e6c9e4 100644 --- a/examples/order-management/python-lib-core/requests/customer_request.py +++ b/examples/order-management/python-lib-core/requests/customer_request.py @@ -1,6 +1,5 @@ from teaql.core.query import RelationAggregate, SelectQuery from teaql.core.list import SmartList, TeaQLPage -from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest from teaql.core import QueryIntent from copy import deepcopy @@ -714,9 +713,8 @@ async def execute_for_rows(self, context): async def execute_for_list(self, context) -> SmartList[Customer]: result = await self.execute_for_result(context) - query_root = EntityRoot() return SmartList( - (Customer(_entity_root=query_root, **row) for row in result.rows), + (Customer(**row) for row in result.rows), facets=result.facets) async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[Customer]: @@ -743,8 +741,7 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) - query_root = EntityRoot() - data = SmartList(Customer(_entity_root=query_root, **row) for row in row_result.rows) + data = SmartList(Customer(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): @@ -761,7 +758,6 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - query_root = EntityRoot() async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: - yield Customer(_entity_root=query_root, **row) + yield Customer(**row) diff --git a/examples/order-management/python-lib-core/requests/order_line_request.py b/examples/order-management/python-lib-core/requests/order_line_request.py index fde4639..87785fc 100644 --- a/examples/order-management/python-lib-core/requests/order_line_request.py +++ b/examples/order-management/python-lib-core/requests/order_line_request.py @@ -1,6 +1,5 @@ from teaql.core.query import RelationAggregate, SelectQuery from teaql.core.list import SmartList, TeaQLPage -from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest from teaql.core import QueryIntent from copy import deepcopy @@ -714,9 +713,8 @@ async def execute_for_rows(self, context): async def execute_for_list(self, context) -> SmartList[OrderLine]: result = await self.execute_for_result(context) - query_root = EntityRoot() return SmartList( - (OrderLine(_entity_root=query_root, **row) for row in result.rows), + (OrderLine(**row) for row in result.rows), facets=result.facets) async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[OrderLine]: @@ -743,8 +741,7 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) - query_root = EntityRoot() - data = SmartList(OrderLine(_entity_root=query_root, **row) for row in row_result.rows) + data = SmartList(OrderLine(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): @@ -761,7 +758,6 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - query_root = EntityRoot() async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: - yield OrderLine(_entity_root=query_root, **row) + yield OrderLine(**row) diff --git a/examples/order-management/python-lib-core/requests/order_search_preset_request.py b/examples/order-management/python-lib-core/requests/order_search_preset_request.py index aa62b1a..c14adb3 100644 --- a/examples/order-management/python-lib-core/requests/order_search_preset_request.py +++ b/examples/order-management/python-lib-core/requests/order_search_preset_request.py @@ -1,6 +1,5 @@ from teaql.core.query import RelationAggregate, SelectQuery from teaql.core.list import SmartList, TeaQLPage -from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest from teaql.core import QueryIntent from copy import deepcopy @@ -768,9 +767,8 @@ async def execute_for_rows(self, context): async def execute_for_list(self, context) -> SmartList[OrderSearchPreset]: result = await self.execute_for_result(context) - query_root = EntityRoot() return SmartList( - (OrderSearchPreset(_entity_root=query_root, **row) for row in result.rows), + (OrderSearchPreset(**row) for row in result.rows), facets=result.facets) async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[OrderSearchPreset]: @@ -797,8 +795,7 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) - query_root = EntityRoot() - data = SmartList(OrderSearchPreset(_entity_root=query_root, **row) for row in row_result.rows) + data = SmartList(OrderSearchPreset(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): @@ -815,7 +812,6 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - query_root = EntityRoot() async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: - yield OrderSearchPreset(_entity_root=query_root, **row) + yield OrderSearchPreset(**row) diff --git a/examples/order-management/python-lib-core/requests/order_status_request.py b/examples/order-management/python-lib-core/requests/order_status_request.py index 58d1e66..7af441b 100644 --- a/examples/order-management/python-lib-core/requests/order_status_request.py +++ b/examples/order-management/python-lib-core/requests/order_status_request.py @@ -1,6 +1,5 @@ from teaql.core.query import RelationAggregate, SelectQuery from teaql.core.list import SmartList, TeaQLPage -from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest from teaql.core import QueryIntent from copy import deepcopy @@ -789,9 +788,8 @@ async def execute_for_rows(self, context): async def execute_for_list(self, context) -> SmartList[OrderStatus]: result = await self.execute_for_result(context) - query_root = EntityRoot() return SmartList( - (OrderStatus(_entity_root=query_root, **row) for row in result.rows), + (OrderStatus(**row) for row in result.rows), facets=result.facets) async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[OrderStatus]: @@ -818,8 +816,7 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) - query_root = EntityRoot() - data = SmartList(OrderStatus(_entity_root=query_root, **row) for row in row_result.rows) + data = SmartList(OrderStatus(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): @@ -836,7 +833,6 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - query_root = EntityRoot() async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: - yield OrderStatus(_entity_root=query_root, **row) + yield OrderStatus(**row) diff --git a/examples/order-management/python-lib-core/requests/product_request.py b/examples/order-management/python-lib-core/requests/product_request.py index 0113db3..d73b94b 100644 --- a/examples/order-management/python-lib-core/requests/product_request.py +++ b/examples/order-management/python-lib-core/requests/product_request.py @@ -1,6 +1,5 @@ from teaql.core.query import RelationAggregate, SelectQuery from teaql.core.list import SmartList, TeaQLPage -from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest from teaql.core import QueryIntent from copy import deepcopy @@ -804,9 +803,8 @@ async def execute_for_rows(self, context): async def execute_for_list(self, context) -> SmartList[Product]: result = await self.execute_for_result(context) - query_root = EntityRoot() return SmartList( - (Product(_entity_root=query_root, **row) for row in result.rows), + (Product(**row) for row in result.rows), facets=result.facets) async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[Product]: @@ -833,8 +831,7 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) - query_root = EntityRoot() - data = SmartList(Product(_entity_root=query_root, **row) for row in row_result.rows) + data = SmartList(Product(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): @@ -851,7 +848,6 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - query_root = EntityRoot() async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: - yield Product(_entity_root=query_root, **row) + yield Product(**row) diff --git a/examples/school-management/models/platform.py b/examples/school-management/models/platform.py index 7a53552..264d8ad 100644 --- a/examples/school-management/models/platform.py +++ b/examples/school-management/models/platform.py @@ -59,13 +59,13 @@ def __init__(self, **kwargs): if self._school_type_list: from models.school_type import SchoolType self._school_type_list = [ - item if isinstance(item, SchoolType) else SchoolType(**item) + item if isinstance(item, SchoolType) else SchoolType(_entity_root=self._entity_root, **item) for item in self._school_type_list ] if self._school_list: from models.school import School self._school_list = [ - item if isinstance(item, School) else School(**item) + item if isinstance(item, School) else School(_entity_root=self._entity_root, **item) for item in self._school_list ] self._ledger_id = getattr(self, "id", None) @@ -81,8 +81,9 @@ def _teaql_entity_key(self): return EntityKey("Platform", self._ledger_id) def _teaql_attach_root(self, root): - if self._entity_root is not root: - root.merge_from(self._entity_root) + key = self._teaql_entity_key() + if self._entity_root is not root and self._entity_root.has_pending(key): + root.merge_entity_from(self._entity_root, key) self._entity_root = root for child in self._school_type_list: child._teaql_attach_root(root) @@ -175,38 +176,45 @@ def _teaql_build_command(self): if action == "Create": cmd = InsertCommand("Platform", payload) elif action == "Update": - cmd = UpdateCommand("Platform", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = UpdateCommand("Platform", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) for key, value in payload.items(): if key not in ("id", "version"): cmd.value(key, value) else: - cmd = DeleteCommand("Platform", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = DeleteCommand("Platform", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) return action, cmd def _teaql_preflight_graph(self, graph): context = graph.context - if self._action == "Update": - if "id" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) - if "name" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("name"), message="Mutation requires a fully loaded entity")]) - if "baseUrl" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("base_url"), message="Mutation requires a fully loaded entity")]) - if "createTime" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("create_time"), message="Mutation requires a fully loaded entity")]) - if "updateTime" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("update_time"), message="Mutation requires a fully loaded entity")]) - if "version" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) - _action, cmd = self._teaql_build_command() - try: - context.preflight_mutation(cmd) - finally: - for field, value in getattr(cmd, "values", {}).items(): - if field not in ("id", "version"): - self._entity_root.set(self._teaql_entity_key(), field, value) + if self._action != "Update" or self._entity_root.has_pending(self._teaql_entity_key()): + if self._action == "Update": + if "id" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) + if "name" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("name"), message="Mutation requires a fully loaded entity")]) + if "baseUrl" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("base_url"), message="Mutation requires a fully loaded entity")]) + if "createTime" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("create_time"), message="Mutation requires a fully loaded entity")]) + if "updateTime" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("update_time"), message="Mutation requires a fully loaded entity")]) + if "version" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) + _action, cmd = self._teaql_build_command() + try: + context.preflight_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) for index, child in enumerate(self._school_type_list): child._teaql_attach_root(self._entity_root) - child.update_platform(self) + current = getattr(child, "platform", None) + if getattr(current, "id", current) != self.id: + child.update_platform(self) try: child._teaql_preflight_graph(graph) except CheckException as error: @@ -217,7 +225,9 @@ def _teaql_preflight_graph(self, graph): ]) from error for index, child in enumerate(self._school_list): child._teaql_attach_root(self._entity_root) - child.update_platform(self) + current = getattr(child, "platform", None) + if getattr(current, "id", current) != self.id: + child.update_platform(self) try: child._teaql_preflight_graph(graph) except CheckException as error: @@ -231,6 +241,10 @@ async def _teaql_save_within_graph(self, graph, parent_scope=None): context = graph.context scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) + if self._action == "Update" and not self._entity_root.has_pending(self._teaql_entity_key()): + await self._teaql_save_children(graph, scope) + return self + self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() @@ -308,35 +322,34 @@ def rollback_entity(): if action != "Delete": self._action = "Update" - cascade_relations = [] - cascade_relations.append(("school_type_list", self._school_type_list, "update_platform")) - cascade_relations.append(("school_list", self._school_list, "update_platform")) if action != "Delete": - for relation_name, children, updater in cascade_relations: - for index, child in enumerate(children): - child._teaql_attach_root(self._entity_root) - getattr(child, updater)(self) - try: - await child._teaql_save_within_graph(graph, scope) - except CheckException as error: - prefix = ObjectLocation().property(relation_name).index(index) - raise CheckException([ - CheckResult( - violation.rule_id, - violation.location.prefixed_by(prefix), - violation.input_value, - violation.system_value, - violation.message, - ) - for violation in error.violations - ]) from error + await self._teaql_save_children(graph, scope) def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: - self._entity_root.set_original_version(new_key, int(self.version)) + self._entity_root.accept_committed_version(new_key, int(self.version)) graph.after_commit(commit_entity) return self + async def _teaql_save_children(self, graph, scope): + cascade_relations = [] + cascade_relations.append(("school_type_list", self._school_type_list, "update_platform", "platform")) + cascade_relations.append(("school_list", self._school_list, "update_platform", "platform")) + for relation_name, children, updater, member in cascade_relations: + for index, child in enumerate(children): + child._teaql_attach_root(self._entity_root) + current = getattr(child, member, None) + if getattr(current, "id", current) != self.id: + getattr(child, updater)(self) + try: + await child._teaql_save_within_graph(graph, scope) + except CheckException as error: + prefix = ObjectLocation().property(relation_name).index(index) + raise CheckException([ + CheckResult(v.rule_id, v.location.prefixed_by(prefix), v.input_value, v.system_value, v.message) + for v in error.violations + ]) from error + def update_id(self, value): self.id = value self._loaded_fields.add("id") diff --git a/examples/school-management/models/school.py b/examples/school-management/models/school.py index 04c9079..b92d8e1 100644 --- a/examples/school-management/models/school.py +++ b/examples/school-management/models/school.py @@ -89,8 +89,9 @@ def _teaql_entity_key(self): return EntityKey("School", self._ledger_id) def _teaql_attach_root(self, root): - if self._entity_root is not root: - root.merge_from(self._entity_root) + key = self._teaql_entity_key() + if self._entity_root is not root and self._entity_root.has_pending(key): + root.merge_entity_from(self._entity_root, key) self._entity_root = root return self def mark_for_deletion(self): @@ -196,50 +197,59 @@ def _teaql_build_command(self): if action == "Create": cmd = InsertCommand("School", payload) elif action == "Update": - cmd = UpdateCommand("School", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = UpdateCommand("School", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) for key, value in payload.items(): if key not in ("id", "version"): cmd.value(key, value) else: - cmd = DeleteCommand("School", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = DeleteCommand("School", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) return action, cmd def _teaql_preflight_graph(self, graph): context = graph.context - if self._action == "Update": - if "id" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) - if "platform" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("platform"), message="Mutation requires a fully loaded entity")]) - if "schoolType" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("school_type"), message="Mutation requires a fully loaded entity")]) - if "name" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("name"), message="Mutation requires a fully loaded entity")]) - if "address" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("address"), message="Mutation requires a fully loaded entity")]) - if "establishedDate" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("established_date"), message="Mutation requires a fully loaded entity")]) - if "studentCapacity" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("student_capacity"), message="Mutation requires a fully loaded entity")]) - if "active" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("active"), message="Mutation requires a fully loaded entity")]) - if "createTime" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("create_time"), message="Mutation requires a fully loaded entity")]) - if "updateTime" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("update_time"), message="Mutation requires a fully loaded entity")]) - if "version" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) - _action, cmd = self._teaql_build_command() - try: - context.preflight_mutation(cmd) - finally: - for field, value in getattr(cmd, "values", {}).items(): - if field not in ("id", "version"): - self._entity_root.set(self._teaql_entity_key(), field, value) + if self._action != "Update" or self._entity_root.has_pending(self._teaql_entity_key()): + if self._action == "Update": + if "id" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) + if "platform" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("platform"), message="Mutation requires a fully loaded entity")]) + if "schoolType" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("school_type"), message="Mutation requires a fully loaded entity")]) + if "name" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("name"), message="Mutation requires a fully loaded entity")]) + if "address" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("address"), message="Mutation requires a fully loaded entity")]) + if "establishedDate" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("established_date"), message="Mutation requires a fully loaded entity")]) + if "studentCapacity" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("student_capacity"), message="Mutation requires a fully loaded entity")]) + if "active" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("active"), message="Mutation requires a fully loaded entity")]) + if "createTime" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("create_time"), message="Mutation requires a fully loaded entity")]) + if "updateTime" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("update_time"), message="Mutation requires a fully loaded entity")]) + if "version" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) + _action, cmd = self._teaql_build_command() + try: + context.preflight_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) async def _teaql_save_within_graph(self, graph, parent_scope=None): context = graph.context scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) + if self._action == "Update" and not self._entity_root.has_pending(self._teaql_entity_key()): + await self._teaql_save_children(graph, scope) + return self + self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() @@ -347,33 +357,32 @@ def rollback_entity(): if action != "Delete": self._action = "Update" - cascade_relations = [] if action != "Delete": - for relation_name, children, updater in cascade_relations: - for index, child in enumerate(children): - child._teaql_attach_root(self._entity_root) - getattr(child, updater)(self) - try: - await child._teaql_save_within_graph(graph, scope) - except CheckException as error: - prefix = ObjectLocation().property(relation_name).index(index) - raise CheckException([ - CheckResult( - violation.rule_id, - violation.location.prefixed_by(prefix), - violation.input_value, - violation.system_value, - violation.message, - ) - for violation in error.violations - ]) from error + await self._teaql_save_children(graph, scope) def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: - self._entity_root.set_original_version(new_key, int(self.version)) + self._entity_root.accept_committed_version(new_key, int(self.version)) graph.after_commit(commit_entity) return self + async def _teaql_save_children(self, graph, scope): + cascade_relations = [] + for relation_name, children, updater, member in cascade_relations: + for index, child in enumerate(children): + child._teaql_attach_root(self._entity_root) + current = getattr(child, member, None) + if getattr(current, "id", current) != self.id: + getattr(child, updater)(self) + try: + await child._teaql_save_within_graph(graph, scope) + except CheckException as error: + prefix = ObjectLocation().property(relation_name).index(index) + raise CheckException([ + CheckResult(v.rule_id, v.location.prefixed_by(prefix), v.input_value, v.system_value, v.message) + for v in error.violations + ]) from error + def update_id(self, value): self.id = value self._loaded_fields.add("id") diff --git a/examples/school-management/models/school_type.py b/examples/school-management/models/school_type.py index 3cf3c17..ca3f0ae 100644 --- a/examples/school-management/models/school_type.py +++ b/examples/school-management/models/school_type.py @@ -59,7 +59,7 @@ def __init__(self, **kwargs): if self._school_list: from models.school import School self._school_list = [ - item if isinstance(item, School) else School(**item) + item if isinstance(item, School) else School(_entity_root=self._entity_root, **item) for item in self._school_list ] self._ledger_id = getattr(self, "id", None) @@ -75,8 +75,9 @@ def _teaql_entity_key(self): return EntityKey("SchoolType", self._ledger_id) def _teaql_attach_root(self, root): - if self._entity_root is not root: - root.merge_from(self._entity_root) + key = self._teaql_entity_key() + if self._entity_root is not root and self._entity_root.has_pending(key): + root.merge_entity_from(self._entity_root, key) self._entity_root = root for child in self._school_list: child._teaql_attach_root(root) @@ -168,38 +169,45 @@ def _teaql_build_command(self): if action == "Create": cmd = InsertCommand("SchoolType", payload) elif action == "Update": - cmd = UpdateCommand("SchoolType", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = UpdateCommand("SchoolType", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) for key, value in payload.items(): if key not in ("id", "version"): cmd.value(key, value) else: - cmd = DeleteCommand("SchoolType", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = DeleteCommand("SchoolType", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) return action, cmd def _teaql_preflight_graph(self, graph): context = graph.context - if self._action == "Update": - if "platform" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("platform"), message="Mutation requires a fully loaded entity")]) - if "id" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) - if "name" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("name"), message="Mutation requires a fully loaded entity")]) - if "code" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("code"), message="Mutation requires a fully loaded entity")]) - if "displayOrder" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("display_order"), message="Mutation requires a fully loaded entity")]) - if "version" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) - _action, cmd = self._teaql_build_command() - try: - context.preflight_mutation(cmd) - finally: - for field, value in getattr(cmd, "values", {}).items(): - if field not in ("id", "version"): - self._entity_root.set(self._teaql_entity_key(), field, value) + if self._action != "Update" or self._entity_root.has_pending(self._teaql_entity_key()): + if self._action == "Update": + if "platform" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("platform"), message="Mutation requires a fully loaded entity")]) + if "id" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) + if "name" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("name"), message="Mutation requires a fully loaded entity")]) + if "code" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("code"), message="Mutation requires a fully loaded entity")]) + if "displayOrder" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("display_order"), message="Mutation requires a fully loaded entity")]) + if "version" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) + _action, cmd = self._teaql_build_command() + try: + context.preflight_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) for index, child in enumerate(self._school_list): child._teaql_attach_root(self._entity_root) - child.update_school_type(self) + current = getattr(child, "schoolType", None) + if getattr(current, "id", current) != self.id: + child.update_school_type(self) try: child._teaql_preflight_graph(graph) except CheckException as error: @@ -213,6 +221,10 @@ async def _teaql_save_within_graph(self, graph, parent_scope=None): context = graph.context scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) + if self._action == "Update" and not self._entity_root.has_pending(self._teaql_entity_key()): + await self._teaql_save_children(graph, scope) + return self + self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() @@ -290,34 +302,33 @@ def rollback_entity(): if action != "Delete": self._action = "Update" - cascade_relations = [] - cascade_relations.append(("school_list", self._school_list, "update_school_type")) if action != "Delete": - for relation_name, children, updater in cascade_relations: - for index, child in enumerate(children): - child._teaql_attach_root(self._entity_root) - getattr(child, updater)(self) - try: - await child._teaql_save_within_graph(graph, scope) - except CheckException as error: - prefix = ObjectLocation().property(relation_name).index(index) - raise CheckException([ - CheckResult( - violation.rule_id, - violation.location.prefixed_by(prefix), - violation.input_value, - violation.system_value, - violation.message, - ) - for violation in error.violations - ]) from error + await self._teaql_save_children(graph, scope) def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: - self._entity_root.set_original_version(new_key, int(self.version)) + self._entity_root.accept_committed_version(new_key, int(self.version)) graph.after_commit(commit_entity) return self + async def _teaql_save_children(self, graph, scope): + cascade_relations = [] + cascade_relations.append(("school_list", self._school_list, "update_school_type", "schoolType")) + for relation_name, children, updater, member in cascade_relations: + for index, child in enumerate(children): + child._teaql_attach_root(self._entity_root) + current = getattr(child, member, None) + if getattr(current, "id", current) != self.id: + getattr(child, updater)(self) + try: + await child._teaql_save_within_graph(graph, scope) + except CheckException as error: + prefix = ObjectLocation().property(relation_name).index(index) + raise CheckException([ + CheckResult(v.rule_id, v.location.prefixed_by(prefix), v.input_value, v.system_value, v.message) + for v in error.violations + ]) from error + def update_id(self, value): self.id = value self._loaded_fields.add("id") diff --git a/examples/school-management/requests/platform_request.py b/examples/school-management/requests/platform_request.py index 8857005..cf7d25b 100644 --- a/examples/school-management/requests/platform_request.py +++ b/examples/school-management/requests/platform_request.py @@ -1,6 +1,5 @@ from teaql.core.query import RelationAggregate, SelectQuery from teaql.core.list import SmartList, TeaQLPage -from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest from teaql.core import QueryIntent from copy import deepcopy @@ -801,9 +800,8 @@ async def execute_for_rows(self, context): async def execute_for_list(self, context) -> SmartList[Platform]: result = await self.execute_for_result(context) - query_root = EntityRoot() return SmartList( - (Platform(_entity_root=query_root, **row) for row in result.rows), + (Platform(**row) for row in result.rows), facets=result.facets) async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[Platform]: @@ -830,8 +828,7 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) - query_root = EntityRoot() - data = SmartList(Platform(_entity_root=query_root, **row) for row in row_result.rows) + data = SmartList(Platform(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): @@ -848,7 +845,6 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - query_root = EntityRoot() async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: - yield Platform(_entity_root=query_root, **row) + yield Platform(**row) diff --git a/examples/school-management/requests/school_request.py b/examples/school-management/requests/school_request.py index 14e9f59..413f4c0 100644 --- a/examples/school-management/requests/school_request.py +++ b/examples/school-management/requests/school_request.py @@ -1,6 +1,5 @@ from teaql.core.query import RelationAggregate, SelectQuery from teaql.core.list import SmartList, TeaQLPage -from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest from teaql.core import QueryIntent from copy import deepcopy @@ -870,9 +869,8 @@ async def execute_for_rows(self, context): async def execute_for_list(self, context) -> SmartList[School]: result = await self.execute_for_result(context) - query_root = EntityRoot() return SmartList( - (School(_entity_root=query_root, **row) for row in result.rows), + (School(**row) for row in result.rows), facets=result.facets) async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[School]: @@ -899,8 +897,7 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) - query_root = EntityRoot() - data = SmartList(School(_entity_root=query_root, **row) for row in row_result.rows) + data = SmartList(School(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): @@ -917,7 +914,6 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - query_root = EntityRoot() async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: - yield School(_entity_root=query_root, **row) + yield School(**row) diff --git a/examples/school-management/requests/school_type_request.py b/examples/school-management/requests/school_type_request.py index 5d374aa..4c27973 100644 --- a/examples/school-management/requests/school_type_request.py +++ b/examples/school-management/requests/school_type_request.py @@ -1,6 +1,5 @@ from teaql.core.query import RelationAggregate, SelectQuery from teaql.core.list import SmartList, TeaQLPage -from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest from teaql.core import QueryIntent from copy import deepcopy @@ -697,9 +696,8 @@ async def execute_for_rows(self, context): async def execute_for_list(self, context) -> SmartList[SchoolType]: result = await self.execute_for_result(context) - query_root = EntityRoot() return SmartList( - (SchoolType(_entity_root=query_root, **row) for row in result.rows), + (SchoolType(**row) for row in result.rows), facets=result.facets) async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[SchoolType]: @@ -726,8 +724,7 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) - query_root = EntityRoot() - data = SmartList(SchoolType(_entity_root=query_root, **row) for row in row_result.rows) + data = SmartList(SchoolType(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): @@ -744,7 +741,6 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - query_root = EntityRoot() async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: - yield SchoolType(_entity_root=query_root, **row) + yield SchoolType(**row) diff --git a/examples/task_board/generated/models/platform.py b/examples/task_board/generated/models/platform.py index 16c2c17..fb8747c 100644 --- a/examples/task_board/generated/models/platform.py +++ b/examples/task_board/generated/models/platform.py @@ -55,13 +55,13 @@ def __init__(self, **kwargs): if self._task_status_list: from models.task_status import TaskStatus self._task_status_list = [ - item if isinstance(item, TaskStatus) else TaskStatus(**item) + item if isinstance(item, TaskStatus) else TaskStatus(_entity_root=self._entity_root, **item) for item in self._task_status_list ] if self._task_list: from models.task import Task self._task_list = [ - item if isinstance(item, Task) else Task(**item) + item if isinstance(item, Task) else Task(_entity_root=self._entity_root, **item) for item in self._task_list ] self._ledger_id = getattr(self, "id", None) @@ -77,8 +77,9 @@ def _teaql_entity_key(self): return EntityKey("Platform", self._ledger_id) def _teaql_attach_root(self, root): - if self._entity_root is not root: - root.merge_from(self._entity_root) + key = self._teaql_entity_key() + if self._entity_root is not root and self._entity_root.has_pending(key): + root.merge_entity_from(self._entity_root, key) self._entity_root = root for child in self._task_status_list: child._teaql_attach_root(root) @@ -168,36 +169,43 @@ def _teaql_build_command(self): if action == "Create": cmd = InsertCommand("Platform", payload) elif action == "Update": - cmd = UpdateCommand("Platform", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = UpdateCommand("Platform", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) for key, value in payload.items(): if key not in ("id", "version"): cmd.value(key, value) else: - cmd = DeleteCommand("Platform", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = DeleteCommand("Platform", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) return action, cmd def _teaql_preflight_graph(self, graph): context = graph.context - if self._action == "Update": - if "id" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) - if "name" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("name"), message="Mutation requires a fully loaded entity")]) - if "founded" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("founded"), message="Mutation requires a fully loaded entity")]) - if "userEmail" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("user_email"), message="Mutation requires a fully loaded entity")]) - if "version" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) - _action, cmd = self._teaql_build_command() - try: - context.preflight_mutation(cmd) - finally: - for field, value in getattr(cmd, "values", {}).items(): - if field not in ("id", "version"): - self._entity_root.set(self._teaql_entity_key(), field, value) + if self._action != "Update" or self._entity_root.has_pending(self._teaql_entity_key()): + if self._action == "Update": + if "id" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) + if "name" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("name"), message="Mutation requires a fully loaded entity")]) + if "founded" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("founded"), message="Mutation requires a fully loaded entity")]) + if "userEmail" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("user_email"), message="Mutation requires a fully loaded entity")]) + if "version" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) + _action, cmd = self._teaql_build_command() + try: + context.preflight_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) for index, child in enumerate(self._task_status_list): child._teaql_attach_root(self._entity_root) - child.update_platform(self) + current = getattr(child, "platform", None) + if getattr(current, "id", current) != self.id: + child.update_platform(self) try: child._teaql_preflight_graph(graph) except CheckException as error: @@ -208,7 +216,9 @@ def _teaql_preflight_graph(self, graph): ]) from error for index, child in enumerate(self._task_list): child._teaql_attach_root(self._entity_root) - child.update_platform(self) + current = getattr(child, "platform", None) + if getattr(current, "id", current) != self.id: + child.update_platform(self) try: child._teaql_preflight_graph(graph) except CheckException as error: @@ -222,6 +232,10 @@ async def _teaql_save_within_graph(self, graph, parent_scope=None): context = graph.context scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) + if self._action == "Update" and not self._entity_root.has_pending(self._teaql_entity_key()): + await self._teaql_save_children(graph, scope) + return self + self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() @@ -293,35 +307,34 @@ def rollback_entity(): if action != "Delete": self._action = "Update" - cascade_relations = [] - cascade_relations.append(("task_status_list", self._task_status_list, "update_platform")) - cascade_relations.append(("task_list", self._task_list, "update_platform")) if action != "Delete": - for relation_name, children, updater in cascade_relations: - for index, child in enumerate(children): - child._teaql_attach_root(self._entity_root) - getattr(child, updater)(self) - try: - await child._teaql_save_within_graph(graph, scope) - except CheckException as error: - prefix = ObjectLocation().property(relation_name).index(index) - raise CheckException([ - CheckResult( - violation.rule_id, - violation.location.prefixed_by(prefix), - violation.input_value, - violation.system_value, - violation.message, - ) - for violation in error.violations - ]) from error + await self._teaql_save_children(graph, scope) def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: - self._entity_root.set_original_version(new_key, int(self.version)) + self._entity_root.accept_committed_version(new_key, int(self.version)) graph.after_commit(commit_entity) return self + async def _teaql_save_children(self, graph, scope): + cascade_relations = [] + cascade_relations.append(("task_status_list", self._task_status_list, "update_platform", "platform")) + cascade_relations.append(("task_list", self._task_list, "update_platform", "platform")) + for relation_name, children, updater, member in cascade_relations: + for index, child in enumerate(children): + child._teaql_attach_root(self._entity_root) + current = getattr(child, member, None) + if getattr(current, "id", current) != self.id: + getattr(child, updater)(self) + try: + await child._teaql_save_within_graph(graph, scope) + except CheckException as error: + prefix = ObjectLocation().property(relation_name).index(index) + raise CheckException([ + CheckResult(v.rule_id, v.location.prefixed_by(prefix), v.input_value, v.system_value, v.message) + for v in error.violations + ]) from error + def update_id(self, value): self.id = value self._loaded_fields.add("id") diff --git a/examples/task_board/generated/models/task.py b/examples/task_board/generated/models/task.py index 061810d..c56b8a8 100644 --- a/examples/task_board/generated/models/task.py +++ b/examples/task_board/generated/models/task.py @@ -58,7 +58,7 @@ def __init__(self, **kwargs): if self._task_execution_log_list: from models.task_execution_log import TaskExecutionLog self._task_execution_log_list = [ - item if isinstance(item, TaskExecutionLog) else TaskExecutionLog(**item) + item if isinstance(item, TaskExecutionLog) else TaskExecutionLog(_entity_root=self._entity_root, **item) for item in self._task_execution_log_list ] self._ledger_id = getattr(self, "id", None) @@ -74,8 +74,9 @@ def _teaql_entity_key(self): return EntityKey("Task", self._ledger_id) def _teaql_attach_root(self, root): - if self._entity_root is not root: - root.merge_from(self._entity_root) + key = self._teaql_entity_key() + if self._entity_root is not root and self._entity_root.has_pending(key): + root.merge_entity_from(self._entity_root, key) self._entity_root = root for child in self._task_execution_log_list: child._teaql_attach_root(root) @@ -168,36 +169,43 @@ def _teaql_build_command(self): if action == "Create": cmd = InsertCommand("Task", payload) elif action == "Update": - cmd = UpdateCommand("Task", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = UpdateCommand("Task", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) for key, value in payload.items(): if key not in ("id", "version"): cmd.value(key, value) else: - cmd = DeleteCommand("Task", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = DeleteCommand("Task", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) return action, cmd def _teaql_preflight_graph(self, graph): context = graph.context - if self._action == "Update": - if "id" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) - if "name" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("name"), message="Mutation requires a fully loaded entity")]) - if "status" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("status"), message="Mutation requires a fully loaded entity")]) - if "platform" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("platform"), message="Mutation requires a fully loaded entity")]) - if "version" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) - _action, cmd = self._teaql_build_command() - try: - context.preflight_mutation(cmd) - finally: - for field, value in getattr(cmd, "values", {}).items(): - if field not in ("id", "version"): - self._entity_root.set(self._teaql_entity_key(), field, value) + if self._action != "Update" or self._entity_root.has_pending(self._teaql_entity_key()): + if self._action == "Update": + if "id" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) + if "name" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("name"), message="Mutation requires a fully loaded entity")]) + if "status" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("status"), message="Mutation requires a fully loaded entity")]) + if "platform" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("platform"), message="Mutation requires a fully loaded entity")]) + if "version" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) + _action, cmd = self._teaql_build_command() + try: + context.preflight_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) for index, child in enumerate(self._task_execution_log_list): child._teaql_attach_root(self._entity_root) - child.update_task(self) + current = getattr(child, "task", None) + if getattr(current, "id", current) != self.id: + child.update_task(self) try: child._teaql_preflight_graph(graph) except CheckException as error: @@ -211,6 +219,10 @@ async def _teaql_save_within_graph(self, graph, parent_scope=None): context = graph.context scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) + if self._action == "Update" and not self._entity_root.has_pending(self._teaql_entity_key()): + await self._teaql_save_children(graph, scope) + return self + self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() @@ -282,34 +294,33 @@ def rollback_entity(): if action != "Delete": self._action = "Update" - cascade_relations = [] - cascade_relations.append(("task_execution_log_list", self._task_execution_log_list, "update_task")) if action != "Delete": - for relation_name, children, updater in cascade_relations: - for index, child in enumerate(children): - child._teaql_attach_root(self._entity_root) - getattr(child, updater)(self) - try: - await child._teaql_save_within_graph(graph, scope) - except CheckException as error: - prefix = ObjectLocation().property(relation_name).index(index) - raise CheckException([ - CheckResult( - violation.rule_id, - violation.location.prefixed_by(prefix), - violation.input_value, - violation.system_value, - violation.message, - ) - for violation in error.violations - ]) from error + await self._teaql_save_children(graph, scope) def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: - self._entity_root.set_original_version(new_key, int(self.version)) + self._entity_root.accept_committed_version(new_key, int(self.version)) graph.after_commit(commit_entity) return self + async def _teaql_save_children(self, graph, scope): + cascade_relations = [] + cascade_relations.append(("task_execution_log_list", self._task_execution_log_list, "update_task", "task")) + for relation_name, children, updater, member in cascade_relations: + for index, child in enumerate(children): + child._teaql_attach_root(self._entity_root) + current = getattr(child, member, None) + if getattr(current, "id", current) != self.id: + getattr(child, updater)(self) + try: + await child._teaql_save_within_graph(graph, scope) + except CheckException as error: + prefix = ObjectLocation().property(relation_name).index(index) + raise CheckException([ + CheckResult(v.rule_id, v.location.prefixed_by(prefix), v.input_value, v.system_value, v.message) + for v in error.violations + ]) from error + def update_id(self, value): self.id = value self._loaded_fields.add("id") diff --git a/examples/task_board/generated/models/task_execution_log.py b/examples/task_board/generated/models/task_execution_log.py index d455db5..cc6b57e 100644 --- a/examples/task_board/generated/models/task_execution_log.py +++ b/examples/task_board/generated/models/task_execution_log.py @@ -62,8 +62,9 @@ def _teaql_entity_key(self): return EntityKey("TaskExecutionLog", self._ledger_id) def _teaql_attach_root(self, root): - if self._entity_root is not root: - root.merge_from(self._entity_root) + key = self._teaql_entity_key() + if self._entity_root is not root and self._entity_root.has_pending(key): + root.merge_entity_from(self._entity_root, key) self._entity_root = root return self def mark_for_deletion(self): @@ -147,38 +148,47 @@ def _teaql_build_command(self): if action == "Create": cmd = InsertCommand("TaskExecutionLog", payload) elif action == "Update": - cmd = UpdateCommand("TaskExecutionLog", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = UpdateCommand("TaskExecutionLog", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) for key, value in payload.items(): if key not in ("id", "version"): cmd.value(key, value) else: - cmd = DeleteCommand("TaskExecutionLog", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = DeleteCommand("TaskExecutionLog", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) return action, cmd def _teaql_preflight_graph(self, graph): context = graph.context - if self._action == "Update": - if "id" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) - if "task" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("task"), message="Mutation requires a fully loaded entity")]) - if "action" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("action"), message="Mutation requires a fully loaded entity")]) - if "detail" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("detail"), message="Mutation requires a fully loaded entity")]) - if "version" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) - _action, cmd = self._teaql_build_command() - try: - context.preflight_mutation(cmd) - finally: - for field, value in getattr(cmd, "values", {}).items(): - if field not in ("id", "version"): - self._entity_root.set(self._teaql_entity_key(), field, value) + if self._action != "Update" or self._entity_root.has_pending(self._teaql_entity_key()): + if self._action == "Update": + if "id" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) + if "task" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("task"), message="Mutation requires a fully loaded entity")]) + if "action" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("action"), message="Mutation requires a fully loaded entity")]) + if "detail" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("detail"), message="Mutation requires a fully loaded entity")]) + if "version" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) + _action, cmd = self._teaql_build_command() + try: + context.preflight_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) async def _teaql_save_within_graph(self, graph, parent_scope=None): context = graph.context scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) + if self._action == "Update" and not self._entity_root.has_pending(self._teaql_entity_key()): + await self._teaql_save_children(graph, scope) + return self + self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() @@ -250,33 +260,32 @@ def rollback_entity(): if action != "Delete": self._action = "Update" - cascade_relations = [] if action != "Delete": - for relation_name, children, updater in cascade_relations: - for index, child in enumerate(children): - child._teaql_attach_root(self._entity_root) - getattr(child, updater)(self) - try: - await child._teaql_save_within_graph(graph, scope) - except CheckException as error: - prefix = ObjectLocation().property(relation_name).index(index) - raise CheckException([ - CheckResult( - violation.rule_id, - violation.location.prefixed_by(prefix), - violation.input_value, - violation.system_value, - violation.message, - ) - for violation in error.violations - ]) from error + await self._teaql_save_children(graph, scope) def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: - self._entity_root.set_original_version(new_key, int(self.version)) + self._entity_root.accept_committed_version(new_key, int(self.version)) graph.after_commit(commit_entity) return self + async def _teaql_save_children(self, graph, scope): + cascade_relations = [] + for relation_name, children, updater, member in cascade_relations: + for index, child in enumerate(children): + child._teaql_attach_root(self._entity_root) + current = getattr(child, member, None) + if getattr(current, "id", current) != self.id: + getattr(child, updater)(self) + try: + await child._teaql_save_within_graph(graph, scope) + except CheckException as error: + prefix = ObjectLocation().property(relation_name).index(index) + raise CheckException([ + CheckResult(v.rule_id, v.location.prefixed_by(prefix), v.input_value, v.system_value, v.message) + for v in error.violations + ]) from error + def update_id(self, value): self.id = value self._loaded_fields.add("id") diff --git a/examples/task_board/generated/models/task_status.py b/examples/task_board/generated/models/task_status.py index f5de881..bf959af 100644 --- a/examples/task_board/generated/models/task_status.py +++ b/examples/task_board/generated/models/task_status.py @@ -67,7 +67,7 @@ def __init__(self, **kwargs): if self._task_list: from models.task import Task self._task_list = [ - item if isinstance(item, Task) else Task(**item) + item if isinstance(item, Task) else Task(_entity_root=self._entity_root, **item) for item in self._task_list ] self._ledger_id = getattr(self, "id", None) @@ -83,8 +83,9 @@ def _teaql_entity_key(self): return EntityKey("TaskStatus", self._ledger_id) def _teaql_attach_root(self, root): - if self._entity_root is not root: - root.merge_from(self._entity_root) + key = self._teaql_entity_key() + if self._entity_root is not root and self._entity_root.has_pending(key): + root.merge_entity_from(self._entity_root, key) self._entity_root = root for child in self._task_list: child._teaql_attach_root(root) @@ -182,42 +183,49 @@ def _teaql_build_command(self): if action == "Create": cmd = InsertCommand("TaskStatus", payload) elif action == "Update": - cmd = UpdateCommand("TaskStatus", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = UpdateCommand("TaskStatus", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) for key, value in payload.items(): if key not in ("id", "version"): cmd.value(key, value) else: - cmd = DeleteCommand("TaskStatus", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = DeleteCommand("TaskStatus", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) return action, cmd def _teaql_preflight_graph(self, graph): context = graph.context - if self._action == "Update": - if "id" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) - if "name" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("name"), message="Mutation requires a fully loaded entity")]) - if "code" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("code"), message="Mutation requires a fully loaded entity")]) - if "color" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("color"), message="Mutation requires a fully loaded entity")]) - if "displayOrder" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("display_order"), message="Mutation requires a fully loaded entity")]) - if "progress" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("progress"), message="Mutation requires a fully loaded entity")]) - if "platform" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("platform"), message="Mutation requires a fully loaded entity")]) - if "version" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) - _action, cmd = self._teaql_build_command() - try: - context.preflight_mutation(cmd) - finally: - for field, value in getattr(cmd, "values", {}).items(): - if field not in ("id", "version"): - self._entity_root.set(self._teaql_entity_key(), field, value) + if self._action != "Update" or self._entity_root.has_pending(self._teaql_entity_key()): + if self._action == "Update": + if "id" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) + if "name" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("name"), message="Mutation requires a fully loaded entity")]) + if "code" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("code"), message="Mutation requires a fully loaded entity")]) + if "color" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("color"), message="Mutation requires a fully loaded entity")]) + if "displayOrder" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("display_order"), message="Mutation requires a fully loaded entity")]) + if "progress" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("progress"), message="Mutation requires a fully loaded entity")]) + if "platform" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("platform"), message="Mutation requires a fully loaded entity")]) + if "version" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) + _action, cmd = self._teaql_build_command() + try: + context.preflight_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) for index, child in enumerate(self._task_list): child._teaql_attach_root(self._entity_root) - child.update_status(self) + current = getattr(child, "status", None) + if getattr(current, "id", current) != self.id: + child.update_status(self) try: child._teaql_preflight_graph(graph) except CheckException as error: @@ -231,6 +239,10 @@ async def _teaql_save_within_graph(self, graph, parent_scope=None): context = graph.context scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) + if self._action == "Update" and not self._entity_root.has_pending(self._teaql_entity_key()): + await self._teaql_save_children(graph, scope) + return self + self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() @@ -320,34 +332,33 @@ def rollback_entity(): if action != "Delete": self._action = "Update" - cascade_relations = [] - cascade_relations.append(("task_list", self._task_list, "update_status")) if action != "Delete": - for relation_name, children, updater in cascade_relations: - for index, child in enumerate(children): - child._teaql_attach_root(self._entity_root) - getattr(child, updater)(self) - try: - await child._teaql_save_within_graph(graph, scope) - except CheckException as error: - prefix = ObjectLocation().property(relation_name).index(index) - raise CheckException([ - CheckResult( - violation.rule_id, - violation.location.prefixed_by(prefix), - violation.input_value, - violation.system_value, - violation.message, - ) - for violation in error.violations - ]) from error + await self._teaql_save_children(graph, scope) def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: - self._entity_root.set_original_version(new_key, int(self.version)) + self._entity_root.accept_committed_version(new_key, int(self.version)) graph.after_commit(commit_entity) return self + async def _teaql_save_children(self, graph, scope): + cascade_relations = [] + cascade_relations.append(("task_list", self._task_list, "update_status", "status")) + for relation_name, children, updater, member in cascade_relations: + for index, child in enumerate(children): + child._teaql_attach_root(self._entity_root) + current = getattr(child, member, None) + if getattr(current, "id", current) != self.id: + getattr(child, updater)(self) + try: + await child._teaql_save_within_graph(graph, scope) + except CheckException as error: + prefix = ObjectLocation().property(relation_name).index(index) + raise CheckException([ + CheckResult(v.rule_id, v.location.prefixed_by(prefix), v.input_value, v.system_value, v.message) + for v in error.violations + ]) from error + def update_id(self, value): self.id = value self._loaded_fields.add("id") diff --git a/examples/task_board/generated/requests/platform_request.py b/examples/task_board/generated/requests/platform_request.py index f7fc641..3be8eae 100644 --- a/examples/task_board/generated/requests/platform_request.py +++ b/examples/task_board/generated/requests/platform_request.py @@ -1,6 +1,5 @@ from teaql.core.query import RelationAggregate, SelectQuery from teaql.core.list import SmartList, TeaQLPage -from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest from teaql.core import QueryIntent from copy import deepcopy @@ -739,9 +738,8 @@ async def execute_for_rows(self, context): async def execute_for_list(self, context) -> SmartList[Platform]: result = await self.execute_for_result(context) - query_root = EntityRoot() return SmartList( - (Platform(_entity_root=query_root, **row) for row in result.rows), + (Platform(**row) for row in result.rows), facets=result.facets) async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[Platform]: @@ -768,8 +766,7 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) - query_root = EntityRoot() - data = SmartList(Platform(_entity_root=query_root, **row) for row in row_result.rows) + data = SmartList(Platform(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): @@ -786,7 +783,6 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - query_root = EntityRoot() async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: - yield Platform(_entity_root=query_root, **row) + yield Platform(**row) diff --git a/examples/task_board/generated/requests/task_execution_log_request.py b/examples/task_board/generated/requests/task_execution_log_request.py index 07ab42b..e3fc9cf 100644 --- a/examples/task_board/generated/requests/task_execution_log_request.py +++ b/examples/task_board/generated/requests/task_execution_log_request.py @@ -1,6 +1,5 @@ from teaql.core.query import RelationAggregate, SelectQuery from teaql.core.list import SmartList, TeaQLPage -from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest from teaql.core import QueryIntent from copy import deepcopy @@ -462,9 +461,8 @@ async def execute_for_rows(self, context): async def execute_for_list(self, context) -> SmartList[TaskExecutionLog]: result = await self.execute_for_result(context) - query_root = EntityRoot() return SmartList( - (TaskExecutionLog(_entity_root=query_root, **row) for row in result.rows), + (TaskExecutionLog(**row) for row in result.rows), facets=result.facets) async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[TaskExecutionLog]: @@ -491,8 +489,7 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) - query_root = EntityRoot() - data = SmartList(TaskExecutionLog(_entity_root=query_root, **row) for row in row_result.rows) + data = SmartList(TaskExecutionLog(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): @@ -509,7 +506,6 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - query_root = EntityRoot() async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: - yield TaskExecutionLog(_entity_root=query_root, **row) + yield TaskExecutionLog(**row) diff --git a/examples/task_board/generated/requests/task_request.py b/examples/task_board/generated/requests/task_request.py index fd651d4..e05aedc 100644 --- a/examples/task_board/generated/requests/task_request.py +++ b/examples/task_board/generated/requests/task_request.py @@ -1,6 +1,5 @@ from teaql.core.query import RelationAggregate, SelectQuery from teaql.core.list import SmartList, TeaQLPage -from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest from teaql.core import QueryIntent from copy import deepcopy @@ -462,9 +461,8 @@ async def execute_for_rows(self, context): async def execute_for_list(self, context) -> SmartList[Task]: result = await self.execute_for_result(context) - query_root = EntityRoot() return SmartList( - (Task(_entity_root=query_root, **row) for row in result.rows), + (Task(**row) for row in result.rows), facets=result.facets) async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[Task]: @@ -491,8 +489,7 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) - query_root = EntityRoot() - data = SmartList(Task(_entity_root=query_root, **row) for row in row_result.rows) + data = SmartList(Task(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): @@ -509,7 +506,6 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - query_root = EntityRoot() async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: - yield Task(_entity_root=query_root, **row) + yield Task(**row) diff --git a/examples/task_board/generated/requests/task_status_request.py b/examples/task_board/generated/requests/task_status_request.py index f73ebcd..5d1b5f0 100644 --- a/examples/task_board/generated/requests/task_status_request.py +++ b/examples/task_board/generated/requests/task_status_request.py @@ -1,6 +1,5 @@ from teaql.core.query import RelationAggregate, SelectQuery from teaql.core.list import SmartList, TeaQLPage -from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest from teaql.core import QueryIntent from copy import deepcopy @@ -813,9 +812,8 @@ async def execute_for_rows(self, context): async def execute_for_list(self, context) -> SmartList[TaskStatus]: result = await self.execute_for_result(context) - query_root = EntityRoot() return SmartList( - (TaskStatus(_entity_root=query_root, **row) for row in result.rows), + (TaskStatus(**row) for row in result.rows), facets=result.facets) async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[TaskStatus]: @@ -842,8 +840,7 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) - query_root = EntityRoot() - data = SmartList(TaskStatus(_entity_root=query_root, **row) for row in row_result.rows) + data = SmartList(TaskStatus(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): @@ -860,7 +857,6 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - query_root = EntityRoot() async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: - yield TaskStatus(_entity_root=query_root, **row) + yield TaskStatus(**row) diff --git a/examples/trace-chain/README.md b/examples/trace-chain/README.md index 92071fa..ab6696b 100644 --- a/examples/trace-chain/README.md +++ b/examples/trace-chain/README.md @@ -12,10 +12,12 @@ cd /path/to/teaql-python bash examples/trace-chain/verify.sh ``` -The verifier uses this repository's runtime source, executes twice on one unique -SQLite file without cleanup, and checks the generated library hashes afterward. -Set `TEAQL_TRACE_CHAIN_DB` to retain and replay a particular database. It uses -unique business labels; it does not delete existing demo records. +The verifier uses this repository's runtime source and executes both suites +twice without cleanup, then checks the generated library hashes. The normative +graph and ownership fixtures require separate SQLite files because their graph +sizes differ. Set `TEAQL_TRACE_CHAIN_DB` and `TEAQL_TRACE_CHAIN_SHARED_DB` to retain +and replay those files. Each suite reuses its file across both runs. Unique +business labels avoid deleting existing demo records. Seven scenario groups verify missing root reason before transaction access, six-item branch/deletion lineage at request/SQL/audit boundaries, Q/E reload and @@ -23,6 +25,16 @@ retained soft deletion, three relation levels with inherited query intent, two concurrent graph saves in one Context, failed mutation, and failed authoritative readback. Failures retain SQL evidence and emit no committed audits. +Four additional ownership cases use generated Q/E/save: two independent loaded +roots share the same provider-returned Platform record without sharing mutable +ledgers; adoption imports only a reached changed child; a clean ancestor emits +no write while retaining its changed child's audit scope; and conflicting loaded +versions reject before business SQL. Public async saves overlap but transactions +and Checker preparation serialize at the Context gate. The shared record is a +mutable dictionary deliberately used read-only and checked unchanged, not a +structurally immutable snapshot. JSON observations retain actual commands, +optimistic versions, SQL metadata and committed audits. + The model evaluation reports zero errors, warnings and suggestions, with seventeen Solids. Current model-aware Assist and evaluation evidence are retained under `evidence/`. `AGENTS.md` governs application implementation. The library is diff --git a/examples/trace-chain/evidence/assist/python-assist-delete/customer_order.md b/examples/trace-chain/evidence/assist/python-assist-delete/customer_order.md index da526d7..0954fe7 100644 --- a/examples/trace-chain/evidence/assist/python-assist-delete/customer_order.md +++ b/examples/trace-chain/evidence/assist/python-assist-delete/customer_order.md @@ -61,7 +61,7 @@ model-aware Assist. Do not inspect generated domain-library source. Capability: `delete`. -- Load the tenant-scoped current entity and use the generated hard-delete or - domain-specific soft-delete API; do not invent a deletion method. +- Load the policy-scoped current entity, mark it for deletion, then use audited + save with the same UserContext. Do not invent a physical-delete API. - Require an audit reason and optimistic version. Test missing audit and stale version as explicit failures. diff --git a/examples/trace-chain/evidence/assist/python-assist-delete/order_item.md b/examples/trace-chain/evidence/assist/python-assist-delete/order_item.md index 80bbf93..a163ea4 100644 --- a/examples/trace-chain/evidence/assist/python-assist-delete/order_item.md +++ b/examples/trace-chain/evidence/assist/python-assist-delete/order_item.md @@ -61,7 +61,7 @@ model-aware Assist. Do not inspect generated domain-library source. Capability: `delete`. -- Load the tenant-scoped current entity and use the generated hard-delete or - domain-specific soft-delete API; do not invent a deletion method. +- Load the policy-scoped current entity, mark it for deletion, then use audited + save with the same UserContext. Do not invent a physical-delete API. - Require an audit reason and optimistic version. Test missing audit and stale version as explicit failures. diff --git a/examples/trace-chain/evidence/assist/python-assist-delete/payment.md b/examples/trace-chain/evidence/assist/python-assist-delete/payment.md index 9ad287a..ef93b65 100644 --- a/examples/trace-chain/evidence/assist/python-assist-delete/payment.md +++ b/examples/trace-chain/evidence/assist/python-assist-delete/payment.md @@ -61,7 +61,7 @@ model-aware Assist. Do not inspect generated domain-library source. Capability: `delete`. -- Load the tenant-scoped current entity and use the generated hard-delete or - domain-specific soft-delete API; do not invent a deletion method. +- Load the policy-scoped current entity, mark it for deletion, then use audited + save with the same UserContext. Do not invent a physical-delete API. - Require an audit reason and optimistic version. Test missing audit and stale version as explicit failures. diff --git a/examples/trace-chain/evidence/assist/python-assist-delete/payment_attempt.md b/examples/trace-chain/evidence/assist/python-assist-delete/payment_attempt.md index 03b11c4..8fc3fca 100644 --- a/examples/trace-chain/evidence/assist/python-assist-delete/payment_attempt.md +++ b/examples/trace-chain/evidence/assist/python-assist-delete/payment_attempt.md @@ -61,7 +61,7 @@ model-aware Assist. Do not inspect generated domain-library source. Capability: `delete`. -- Load the tenant-scoped current entity and use the generated hard-delete or - domain-specific soft-delete API; do not invent a deletion method. +- Load the policy-scoped current entity, mark it for deletion, then use audited + save with the same UserContext. Do not invent a physical-delete API. - Require an audit reason and optimistic version. Test missing audit and stale version as explicit failures. diff --git a/examples/trace-chain/evidence/assist/python-assist-delete/platform.md b/examples/trace-chain/evidence/assist/python-assist-delete/platform.md index 26323c1..37243ea 100644 --- a/examples/trace-chain/evidence/assist/python-assist-delete/platform.md +++ b/examples/trace-chain/evidence/assist/python-assist-delete/platform.md @@ -61,7 +61,7 @@ model-aware Assist. Do not inspect generated domain-library source. Capability: `delete`. -- Load the tenant-scoped current entity and use the generated hard-delete or - domain-specific soft-delete API; do not invent a deletion method. +- Load the policy-scoped current entity, mark it for deletion, then use audited + save with the same UserContext. Do not invent a physical-delete API. - Require an audit reason and optimistic version. Test missing audit and stale version as explicit failures. diff --git a/examples/trace-chain/evidence/assist/python-assist-delete/shipment.md b/examples/trace-chain/evidence/assist/python-assist-delete/shipment.md index a7191c8..c89e451 100644 --- a/examples/trace-chain/evidence/assist/python-assist-delete/shipment.md +++ b/examples/trace-chain/evidence/assist/python-assist-delete/shipment.md @@ -61,7 +61,7 @@ model-aware Assist. Do not inspect generated domain-library source. Capability: `delete`. -- Load the tenant-scoped current entity and use the generated hard-delete or - domain-specific soft-delete API; do not invent a deletion method. +- Load the policy-scoped current entity, mark it for deletion, then use audited + save with the same UserContext. Do not invent a physical-delete API. - Require an audit reason and optimistic version. Test missing audit and stale version as explicit failures. diff --git a/examples/trace-chain/evidence/assist/python-assist-query/customer_order.id.md b/examples/trace-chain/evidence/assist/python-assist-query/customer_order.id.md new file mode 100644 index 0000000..2770507 --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-query/customer_order.id.md @@ -0,0 +1,72 @@ + + +# Python Query Field Assist — `customer_order.id` + +KSML entity: `Customer Order` +KSML field: `id` +Type: `id` + +| Capability | Generated API | +| --- | --- | +| Select | `select_id()` | +| Equality/set | `with_id_is(value)`, `with_id_is_not(value)`, `with_id_in(*values)`, `with_id_not_in(*values)` | +| Comparison | `with_id_greater_than(value)`, `with_id_greater_than_or_equal_to(value)`, `with_id_less_than(value)`, `with_id_less_than_or_equal_to(value)`, `with_id_between(lower, upper)` | +| Null state | `with_id_is_known()`, `with_id_is_unknown()` | +| Order | `order_by_id_ascending()`, `order_by_id_descending()` | +| Group | `group_by_id()`, `group_by_id_as(alias)` | +Set predicates accept positional values. Pass separate arguments, for example +`with_id_in(first, second)`, or unpack a sequence with +`with_id_in(*values)`. Do not pass a list as one argument. +Execution requires `comment(...)`, `purpose(...)`, and exactly one `context`. + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `query`. + +- Use generated projection, relation, predicate, sort, and aggregate APIs; never + concatenate application SQL or silently ignore an unsupported filter. +- Rows, record count, facets, and aggregates must share the same active filter. +- Add negative tests for missing purpose/comment and forbidden dynamic fields. + +### Optional per-parent Top-N optimization + +- Configure `top_n_probe_parent_threshold(threshold)` on the nested child request before its intent/execution + stage. Use the field-specific relation Assist for the exact generated selector. +- Give the child a per-parent limit and deterministic ordering with an ID tie-breaker; + bound the parent query separately. A single-parent detail query is not batch Top-N. +- Server providers default to a window query (`threshold = 0`). A positive threshold + permits bounded probes only when the already-loaded parent count is at or below it; + above it, use the window plan. SQLite uses its internal AlwaysProbe policy by default. +- Measure before opting in. Do not infer a threshold from table size or execute COUNT + or statistics queries to choose a plan. Keep an index on foreign key + sort + ID. +- Inspect selected-plan, parent-count, per-parent-limit and probe-count telemetry. + Runtime-managed probes are not application-owned N+1 loops; neither strategy changes + authorization, loaded-state semantics or the required comment/purpose. diff --git a/examples/trace-chain/evidence/assist/python-assist-query/customer_order.order_number.md b/examples/trace-chain/evidence/assist/python-assist-query/customer_order.order_number.md new file mode 100644 index 0000000..2954022 --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-query/customer_order.order_number.md @@ -0,0 +1,73 @@ + + +# Python Query Field Assist — `customer_order.order_number` + +KSML entity: `Customer Order` +KSML field: `order_number` +Type: `string` + +| Capability | Generated API | +| --- | --- | +| Select | `select_order_number()` | +| Equality/set | `with_order_number_is(value)`, `with_order_number_is_not(value)`, `with_order_number_in(*values)`, `with_order_number_not_in(*values)` | +| Comparison | `with_order_number_greater_than(value)`, `with_order_number_greater_than_or_equal_to(value)`, `with_order_number_less_than(value)`, `with_order_number_less_than_or_equal_to(value)`, `with_order_number_between(lower, upper)` | +| Null state | `with_order_number_is_known()`, `with_order_number_is_unknown()` | +| String | `with_order_number_containing(value)`, `with_order_number_not_containing(value)`, `with_order_number_starting_with(value)`, `with_order_number_ending_with(value)`, `with_order_number_sounding_like(value)` | +| Order | `order_by_order_number_ascending()`, `order_by_order_number_descending()` | +| Group | `group_by_order_number()`, `group_by_order_number_as(alias)` | +Set predicates accept positional values. Pass separate arguments, for example +`with_order_number_in(first, second)`, or unpack a sequence with +`with_order_number_in(*values)`. Do not pass a list as one argument. +Execution requires `comment(...)`, `purpose(...)`, and exactly one `context`. + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `query`. + +- Use generated projection, relation, predicate, sort, and aggregate APIs; never + concatenate application SQL or silently ignore an unsupported filter. +- Rows, record count, facets, and aggregates must share the same active filter. +- Add negative tests for missing purpose/comment and forbidden dynamic fields. + +### Optional per-parent Top-N optimization + +- Configure `top_n_probe_parent_threshold(threshold)` on the nested child request before its intent/execution + stage. Use the field-specific relation Assist for the exact generated selector. +- Give the child a per-parent limit and deterministic ordering with an ID tie-breaker; + bound the parent query separately. A single-parent detail query is not batch Top-N. +- Server providers default to a window query (`threshold = 0`). A positive threshold + permits bounded probes only when the already-loaded parent count is at or below it; + above it, use the window plan. SQLite uses its internal AlwaysProbe policy by default. +- Measure before opting in. Do not infer a threshold from table size or execute COUNT + or statistics queries to choose a plan. Keep an index on foreign key + sort + ID. +- Inspect selected-plan, parent-count, per-parent-limit and probe-count telemetry. + Runtime-managed probes are not application-owned N+1 loops; neither strategy changes + authorization, loaded-state semantics or the required comment/purpose. diff --git a/examples/trace-chain/evidence/assist/python-assist-query/payment.reference_code.md b/examples/trace-chain/evidence/assist/python-assist-query/payment.reference_code.md index 71773c4..2f9c634 100644 --- a/examples/trace-chain/evidence/assist/python-assist-query/payment.reference_code.md +++ b/examples/trace-chain/evidence/assist/python-assist-query/payment.reference_code.md @@ -9,12 +9,15 @@ Type: `string` | Capability | Generated API | | --- | --- | | Select | `select_reference_code()` | -| Equality/set | `with_reference_code_is(value)`, `with_reference_code_is_not(value)`, `with_reference_code_in(values)`, `with_reference_code_not_in(values)` | +| Equality/set | `with_reference_code_is(value)`, `with_reference_code_is_not(value)`, `with_reference_code_in(*values)`, `with_reference_code_not_in(*values)` | | Comparison | `with_reference_code_greater_than(value)`, `with_reference_code_greater_than_or_equal_to(value)`, `with_reference_code_less_than(value)`, `with_reference_code_less_than_or_equal_to(value)`, `with_reference_code_between(lower, upper)` | | Null state | `with_reference_code_is_known()`, `with_reference_code_is_unknown()` | | String | `with_reference_code_containing(value)`, `with_reference_code_not_containing(value)`, `with_reference_code_starting_with(value)`, `with_reference_code_ending_with(value)`, `with_reference_code_sounding_like(value)` | | Order | `order_by_reference_code_ascending()`, `order_by_reference_code_descending()` | | Group | `group_by_reference_code()`, `group_by_reference_code_as(alias)` | +Set predicates accept positional values. Pass separate arguments, for example +`with_reference_code_in(first, second)`, or unpack a sequence with +`with_reference_code_in(*values)`. Do not pass a list as one argument. Execution requires `comment(...)`, `purpose(...)`, and exactly one `context`. --- diff --git a/examples/trace-chain/evidence/assist/python-assist-query/payment_attempt.reference_code.md b/examples/trace-chain/evidence/assist/python-assist-query/payment_attempt.reference_code.md index b754893..b23f56e 100644 --- a/examples/trace-chain/evidence/assist/python-assist-query/payment_attempt.reference_code.md +++ b/examples/trace-chain/evidence/assist/python-assist-query/payment_attempt.reference_code.md @@ -9,12 +9,15 @@ Type: `string` | Capability | Generated API | | --- | --- | | Select | `select_reference_code()` | -| Equality/set | `with_reference_code_is(value)`, `with_reference_code_is_not(value)`, `with_reference_code_in(values)`, `with_reference_code_not_in(values)` | +| Equality/set | `with_reference_code_is(value)`, `with_reference_code_is_not(value)`, `with_reference_code_in(*values)`, `with_reference_code_not_in(*values)` | | Comparison | `with_reference_code_greater_than(value)`, `with_reference_code_greater_than_or_equal_to(value)`, `with_reference_code_less_than(value)`, `with_reference_code_less_than_or_equal_to(value)`, `with_reference_code_between(lower, upper)` | | Null state | `with_reference_code_is_known()`, `with_reference_code_is_unknown()` | | String | `with_reference_code_containing(value)`, `with_reference_code_not_containing(value)`, `with_reference_code_starting_with(value)`, `with_reference_code_ending_with(value)`, `with_reference_code_sounding_like(value)` | | Order | `order_by_reference_code_ascending()`, `order_by_reference_code_descending()` | | Group | `group_by_reference_code()`, `group_by_reference_code_as(alias)` | +Set predicates accept positional values. Pass separate arguments, for example +`with_reference_code_in(first, second)`, or unpack a sequence with +`with_reference_code_in(*values)`. Do not pass a list as one argument. Execution requires `comment(...)`, `purpose(...)`, and exactly one `context`. --- diff --git a/examples/trace-chain/lib/models/customer_order.py b/examples/trace-chain/lib/models/customer_order.py index 6c88fd4..7b39bb9 100644 --- a/examples/trace-chain/lib/models/customer_order.py +++ b/examples/trace-chain/lib/models/customer_order.py @@ -61,19 +61,19 @@ def __init__(self, **kwargs): if self._order_item_list: from models.order_item import OrderItem self._order_item_list = [ - item if isinstance(item, OrderItem) else OrderItem(**item) + item if isinstance(item, OrderItem) else OrderItem(_entity_root=self._entity_root, **item) for item in self._order_item_list ] if self._payment_list: from models.payment import Payment self._payment_list = [ - item if isinstance(item, Payment) else Payment(**item) + item if isinstance(item, Payment) else Payment(_entity_root=self._entity_root, **item) for item in self._payment_list ] if self._shipment_list: from models.shipment import Shipment self._shipment_list = [ - item if isinstance(item, Shipment) else Shipment(**item) + item if isinstance(item, Shipment) else Shipment(_entity_root=self._entity_root, **item) for item in self._shipment_list ] self._ledger_id = getattr(self, "id", None) @@ -89,8 +89,9 @@ def _teaql_entity_key(self): return EntityKey("CustomerOrder", self._ledger_id) def _teaql_attach_root(self, root): - if self._entity_root is not root: - root.merge_from(self._entity_root) + key = self._teaql_entity_key() + if self._entity_root is not root and self._entity_root.has_pending(key): + root.merge_entity_from(self._entity_root, key) self._entity_root = root for child in self._order_item_list: child._teaql_attach_root(root) @@ -189,36 +190,43 @@ def _teaql_build_command(self): if action == "Create": cmd = InsertCommand("CustomerOrder", payload) elif action == "Update": - cmd = UpdateCommand("CustomerOrder", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = UpdateCommand("CustomerOrder", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) for key, value in payload.items(): if key not in ("id", "version"): cmd.value(key, value) else: - cmd = DeleteCommand("CustomerOrder", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = DeleteCommand("CustomerOrder", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) return action, cmd def _teaql_preflight_graph(self, graph): context = graph.context - if self._action == "Update": - if "id" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) - if "platform" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("platform"), message="Mutation requires a fully loaded entity")]) - if "orderNumber" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("order_number"), message="Mutation requires a fully loaded entity")]) - if "description" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("description"), message="Mutation requires a fully loaded entity")]) - if "version" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) - _action, cmd = self._teaql_build_command() - try: - context.preflight_mutation(cmd) - finally: - for field, value in getattr(cmd, "values", {}).items(): - if field not in ("id", "version"): - self._entity_root.set(self._teaql_entity_key(), field, value) + if self._action != "Update" or self._entity_root.has_pending(self._teaql_entity_key()): + if self._action == "Update": + if "id" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) + if "platform" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("platform"), message="Mutation requires a fully loaded entity")]) + if "orderNumber" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("order_number"), message="Mutation requires a fully loaded entity")]) + if "description" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("description"), message="Mutation requires a fully loaded entity")]) + if "version" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) + _action, cmd = self._teaql_build_command() + try: + context.preflight_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) for index, child in enumerate(self._order_item_list): child._teaql_attach_root(self._entity_root) - child.update_customer_order(self) + current = getattr(child, "customerOrder", None) + if getattr(current, "id", current) != self.id: + child.update_customer_order(self) try: child._teaql_preflight_graph(graph) except CheckException as error: @@ -229,7 +237,9 @@ def _teaql_preflight_graph(self, graph): ]) from error for index, child in enumerate(self._payment_list): child._teaql_attach_root(self._entity_root) - child.update_customer_order(self) + current = getattr(child, "customerOrder", None) + if getattr(current, "id", current) != self.id: + child.update_customer_order(self) try: child._teaql_preflight_graph(graph) except CheckException as error: @@ -240,7 +250,9 @@ def _teaql_preflight_graph(self, graph): ]) from error for index, child in enumerate(self._shipment_list): child._teaql_attach_root(self._entity_root) - child.update_customer_order(self) + current = getattr(child, "customerOrder", None) + if getattr(current, "id", current) != self.id: + child.update_customer_order(self) try: child._teaql_preflight_graph(graph) except CheckException as error: @@ -254,6 +266,10 @@ async def _teaql_save_within_graph(self, graph, parent_scope=None): context = graph.context scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) + if self._action == "Update" and not self._entity_root.has_pending(self._teaql_entity_key()): + await self._teaql_save_children(graph, scope) + return self + self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() @@ -325,36 +341,35 @@ def rollback_entity(): if action != "Delete": self._action = "Update" - cascade_relations = [] - cascade_relations.append(("order_item_list", self._order_item_list, "update_customer_order")) - cascade_relations.append(("payment_list", self._payment_list, "update_customer_order")) - cascade_relations.append(("shipment_list", self._shipment_list, "update_customer_order")) if action != "Delete": - for relation_name, children, updater in cascade_relations: - for index, child in enumerate(children): - child._teaql_attach_root(self._entity_root) - getattr(child, updater)(self) - try: - await child._teaql_save_within_graph(graph, scope) - except CheckException as error: - prefix = ObjectLocation().property(relation_name).index(index) - raise CheckException([ - CheckResult( - violation.rule_id, - violation.location.prefixed_by(prefix), - violation.input_value, - violation.system_value, - violation.message, - ) - for violation in error.violations - ]) from error + await self._teaql_save_children(graph, scope) def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: - self._entity_root.set_original_version(new_key, int(self.version)) + self._entity_root.accept_committed_version(new_key, int(self.version)) graph.after_commit(commit_entity) return self + async def _teaql_save_children(self, graph, scope): + cascade_relations = [] + cascade_relations.append(("order_item_list", self._order_item_list, "update_customer_order", "customerOrder")) + cascade_relations.append(("payment_list", self._payment_list, "update_customer_order", "customerOrder")) + cascade_relations.append(("shipment_list", self._shipment_list, "update_customer_order", "customerOrder")) + for relation_name, children, updater, member in cascade_relations: + for index, child in enumerate(children): + child._teaql_attach_root(self._entity_root) + current = getattr(child, member, None) + if getattr(current, "id", current) != self.id: + getattr(child, updater)(self) + try: + await child._teaql_save_within_graph(graph, scope) + except CheckException as error: + prefix = ObjectLocation().property(relation_name).index(index) + raise CheckException([ + CheckResult(v.rule_id, v.location.prefixed_by(prefix), v.input_value, v.system_value, v.message) + for v in error.violations + ]) from error + def update_id(self, value): self.id = value self._loaded_fields.add("id") diff --git a/examples/trace-chain/lib/models/order_item.py b/examples/trace-chain/lib/models/order_item.py index 02f382c..90c4cb0 100644 --- a/examples/trace-chain/lib/models/order_item.py +++ b/examples/trace-chain/lib/models/order_item.py @@ -58,8 +58,9 @@ def _teaql_entity_key(self): return EntityKey("OrderItem", self._ledger_id) def _teaql_attach_root(self, root): - if self._entity_root is not root: - root.merge_from(self._entity_root) + key = self._teaql_entity_key() + if self._entity_root is not root and self._entity_root.has_pending(key): + root.merge_entity_from(self._entity_root, key) self._entity_root = root return self def mark_for_deletion(self): @@ -140,36 +141,45 @@ def _teaql_build_command(self): if action == "Create": cmd = InsertCommand("OrderItem", payload) elif action == "Update": - cmd = UpdateCommand("OrderItem", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = UpdateCommand("OrderItem", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) for key, value in payload.items(): if key not in ("id", "version"): cmd.value(key, value) else: - cmd = DeleteCommand("OrderItem", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = DeleteCommand("OrderItem", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) return action, cmd def _teaql_preflight_graph(self, graph): context = graph.context - if self._action == "Update": - if "id" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) - if "customerOrder" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("customer_order"), message="Mutation requires a fully loaded entity")]) - if "name" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("name"), message="Mutation requires a fully loaded entity")]) - if "version" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) - _action, cmd = self._teaql_build_command() - try: - context.preflight_mutation(cmd) - finally: - for field, value in getattr(cmd, "values", {}).items(): - if field not in ("id", "version"): - self._entity_root.set(self._teaql_entity_key(), field, value) + if self._action != "Update" or self._entity_root.has_pending(self._teaql_entity_key()): + if self._action == "Update": + if "id" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) + if "customerOrder" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("customer_order"), message="Mutation requires a fully loaded entity")]) + if "name" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("name"), message="Mutation requires a fully loaded entity")]) + if "version" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) + _action, cmd = self._teaql_build_command() + try: + context.preflight_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) async def _teaql_save_within_graph(self, graph, parent_scope=None): context = graph.context scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) + if self._action == "Update" and not self._entity_root.has_pending(self._teaql_entity_key()): + await self._teaql_save_children(graph, scope) + return self + self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() @@ -235,33 +245,32 @@ def rollback_entity(): if action != "Delete": self._action = "Update" - cascade_relations = [] if action != "Delete": - for relation_name, children, updater in cascade_relations: - for index, child in enumerate(children): - child._teaql_attach_root(self._entity_root) - getattr(child, updater)(self) - try: - await child._teaql_save_within_graph(graph, scope) - except CheckException as error: - prefix = ObjectLocation().property(relation_name).index(index) - raise CheckException([ - CheckResult( - violation.rule_id, - violation.location.prefixed_by(prefix), - violation.input_value, - violation.system_value, - violation.message, - ) - for violation in error.violations - ]) from error + await self._teaql_save_children(graph, scope) def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: - self._entity_root.set_original_version(new_key, int(self.version)) + self._entity_root.accept_committed_version(new_key, int(self.version)) graph.after_commit(commit_entity) return self + async def _teaql_save_children(self, graph, scope): + cascade_relations = [] + for relation_name, children, updater, member in cascade_relations: + for index, child in enumerate(children): + child._teaql_attach_root(self._entity_root) + current = getattr(child, member, None) + if getattr(current, "id", current) != self.id: + getattr(child, updater)(self) + try: + await child._teaql_save_within_graph(graph, scope) + except CheckException as error: + prefix = ObjectLocation().property(relation_name).index(index) + raise CheckException([ + CheckResult(v.rule_id, v.location.prefixed_by(prefix), v.input_value, v.system_value, v.message) + for v in error.violations + ]) from error + def update_id(self, value): self.id = value self._loaded_fields.add("id") diff --git a/examples/trace-chain/lib/models/payment.py b/examples/trace-chain/lib/models/payment.py index eee897b..a6498bc 100644 --- a/examples/trace-chain/lib/models/payment.py +++ b/examples/trace-chain/lib/models/payment.py @@ -51,7 +51,7 @@ def __init__(self, **kwargs): if self._payment_attempt_list: from models.payment_attempt import PaymentAttempt self._payment_attempt_list = [ - item if isinstance(item, PaymentAttempt) else PaymentAttempt(**item) + item if isinstance(item, PaymentAttempt) else PaymentAttempt(_entity_root=self._entity_root, **item) for item in self._payment_attempt_list ] self._ledger_id = getattr(self, "id", None) @@ -67,8 +67,9 @@ def _teaql_entity_key(self): return EntityKey("Payment", self._ledger_id) def _teaql_attach_root(self, root): - if self._entity_root is not root: - root.merge_from(self._entity_root) + key = self._teaql_entity_key() + if self._entity_root is not root and self._entity_root.has_pending(key): + root.merge_entity_from(self._entity_root, key) self._entity_root = root for child in self._payment_attempt_list: child._teaql_attach_root(root) @@ -154,34 +155,41 @@ def _teaql_build_command(self): if action == "Create": cmd = InsertCommand("Payment", payload) elif action == "Update": - cmd = UpdateCommand("Payment", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = UpdateCommand("Payment", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) for key, value in payload.items(): if key not in ("id", "version"): cmd.value(key, value) else: - cmd = DeleteCommand("Payment", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = DeleteCommand("Payment", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) return action, cmd def _teaql_preflight_graph(self, graph): context = graph.context - if self._action == "Update": - if "id" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) - if "customerOrder" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("customer_order"), message="Mutation requires a fully loaded entity")]) - if "referenceCode" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("reference_code"), message="Mutation requires a fully loaded entity")]) - if "version" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) - _action, cmd = self._teaql_build_command() - try: - context.preflight_mutation(cmd) - finally: - for field, value in getattr(cmd, "values", {}).items(): - if field not in ("id", "version"): - self._entity_root.set(self._teaql_entity_key(), field, value) + if self._action != "Update" or self._entity_root.has_pending(self._teaql_entity_key()): + if self._action == "Update": + if "id" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) + if "customerOrder" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("customer_order"), message="Mutation requires a fully loaded entity")]) + if "referenceCode" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("reference_code"), message="Mutation requires a fully loaded entity")]) + if "version" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) + _action, cmd = self._teaql_build_command() + try: + context.preflight_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) for index, child in enumerate(self._payment_attempt_list): child._teaql_attach_root(self._entity_root) - child.update_payment(self) + current = getattr(child, "payment", None) + if getattr(current, "id", current) != self.id: + child.update_payment(self) try: child._teaql_preflight_graph(graph) except CheckException as error: @@ -195,6 +203,10 @@ async def _teaql_save_within_graph(self, graph, parent_scope=None): context = graph.context scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) + if self._action == "Update" and not self._entity_root.has_pending(self._teaql_entity_key()): + await self._teaql_save_children(graph, scope) + return self + self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() @@ -260,34 +272,33 @@ def rollback_entity(): if action != "Delete": self._action = "Update" - cascade_relations = [] - cascade_relations.append(("payment_attempt_list", self._payment_attempt_list, "update_payment")) if action != "Delete": - for relation_name, children, updater in cascade_relations: - for index, child in enumerate(children): - child._teaql_attach_root(self._entity_root) - getattr(child, updater)(self) - try: - await child._teaql_save_within_graph(graph, scope) - except CheckException as error: - prefix = ObjectLocation().property(relation_name).index(index) - raise CheckException([ - CheckResult( - violation.rule_id, - violation.location.prefixed_by(prefix), - violation.input_value, - violation.system_value, - violation.message, - ) - for violation in error.violations - ]) from error + await self._teaql_save_children(graph, scope) def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: - self._entity_root.set_original_version(new_key, int(self.version)) + self._entity_root.accept_committed_version(new_key, int(self.version)) graph.after_commit(commit_entity) return self + async def _teaql_save_children(self, graph, scope): + cascade_relations = [] + cascade_relations.append(("payment_attempt_list", self._payment_attempt_list, "update_payment", "payment")) + for relation_name, children, updater, member in cascade_relations: + for index, child in enumerate(children): + child._teaql_attach_root(self._entity_root) + current = getattr(child, member, None) + if getattr(current, "id", current) != self.id: + getattr(child, updater)(self) + try: + await child._teaql_save_within_graph(graph, scope) + except CheckException as error: + prefix = ObjectLocation().property(relation_name).index(index) + raise CheckException([ + CheckResult(v.rule_id, v.location.prefixed_by(prefix), v.input_value, v.system_value, v.message) + for v in error.violations + ]) from error + def update_id(self, value): self.id = value self._loaded_fields.add("id") diff --git a/examples/trace-chain/lib/models/payment_attempt.py b/examples/trace-chain/lib/models/payment_attempt.py index 075c96c..7093369 100644 --- a/examples/trace-chain/lib/models/payment_attempt.py +++ b/examples/trace-chain/lib/models/payment_attempt.py @@ -58,8 +58,9 @@ def _teaql_entity_key(self): return EntityKey("PaymentAttempt", self._ledger_id) def _teaql_attach_root(self, root): - if self._entity_root is not root: - root.merge_from(self._entity_root) + key = self._teaql_entity_key() + if self._entity_root is not root and self._entity_root.has_pending(key): + root.merge_entity_from(self._entity_root, key) self._entity_root = root return self def mark_for_deletion(self): @@ -140,36 +141,45 @@ def _teaql_build_command(self): if action == "Create": cmd = InsertCommand("PaymentAttempt", payload) elif action == "Update": - cmd = UpdateCommand("PaymentAttempt", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = UpdateCommand("PaymentAttempt", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) for key, value in payload.items(): if key not in ("id", "version"): cmd.value(key, value) else: - cmd = DeleteCommand("PaymentAttempt", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = DeleteCommand("PaymentAttempt", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) return action, cmd def _teaql_preflight_graph(self, graph): context = graph.context - if self._action == "Update": - if "id" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) - if "payment" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("payment"), message="Mutation requires a fully loaded entity")]) - if "referenceCode" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("reference_code"), message="Mutation requires a fully loaded entity")]) - if "version" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) - _action, cmd = self._teaql_build_command() - try: - context.preflight_mutation(cmd) - finally: - for field, value in getattr(cmd, "values", {}).items(): - if field not in ("id", "version"): - self._entity_root.set(self._teaql_entity_key(), field, value) + if self._action != "Update" or self._entity_root.has_pending(self._teaql_entity_key()): + if self._action == "Update": + if "id" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) + if "payment" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("payment"), message="Mutation requires a fully loaded entity")]) + if "referenceCode" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("reference_code"), message="Mutation requires a fully loaded entity")]) + if "version" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) + _action, cmd = self._teaql_build_command() + try: + context.preflight_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) async def _teaql_save_within_graph(self, graph, parent_scope=None): context = graph.context scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) + if self._action == "Update" and not self._entity_root.has_pending(self._teaql_entity_key()): + await self._teaql_save_children(graph, scope) + return self + self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() @@ -235,33 +245,32 @@ def rollback_entity(): if action != "Delete": self._action = "Update" - cascade_relations = [] if action != "Delete": - for relation_name, children, updater in cascade_relations: - for index, child in enumerate(children): - child._teaql_attach_root(self._entity_root) - getattr(child, updater)(self) - try: - await child._teaql_save_within_graph(graph, scope) - except CheckException as error: - prefix = ObjectLocation().property(relation_name).index(index) - raise CheckException([ - CheckResult( - violation.rule_id, - violation.location.prefixed_by(prefix), - violation.input_value, - violation.system_value, - violation.message, - ) - for violation in error.violations - ]) from error + await self._teaql_save_children(graph, scope) def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: - self._entity_root.set_original_version(new_key, int(self.version)) + self._entity_root.accept_committed_version(new_key, int(self.version)) graph.after_commit(commit_entity) return self + async def _teaql_save_children(self, graph, scope): + cascade_relations = [] + for relation_name, children, updater, member in cascade_relations: + for index, child in enumerate(children): + child._teaql_attach_root(self._entity_root) + current = getattr(child, member, None) + if getattr(current, "id", current) != self.id: + getattr(child, updater)(self) + try: + await child._teaql_save_within_graph(graph, scope) + except CheckException as error: + prefix = ObjectLocation().property(relation_name).index(index) + raise CheckException([ + CheckResult(v.rule_id, v.location.prefixed_by(prefix), v.input_value, v.system_value, v.message) + for v in error.violations + ]) from error + def update_id(self, value): self.id = value self._loaded_fields.add("id") diff --git a/examples/trace-chain/lib/models/platform.py b/examples/trace-chain/lib/models/platform.py index 17da379..38a3d1b 100644 --- a/examples/trace-chain/lib/models/platform.py +++ b/examples/trace-chain/lib/models/platform.py @@ -44,7 +44,7 @@ def __init__(self, **kwargs): if self._customer_order_list: from models.customer_order import CustomerOrder self._customer_order_list = [ - item if isinstance(item, CustomerOrder) else CustomerOrder(**item) + item if isinstance(item, CustomerOrder) else CustomerOrder(_entity_root=self._entity_root, **item) for item in self._customer_order_list ] self._ledger_id = getattr(self, "id", None) @@ -60,8 +60,9 @@ def _teaql_entity_key(self): return EntityKey("Platform", self._ledger_id) def _teaql_attach_root(self, root): - if self._entity_root is not root: - root.merge_from(self._entity_root) + key = self._teaql_entity_key() + if self._entity_root is not root and self._entity_root.has_pending(key): + root.merge_entity_from(self._entity_root, key) self._entity_root = root for child in self._customer_order_list: child._teaql_attach_root(root) @@ -140,32 +141,39 @@ def _teaql_build_command(self): if action == "Create": cmd = InsertCommand("Platform", payload) elif action == "Update": - cmd = UpdateCommand("Platform", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = UpdateCommand("Platform", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) for key, value in payload.items(): if key not in ("id", "version"): cmd.value(key, value) else: - cmd = DeleteCommand("Platform", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = DeleteCommand("Platform", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) return action, cmd def _teaql_preflight_graph(self, graph): context = graph.context - if self._action == "Update": - if "id" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) - if "name" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("name"), message="Mutation requires a fully loaded entity")]) - if "version" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) - _action, cmd = self._teaql_build_command() - try: - context.preflight_mutation(cmd) - finally: - for field, value in getattr(cmd, "values", {}).items(): - if field not in ("id", "version"): - self._entity_root.set(self._teaql_entity_key(), field, value) + if self._action != "Update" or self._entity_root.has_pending(self._teaql_entity_key()): + if self._action == "Update": + if "id" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) + if "name" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("name"), message="Mutation requires a fully loaded entity")]) + if "version" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) + _action, cmd = self._teaql_build_command() + try: + context.preflight_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) for index, child in enumerate(self._customer_order_list): child._teaql_attach_root(self._entity_root) - child.update_platform(self) + current = getattr(child, "platform", None) + if getattr(current, "id", current) != self.id: + child.update_platform(self) try: child._teaql_preflight_graph(graph) except CheckException as error: @@ -179,6 +187,10 @@ async def _teaql_save_within_graph(self, graph, parent_scope=None): context = graph.context scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) + if self._action == "Update" and not self._entity_root.has_pending(self._teaql_entity_key()): + await self._teaql_save_children(graph, scope) + return self + self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() @@ -238,34 +250,33 @@ def rollback_entity(): if action != "Delete": self._action = "Update" - cascade_relations = [] - cascade_relations.append(("customer_order_list", self._customer_order_list, "update_platform")) if action != "Delete": - for relation_name, children, updater in cascade_relations: - for index, child in enumerate(children): - child._teaql_attach_root(self._entity_root) - getattr(child, updater)(self) - try: - await child._teaql_save_within_graph(graph, scope) - except CheckException as error: - prefix = ObjectLocation().property(relation_name).index(index) - raise CheckException([ - CheckResult( - violation.rule_id, - violation.location.prefixed_by(prefix), - violation.input_value, - violation.system_value, - violation.message, - ) - for violation in error.violations - ]) from error + await self._teaql_save_children(graph, scope) def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: - self._entity_root.set_original_version(new_key, int(self.version)) + self._entity_root.accept_committed_version(new_key, int(self.version)) graph.after_commit(commit_entity) return self + async def _teaql_save_children(self, graph, scope): + cascade_relations = [] + cascade_relations.append(("customer_order_list", self._customer_order_list, "update_platform", "platform")) + for relation_name, children, updater, member in cascade_relations: + for index, child in enumerate(children): + child._teaql_attach_root(self._entity_root) + current = getattr(child, member, None) + if getattr(current, "id", current) != self.id: + getattr(child, updater)(self) + try: + await child._teaql_save_within_graph(graph, scope) + except CheckException as error: + prefix = ObjectLocation().property(relation_name).index(index) + raise CheckException([ + CheckResult(v.rule_id, v.location.prefixed_by(prefix), v.input_value, v.system_value, v.message) + for v in error.violations + ]) from error + def update_id(self, value): self.id = value self._loaded_fields.add("id") diff --git a/examples/trace-chain/lib/models/shipment.py b/examples/trace-chain/lib/models/shipment.py index 64e3fab..7b2ce19 100644 --- a/examples/trace-chain/lib/models/shipment.py +++ b/examples/trace-chain/lib/models/shipment.py @@ -58,8 +58,9 @@ def _teaql_entity_key(self): return EntityKey("Shipment", self._ledger_id) def _teaql_attach_root(self, root): - if self._entity_root is not root: - root.merge_from(self._entity_root) + key = self._teaql_entity_key() + if self._entity_root is not root and self._entity_root.has_pending(key): + root.merge_entity_from(self._entity_root, key) self._entity_root = root return self def mark_for_deletion(self): @@ -140,36 +141,45 @@ def _teaql_build_command(self): if action == "Create": cmd = InsertCommand("Shipment", payload) elif action == "Update": - cmd = UpdateCommand("Shipment", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = UpdateCommand("Shipment", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) for key, value in payload.items(): if key not in ("id", "version"): cmd.value(key, value) else: - cmd = DeleteCommand("Shipment", Value.from_any(getattr(self, "id", None)), getattr(self, "version", None)) + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = DeleteCommand("Shipment", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) return action, cmd def _teaql_preflight_graph(self, graph): context = graph.context - if self._action == "Update": - if "id" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) - if "customerOrder" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("customer_order"), message="Mutation requires a fully loaded entity")]) - if "referenceCode" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("reference_code"), message="Mutation requires a fully loaded entity")]) - if "version" not in self._loaded_fields: - raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) - _action, cmd = self._teaql_build_command() - try: - context.preflight_mutation(cmd) - finally: - for field, value in getattr(cmd, "values", {}).items(): - if field not in ("id", "version"): - self._entity_root.set(self._teaql_entity_key(), field, value) + if self._action != "Update" or self._entity_root.has_pending(self._teaql_entity_key()): + if self._action == "Update": + if "id" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) + if "customerOrder" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("customer_order"), message="Mutation requires a fully loaded entity")]) + if "referenceCode" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("reference_code"), message="Mutation requires a fully loaded entity")]) + if "version" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) + _action, cmd = self._teaql_build_command() + try: + context.preflight_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) async def _teaql_save_within_graph(self, graph, parent_scope=None): context = graph.context scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) + if self._action == "Update" and not self._entity_root.has_pending(self._teaql_entity_key()): + await self._teaql_save_children(graph, scope) + return self + self._teaql_attach_root(self._entity_root) action, cmd = self._teaql_build_command() @@ -235,33 +245,32 @@ def rollback_entity(): if action != "Delete": self._action = "Update" - cascade_relations = [] if action != "Delete": - for relation_name, children, updater in cascade_relations: - for index, child in enumerate(children): - child._teaql_attach_root(self._entity_root) - getattr(child, updater)(self) - try: - await child._teaql_save_within_graph(graph, scope) - except CheckException as error: - prefix = ObjectLocation().property(relation_name).index(index) - raise CheckException([ - CheckResult( - violation.rule_id, - violation.location.prefixed_by(prefix), - violation.input_value, - violation.system_value, - violation.message, - ) - for violation in error.violations - ]) from error + await self._teaql_save_children(graph, scope) def commit_entity(): self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: - self._entity_root.set_original_version(new_key, int(self.version)) + self._entity_root.accept_committed_version(new_key, int(self.version)) graph.after_commit(commit_entity) return self + async def _teaql_save_children(self, graph, scope): + cascade_relations = [] + for relation_name, children, updater, member in cascade_relations: + for index, child in enumerate(children): + child._teaql_attach_root(self._entity_root) + current = getattr(child, member, None) + if getattr(current, "id", current) != self.id: + getattr(child, updater)(self) + try: + await child._teaql_save_within_graph(graph, scope) + except CheckException as error: + prefix = ObjectLocation().property(relation_name).index(index) + raise CheckException([ + CheckResult(v.rule_id, v.location.prefixed_by(prefix), v.input_value, v.system_value, v.message) + for v in error.violations + ]) from error + def update_id(self, value): self.id = value self._loaded_fields.add("id") diff --git a/examples/trace-chain/lib/requests/customer_order_request.py b/examples/trace-chain/lib/requests/customer_order_request.py index d9ef15e..12b7ba2 100644 --- a/examples/trace-chain/lib/requests/customer_order_request.py +++ b/examples/trace-chain/lib/requests/customer_order_request.py @@ -1,6 +1,5 @@ from teaql.core.query import RelationAggregate, SelectQuery from teaql.core.list import SmartList, TeaQLPage -from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest from teaql.core import QueryIntent from copy import deepcopy @@ -579,9 +578,8 @@ async def execute_for_rows(self, context): async def execute_for_list(self, context) -> SmartList[CustomerOrder]: result = await self.execute_for_result(context) - query_root = EntityRoot() return SmartList( - (CustomerOrder(_entity_root=query_root, **row) for row in result.rows), + (CustomerOrder(**row) for row in result.rows), facets=result.facets) async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[CustomerOrder]: @@ -608,8 +606,7 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) - query_root = EntityRoot() - data = SmartList(CustomerOrder(_entity_root=query_root, **row) for row in row_result.rows) + data = SmartList(CustomerOrder(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): @@ -626,7 +623,6 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - query_root = EntityRoot() async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: - yield CustomerOrder(_entity_root=query_root, **row) + yield CustomerOrder(**row) diff --git a/examples/trace-chain/lib/requests/order_item_request.py b/examples/trace-chain/lib/requests/order_item_request.py index 284142e..ab18a7b 100644 --- a/examples/trace-chain/lib/requests/order_item_request.py +++ b/examples/trace-chain/lib/requests/order_item_request.py @@ -1,6 +1,5 @@ from teaql.core.query import RelationAggregate, SelectQuery from teaql.core.list import SmartList, TeaQLPage -from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest from teaql.core import QueryIntent from copy import deepcopy @@ -372,9 +371,8 @@ async def execute_for_rows(self, context): async def execute_for_list(self, context) -> SmartList[OrderItem]: result = await self.execute_for_result(context) - query_root = EntityRoot() return SmartList( - (OrderItem(_entity_root=query_root, **row) for row in result.rows), + (OrderItem(**row) for row in result.rows), facets=result.facets) async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[OrderItem]: @@ -401,8 +399,7 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) - query_root = EntityRoot() - data = SmartList(OrderItem(_entity_root=query_root, **row) for row in row_result.rows) + data = SmartList(OrderItem(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): @@ -419,7 +416,6 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - query_root = EntityRoot() async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: - yield OrderItem(_entity_root=query_root, **row) + yield OrderItem(**row) diff --git a/examples/trace-chain/lib/requests/payment_attempt_request.py b/examples/trace-chain/lib/requests/payment_attempt_request.py index 3982605..7718f89 100644 --- a/examples/trace-chain/lib/requests/payment_attempt_request.py +++ b/examples/trace-chain/lib/requests/payment_attempt_request.py @@ -1,6 +1,5 @@ from teaql.core.query import RelationAggregate, SelectQuery from teaql.core.list import SmartList, TeaQLPage -from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest from teaql.core import QueryIntent from copy import deepcopy @@ -372,9 +371,8 @@ async def execute_for_rows(self, context): async def execute_for_list(self, context) -> SmartList[PaymentAttempt]: result = await self.execute_for_result(context) - query_root = EntityRoot() return SmartList( - (PaymentAttempt(_entity_root=query_root, **row) for row in result.rows), + (PaymentAttempt(**row) for row in result.rows), facets=result.facets) async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[PaymentAttempt]: @@ -401,8 +399,7 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) - query_root = EntityRoot() - data = SmartList(PaymentAttempt(_entity_root=query_root, **row) for row in row_result.rows) + data = SmartList(PaymentAttempt(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): @@ -419,7 +416,6 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - query_root = EntityRoot() async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: - yield PaymentAttempt(_entity_root=query_root, **row) + yield PaymentAttempt(**row) diff --git a/examples/trace-chain/lib/requests/payment_request.py b/examples/trace-chain/lib/requests/payment_request.py index 229cf52..69b9b72 100644 --- a/examples/trace-chain/lib/requests/payment_request.py +++ b/examples/trace-chain/lib/requests/payment_request.py @@ -1,6 +1,5 @@ from teaql.core.query import RelationAggregate, SelectQuery from teaql.core.list import SmartList, TeaQLPage -from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest from teaql.core import QueryIntent from copy import deepcopy @@ -411,9 +410,8 @@ async def execute_for_rows(self, context): async def execute_for_list(self, context) -> SmartList[Payment]: result = await self.execute_for_result(context) - query_root = EntityRoot() return SmartList( - (Payment(_entity_root=query_root, **row) for row in result.rows), + (Payment(**row) for row in result.rows), facets=result.facets) async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[Payment]: @@ -440,8 +438,7 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) - query_root = EntityRoot() - data = SmartList(Payment(_entity_root=query_root, **row) for row in row_result.rows) + data = SmartList(Payment(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): @@ -458,7 +455,6 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - query_root = EntityRoot() async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: - yield Payment(_entity_root=query_root, **row) + yield Payment(**row) diff --git a/examples/trace-chain/lib/requests/platform_request.py b/examples/trace-chain/lib/requests/platform_request.py index 9e10715..a34b63f 100644 --- a/examples/trace-chain/lib/requests/platform_request.py +++ b/examples/trace-chain/lib/requests/platform_request.py @@ -1,6 +1,5 @@ from teaql.core.query import RelationAggregate, SelectQuery from teaql.core.list import SmartList, TeaQLPage -from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest from teaql.core import QueryIntent from copy import deepcopy @@ -372,9 +371,8 @@ async def execute_for_rows(self, context): async def execute_for_list(self, context) -> SmartList[Platform]: result = await self.execute_for_result(context) - query_root = EntityRoot() return SmartList( - (Platform(_entity_root=query_root, **row) for row in result.rows), + (Platform(**row) for row in result.rows), facets=result.facets) async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[Platform]: @@ -401,8 +399,7 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) - query_root = EntityRoot() - data = SmartList(Platform(_entity_root=query_root, **row) for row in row_result.rows) + data = SmartList(Platform(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): @@ -419,7 +416,6 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - query_root = EntityRoot() async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: - yield Platform(_entity_root=query_root, **row) + yield Platform(**row) diff --git a/examples/trace-chain/lib/requests/shipment_request.py b/examples/trace-chain/lib/requests/shipment_request.py index 89565fd..20ad7c5 100644 --- a/examples/trace-chain/lib/requests/shipment_request.py +++ b/examples/trace-chain/lib/requests/shipment_request.py @@ -1,6 +1,5 @@ from teaql.core.query import RelationAggregate, SelectQuery from teaql.core.list import SmartList, TeaQLPage -from teaql.runtime import EntityRoot from teaql.data_service import QueryRequest from teaql.core import QueryIntent from copy import deepcopy @@ -372,9 +371,8 @@ async def execute_for_rows(self, context): async def execute_for_list(self, context) -> SmartList[Shipment]: result = await self.execute_for_result(context) - query_root = EntityRoot() return SmartList( - (Shipment(_entity_root=query_root, **row) for row in result.rows), + (Shipment(**row) for row in result.rows), facets=result.facets) async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[Shipment]: @@ -401,8 +399,7 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) - query_root = EntityRoot() - data = SmartList(Shipment(_entity_root=query_root, **row) for row in row_result.rows) + data = SmartList(Shipment(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) async def execute_for_one(self, context): @@ -419,7 +416,6 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - query_root = EntityRoot() async for chunk in service.query_stream(context, req, chunk_size): for row in chunk.rows: - yield Shipment(_entity_root=query_root, **row) + yield Shipment(**row) diff --git a/examples/trace-chain/main.py b/examples/trace-chain/main.py index e227453..b4421fd 100644 --- a/examples/trace-chain/main.py +++ b/examples/trace-chain/main.py @@ -165,8 +165,10 @@ def resolve_policy(request_key): await order.audit_as('prepare complete graph').save(context) assert E.payment(payment).id().eval() == E.customer_order(order).id().eval(), 'fixture must exercise equal IDs of different types' order.update_description('Submitted trace example') - payment.audit_as('authorize payment') - shipment.audit_as('dispatch shipment') + item.update_name('Verified available item') + payment.update_reference_code(label + '-payment-authorized').audit_as('authorize payment') + attempt.update_reference_code(label + '-attempt-authorized') + shipment.update_reference_code(label + '-shipment-dispatched').audit_as('dispatch shipment') removed.mark_for_deletion().audit_as('remove unavailable item') reset(service, sink, context) await order.audit_as('submit order').save(context) @@ -203,7 +205,7 @@ def resolve_policy(request_key): .select_payment_with(Q.payments().limit(1).select_customer_order_with( Q.customer_orders().limit(1).select_platform_with(Q.platforms().limit(1)))) .limit(1).comment('what: inspect three relation levels').purpose('why: retain root query intent').execute_for_one(context)) - assert E.payment_attempt(deep).reference_code().eval() == label + '-attempt' + assert E.payment_attempt(deep).reference_code().eval() == label + '-attempt-authorized' entries = context.sql_logs() assert len(entries) == 4 for depth, entry in enumerate(entries): diff --git a/examples/trace-chain/shared_reference.py b/examples/trace-chain/shared_reference.py new file mode 100644 index 0000000..120ad77 --- /dev/null +++ b/examples/trace-chain/shared_reference.py @@ -0,0 +1,258 @@ +"""App-owned generated Q/E/save acceptance; no generated-source API discovery.""" +import asyncio +import copy +import json +import os +import uuid + +from E import E +from Q import Q +from runtime_module import GENERATED_RUNTIME_MODULE +from teaql.core.entity import EntityKey +from teaql.provider.sqlite import create_sqlite_service +from teaql.runtime import UserContext + +from main import AuditSink, ObservedService, ObservedTransaction, new, reset, scalar + + +class VersionTransaction(ObservedTransaction): + async def mutate(self, context, request): + self.owner.versions.append(getattr(request._data, 'expected_version_val', None)) + return await super().mutate(context, request) + + +class SharedService(ObservedService): + def __init__(self, service): + super().__init__(service) + self.reuse_platform = False + self.platform_snapshot = None + self.snapshot_uses = 0 + self.pause = None + self.versions = [] + + async def query(self, context, request): + result = await self.service.query(context, request) + if self.reuse_platform: + for row in result.rows: + candidate = row.get('platform') + if isinstance(candidate, dict): + if self.platform_snapshot is None: + self.platform_snapshot = candidate + else: + assert candidate == self.platform_snapshot + row['platform'] = self.platform_snapshot + self.snapshot_uses += 1 + return result + + async def begin(self, context): + pause, self.pause = self.pause, None + if pause: + entered, release = pause + entered.set() + await asyncio.wait_for(release.wait(), 10) + self.begins += 1 + return VersionTransaction(self, await self.service.begin(context)) + + +def pending(ledger, key): + return key in dict(ledger.current_change_set().changes()) or key in ledger.new_keys() or key in ledger.deleted_keys() + + +def observe(scenario, service, sink, context): + sql = [entry for entry in context.sql_logs() if entry.mutation_lineage] + print('OWNERSHIP EVIDENCE ' + json.dumps({ + 'scenario': scenario, + 'commands': service.requests, + 'versions': service.versions[-len(service.requests):] if service.requests else [], + 'sql': [{'outcome': entry.execution_outcome, + 'comment': entry.comment, 'debugSQL': entry.debug_sql, + 'path': [(node.kind, node.name, node.entity_id, node.comment) for node in entry.trace_path], + 'lineage': [(node.kind, node.name, node.entity_id, node.comment) for node in entry.mutation_lineage]} + for entry in sql], + 'audit': [(event.entity, scalar(event.entity_id), + [(node.kind, node.name, node.entity_id, node.comment) for node in event.trace_chain]) + for event in sink.events], + })) + + +async def seed(context, platform, children=1): + label = 'OWN-' + uuid.uuid4().hex + order = new(Q.customer_orders(), context).update_platform(platform) + order.update_order_number(label).update_description('original ' + label) + for index in range(children): + child = new(Q.order_items(), context).update_customer_order(order).update_name(label + str(index)) + order.order_item_list().append(child) + await order.audit_as('prepare ownership graph').save(context) + return order + + +async def load(context, entity_id): + return await (Q.customer_orders().with_id_is(entity_id) + .select_order_item_list_with(Q.order_items().limit(10)).limit(1) + .comment('load ownership graph').purpose('verify graph mutation ownership') + .execute_for_one(context)) + + +def assert_writes(service, sink, context, count): + sql = [entry for entry in context.sql_logs() if entry.mutation_lineage] + assert len(service.requests) == len(sink.events) == len(sql) == count, 'only reached changed entities emit commands' + for request, entry, event in zip(service.requests, sql, sink.events): + entity, identity, lineage = request + actual = [(node.name or node.entity_type, node.entity_id, node.comment) for node in entry.mutation_lineage] + audit = [(node.name or node.entity_type, node.entity_id, node.comment) for node in event.trace_chain] + assert actual == audit == lineage + assert (event.entity, scalar(event.entity_id)) == (entity, identity) + assert entry.execution_outcome == 'success' + assert [node.kind for node in entry.trace_path] == ['operation', 'entity', 'provider', 'sql'] + assert entry.trace_path[0].name == 'CustomerOrder' + + +async def shared(context, platform, service, sink): + a, b = await seed(context, platform), await seed(context, platform) + revised = await load(context, E.customer_order(a).id().eval()) + await revised.update_description('prior revision').audit_as('advance one root').save(context) + service.reuse_platform = True + try: + roots = await (Q.customer_orders().with_id_in(E.customer_order(a).id().eval(), E.customer_order(b).id().eval()) + .order_by_id_ascending().select_platform_with(Q.platforms().limit(1)) + .select_order_item_list_with(Q.order_items().limit(10)).limit(2) + .comment('load two independent roots').purpose('verify shared read-only provider record') + .execute_for_list(context)) + finally: + service.reuse_platform = False + first, second = roots + assert service.snapshot_uses == 2, 'two roots must use the same actual provider-returned record' + before = copy.deepcopy(service.platform_snapshot) + first_platform, second_platform = E.customer_order(first).platform().eval(), E.customer_order(second).platform().eval() + assert first._entity_root is not second._entity_root, 'independent loaded roots must not share a mutable ledger' + assert first_platform is not second_platform and first_platform._entity_root is not second_platform._entity_root + versions = [E.customer_order(row).version().eval() for row in roots] + assert versions == [2, 1] + child_versions = [E.order_item(row.order_item_list()[0]).version().eval() for row in roots] + platform_version = E.platform(first_platform).version().eval() + first.update_description('saved first root') + second.update_description('saved second root') + reset(service, sink, context) + entered, release = asyncio.Event(), asyncio.Event() + service.pause = (entered, release) + first_task = asyncio.create_task(first.audit_as('save shared first').save(context)) + await asyncio.wait_for(entered.wait(), 10) + second_task = asyncio.create_task(second.audit_as('save shared second').save(context)) + await asyncio.sleep(0) + assert not first_task.done() and not second_task.done() + assert context._graph_save_lock.locked(), 'first save holds the actual Context graph gate' + release.set() + await asyncio.wait_for(asyncio.gather(first_task, second_task), 15) + observe('shared', service, sink, context) + assert_writes(service, sink, context, 2) + assert all(entity == 'CustomerOrder' for entity, _, _ in service.requests) + assert service.platform_snapshot == before + for index, row in enumerate(roots): + persisted = await load(context, E.customer_order(row).id().eval()) + assert E.customer_order(persisted).version().eval() == versions[index] + 1 + assert E.order_item(persisted.order_item_list()[0]).version().eval() == child_versions[index] + assert E.platform(first_platform).version().eval() == platform_version + + +async def scoped(context, platform, service, sink): + foreign_seed, target_seed = await seed(context, platform, 2), await seed(context, platform, 0) + foreign = await load(context, E.customer_order(foreign_seed).id().eval()) + # The public save composes the loaded reverse graph even on the old producer. + await foreign.audit_as('prepare loaded source graph').save(context) + target = await load(context, E.customer_order(target_seed).id().eval()) + reached, sibling = foreign.order_item_list() + source = foreign._entity_root + assert reached._entity_root is source and sibling._entity_root is source + foreign.update_description('foreign pending root') + sibling.update_name('unreached pending sibling') + reached.update_name('reached changed child').audit_as('adopt reached child') + target.update_description('target changed root') + target.order_item_list().append(reached) + reset(service, sink, context) + await target.audit_as('save target graph').save(context) + observe('scoped', service, sink, context) + foreign_key = EntityKey('CustomerOrder', E.customer_order(foreign).id().eval()) + sibling_key = EntityKey('OrderItem', E.order_item(sibling).id().eval()) + reached_key = EntityKey('OrderItem', E.order_item(reached).id().eval()) + assert not pending(target._entity_root, foreign_key), 'foreign root must not be imported' + assert not pending(target._entity_root, sibling_key), 'unreached sibling must not be imported' + assert all(pending(source, key) for key in [foreign_key, sibling_key, reached_key]) + assert_writes(service, sink, context, 2) + persisted = await load(context, E.customer_order(target).id().eval()) + assert E.order_item(persisted.order_item_list()[0]).name().eval() == 'reached changed child' + assert E.order_item(persisted.order_item_list()[0]).customer_order_id().eval() == E.customer_order(target).id().eval() + foreign_persisted = await load(context, E.customer_order(foreign).id().eval()) + assert E.customer_order(foreign_persisted).description().eval() != 'foreign pending root' + assert E.order_item(foreign_persisted.order_item_list()[0]).name().eval() != 'unreached pending sibling' + + +async def descendant(context, platform, service, sink): + seeded = await seed(context, platform) + order = await load(context, E.customer_order(seeded).id().eval()) + child = order.order_item_list()[0] + parent_version = E.customer_order(order).version().eval() + child.update_name('changed descendant only').audit_as('repair descendant') + reset(service, sink, context) + await order.audit_as('save clean ancestor').save(context) + observe('descendant', service, sink, context) + assert_writes(service, sink, context, 1) + assert service.requests[0][2][-1][2] == 'repair descendant' + assert E.customer_order(order).version().eval() == parent_version + persisted = await load(context, E.customer_order(order).id().eval()) + assert E.customer_order(persisted).version().eval() == parent_version + assert E.order_item(persisted.order_item_list()[0]).name().eval() == 'changed descendant only' + + +async def conflict(context, platform, service, sink): + seeded = await seed(context, platform) + old = await load(context, E.customer_order(seeded).id().eval()) + old_child = old.order_item_list()[0] + advance = await Q.order_items().with_id_is(E.order_item(old_child).id().eval()).limit(1).comment( + 'load child to create a new version').purpose('verify mixed loaded version rejection').execute_for_one(context) + await advance.update_name('committed newer child').audit_as('advance child independently').save(context) + current = await load(context, E.customer_order(seeded).id().eval()) + current_child = current.order_item_list()[0] + assert E.order_item(old_child).version().eval() != E.order_item(current_child).version().eval() + old_ledger, current_ledger = old_child._entity_root, current_child._entity_root + old_child.update_name('old pending value') + current_child.update_name('current pending value') + old.order_item_list().append(current_child) + reset(service, sink, context) + try: + await old.audit_as('reject conflicting loaded graph').save(context) + except ValueError as error: + assert 'ENTITY_VERSION_CONFLICT' in str(error) + except BaseException: + observe('conflict', service, sink, context) + raise + else: + raise AssertionError('conflicting loaded versions must be rejected') + assert service.requests == [] and sink.events == [] + key = EntityKey('OrderItem', E.order_item(old_child).id().eval()) + assert pending(old_ledger, key) and pending(current_ledger, key) + assert scalar(old_ledger.get(key, 'name')) == 'old pending value' + assert scalar(current_ledger.get(key, 'name')) == 'current pending value' + observe('conflict', service, sink, context) + + +async def main(): + context = UserContext.new().install(GENERATED_RUNTIME_MODULE) + service = SharedService(create_sqlite_service(os.environ['TEAQL_TRACE_CHAIN_DB'])) + sink = AuditSink(service) + context.insert_resource('dataService', service).with_app_audit_event_sink(sink) + context.set_diagnostic_sql_log_sink(type('Silent', (), {'write': lambda self, entry: None})()) + await context.ensure_schema() + platform = await Q.platforms().with_id_is(1).limit(1).comment('reuse generated root').purpose('attach ownership fixtures').execute_for_one(context) + checks = {'shared': shared, 'scoped': scoped, 'descendant': descendant, 'conflict': conflict} + selected = os.environ.get('TEAQL_TRACE_CHAIN_SCENARIO') + assert selected is None or selected in checks + for name, check in checks.items(): + if selected is None or selected == name: + await check(context, platform, service, sink) + print('PASS TC-OWN-' + name + ': generated Q/E/save, actual SQL and committed audit') + if selected is None: + print('PASS: Python shared ownership 4 scenarios') + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/examples/trace-chain/verify.sh b/examples/trace-chain/verify.sh index 8b89e37..de24c6e 100644 --- a/examples/trace-chain/verify.sh +++ b/examples/trace-chain/verify.sh @@ -6,13 +6,26 @@ if [[ -z "${TEAQL_TRACE_CHAIN_DB:-}" ]]; then trace_test_directory="$(mktemp -d)" export TEAQL_TRACE_CHAIN_DB="$trace_test_directory/trace-chain.sqlite" fi +if [[ -z "${TEAQL_TRACE_CHAIN_SHARED_DB:-}" ]]; then + ownership_directory="$(mktemp -d -t teaql-python-shared.XXXXXX)" + export TEAQL_TRACE_CHAIN_SHARED_DB="$ownership_directory/shared.sqlite" +fi +if [[ "$TEAQL_TRACE_CHAIN_SHARED_DB" == "$TEAQL_TRACE_CHAIN_DB" ]]; then + echo 'FAIL: normative and ownership fixtures require separate databases' >&2 + exit 1 +fi export PYTHONDONTWRITEBYTECODE=1 export PYTHONPATH="$example/lib:$repo/src${PYTHONPATH:+:$PYTHONPATH}" snapshot="$(mktemp)" -(cd "$example/lib" && find . -type f ! -path '*/__pycache__/*' -print0 | sort -z | xargs -0 sha256sum) > "$snapshot" +(cd "$example/lib" && rg --files --hidden -g '!**/__pycache__/**' | LC_ALL=C sort | xargs -d '\n' sha256sum) > "$snapshot" for attempt in first second; do echo "Run $attempt on $TEAQL_TRACE_CHAIN_DB without cleanup" python "$example/main.py" + echo "Ownership $attempt on $TEAQL_TRACE_CHAIN_SHARED_DB without cleanup" + run_log="$(mktemp -t teaql-python-shared.XXXXXX.log)" + env -u TEAQL_TRACE_CHAIN_SCENARIO TEAQL_TRACE_CHAIN_DB="$TEAQL_TRACE_CHAIN_SHARED_DB" \ + python "$example/shared_reference.py" | tee "$run_log" + rg -Fq 'PASS: Python shared ownership 4 scenarios' "$run_log" done (cd "$example/lib" && sha256sum --check "$snapshot") echo "PASS: two runs on the same database; generated library hashes unchanged" diff --git a/src/teaql/core/entity.py b/src/teaql/core/entity.py index 9e1db95..a122c98 100644 --- a/src/teaql/core/entity.py +++ b/src/teaql/core/entity.py @@ -111,8 +111,21 @@ def mark_as_deleted(self, key: EntityKey) -> None: def set_original_version(self, key: EntityKey, version: int) -> None: with self._lock: + self._validate_version(key, version) self._original_versions[key] = version + def accept_committed_version(self, key: EntityKey, version: int) -> None: + """Advance authoritative state only after this key's changes clear.""" + with self._lock: + if self.has_pending(key): + raise ValueError('ENTITY_PENDING_CHANGES: clear committed changes before accepting a version') + self._original_versions[key] = version + + def has_pending(self, key: EntityKey) -> bool: + with self._lock: + return (key in self._new_keys or key in self._deleted_keys or + any(key in change_set._changes for change_set in self._change_sets)) + def original_version(self, key: EntityKey) -> Optional[int]: with self._lock: return self._original_versions.get(key) @@ -135,15 +148,56 @@ def clear_committed(self) -> None: def merge_from(self, other: 'EntityRoot') -> None: if other is self: return - with self._lock, other._lock: - self._change_sets[-1].merge_from(other._change_sets[-1]) - self._original_versions.update(other._original_versions) - self._new_keys.update(other._new_keys) - self._deleted_keys.update(other._deleted_keys) - self._trace_chains.update(deepcopy(other._trace_chains)) + # Release the source lock before acquiring the target: opposing imports + # must not hold both graph locks in opposite order. + with other._lock: + keys = (set(other._original_versions) | other._new_keys | other._deleted_keys | + set(other._trace_chains) | set(other._change_sets[-1]._changes)) + entries = [other._snapshot(key) for key in keys] + with self._lock: + for key, _, version, _, _, _ in entries: + self._validate_version(key, version) + for entry in entries: + self._import(entry) + + def merge_entity_from(self, other: 'EntityRoot', key: EntityKey) -> None: + """Copy one reached typed key without draining its source graph.""" + if other is self: + return + entry = other._snapshot(key) + with self._lock: + self._validate_version(key, entry[2]) + self._import(entry) + + def _snapshot(self, key): + with self._lock: + return (key, dict(self._change_sets[-1]._changes.get(key, {})), + self._original_versions.get(key), key in self._new_keys, + key in self._deleted_keys, deepcopy(self._trace_chains.get(key))) + + def _validate_version(self, key, incoming): + original = self._original_versions.get(key) + if incoming is not None and original is not None and incoming != original: + raise ValueError(f'ENTITY_VERSION_CONFLICT: {key.entity} has loaded versions {original} and {incoming}') + + def _import(self, entry): + key, values, version, added, removed, trace = entry + for field, value in values.items(): + self._change_sets[-1].set(key, field, value) + if version is not None: + self._original_versions[key] = version + if added: + self._new_keys.add(key) + if removed: + self.mark_as_deleted(key) + if trace is not None: + self._trace_chains[key] = trace def rekey(self, old_key: EntityKey, new_key: EntityKey) -> None: + if old_key == new_key: + return with self._lock: + self._validate_version(new_key, self._original_versions.get(old_key)) for change_set in self._change_sets: change_set.rekey(old_key, new_key) if old_key in self._original_versions: diff --git a/tests/core/test_entity_root_ownership.py b/tests/core/test_entity_root_ownership.py new file mode 100644 index 0000000..59b61ff --- /dev/null +++ b/tests/core/test_entity_root_ownership.py @@ -0,0 +1,121 @@ +"""Reached-key imports must not compose another graph's pending mutations.""" +from concurrent.futures import ThreadPoolExecutor +from threading import Barrier + +import pytest + +from teaql.core.entity import EntityKey, EntityRoot +from teaql.core.mutation import TraceNode +from teaql.core.value import Value + + +def pending(root, key): + return key in dict(root.current_change_set().changes()) or key in root.new_keys() or key in root.deleted_keys() + + +def test_scoped_import_keeps_unreached_and_source_changes(): + source, target = EntityRoot(), EntityRoot() + parent, child, sibling = EntityKey('Order', 1), EntityKey('OrderItem', 1), EntityKey('OrderItem', 2) + source.set_original_version(parent, 9) + source.set_original_version(child, 2) + target.set_original_version(parent, 3) + source.set(parent, 'name', Value.Text('foreign root')) + source.set(child, 'name', Value.Text('reached child')) + source.set(sibling, 'name', Value.Text('foreign sibling')) + source.set_trace_chain(child, [TraceNode('Order', 1, 'import child', kind='auditReason', name='Order')]) + + target.merge_entity_from(source, child) + + assert dict(target.current_change_set().changes()) == {child: {'name': Value.Text('reached child')}} + assert target.original_version(parent) == 3 + assert target.original_version(child) == 2 + assert not pending(target, parent) and not pending(target, sibling) + assert target.trace_chain(child)[0].comment == 'import child' + target.clear_entity(child) + assert pending(source, child) and pending(source, parent) and pending(source, sibling) + + +def test_scoped_import_copies_lifecycle_without_pending_hydration(): + source, target = EntityRoot(), EntityRoot() + loaded, added, removed = EntityKey('Platform', 1), EntityKey('OrderItem', -1), EntityKey('OrderItem', 2) + source.set_original_version(loaded, 1) + source.mark_as_new(added) + source.set(added, 'name', Value.Text('new')) + source.set_original_version(removed, 3) + source.mark_as_deleted(removed) + for key in (loaded, added, removed): + target.merge_entity_from(source, key) + assert not target.has_pending(loaded) + assert added in target.new_keys() and removed in target.deleted_keys() + assert added in source.new_keys() and removed in source.deleted_keys() + + +def test_version_conflict_rejects_scoped_and_whole_import_before_copying(): + source, target = EntityRoot(), EntityRoot() + key, unrelated = EntityKey('Order', 1), EntityKey('OrderItem', 2) + target.set_original_version(key, 1) + target.set(key, 'name', Value.Text('original')) + source.set_original_version(key, 2) + source.set(key, 'name', Value.Text('replacement')) + source.mark_as_deleted(key) + source.mark_as_new(unrelated) + source.set(unrelated, 'name', Value.Text('unreached')) + source.set_trace_chain(key, [TraceNode('Order', 1, 'foreign', kind='auditReason', name='Order')]) + for operation in (lambda: target.merge_entity_from(source, key), lambda: target.merge_from(source)): + with pytest.raises(ValueError, match='ENTITY_VERSION_CONFLICT'): + operation() + assert target.original_version(key) == 1 + assert target.get(key, 'name') == Value.Text('original') + assert not pending(target, unrelated) and key not in target.deleted_keys() + assert target.trace_chain(key) == () + assert key in source.deleted_keys() + + +def test_conflicting_rekey_preserves_both_keys(): + root = EntityRoot() + old, new = EntityKey('Order', -1), EntityKey('Order', 1) + root.set_original_version(old, 2) + root.set_original_version(new, 1) + root.mark_as_new(old) + root.set(old, 'name', Value.Text('old')) + root.set(new, 'name', Value.Text('new')) + with pytest.raises(ValueError, match='ENTITY_VERSION_CONFLICT'): + root.rekey(old, new) + assert old in root.new_keys() + assert root.original_version(old) == 2 and root.original_version(new) == 1 + assert root.get(old, 'name') == Value.Text('old') and root.get(new, 'name') == Value.Text('new') + + +def test_committed_version_requires_cleared_changes_and_keeps_typed_keys(): + root = EntityRoot() + order, payment = EntityKey('Order', 1), EntityKey('Payment', 1) + root.set_original_version(order, 7) + root.set_original_version(payment, 1) + root.set_original_version(order, 7) + with pytest.raises(ValueError, match='ENTITY_VERSION_CONFLICT'): + root.set_original_version(order, 8) + root.set(order, 'name', Value.Text('pending')) + with pytest.raises(ValueError, match='ENTITY_PENDING_CHANGES'): + root.accept_committed_version(order, 8) + root.clear_entity(order) + root.accept_committed_version(order, 8) + assert root.original_version(order) == 8 and root.original_version(payment) == 1 + + +def test_concurrent_conflicting_version_registration_has_one_winner(): + root, key, start = EntityRoot(), EntityKey('Order', 1), Barrier(2) + + def register(version): + start.wait(timeout=5) + try: + root.set_original_version(key, version) + return True + except ValueError as error: + assert 'ENTITY_VERSION_CONFLICT' in str(error) + return False + + with ThreadPoolExecutor(max_workers=2) as executor: + first = executor.submit(register, 1) + second = executor.submit(register, 2) + assert sum([first.result(timeout=10), second.result(timeout=10)]) == 1 + assert root.original_version(key) in (1, 2) From de95831c4878e71ff613b78c67db79f2cf4ffd34 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sat, 3 Oct 2026 06:57:20 +0800 Subject: [PATCH 05/40] fix(trace): own Python page and stream intent across deferred execution (#43) Signed-off-by: Philip Z --- examples/conformance/models/platform.py | 6 + examples/conformance/models/work_item.py | 6 + .../conformance/requests/platform_request.py | 28 +- .../conformance/requests/work_item_request.py | 28 +- .../models/commerce_platform.py | 6 + .../python-lib-core/models/customer.py | 6 + .../python-lib-core/models/customer_order.py | 6 + .../python-lib-core/models/order_line.py | 6 + .../models/order_search_preset.py | 6 + .../python-lib-core/models/order_status.py | 6 + .../python-lib-core/models/product.py | 6 + .../requests/commerce_platform_request.py | 28 +- .../requests/customer_order_request.py | 28 +- .../requests/customer_request.py | 28 +- .../requests/order_line_request.py | 28 +- .../requests/order_search_preset_request.py | 28 +- .../requests/order_status_request.py | 28 +- .../requests/product_request.py | 28 +- examples/school-management/models/platform.py | 6 + examples/school-management/models/school.py | 6 + .../school-management/models/school_type.py | 6 + .../requests/platform_request.py | 28 +- .../requests/school_request.py | 28 +- .../requests/school_type_request.py | 28 +- .../task_board/generated/models/platform.py | 6 + examples/task_board/generated/models/task.py | 6 + .../generated/models/task_execution_log.py | 6 + .../generated/models/task_status.py | 6 + .../generated/requests/platform_request.py | 28 +- .../requests/task_execution_log_request.py | 28 +- .../generated/requests/task_request.py | 28 +- .../generated/requests/task_status_request.py | 28 +- examples/trace-chain/README.md | 28 +- .../python-assist-list-page/customer_order.md | 98 +++++++ .../python-assist-query/order_item.name.md | 73 +++++ .../trace-chain/lib/models/customer_order.py | 6 + examples/trace-chain/lib/models/order_item.py | 6 + examples/trace-chain/lib/models/payment.py | 6 + .../trace-chain/lib/models/payment_attempt.py | 6 + examples/trace-chain/lib/models/platform.py | 6 + examples/trace-chain/lib/models/shipment.py | 6 + .../lib/requests/customer_order_request.py | 28 +- .../lib/requests/order_item_request.py | 28 +- .../lib/requests/payment_attempt_request.py | 28 +- .../lib/requests/payment_request.py | 28 +- .../lib/requests/platform_request.py | 28 +- .../lib/requests/shipment_request.py | 28 +- examples/trace-chain/page_stream.py | 263 ++++++++++++++++++ examples/trace-chain/verify.sh | 10 + src/teaql/data_service/__init__.py | 22 +- src/teaql/sql/executor.py | 19 +- tests/provider/sqlite/test_trace_chain.py | 44 +++ tests/runtime/test_sql_mask_lifecycle.py | 31 +++ 53 files changed, 1107 insertions(+), 229 deletions(-) create mode 100644 examples/trace-chain/evidence/assist/python-assist-list-page/customer_order.md create mode 100644 examples/trace-chain/evidence/assist/python-assist-query/order_item.name.md create mode 100644 examples/trace-chain/page_stream.py diff --git a/examples/conformance/models/platform.py b/examples/conformance/models/platform.py index d75a5d6..3ba5b49 100644 --- a/examples/conformance/models/platform.py +++ b/examples/conformance/models/platform.py @@ -278,6 +278,12 @@ async def _teaql_save_children(self, graph, scope): ]) from error def update_id(self, value): + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + raise ValueError("id must be a positive integer") + old_key = self._teaql_entity_key() + new_key = EntityKey(old_key.entity, value) + self._entity_root.rekey(old_key, new_key) + self._ledger_id = value self.id = value self._loaded_fields.add("id") self._entity_root.set(self._teaql_entity_key(), "id", Value.from_any(value)) diff --git a/examples/conformance/models/work_item.py b/examples/conformance/models/work_item.py index d977869..6d2e85b 100644 --- a/examples/conformance/models/work_item.py +++ b/examples/conformance/models/work_item.py @@ -287,6 +287,12 @@ async def _teaql_save_children(self, graph, scope): ]) from error def update_id(self, value): + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + raise ValueError("id must be a positive integer") + old_key = self._teaql_entity_key() + new_key = EntityKey(old_key.entity, value) + self._entity_root.rekey(old_key, new_key) + self._ledger_id = value self.id = value self._loaded_fields.add("id") self._entity_root.set(self._teaql_entity_key(), "id", Value.from_any(value)) diff --git a/examples/conformance/requests/platform_request.py b/examples/conformance/requests/platform_request.py index 0c4413e..ea0f3a3 100644 --- a/examples/conformance/requests/platform_request.py +++ b/examples/conformance/requests/platform_request.py @@ -380,25 +380,25 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ intent = QueryIntent(request._comment, request._purpose) query = deepcopy(request.query) query.offset(offset).limit(limit) - authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)) service = context.require_resource("dataService") alias = "__teaql_total" - if authorized.id_set_pagination is not None: - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + if authorized.query.id_set_pagination is not None: + row_result = await service.query(context, authorized) retained_count, accuracy = context.id_set_count() if accuracy == "EXACT": total_count = retained_count else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + row_result = await service.query(context, authorized) data = SmartList(Platform(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) @@ -408,7 +408,7 @@ async def execute_for_one(self, context): entities = await ExecutablePlatformRequest(request).execute_for_list(context) return entities[0] if entities else None - async def execute_for_stream(self, context, chunk_size: int = 1000): + def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) @@ -416,6 +416,14 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - async for chunk in service.query_stream(context, req, chunk_size): - for row in chunk.rows: - yield Platform(**row) + stream = service.query_stream(context, req, chunk_size) + async def entities(): + try: + async for chunk in stream: + for row in chunk.rows: + yield Platform(**row) + finally: + close = getattr(stream, "aclose", None) + if close is not None: + await close() + return entities() diff --git a/examples/conformance/requests/work_item_request.py b/examples/conformance/requests/work_item_request.py index 9940d7d..0728807 100644 --- a/examples/conformance/requests/work_item_request.py +++ b/examples/conformance/requests/work_item_request.py @@ -470,25 +470,25 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ intent = QueryIntent(request._comment, request._purpose) query = deepcopy(request.query) query.offset(offset).limit(limit) - authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)) service = context.require_resource("dataService") alias = "__teaql_total" - if authorized.id_set_pagination is not None: - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + if authorized.query.id_set_pagination is not None: + row_result = await service.query(context, authorized) retained_count, accuracy = context.id_set_count() if accuracy == "EXACT": total_count = retained_count else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + row_result = await service.query(context, authorized) data = SmartList(WorkItem(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) @@ -498,7 +498,7 @@ async def execute_for_one(self, context): entities = await ExecutableWorkItemRequest(request).execute_for_list(context) return entities[0] if entities else None - async def execute_for_stream(self, context, chunk_size: int = 1000): + def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) @@ -506,6 +506,14 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - async for chunk in service.query_stream(context, req, chunk_size): - for row in chunk.rows: - yield WorkItem(**row) + stream = service.query_stream(context, req, chunk_size) + async def entities(): + try: + async for chunk in stream: + for row in chunk.rows: + yield WorkItem(**row) + finally: + close = getattr(stream, "aclose", None) + if close is not None: + await close() + return entities() diff --git a/examples/order-management/python-lib-core/models/commerce_platform.py b/examples/order-management/python-lib-core/models/commerce_platform.py index d44909b..397a980 100644 --- a/examples/order-management/python-lib-core/models/commerce_platform.py +++ b/examples/order-management/python-lib-core/models/commerce_platform.py @@ -448,6 +448,12 @@ async def _teaql_save_children(self, graph, scope): ]) from error def update_id(self, value): + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + raise ValueError("id must be a positive integer") + old_key = self._teaql_entity_key() + new_key = EntityKey(old_key.entity, value) + self._entity_root.rekey(old_key, new_key) + self._ledger_id = value self.id = value self._loaded_fields.add("id") self._entity_root.set(self._teaql_entity_key(), "id", Value.from_any(value)) diff --git a/examples/order-management/python-lib-core/models/customer.py b/examples/order-management/python-lib-core/models/customer.py index 9b4d7bf..0a3e343 100644 --- a/examples/order-management/python-lib-core/models/customer.py +++ b/examples/order-management/python-lib-core/models/customer.py @@ -345,6 +345,12 @@ async def _teaql_save_children(self, graph, scope): ]) from error def update_id(self, value): + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + raise ValueError("id must be a positive integer") + old_key = self._teaql_entity_key() + new_key = EntityKey(old_key.entity, value) + self._entity_root.rekey(old_key, new_key) + self._ledger_id = value self.id = value self._loaded_fields.add("id") self._entity_root.set(self._teaql_entity_key(), "id", Value.from_any(value)) diff --git a/examples/order-management/python-lib-core/models/customer_order.py b/examples/order-management/python-lib-core/models/customer_order.py index 0e6a67f..1a004d8 100644 --- a/examples/order-management/python-lib-core/models/customer_order.py +++ b/examples/order-management/python-lib-core/models/customer_order.py @@ -404,6 +404,12 @@ async def _teaql_save_children(self, graph, scope): ]) from error def update_id(self, value): + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + raise ValueError("id must be a positive integer") + old_key = self._teaql_entity_key() + new_key = EntityKey(old_key.entity, value) + self._entity_root.rekey(old_key, new_key) + self._ledger_id = value self.id = value self._loaded_fields.add("id") self._entity_root.set(self._teaql_entity_key(), "id", Value.from_any(value)) diff --git a/examples/order-management/python-lib-core/models/order_line.py b/examples/order-management/python-lib-core/models/order_line.py index f2b9624..2a49c37 100644 --- a/examples/order-management/python-lib-core/models/order_line.py +++ b/examples/order-management/python-lib-core/models/order_line.py @@ -361,6 +361,12 @@ async def _teaql_save_children(self, graph, scope): ]) from error def update_id(self, value): + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + raise ValueError("id must be a positive integer") + old_key = self._teaql_entity_key() + new_key = EntityKey(old_key.entity, value) + self._entity_root.rekey(old_key, new_key) + self._ledger_id = value self.id = value self._loaded_fields.add("id") self._entity_root.set(self._teaql_entity_key(), "id", Value.from_any(value)) diff --git a/examples/order-management/python-lib-core/models/order_search_preset.py b/examples/order-management/python-lib-core/models/order_search_preset.py index 62bba0b..0405842 100644 --- a/examples/order-management/python-lib-core/models/order_search_preset.py +++ b/examples/order-management/python-lib-core/models/order_search_preset.py @@ -347,6 +347,12 @@ async def _teaql_save_children(self, graph, scope): ]) from error def update_id(self, value): + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + raise ValueError("id must be a positive integer") + old_key = self._teaql_entity_key() + new_key = EntityKey(old_key.entity, value) + self._entity_root.rekey(old_key, new_key) + self._ledger_id = value self.id = value self._loaded_fields.add("id") self._entity_root.set(self._teaql_entity_key(), "id", Value.from_any(value)) diff --git a/examples/order-management/python-lib-core/models/order_status.py b/examples/order-management/python-lib-core/models/order_status.py index 0703e32..3db1740 100644 --- a/examples/order-management/python-lib-core/models/order_status.py +++ b/examples/order-management/python-lib-core/models/order_status.py @@ -345,6 +345,12 @@ async def _teaql_save_children(self, graph, scope): ]) from error def update_id(self, value): + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + raise ValueError("id must be a positive integer") + old_key = self._teaql_entity_key() + new_key = EntityKey(old_key.entity, value) + self._entity_root.rekey(old_key, new_key) + self._ledger_id = value self.id = value self._loaded_fields.add("id") self._entity_root.set(self._teaql_entity_key(), "id", Value.from_any(value)) diff --git a/examples/order-management/python-lib-core/models/product.py b/examples/order-management/python-lib-core/models/product.py index 8623ebb..05c2d23 100644 --- a/examples/order-management/python-lib-core/models/product.py +++ b/examples/order-management/python-lib-core/models/product.py @@ -360,6 +360,12 @@ async def _teaql_save_children(self, graph, scope): ]) from error def update_id(self, value): + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + raise ValueError("id must be a positive integer") + old_key = self._teaql_entity_key() + new_key = EntityKey(old_key.entity, value) + self._entity_root.rekey(old_key, new_key) + self._ledger_id = value self.id = value self._loaded_fields.add("id") self._entity_root.set(self._teaql_entity_key(), "id", Value.from_any(value)) diff --git a/examples/order-management/python-lib-core/requests/commerce_platform_request.py b/examples/order-management/python-lib-core/requests/commerce_platform_request.py index 2c3557a..633177d 100644 --- a/examples/order-management/python-lib-core/requests/commerce_platform_request.py +++ b/examples/order-management/python-lib-core/requests/commerce_platform_request.py @@ -962,25 +962,25 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ intent = QueryIntent(request._comment, request._purpose) query = deepcopy(request.query) query.offset(offset).limit(limit) - authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)) service = context.require_resource("dataService") alias = "__teaql_total" - if authorized.id_set_pagination is not None: - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + if authorized.query.id_set_pagination is not None: + row_result = await service.query(context, authorized) retained_count, accuracy = context.id_set_count() if accuracy == "EXACT": total_count = retained_count else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + row_result = await service.query(context, authorized) data = SmartList(CommercePlatform(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) @@ -990,7 +990,7 @@ async def execute_for_one(self, context): entities = await ExecutableCommercePlatformRequest(request).execute_for_list(context) return entities[0] if entities else None - async def execute_for_stream(self, context, chunk_size: int = 1000): + def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) @@ -998,6 +998,14 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - async for chunk in service.query_stream(context, req, chunk_size): - for row in chunk.rows: - yield CommercePlatform(**row) + stream = service.query_stream(context, req, chunk_size) + async def entities(): + try: + async for chunk in stream: + for row in chunk.rows: + yield CommercePlatform(**row) + finally: + close = getattr(stream, "aclose", None) + if close is not None: + await close() + return entities() diff --git a/examples/order-management/python-lib-core/requests/customer_order_request.py b/examples/order-management/python-lib-core/requests/customer_order_request.py index dbc5fd3..3d1c568 100644 --- a/examples/order-management/python-lib-core/requests/customer_order_request.py +++ b/examples/order-management/python-lib-core/requests/customer_order_request.py @@ -890,25 +890,25 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ intent = QueryIntent(request._comment, request._purpose) query = deepcopy(request.query) query.offset(offset).limit(limit) - authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)) service = context.require_resource("dataService") alias = "__teaql_total" - if authorized.id_set_pagination is not None: - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + if authorized.query.id_set_pagination is not None: + row_result = await service.query(context, authorized) retained_count, accuracy = context.id_set_count() if accuracy == "EXACT": total_count = retained_count else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + row_result = await service.query(context, authorized) data = SmartList(CustomerOrder(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) @@ -918,7 +918,7 @@ async def execute_for_one(self, context): entities = await ExecutableCustomerOrderRequest(request).execute_for_list(context) return entities[0] if entities else None - async def execute_for_stream(self, context, chunk_size: int = 1000): + def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) @@ -926,6 +926,14 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - async for chunk in service.query_stream(context, req, chunk_size): - for row in chunk.rows: - yield CustomerOrder(**row) + stream = service.query_stream(context, req, chunk_size) + async def entities(): + try: + async for chunk in stream: + for row in chunk.rows: + yield CustomerOrder(**row) + finally: + close = getattr(stream, "aclose", None) + if close is not None: + await close() + return entities() diff --git a/examples/order-management/python-lib-core/requests/customer_request.py b/examples/order-management/python-lib-core/requests/customer_request.py index 2e6c9e4..500630a 100644 --- a/examples/order-management/python-lib-core/requests/customer_request.py +++ b/examples/order-management/python-lib-core/requests/customer_request.py @@ -722,25 +722,25 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ intent = QueryIntent(request._comment, request._purpose) query = deepcopy(request.query) query.offset(offset).limit(limit) - authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)) service = context.require_resource("dataService") alias = "__teaql_total" - if authorized.id_set_pagination is not None: - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + if authorized.query.id_set_pagination is not None: + row_result = await service.query(context, authorized) retained_count, accuracy = context.id_set_count() if accuracy == "EXACT": total_count = retained_count else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + row_result = await service.query(context, authorized) data = SmartList(Customer(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) @@ -750,7 +750,7 @@ async def execute_for_one(self, context): entities = await ExecutableCustomerRequest(request).execute_for_list(context) return entities[0] if entities else None - async def execute_for_stream(self, context, chunk_size: int = 1000): + def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) @@ -758,6 +758,14 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - async for chunk in service.query_stream(context, req, chunk_size): - for row in chunk.rows: - yield Customer(**row) + stream = service.query_stream(context, req, chunk_size) + async def entities(): + try: + async for chunk in stream: + for row in chunk.rows: + yield Customer(**row) + finally: + close = getattr(stream, "aclose", None) + if close is not None: + await close() + return entities() diff --git a/examples/order-management/python-lib-core/requests/order_line_request.py b/examples/order-management/python-lib-core/requests/order_line_request.py index 87785fc..a357d5c 100644 --- a/examples/order-management/python-lib-core/requests/order_line_request.py +++ b/examples/order-management/python-lib-core/requests/order_line_request.py @@ -722,25 +722,25 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ intent = QueryIntent(request._comment, request._purpose) query = deepcopy(request.query) query.offset(offset).limit(limit) - authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)) service = context.require_resource("dataService") alias = "__teaql_total" - if authorized.id_set_pagination is not None: - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + if authorized.query.id_set_pagination is not None: + row_result = await service.query(context, authorized) retained_count, accuracy = context.id_set_count() if accuracy == "EXACT": total_count = retained_count else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + row_result = await service.query(context, authorized) data = SmartList(OrderLine(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) @@ -750,7 +750,7 @@ async def execute_for_one(self, context): entities = await ExecutableOrderLineRequest(request).execute_for_list(context) return entities[0] if entities else None - async def execute_for_stream(self, context, chunk_size: int = 1000): + def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) @@ -758,6 +758,14 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - async for chunk in service.query_stream(context, req, chunk_size): - for row in chunk.rows: - yield OrderLine(**row) + stream = service.query_stream(context, req, chunk_size) + async def entities(): + try: + async for chunk in stream: + for row in chunk.rows: + yield OrderLine(**row) + finally: + close = getattr(stream, "aclose", None) + if close is not None: + await close() + return entities() diff --git a/examples/order-management/python-lib-core/requests/order_search_preset_request.py b/examples/order-management/python-lib-core/requests/order_search_preset_request.py index c14adb3..85ddaca 100644 --- a/examples/order-management/python-lib-core/requests/order_search_preset_request.py +++ b/examples/order-management/python-lib-core/requests/order_search_preset_request.py @@ -776,25 +776,25 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ intent = QueryIntent(request._comment, request._purpose) query = deepcopy(request.query) query.offset(offset).limit(limit) - authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)) service = context.require_resource("dataService") alias = "__teaql_total" - if authorized.id_set_pagination is not None: - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + if authorized.query.id_set_pagination is not None: + row_result = await service.query(context, authorized) retained_count, accuracy = context.id_set_count() if accuracy == "EXACT": total_count = retained_count else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + row_result = await service.query(context, authorized) data = SmartList(OrderSearchPreset(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) @@ -804,7 +804,7 @@ async def execute_for_one(self, context): entities = await ExecutableOrderSearchPresetRequest(request).execute_for_list(context) return entities[0] if entities else None - async def execute_for_stream(self, context, chunk_size: int = 1000): + def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) @@ -812,6 +812,14 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - async for chunk in service.query_stream(context, req, chunk_size): - for row in chunk.rows: - yield OrderSearchPreset(**row) + stream = service.query_stream(context, req, chunk_size) + async def entities(): + try: + async for chunk in stream: + for row in chunk.rows: + yield OrderSearchPreset(**row) + finally: + close = getattr(stream, "aclose", None) + if close is not None: + await close() + return entities() diff --git a/examples/order-management/python-lib-core/requests/order_status_request.py b/examples/order-management/python-lib-core/requests/order_status_request.py index 7af441b..cc1a2ce 100644 --- a/examples/order-management/python-lib-core/requests/order_status_request.py +++ b/examples/order-management/python-lib-core/requests/order_status_request.py @@ -797,25 +797,25 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ intent = QueryIntent(request._comment, request._purpose) query = deepcopy(request.query) query.offset(offset).limit(limit) - authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)) service = context.require_resource("dataService") alias = "__teaql_total" - if authorized.id_set_pagination is not None: - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + if authorized.query.id_set_pagination is not None: + row_result = await service.query(context, authorized) retained_count, accuracy = context.id_set_count() if accuracy == "EXACT": total_count = retained_count else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + row_result = await service.query(context, authorized) data = SmartList(OrderStatus(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) @@ -825,7 +825,7 @@ async def execute_for_one(self, context): entities = await ExecutableOrderStatusRequest(request).execute_for_list(context) return entities[0] if entities else None - async def execute_for_stream(self, context, chunk_size: int = 1000): + def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) @@ -833,6 +833,14 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - async for chunk in service.query_stream(context, req, chunk_size): - for row in chunk.rows: - yield OrderStatus(**row) + stream = service.query_stream(context, req, chunk_size) + async def entities(): + try: + async for chunk in stream: + for row in chunk.rows: + yield OrderStatus(**row) + finally: + close = getattr(stream, "aclose", None) + if close is not None: + await close() + return entities() diff --git a/examples/order-management/python-lib-core/requests/product_request.py b/examples/order-management/python-lib-core/requests/product_request.py index d73b94b..6223f4a 100644 --- a/examples/order-management/python-lib-core/requests/product_request.py +++ b/examples/order-management/python-lib-core/requests/product_request.py @@ -812,25 +812,25 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ intent = QueryIntent(request._comment, request._purpose) query = deepcopy(request.query) query.offset(offset).limit(limit) - authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)) service = context.require_resource("dataService") alias = "__teaql_total" - if authorized.id_set_pagination is not None: - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + if authorized.query.id_set_pagination is not None: + row_result = await service.query(context, authorized) retained_count, accuracy = context.id_set_count() if accuracy == "EXACT": total_count = retained_count else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + row_result = await service.query(context, authorized) data = SmartList(Product(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) @@ -840,7 +840,7 @@ async def execute_for_one(self, context): entities = await ExecutableProductRequest(request).execute_for_list(context) return entities[0] if entities else None - async def execute_for_stream(self, context, chunk_size: int = 1000): + def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) @@ -848,6 +848,14 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - async for chunk in service.query_stream(context, req, chunk_size): - for row in chunk.rows: - yield Product(**row) + stream = service.query_stream(context, req, chunk_size) + async def entities(): + try: + async for chunk in stream: + for row in chunk.rows: + yield Product(**row) + finally: + close = getattr(stream, "aclose", None) + if close is not None: + await close() + return entities() diff --git a/examples/school-management/models/platform.py b/examples/school-management/models/platform.py index 264d8ad..41aa778 100644 --- a/examples/school-management/models/platform.py +++ b/examples/school-management/models/platform.py @@ -351,6 +351,12 @@ async def _teaql_save_children(self, graph, scope): ]) from error def update_id(self, value): + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + raise ValueError("id must be a positive integer") + old_key = self._teaql_entity_key() + new_key = EntityKey(old_key.entity, value) + self._entity_root.rekey(old_key, new_key) + self._ledger_id = value self.id = value self._loaded_fields.add("id") self._entity_root.set(self._teaql_entity_key(), "id", Value.from_any(value)) diff --git a/examples/school-management/models/school.py b/examples/school-management/models/school.py index b92d8e1..84825fc 100644 --- a/examples/school-management/models/school.py +++ b/examples/school-management/models/school.py @@ -384,6 +384,12 @@ async def _teaql_save_children(self, graph, scope): ]) from error def update_id(self, value): + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + raise ValueError("id must be a positive integer") + old_key = self._teaql_entity_key() + new_key = EntityKey(old_key.entity, value) + self._entity_root.rekey(old_key, new_key) + self._ledger_id = value self.id = value self._loaded_fields.add("id") self._entity_root.set(self._teaql_entity_key(), "id", Value.from_any(value)) diff --git a/examples/school-management/models/school_type.py b/examples/school-management/models/school_type.py index ca3f0ae..0358694 100644 --- a/examples/school-management/models/school_type.py +++ b/examples/school-management/models/school_type.py @@ -330,6 +330,12 @@ async def _teaql_save_children(self, graph, scope): ]) from error def update_id(self, value): + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + raise ValueError("id must be a positive integer") + old_key = self._teaql_entity_key() + new_key = EntityKey(old_key.entity, value) + self._entity_root.rekey(old_key, new_key) + self._ledger_id = value self.id = value self._loaded_fields.add("id") self._entity_root.set(self._teaql_entity_key(), "id", Value.from_any(value)) diff --git a/examples/school-management/requests/platform_request.py b/examples/school-management/requests/platform_request.py index cf7d25b..d07370d 100644 --- a/examples/school-management/requests/platform_request.py +++ b/examples/school-management/requests/platform_request.py @@ -809,25 +809,25 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ intent = QueryIntent(request._comment, request._purpose) query = deepcopy(request.query) query.offset(offset).limit(limit) - authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)) service = context.require_resource("dataService") alias = "__teaql_total" - if authorized.id_set_pagination is not None: - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + if authorized.query.id_set_pagination is not None: + row_result = await service.query(context, authorized) retained_count, accuracy = context.id_set_count() if accuracy == "EXACT": total_count = retained_count else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + row_result = await service.query(context, authorized) data = SmartList(Platform(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) @@ -837,7 +837,7 @@ async def execute_for_one(self, context): entities = await ExecutablePlatformRequest(request).execute_for_list(context) return entities[0] if entities else None - async def execute_for_stream(self, context, chunk_size: int = 1000): + def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) @@ -845,6 +845,14 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - async for chunk in service.query_stream(context, req, chunk_size): - for row in chunk.rows: - yield Platform(**row) + stream = service.query_stream(context, req, chunk_size) + async def entities(): + try: + async for chunk in stream: + for row in chunk.rows: + yield Platform(**row) + finally: + close = getattr(stream, "aclose", None) + if close is not None: + await close() + return entities() diff --git a/examples/school-management/requests/school_request.py b/examples/school-management/requests/school_request.py index 413f4c0..3a20d9c 100644 --- a/examples/school-management/requests/school_request.py +++ b/examples/school-management/requests/school_request.py @@ -878,25 +878,25 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ intent = QueryIntent(request._comment, request._purpose) query = deepcopy(request.query) query.offset(offset).limit(limit) - authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)) service = context.require_resource("dataService") alias = "__teaql_total" - if authorized.id_set_pagination is not None: - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + if authorized.query.id_set_pagination is not None: + row_result = await service.query(context, authorized) retained_count, accuracy = context.id_set_count() if accuracy == "EXACT": total_count = retained_count else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + row_result = await service.query(context, authorized) data = SmartList(School(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) @@ -906,7 +906,7 @@ async def execute_for_one(self, context): entities = await ExecutableSchoolRequest(request).execute_for_list(context) return entities[0] if entities else None - async def execute_for_stream(self, context, chunk_size: int = 1000): + def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) @@ -914,6 +914,14 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - async for chunk in service.query_stream(context, req, chunk_size): - for row in chunk.rows: - yield School(**row) + stream = service.query_stream(context, req, chunk_size) + async def entities(): + try: + async for chunk in stream: + for row in chunk.rows: + yield School(**row) + finally: + close = getattr(stream, "aclose", None) + if close is not None: + await close() + return entities() diff --git a/examples/school-management/requests/school_type_request.py b/examples/school-management/requests/school_type_request.py index 4c27973..605dca8 100644 --- a/examples/school-management/requests/school_type_request.py +++ b/examples/school-management/requests/school_type_request.py @@ -705,25 +705,25 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ intent = QueryIntent(request._comment, request._purpose) query = deepcopy(request.query) query.offset(offset).limit(limit) - authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)) service = context.require_resource("dataService") alias = "__teaql_total" - if authorized.id_set_pagination is not None: - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + if authorized.query.id_set_pagination is not None: + row_result = await service.query(context, authorized) retained_count, accuracy = context.id_set_count() if accuracy == "EXACT": total_count = retained_count else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + row_result = await service.query(context, authorized) data = SmartList(SchoolType(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) @@ -733,7 +733,7 @@ async def execute_for_one(self, context): entities = await ExecutableSchoolTypeRequest(request).execute_for_list(context) return entities[0] if entities else None - async def execute_for_stream(self, context, chunk_size: int = 1000): + def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) @@ -741,6 +741,14 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - async for chunk in service.query_stream(context, req, chunk_size): - for row in chunk.rows: - yield SchoolType(**row) + stream = service.query_stream(context, req, chunk_size) + async def entities(): + try: + async for chunk in stream: + for row in chunk.rows: + yield SchoolType(**row) + finally: + close = getattr(stream, "aclose", None) + if close is not None: + await close() + return entities() diff --git a/examples/task_board/generated/models/platform.py b/examples/task_board/generated/models/platform.py index fb8747c..4214919 100644 --- a/examples/task_board/generated/models/platform.py +++ b/examples/task_board/generated/models/platform.py @@ -336,6 +336,12 @@ async def _teaql_save_children(self, graph, scope): ]) from error def update_id(self, value): + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + raise ValueError("id must be a positive integer") + old_key = self._teaql_entity_key() + new_key = EntityKey(old_key.entity, value) + self._entity_root.rekey(old_key, new_key) + self._ledger_id = value self.id = value self._loaded_fields.add("id") self._entity_root.set(self._teaql_entity_key(), "id", Value.from_any(value)) diff --git a/examples/task_board/generated/models/task.py b/examples/task_board/generated/models/task.py index c56b8a8..e43d664 100644 --- a/examples/task_board/generated/models/task.py +++ b/examples/task_board/generated/models/task.py @@ -322,6 +322,12 @@ async def _teaql_save_children(self, graph, scope): ]) from error def update_id(self, value): + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + raise ValueError("id must be a positive integer") + old_key = self._teaql_entity_key() + new_key = EntityKey(old_key.entity, value) + self._entity_root.rekey(old_key, new_key) + self._ledger_id = value self.id = value self._loaded_fields.add("id") self._entity_root.set(self._teaql_entity_key(), "id", Value.from_any(value)) diff --git a/examples/task_board/generated/models/task_execution_log.py b/examples/task_board/generated/models/task_execution_log.py index cc6b57e..be6c897 100644 --- a/examples/task_board/generated/models/task_execution_log.py +++ b/examples/task_board/generated/models/task_execution_log.py @@ -287,6 +287,12 @@ async def _teaql_save_children(self, graph, scope): ]) from error def update_id(self, value): + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + raise ValueError("id must be a positive integer") + old_key = self._teaql_entity_key() + new_key = EntityKey(old_key.entity, value) + self._entity_root.rekey(old_key, new_key) + self._ledger_id = value self.id = value self._loaded_fields.add("id") self._entity_root.set(self._teaql_entity_key(), "id", Value.from_any(value)) diff --git a/examples/task_board/generated/models/task_status.py b/examples/task_board/generated/models/task_status.py index bf959af..f029623 100644 --- a/examples/task_board/generated/models/task_status.py +++ b/examples/task_board/generated/models/task_status.py @@ -360,6 +360,12 @@ async def _teaql_save_children(self, graph, scope): ]) from error def update_id(self, value): + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + raise ValueError("id must be a positive integer") + old_key = self._teaql_entity_key() + new_key = EntityKey(old_key.entity, value) + self._entity_root.rekey(old_key, new_key) + self._ledger_id = value self.id = value self._loaded_fields.add("id") self._entity_root.set(self._teaql_entity_key(), "id", Value.from_any(value)) diff --git a/examples/task_board/generated/requests/platform_request.py b/examples/task_board/generated/requests/platform_request.py index 3be8eae..4fd798b 100644 --- a/examples/task_board/generated/requests/platform_request.py +++ b/examples/task_board/generated/requests/platform_request.py @@ -747,25 +747,25 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ intent = QueryIntent(request._comment, request._purpose) query = deepcopy(request.query) query.offset(offset).limit(limit) - authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)) service = context.require_resource("dataService") alias = "__teaql_total" - if authorized.id_set_pagination is not None: - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + if authorized.query.id_set_pagination is not None: + row_result = await service.query(context, authorized) retained_count, accuracy = context.id_set_count() if accuracy == "EXACT": total_count = retained_count else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + row_result = await service.query(context, authorized) data = SmartList(Platform(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) @@ -775,7 +775,7 @@ async def execute_for_one(self, context): entities = await ExecutablePlatformRequest(request).execute_for_list(context) return entities[0] if entities else None - async def execute_for_stream(self, context, chunk_size: int = 1000): + def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) @@ -783,6 +783,14 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - async for chunk in service.query_stream(context, req, chunk_size): - for row in chunk.rows: - yield Platform(**row) + stream = service.query_stream(context, req, chunk_size) + async def entities(): + try: + async for chunk in stream: + for row in chunk.rows: + yield Platform(**row) + finally: + close = getattr(stream, "aclose", None) + if close is not None: + await close() + return entities() diff --git a/examples/task_board/generated/requests/task_execution_log_request.py b/examples/task_board/generated/requests/task_execution_log_request.py index e3fc9cf..895a961 100644 --- a/examples/task_board/generated/requests/task_execution_log_request.py +++ b/examples/task_board/generated/requests/task_execution_log_request.py @@ -470,25 +470,25 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ intent = QueryIntent(request._comment, request._purpose) query = deepcopy(request.query) query.offset(offset).limit(limit) - authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)) service = context.require_resource("dataService") alias = "__teaql_total" - if authorized.id_set_pagination is not None: - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + if authorized.query.id_set_pagination is not None: + row_result = await service.query(context, authorized) retained_count, accuracy = context.id_set_count() if accuracy == "EXACT": total_count = retained_count else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + row_result = await service.query(context, authorized) data = SmartList(TaskExecutionLog(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) @@ -498,7 +498,7 @@ async def execute_for_one(self, context): entities = await ExecutableTaskExecutionLogRequest(request).execute_for_list(context) return entities[0] if entities else None - async def execute_for_stream(self, context, chunk_size: int = 1000): + def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) @@ -506,6 +506,14 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - async for chunk in service.query_stream(context, req, chunk_size): - for row in chunk.rows: - yield TaskExecutionLog(**row) + stream = service.query_stream(context, req, chunk_size) + async def entities(): + try: + async for chunk in stream: + for row in chunk.rows: + yield TaskExecutionLog(**row) + finally: + close = getattr(stream, "aclose", None) + if close is not None: + await close() + return entities() diff --git a/examples/task_board/generated/requests/task_request.py b/examples/task_board/generated/requests/task_request.py index e05aedc..fb2965a 100644 --- a/examples/task_board/generated/requests/task_request.py +++ b/examples/task_board/generated/requests/task_request.py @@ -470,25 +470,25 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ intent = QueryIntent(request._comment, request._purpose) query = deepcopy(request.query) query.offset(offset).limit(limit) - authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)) service = context.require_resource("dataService") alias = "__teaql_total" - if authorized.id_set_pagination is not None: - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + if authorized.query.id_set_pagination is not None: + row_result = await service.query(context, authorized) retained_count, accuracy = context.id_set_count() if accuracy == "EXACT": total_count = retained_count else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + row_result = await service.query(context, authorized) data = SmartList(Task(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) @@ -498,7 +498,7 @@ async def execute_for_one(self, context): entities = await ExecutableTaskRequest(request).execute_for_list(context) return entities[0] if entities else None - async def execute_for_stream(self, context, chunk_size: int = 1000): + def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) @@ -506,6 +506,14 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - async for chunk in service.query_stream(context, req, chunk_size): - for row in chunk.rows: - yield Task(**row) + stream = service.query_stream(context, req, chunk_size) + async def entities(): + try: + async for chunk in stream: + for row in chunk.rows: + yield Task(**row) + finally: + close = getattr(stream, "aclose", None) + if close is not None: + await close() + return entities() diff --git a/examples/task_board/generated/requests/task_status_request.py b/examples/task_board/generated/requests/task_status_request.py index 5d1b5f0..0bc30e6 100644 --- a/examples/task_board/generated/requests/task_status_request.py +++ b/examples/task_board/generated/requests/task_status_request.py @@ -821,25 +821,25 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ intent = QueryIntent(request._comment, request._purpose) query = deepcopy(request.query) query.offset(offset).limit(limit) - authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)) service = context.require_resource("dataService") alias = "__teaql_total" - if authorized.id_set_pagination is not None: - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + if authorized.query.id_set_pagination is not None: + row_result = await service.query(context, authorized) retained_count, accuracy = context.id_set_count() if accuracy == "EXACT": total_count = retained_count else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + row_result = await service.query(context, authorized) data = SmartList(TaskStatus(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) @@ -849,7 +849,7 @@ async def execute_for_one(self, context): entities = await ExecutableTaskStatusRequest(request).execute_for_list(context) return entities[0] if entities else None - async def execute_for_stream(self, context, chunk_size: int = 1000): + def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) @@ -857,6 +857,14 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - async for chunk in service.query_stream(context, req, chunk_size): - for row in chunk.rows: - yield TaskStatus(**row) + stream = service.query_stream(context, req, chunk_size) + async def entities(): + try: + async for chunk in stream: + for row in chunk.rows: + yield TaskStatus(**row) + finally: + close = getattr(stream, "aclose", None) + if close is not None: + await close() + return entities() diff --git a/examples/trace-chain/README.md b/examples/trace-chain/README.md index ab6696b..a71ec9d 100644 --- a/examples/trace-chain/README.md +++ b/examples/trace-chain/README.md @@ -12,11 +12,12 @@ cd /path/to/teaql-python bash examples/trace-chain/verify.sh ``` -The verifier uses this repository's runtime source and executes both suites +The verifier uses this repository's runtime source and executes all three suites twice without cleanup, then checks the generated library hashes. The normative graph and ownership fixtures require separate SQLite files because their graph sizes differ. Set `TEAQL_TRACE_CHAIN_DB` and `TEAQL_TRACE_CHAIN_SHARED_DB` to retain -and replay those files. Each suite reuses its file across both runs. Unique +and replay those files. The page/stream suite has its own +`TEAQL_TRACE_CHAIN_PAGE_STREAM_DB` file. Each suite reuses its file across both runs. Unique business labels avoid deleting existing demo records. Seven scenario groups verify missing root reason before transaction access, @@ -35,6 +36,29 @@ mutable dictionary deliberately used read-only and checked unchanged, not a structurally immutable snapshot. JSON observations retain actual commands, optimistic versions, SQL metadata and committed audits. +Eight page/stream phases use the same model and current list-page/field Assist. +Offset 1 / limit 2 returns two independent roots with original versions 2/1 and +exact total 3. Changing the caller's builder while COUNT is paused does not +change later row/relation intent. Root and child saves emit only their actual +changed operations in the customer policy's reviewed plan, SQL and committed +audit. Shared Platform data remains unchanged. + +Count safely retains private bindings declared by removed descendants, without +executing those descendants or storing privacy on Context. The next independent +query does not inherit that privacy. Two scalar streams retain creation-time +intent across delayed consumption, with two actual SQLite driver generators +live while an independent query runs. Explicit close releases both nested +iterators immediately; successful exhaustion and early cancellation retain +distinct outcomes/cardinalities. Streamed rows own independent ledgers; saving +one row does not write or clear another row's pending changes. Relation hydration +is not supported by scalar streaming; use list or page for relation graphs. + +`PAGE_STREAM_OBSERVED` records actual SQL, policy-reviewed operations, commands, +optimistic versions and safe audits. Native tests additionally cover COUNT +failure and trusted policy removing the private child. Current generation also +rekeys a new object's ledger when an explicit positive integer ID is assigned, +rejecting invalid IDs before changing object/ledger state. + The model evaluation reports zero errors, warnings and suggestions, with seventeen Solids. Current model-aware Assist and evaluation evidence are retained under `evidence/`. `AGENTS.md` governs application implementation. The library is diff --git a/examples/trace-chain/evidence/assist/python-assist-list-page/customer_order.md b/examples/trace-chain/evidence/assist/python-assist-list-page/customer_order.md new file mode 100644 index 0000000..7a5a3c5 --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-list-page/customer_order.md @@ -0,0 +1,98 @@ + + +# Python Assist — List page `Customer Order` + +Use the exact generated entry point and APIs below. The result is a typed page +with exact filtered `total_count`; generated client code cannot override the +runtime's fixed 10,000-row materialization ceiling. + +```python +from Q import Q +from models.customer_order import CustomerOrder +from teaql.core.list import TeaQLPage + +async def list_active_customer_order_page( + context, offset: int, limit: int +) -> TeaQLPage[CustomerOrder]: + return await ( + Q.customer_orders_minimal() + .select_order_number() + .select_description() + .select_platform_with(Q.platforms_minimal()) + .select_order_item_list() + .select_payment_list() + .select_shipment_list() + .order_by_id_ascending() + .comment("what: list the active Customer Order page") + .purpose("why: serve the authorized Customer Order directory") + .execute_for_page(context, offset, limit) + ) +``` + +Compile and execute this source unchanged. Rows and count share the exact +generated and trusted-policy filters. Count excludes projection, relations, +ordering, offset, and limit. Reject negative offsets, limits outside +`1...10000`, unknown filters/sorts, missing intent, raw query dictionaries, and +every generated-client attempt to call `hard_limit`. + +## Bounded scalar streaming + +```python +stream = ( + Q.customer_orders() + .order_by_id_ascending() + .limit(10) + .comment("what: stream bounded Customer Order rows") + .purpose("why: process independent loaded entities") + .execute_for_stream(context, chunk_size=2) +) +try: + async for entity in stream: + pass # Traverse loaded scalars with E; save only a changed entity. +finally: + await stream.aclose() +``` + +Stream creation captures the request and runs trusted policy before delayed +consumption. The database cursor remains lazy. Each yielded entity has its own +mutation ledger. Relation hydration and aggregate enhancement are not supported +by the scalar stream; use the list or page API for those graphs. Explicit close +releases the provider cursor even when the consumer stops before exhaustion. + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `list-page`. + +- Validate offset, page size, filters, deep paths, IN-list size, and sort against + explicit allow-lists. Reject invalid input instead of widening the query. +- Use a stable unique ordering and retain the runtime hard limit. Continuous-page + optimization is opt-in, browsing-only, local runtime policy and cannot cross TFP. +- Run count only when explicitly requested; otherwise use the returned list length. diff --git a/examples/trace-chain/evidence/assist/python-assist-query/order_item.name.md b/examples/trace-chain/evidence/assist/python-assist-query/order_item.name.md new file mode 100644 index 0000000..bc387db --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-query/order_item.name.md @@ -0,0 +1,73 @@ + + +# Python Query Field Assist — `order_item.name` + +KSML entity: `Order Item` +KSML field: `name` +Type: `string` + +| Capability | Generated API | +| --- | --- | +| Select | `select_name()` | +| Equality/set | `with_name_is(value)`, `with_name_is_not(value)`, `with_name_in(*values)`, `with_name_not_in(*values)` | +| Comparison | `with_name_greater_than(value)`, `with_name_greater_than_or_equal_to(value)`, `with_name_less_than(value)`, `with_name_less_than_or_equal_to(value)`, `with_name_between(lower, upper)` | +| Null state | `with_name_is_known()`, `with_name_is_unknown()` | +| String | `with_name_containing(value)`, `with_name_not_containing(value)`, `with_name_starting_with(value)`, `with_name_ending_with(value)`, `with_name_sounding_like(value)` | +| Order | `order_by_name_ascending()`, `order_by_name_descending()` | +| Group | `group_by_name()`, `group_by_name_as(alias)` | +Set predicates accept positional values. Pass separate arguments, for example +`with_name_in(first, second)`, or unpack a sequence with +`with_name_in(*values)`. Do not pass a list as one argument. +Execution requires `comment(...)`, `purpose(...)`, and exactly one `context`. + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact PYTHON generated and +runtime APIs. Discover APIs through the generated application AGENTS.md and progressive +model-aware Assist. Do not inspect generated domain-library source. + +- Do not create plurals by appending `s` or `es`; use the centralized generated plural. +- Human and non-human entities use different generated predicate vocabularies. Preserve + forms such as “who are active” and “whose email is”; never infer them from English. +- Configure filters, projection, paging, and other query options before `purpose(...)`. + Comment may appear anywhere in the chain. Purpose enters the executable stage; execution + requires both values, but comment does not have to immediately precede purpose. +- Every execute/list/stream and every save accepts exactly one context argument: + `UserContext`. Name that argument `context`, never `runtime`; data services and global + policy are injected when the context is built. Reserve `runtime` for process-level + runtime ownership, provider/pool setup, and module assembly. +- Tenant, merchant, identity, permissions, request policy, purpose policy, hard limit, + and continuous-page cursor policy come only from trusted context, never dynamic JSON or TFP. +- If the required operation is absent after current entity/action and required field + Assist, stop that path and report MISSING_ASSIST. Do not guess an API or search the + generated library as a fallback. +- Create each application-owned source file once. After its first compile attempt, + repair only the smallest block identified by the exact compiler or test diagnostic. + Preserve unrelated code; do not rewrite the complete file as an error-recovery loop. +- Before a repair that would replace more than 25% of an existing application file, + stop and report LARGE_REWRITE_REQUEST with the file, exact diagnostic, reason, and + estimated scope. Initial creation and model-driven regeneration are not repairs. + +Capability: `query`. + +- Use generated projection, relation, predicate, sort, and aggregate APIs; never + concatenate application SQL or silently ignore an unsupported filter. +- Rows, record count, facets, and aggregates must share the same active filter. +- Add negative tests for missing purpose/comment and forbidden dynamic fields. + +### Optional per-parent Top-N optimization + +- Configure `top_n_probe_parent_threshold(threshold)` on the nested child request before its intent/execution + stage. Use the field-specific relation Assist for the exact generated selector. +- Give the child a per-parent limit and deterministic ordering with an ID tie-breaker; + bound the parent query separately. A single-parent detail query is not batch Top-N. +- Server providers default to a window query (`threshold = 0`). A positive threshold + permits bounded probes only when the already-loaded parent count is at or below it; + above it, use the window plan. SQLite uses its internal AlwaysProbe policy by default. +- Measure before opting in. Do not infer a threshold from table size or execute COUNT + or statistics queries to choose a plan. Keep an index on foreign key + sort + ID. +- Inspect selected-plan, parent-count, per-parent-limit and probe-count telemetry. + Runtime-managed probes are not application-owned N+1 loops; neither strategy changes + authorization, loaded-state semantics or the required comment/purpose. diff --git a/examples/trace-chain/lib/models/customer_order.py b/examples/trace-chain/lib/models/customer_order.py index 7b39bb9..b0c411a 100644 --- a/examples/trace-chain/lib/models/customer_order.py +++ b/examples/trace-chain/lib/models/customer_order.py @@ -371,6 +371,12 @@ async def _teaql_save_children(self, graph, scope): ]) from error def update_id(self, value): + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + raise ValueError("id must be a positive integer") + old_key = self._teaql_entity_key() + new_key = EntityKey(old_key.entity, value) + self._entity_root.rekey(old_key, new_key) + self._ledger_id = value self.id = value self._loaded_fields.add("id") self._entity_root.set(self._teaql_entity_key(), "id", Value.from_any(value)) diff --git a/examples/trace-chain/lib/models/order_item.py b/examples/trace-chain/lib/models/order_item.py index 90c4cb0..c1aab6e 100644 --- a/examples/trace-chain/lib/models/order_item.py +++ b/examples/trace-chain/lib/models/order_item.py @@ -272,6 +272,12 @@ async def _teaql_save_children(self, graph, scope): ]) from error def update_id(self, value): + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + raise ValueError("id must be a positive integer") + old_key = self._teaql_entity_key() + new_key = EntityKey(old_key.entity, value) + self._entity_root.rekey(old_key, new_key) + self._ledger_id = value self.id = value self._loaded_fields.add("id") self._entity_root.set(self._teaql_entity_key(), "id", Value.from_any(value)) diff --git a/examples/trace-chain/lib/models/payment.py b/examples/trace-chain/lib/models/payment.py index a6498bc..e599b80 100644 --- a/examples/trace-chain/lib/models/payment.py +++ b/examples/trace-chain/lib/models/payment.py @@ -300,6 +300,12 @@ async def _teaql_save_children(self, graph, scope): ]) from error def update_id(self, value): + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + raise ValueError("id must be a positive integer") + old_key = self._teaql_entity_key() + new_key = EntityKey(old_key.entity, value) + self._entity_root.rekey(old_key, new_key) + self._ledger_id = value self.id = value self._loaded_fields.add("id") self._entity_root.set(self._teaql_entity_key(), "id", Value.from_any(value)) diff --git a/examples/trace-chain/lib/models/payment_attempt.py b/examples/trace-chain/lib/models/payment_attempt.py index 7093369..422a706 100644 --- a/examples/trace-chain/lib/models/payment_attempt.py +++ b/examples/trace-chain/lib/models/payment_attempt.py @@ -272,6 +272,12 @@ async def _teaql_save_children(self, graph, scope): ]) from error def update_id(self, value): + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + raise ValueError("id must be a positive integer") + old_key = self._teaql_entity_key() + new_key = EntityKey(old_key.entity, value) + self._entity_root.rekey(old_key, new_key) + self._ledger_id = value self.id = value self._loaded_fields.add("id") self._entity_root.set(self._teaql_entity_key(), "id", Value.from_any(value)) diff --git a/examples/trace-chain/lib/models/platform.py b/examples/trace-chain/lib/models/platform.py index 38a3d1b..9eb5819 100644 --- a/examples/trace-chain/lib/models/platform.py +++ b/examples/trace-chain/lib/models/platform.py @@ -278,6 +278,12 @@ async def _teaql_save_children(self, graph, scope): ]) from error def update_id(self, value): + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + raise ValueError("id must be a positive integer") + old_key = self._teaql_entity_key() + new_key = EntityKey(old_key.entity, value) + self._entity_root.rekey(old_key, new_key) + self._ledger_id = value self.id = value self._loaded_fields.add("id") self._entity_root.set(self._teaql_entity_key(), "id", Value.from_any(value)) diff --git a/examples/trace-chain/lib/models/shipment.py b/examples/trace-chain/lib/models/shipment.py index 7b2ce19..e70879b 100644 --- a/examples/trace-chain/lib/models/shipment.py +++ b/examples/trace-chain/lib/models/shipment.py @@ -272,6 +272,12 @@ async def _teaql_save_children(self, graph, scope): ]) from error def update_id(self, value): + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + raise ValueError("id must be a positive integer") + old_key = self._teaql_entity_key() + new_key = EntityKey(old_key.entity, value) + self._entity_root.rekey(old_key, new_key) + self._ledger_id = value self.id = value self._loaded_fields.add("id") self._entity_root.set(self._teaql_entity_key(), "id", Value.from_any(value)) diff --git a/examples/trace-chain/lib/requests/customer_order_request.py b/examples/trace-chain/lib/requests/customer_order_request.py index 12b7ba2..98aa857 100644 --- a/examples/trace-chain/lib/requests/customer_order_request.py +++ b/examples/trace-chain/lib/requests/customer_order_request.py @@ -587,25 +587,25 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ intent = QueryIntent(request._comment, request._purpose) query = deepcopy(request.query) query.offset(offset).limit(limit) - authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)) service = context.require_resource("dataService") alias = "__teaql_total" - if authorized.id_set_pagination is not None: - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + if authorized.query.id_set_pagination is not None: + row_result = await service.query(context, authorized) retained_count, accuracy = context.id_set_count() if accuracy == "EXACT": total_count = retained_count else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + row_result = await service.query(context, authorized) data = SmartList(CustomerOrder(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) @@ -615,7 +615,7 @@ async def execute_for_one(self, context): entities = await ExecutableCustomerOrderRequest(request).execute_for_list(context) return entities[0] if entities else None - async def execute_for_stream(self, context, chunk_size: int = 1000): + def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) @@ -623,6 +623,14 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - async for chunk in service.query_stream(context, req, chunk_size): - for row in chunk.rows: - yield CustomerOrder(**row) + stream = service.query_stream(context, req, chunk_size) + async def entities(): + try: + async for chunk in stream: + for row in chunk.rows: + yield CustomerOrder(**row) + finally: + close = getattr(stream, "aclose", None) + if close is not None: + await close() + return entities() diff --git a/examples/trace-chain/lib/requests/order_item_request.py b/examples/trace-chain/lib/requests/order_item_request.py index ab18a7b..a1130e4 100644 --- a/examples/trace-chain/lib/requests/order_item_request.py +++ b/examples/trace-chain/lib/requests/order_item_request.py @@ -380,25 +380,25 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ intent = QueryIntent(request._comment, request._purpose) query = deepcopy(request.query) query.offset(offset).limit(limit) - authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)) service = context.require_resource("dataService") alias = "__teaql_total" - if authorized.id_set_pagination is not None: - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + if authorized.query.id_set_pagination is not None: + row_result = await service.query(context, authorized) retained_count, accuracy = context.id_set_count() if accuracy == "EXACT": total_count = retained_count else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + row_result = await service.query(context, authorized) data = SmartList(OrderItem(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) @@ -408,7 +408,7 @@ async def execute_for_one(self, context): entities = await ExecutableOrderItemRequest(request).execute_for_list(context) return entities[0] if entities else None - async def execute_for_stream(self, context, chunk_size: int = 1000): + def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) @@ -416,6 +416,14 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - async for chunk in service.query_stream(context, req, chunk_size): - for row in chunk.rows: - yield OrderItem(**row) + stream = service.query_stream(context, req, chunk_size) + async def entities(): + try: + async for chunk in stream: + for row in chunk.rows: + yield OrderItem(**row) + finally: + close = getattr(stream, "aclose", None) + if close is not None: + await close() + return entities() diff --git a/examples/trace-chain/lib/requests/payment_attempt_request.py b/examples/trace-chain/lib/requests/payment_attempt_request.py index 7718f89..d841ed0 100644 --- a/examples/trace-chain/lib/requests/payment_attempt_request.py +++ b/examples/trace-chain/lib/requests/payment_attempt_request.py @@ -380,25 +380,25 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ intent = QueryIntent(request._comment, request._purpose) query = deepcopy(request.query) query.offset(offset).limit(limit) - authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)) service = context.require_resource("dataService") alias = "__teaql_total" - if authorized.id_set_pagination is not None: - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + if authorized.query.id_set_pagination is not None: + row_result = await service.query(context, authorized) retained_count, accuracy = context.id_set_count() if accuracy == "EXACT": total_count = retained_count else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + row_result = await service.query(context, authorized) data = SmartList(PaymentAttempt(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) @@ -408,7 +408,7 @@ async def execute_for_one(self, context): entities = await ExecutablePaymentAttemptRequest(request).execute_for_list(context) return entities[0] if entities else None - async def execute_for_stream(self, context, chunk_size: int = 1000): + def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) @@ -416,6 +416,14 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - async for chunk in service.query_stream(context, req, chunk_size): - for row in chunk.rows: - yield PaymentAttempt(**row) + stream = service.query_stream(context, req, chunk_size) + async def entities(): + try: + async for chunk in stream: + for row in chunk.rows: + yield PaymentAttempt(**row) + finally: + close = getattr(stream, "aclose", None) + if close is not None: + await close() + return entities() diff --git a/examples/trace-chain/lib/requests/payment_request.py b/examples/trace-chain/lib/requests/payment_request.py index 69b9b72..9c63b9b 100644 --- a/examples/trace-chain/lib/requests/payment_request.py +++ b/examples/trace-chain/lib/requests/payment_request.py @@ -419,25 +419,25 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ intent = QueryIntent(request._comment, request._purpose) query = deepcopy(request.query) query.offset(offset).limit(limit) - authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)) service = context.require_resource("dataService") alias = "__teaql_total" - if authorized.id_set_pagination is not None: - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + if authorized.query.id_set_pagination is not None: + row_result = await service.query(context, authorized) retained_count, accuracy = context.id_set_count() if accuracy == "EXACT": total_count = retained_count else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + row_result = await service.query(context, authorized) data = SmartList(Payment(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) @@ -447,7 +447,7 @@ async def execute_for_one(self, context): entities = await ExecutablePaymentRequest(request).execute_for_list(context) return entities[0] if entities else None - async def execute_for_stream(self, context, chunk_size: int = 1000): + def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) @@ -455,6 +455,14 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - async for chunk in service.query_stream(context, req, chunk_size): - for row in chunk.rows: - yield Payment(**row) + stream = service.query_stream(context, req, chunk_size) + async def entities(): + try: + async for chunk in stream: + for row in chunk.rows: + yield Payment(**row) + finally: + close = getattr(stream, "aclose", None) + if close is not None: + await close() + return entities() diff --git a/examples/trace-chain/lib/requests/platform_request.py b/examples/trace-chain/lib/requests/platform_request.py index a34b63f..7839df1 100644 --- a/examples/trace-chain/lib/requests/platform_request.py +++ b/examples/trace-chain/lib/requests/platform_request.py @@ -380,25 +380,25 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ intent = QueryIntent(request._comment, request._purpose) query = deepcopy(request.query) query.offset(offset).limit(limit) - authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)) service = context.require_resource("dataService") alias = "__teaql_total" - if authorized.id_set_pagination is not None: - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + if authorized.query.id_set_pagination is not None: + row_result = await service.query(context, authorized) retained_count, accuracy = context.id_set_count() if accuracy == "EXACT": total_count = retained_count else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + row_result = await service.query(context, authorized) data = SmartList(Platform(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) @@ -408,7 +408,7 @@ async def execute_for_one(self, context): entities = await ExecutablePlatformRequest(request).execute_for_list(context) return entities[0] if entities else None - async def execute_for_stream(self, context, chunk_size: int = 1000): + def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) @@ -416,6 +416,14 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - async for chunk in service.query_stream(context, req, chunk_size): - for row in chunk.rows: - yield Platform(**row) + stream = service.query_stream(context, req, chunk_size) + async def entities(): + try: + async for chunk in stream: + for row in chunk.rows: + yield Platform(**row) + finally: + close = getattr(stream, "aclose", None) + if close is not None: + await close() + return entities() diff --git a/examples/trace-chain/lib/requests/shipment_request.py b/examples/trace-chain/lib/requests/shipment_request.py index 20ad7c5..ceffe65 100644 --- a/examples/trace-chain/lib/requests/shipment_request.py +++ b/examples/trace-chain/lib/requests/shipment_request.py @@ -380,25 +380,25 @@ async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[ intent = QueryIntent(request._comment, request._purpose) query = deepcopy(request.query) query.offset(offset).limit(limit) - authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)).query + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)) service = context.require_resource("dataService") alias = "__teaql_total" - if authorized.id_set_pagination is not None: - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + if authorized.query.id_set_pagination is not None: + row_result = await service.query(context, authorized) retained_count, accuracy = context.id_set_count() if accuracy == "EXACT": total_count = retained_count else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) else: - count_result = await service.query(context, QueryRequest(authorized.for_exact_count(alias), _comment=request._comment, _purpose=request._purpose)) + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): raise RuntimeError("dataService did not return an exact page count") total_count = int(count_result.rows[0][alias]) - row_result = await service.query(context, QueryRequest(authorized, _comment=request._comment, _purpose=request._purpose)) + row_result = await service.query(context, authorized) data = SmartList(Shipment(**row) for row in row_result.rows) return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) @@ -408,7 +408,7 @@ async def execute_for_one(self, context): entities = await ExecutableShipmentRequest(request).execute_for_list(context) return entities[0] if entities else None - async def execute_for_stream(self, context, chunk_size: int = 1000): + def execute_for_stream(self, context, chunk_size: int = 1000): """Yield entity chunks lazily from the provider cursor.""" request = self._request req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) @@ -416,6 +416,14 @@ async def execute_for_stream(self, context, chunk_size: int = 1000): service = context.require_resource("dataService") if not hasattr(service, "query_stream"): raise RuntimeError("dataService does not implement query_stream") - async for chunk in service.query_stream(context, req, chunk_size): - for row in chunk.rows: - yield Shipment(**row) + stream = service.query_stream(context, req, chunk_size) + async def entities(): + try: + async for chunk in stream: + for row in chunk.rows: + yield Shipment(**row) + finally: + close = getattr(stream, "aclose", None) + if close is not None: + await close() + return entities() diff --git a/examples/trace-chain/page_stream.py b/examples/trace-chain/page_stream.py new file mode 100644 index 0000000..3ce877f --- /dev/null +++ b/examples/trace-chain/page_stream.py @@ -0,0 +1,263 @@ +"""Current Assist -> generated Q/E/save -> observed real SQLite pages/cursors.""" +import asyncio +import copy +import json +import os +import uuid + +from E import E +from Q import Q +from runtime_module import GENERATED_RUNTIME_MODULE +from teaql.core.entity import EntityKey +from teaql.provider.sqlite import create_sqlite_service +from teaql.runtime import UserContext +from teaql.runtime.mutation_policy import ( + DelegatingMutationPolicyRegistry, MutationDecision, MutationPolicyIdentity, +) + +from main import AuditSink, reset, scalar +from shared_reference import SharedService, assert_writes, load, pending, seed + + +class PageStreamService(SharedService): + def __init__(self, service): + super().__init__(service) + self.count_pause = None + self.active_cursors = set() + self.opened_cursors = 0 + self.closed_cursors = 0 + self.active_driver_cursors = set() + self.closed_driver_cursors = 0 + original_stream_sql = service.transport.stream_sql + def observed_driver_sql(compiled, chunk_size): + inner = original_stream_sql(compiled, chunk_size) + token = object() + async def rows(): + try: + async for batch in inner: + self.active_driver_cursors.add(token) + assert inner.ag_frame is not None + yield batch + finally: + try: + await inner.aclose() + finally: + self.active_driver_cursors.discard(token) + self.closed_driver_cursors += 1 + return rows() + service.transport.stream_sql = observed_driver_sql + + async def query(self, context, request): + result = await super().query(context, request) + if self.count_pause and request.query.aggregates: + entered, release = self.count_pause + self.count_pause = None + entered.set() + await asyncio.wait_for(release.wait(), 10) + return result + + def query_stream(self, context, request, chunk_size): + inner = self.service.query_stream(context, request, chunk_size) + token = object() + async def observed(): + self.opened_cursors += 1 + self.active_cursors.add(token) + try: + async for chunk in inner: + yield chunk + finally: + try: + await inner.aclose() + finally: + self.active_cursors.remove(token) + self.closed_cursors += 1 + return observed() + + +class RecordingPolicy: + identity = MutationPolicyIdentity('page-stream-example', '1', 'page-stream-v1') + + def __init__(self): + self.plans = [] + + def review(self, context, plan): + self.plans.append(plan) + return MutationDecision.allowed() + + +def log_row(entry): + return {'comment': entry.comment, 'purpose': entry.purpose, + 'outcome': entry.execution_outcome, 'sql': entry.sql, + 'debugSQL': entry.debug_sql, 'count': entry.result_count, + 'path': [(n.kind, n.name, n.entity_id, n.comment) for n in entry.trace_path], + 'lineage': [(n.kind, n.name, n.entity_id, n.comment) for n in entry.mutation_lineage]} + + +def evidence(phase, service, sink, context, policy, **facts): + print('PAGE_STREAM_OBSERVED ' + json.dumps({ + 'phase': phase, 'facts': facts, + 'sql': [log_row(entry) for entry in context.sql_logs()], + 'commands': service.requests, + 'versions': service.versions[-len(service.requests):] if service.requests else [], + 'plans': [[{'entity': op.entity, 'id': scalar(op.entity_id), + 'original_version': op.original_version, + 'fields': sorted(op.changed_values)} for op in plan.operations] for plan in policy.plans], + 'audit': [(event.entity, scalar(event.entity_id), + [(n.kind, n.name, n.entity_id, n.comment) for n in event.trace_chain]) for event in sink.events], + })) + + +def clear(service, sink, context, policy): + reset(service, sink, context) + policy.plans.clear() + + +async def pages(context, platform, service, sink, policy): + seeded = [await seed(context, platform) for _ in range(3)] + ids = [E.customer_order(row).id().eval() for row in seeded] + revised = await load(context, ids[1]) + await revised.update_description('prior page revision').audit_as('advance paged root').save(context) + builder = (Q.customer_orders().with_id_in(*ids).order_by_id_ascending() + .select_platform_with(Q.platforms().limit(1)) + .select_order_item_list_with(Q.order_items().limit(10)).limit(3)) + executable = builder.comment('load independent page graphs').purpose('verify page mutation boundaries') + entered, release = asyncio.Event(), asyncio.Event() + service.count_pause = (entered, release) + service.reuse_platform = True + clear(service, sink, context, policy) + task = asyncio.create_task(executable.execute_for_page(context, 1, 2)) + await asyncio.wait_for(entered.wait(), 10) + builder.with_id_is(9223372036854775807).comment('LATE-PAGE-COMMENT').purpose('LATE-PAGE-PURPOSE') + release.set() + page = await asyncio.wait_for(task, 10) + service.reuse_platform = False + first, second = page.data + assert page.total_count == 3 and [E.customer_order(row).id().eval() for row in page.data] == ids[1:] + assert [E.customer_order(row).version().eval() for row in page.data] == [2, 1] + assert first._entity_root is not second._entity_root and service.snapshot_uses == 2 + assert all(not row._entity_root.has_pending(row._teaql_entity_key()) for row in page.data) + assert service.requests == [] and policy.plans == [] and sink.events == [] + assert all(entry.comment == 'load independent page graphs' and entry.purpose == 'verify page mutation boundaries' + for entry in context.sql_logs()) + assert sum('COUNT(' in entry.sql.upper() for entry in context.sql_logs()) == 1 + before = copy.deepcopy(service.platform_snapshot) + evidence('page-rows', service, sink, context, policy, ids=ids, total=page.total_count, versions=[2, 1], + independent_ledgers=True, actual_shared_provider_record=True) + first.update_description('saved first paged root') + second.update_description('saved second paged root') + second_key = EntityKey('CustomerOrder', ids[2]) + for phase, row, expected_version in [('page-save-first', first, 2), ('page-save-second', second, 1)]: + clear(service, sink, context, policy) + await row.audit_as(phase).save(context) + assert_writes(service, sink, context, 1) + assert service.requests[0][0] == 'CustomerOrder' and service.versions[-1] == expected_version + assert len(policy.plans) == 1 and len(policy.plans[0].operations) == 1 + assert policy.plans[0].operations[0].original_version == expected_version + if row is first: + assert pending(second._entity_root, second_key) + assert service.platform_snapshot == before + evidence(phase, service, sink, context, policy, id=E.customer_order(row).id().eval(), + second_pending=pending(second._entity_root, second_key)) + child = first.order_item_list()[0] + child.update_name('only changed paged descendant').audit_as('repair page child') + clear(service, sink, context, policy) + await first.audit_as('save clean paged ancestor').save(context) + assert_writes(service, sink, context, 1) + assert service.requests[0][0] == 'OrderItem' and service.versions[-1] == 1 + assert len(policy.plans) == 1 and len(policy.plans[0].operations) == 1 + evidence('page-save-child', service, sink, context, policy, id=E.order_item(child).id().eval()) + reloaded = [await load(context, identity) for identity in ids] + assert [E.customer_order(row).version().eval() for row in reloaded] == [1, 3, 2] + assert [E.order_item(row.order_item_list()[0]).version().eval() for row in reloaded] == [1, 2, 1] + return ids + + +async def count_privacy(context, service, sink, policy, ids): + private = 'PAGE-CHILD-PRIVATE-' + uuid.uuid4().hex + context.configure_audit_policy('OrderItem', ['name']) + clear(service, sink, context, policy) + page = await (Q.customer_orders().with_id_in(*ids) + .select_order_item_list_with(Q.order_items().with_name_is(private).limit(1)).limit(3) + .comment('inspect page referencing ' + private).purpose('count page referencing ' + private) + .execute_for_page(context, 0, 2)) + assert page.total_count == 3 + logs = context.sql_logs() + assert any('COUNT(' in entry.sql.upper() for entry in logs) + assert all(private not in str(entry.comment) + str(entry.purpose) + entry.debug_sql for entry in logs) + assert all('[REDACTED]' in entry.comment and '[REDACTED]' in entry.purpose for entry in logs) + evidence('page-count-privacy', service, sink, context, policy, total=3, canary_absent=True) + clear(service, sink, context, policy) + await Q.customer_orders().with_id_is(ids[0]).limit(1).comment( + 'independent intent ' + private).purpose('no ambient privacy').execute_for_list(context) + assert context.sql_logs()[0].comment.endswith(private) + evidence('page-independent-privacy', service, sink, context, policy, no_ambient_privacy=True) + + +async def streams(context, service, sink, policy, ids): + clear(service, sink, context, policy) + policy_calls = [] + context.with_request_policy(lambda query: policy_calls.append(query.entity)) + builder = Q.customer_orders().with_id_in(*ids).order_by_id_ascending().limit(3) + stream = builder.comment('first captured stream').purpose('verify delayed scalar rows').execute_for_stream(context, chunk_size=1) + other = (Q.customer_orders().with_id_in(*ids).order_by_id_ascending().limit(3).comment('second captured stream') + .purpose('verify overlapping cursor intent').execute_for_stream(context, chunk_size=1)) + assert policy_calls == ['CustomerOrder', 'CustomerOrder'] and service.active_cursors == set() + builder.with_id_is(9223372036854775807).comment('LATE-STREAM-COMMENT').purpose('LATE-STREAM-PURPOSE') + first, other_first = await anext(stream), await anext(other) + assert len(service.active_cursors) == 2 and context._graph_session is None + assert len(service.active_driver_cursors) == 2 + assert context._audit_journal is None + await Q.customer_orders().with_id_is(ids[0]).limit(1).comment('independent live query').purpose( + 'no ambient stream intent').execute_for_one(context) + assert len(service.active_cursors) == 2 + assert len(service.active_driver_cursors) == 2 + await other.aclose() + remaining = [row async for row in stream] + await stream.aclose() + rows = [first, *remaining] + assert [E.customer_order(row).id().eval() for row in rows] == ids + assert E.customer_order(other_first).id().eval() == ids[0] + assert len({id(row._entity_root) for row in rows}) == 3 + assert service.active_cursors == set() and service.opened_cursors == service.closed_cursors == 2 + assert service.active_driver_cursors == set() and service.closed_driver_cursors == 2 + by_comment = {entry.comment: entry for entry in context.sql_logs()} + assert set(by_comment) == {'first captured stream', 'second captured stream', 'independent live query'} + assert by_comment['first captured stream'].result_count == 3 + assert by_comment['first captured stream'].execution_outcome == 'success' + assert by_comment['second captured stream'].execution_outcome == 'cancelled' + assert by_comment['second captured stream'].result_count == 1 + assert all(entry.trace_path[0].name == 'CustomerOrder' for entry in context.sql_logs()) + evidence('streams-overlap', service, sink, context, policy, ids=ids, two_live_cursors=True, + two_live_driver_cursors=True, closed_driver_cursors=service.closed_driver_cursors, + opened=service.opened_cursors, closed=service.closed_cursors, independent_ledgers=True) + rows[0].update_description('saved streamed root') + rows[1].update_description('pending streamed root') + key = EntityKey('CustomerOrder', ids[1]) + clear(service, sink, context, policy) + await rows[0].audit_as('save one streamed row').save(context) + assert_writes(service, sink, context, 1) + assert pending(rows[1]._entity_root, key) and service.requests[0][:2] == ('CustomerOrder', ids[0]) + assert len(policy.plans) == 1 and len(policy.plans[0].operations) == 1 + evidence('stream-save-one', service, sink, context, policy, id=ids[0], untouched_pending_id=ids[1]) + persisted = await load(context, ids[1]) + assert E.customer_order(persisted).description().eval() != 'pending streamed root' + + +async def main(): + context = UserContext.new().install(GENERATED_RUNTIME_MODULE) + service = PageStreamService(create_sqlite_service(os.environ['TEAQL_TRACE_CHAIN_PAGE_STREAM_DB'])) + sink, policy = AuditSink(service), RecordingPolicy() + context.insert_resource('dataService', service).with_app_audit_event_sink(sink) + context.with_mutation_policy_registry(DelegatingMutationPolicyRegistry(lambda _: policy)) + context.set_diagnostic_sql_log_sink(type('Silent', (), {'write': lambda self, entry: None})()) + await context.ensure_schema() + platform = await Q.platforms().with_id_is(1).limit(1).comment('reuse root for pages').purpose( + 'attach generated page fixtures').execute_for_one(context) + ids = await pages(context, platform, service, sink, policy) + await count_privacy(context, service, sink, policy, ids) + await streams(context, service, sink, policy, ids) + print('PASS: Python generated page/stream 8 phases, real SQL/plans/audit, explicit cursor close') + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/examples/trace-chain/verify.sh b/examples/trace-chain/verify.sh index de24c6e..15520e9 100644 --- a/examples/trace-chain/verify.sh +++ b/examples/trace-chain/verify.sh @@ -10,6 +10,14 @@ if [[ -z "${TEAQL_TRACE_CHAIN_SHARED_DB:-}" ]]; then ownership_directory="$(mktemp -d -t teaql-python-shared.XXXXXX)" export TEAQL_TRACE_CHAIN_SHARED_DB="$ownership_directory/shared.sqlite" fi +if [[ -z "${TEAQL_TRACE_CHAIN_PAGE_STREAM_DB:-}" ]]; then + page_stream_directory="$(mktemp -d -t teaql-python-page-stream.XXXXXX)" + export TEAQL_TRACE_CHAIN_PAGE_STREAM_DB="$page_stream_directory/page-stream.sqlite" +fi +if [[ "$TEAQL_TRACE_CHAIN_PAGE_STREAM_DB" == "$TEAQL_TRACE_CHAIN_DB" || "$TEAQL_TRACE_CHAIN_PAGE_STREAM_DB" == "$TEAQL_TRACE_CHAIN_SHARED_DB" ]]; then + echo 'FAIL: page/stream requires its own retained database' >&2 + exit 1 +fi if [[ "$TEAQL_TRACE_CHAIN_SHARED_DB" == "$TEAQL_TRACE_CHAIN_DB" ]]; then echo 'FAIL: normative and ownership fixtures require separate databases' >&2 exit 1 @@ -26,6 +34,8 @@ for attempt in first second; do env -u TEAQL_TRACE_CHAIN_SCENARIO TEAQL_TRACE_CHAIN_DB="$TEAQL_TRACE_CHAIN_SHARED_DB" \ python "$example/shared_reference.py" | tee "$run_log" rg -Fq 'PASS: Python shared ownership 4 scenarios' "$run_log" + echo "Page/stream $attempt on $TEAQL_TRACE_CHAIN_PAGE_STREAM_DB without cleanup" + python "$example/page_stream.py" done (cd "$example/lib" && sha256sum --check "$snapshot") echo "PASS: two runs on the same database; generated library hashes unchanged" diff --git a/src/teaql/data_service/__init__.py b/src/teaql/data_service/__init__.py index a60cf0f..0e5f4e3 100644 --- a/src/teaql/data_service/__init__.py +++ b/src/teaql/data_service/__init__.py @@ -28,7 +28,8 @@ class DataServiceCapabilities: class QueryRequest: - __slots__ = ('query', 'trace_chain', '__intent', '__origin_entity', '_log_intent_source') + __slots__ = ('query', 'trace_chain', '__intent', '__origin_entity', + '_log_intent_source', '_log_intent_queries') _UNSET = object() def __init__(self, query: SelectQuery, trace_chain=None, _comment=_UNSET, _purpose=_UNSET, @@ -68,16 +69,25 @@ def with_query(self, query: SelectQuery) -> 'QueryRequest': result = QueryRequest(query, self.trace_chain, self._comment, self._purpose, _origin_entity=self.origin_entity) if hasattr(self, '_log_intent_source'): - result._log_intent_source = self._log_intent_source + result._log_intent_source = deepcopy(self._log_intent_source) + # Derived work may drop a relation/projection whose private values are + # still mentioned by the originating intent. Retain owned source graphs + # solely for compiler classification, never on Context or log payloads. + result._log_intent_queries = (*deepcopy(getattr(self, '_log_intent_queries', ())), + deepcopy(self.query)) return result def comment(self, text: str) -> 'QueryRequest': - return QueryRequest(self.query, self.trace_chain, text, self._purpose, - _origin_entity=self.origin_entity) + result = self.with_query(self.query) + result.__intent = QueryIntent(text, self._purpose) + result.query.comment_text = result.intent.comment + return result def purpose(self, text: str) -> 'QueryRequest': - return QueryRequest(self.query, self.trace_chain, self._comment, text, - _origin_entity=self.origin_entity) + result = self.with_query(self.query) + result.__intent = QueryIntent(self._comment, text) + result.query.purpose_text = result.intent.purpose + return result class DataServiceOperation(Enum): diff --git a/src/teaql/sql/executor.py b/src/teaql/sql/executor.py index d74ba9c..658c42a 100644 --- a/src/teaql/sql/executor.py +++ b/src/teaql/sql/executor.py @@ -147,6 +147,16 @@ def _query_intent_bindings(self, compiled, request): from teaql.runtime.log_privacy import _binding_policies source = _intent_bindings(compiled, request) pending = [(request.query, request.query.entity)] + for original in getattr(request, '_log_intent_queries', ()): + descriptor = self.schema_provider.get_entity(original.entity) + if descriptor is None: + continue + candidate = deepcopy(original) + self._resolve_subquery_entities(candidate.filter_expr) + bindings = self.dialect.compile_select(descriptor, candidate) + source.params.extend(deepcopy(bindings.params)) + source.parameter_log_policies.extend(_binding_policies(bindings)) + pending.append((original, original.entity)) visited = set() while pending: query, entity = pending.pop() @@ -296,10 +306,15 @@ def _record_statement(self, context, request, compiled, started_at, operation, raise return metadata - async def query_stream(self, context, request: QueryRequest, chunk_size: int): + def query_stream(self, context, request: QueryRequest, chunk_size: int): + """Own the request at cursor creation; opening the driver stays lazy.""" request.validate() if chunk_size <= 0: raise ValueError("chunk_size must be positive") + owned = request.with_query(request.query) + return self._query_stream(context, owned, chunk_size) + + async def _query_stream(self, context, request: QueryRequest, chunk_size: int): if (request.query.relations or request.query.child_enhancements or request.query.object_group_bys or request.query.facets): raise ValueError( @@ -311,6 +326,7 @@ async def query_stream(self, context, request: QueryRequest, chunk_size: int): raise CompileError(SqlCompileError(f"unknown entity: {request.query.entity}")) self._resolve_subquery_entities(request.query.filter_expr) compiled = self.dialect.compile_select(entity_desc, request.query) + request._log_intent_source = self._query_intent_bindings(compiled, request) pending = None index = 0 delivered = 0 @@ -362,6 +378,7 @@ async def query(self, context: 'UserContext', request: QueryRequest) -> QueryRes async def _query(self, context: 'UserContext', request: QueryRequest) -> QueryResult: request.validate() + request = request.with_query(request.query) self._sync_generated_schema(context) request.query.prepare_for_list() execution_query, retained_order, retained_empty = await self._prepare_id_set_page(context, request.query) diff --git a/tests/provider/sqlite/test_trace_chain.py b/tests/provider/sqlite/test_trace_chain.py index f16b0ef..bdf3ac6 100644 --- a/tests/provider/sqlite/test_trace_chain.py +++ b/tests/provider/sqlite/test_trace_chain.py @@ -170,6 +170,50 @@ async def test_unexecuted_aggregate_provenance_does_not_modify_query_builders(tm assert child.relations[0].query.slice is None +@pytest.mark.asyncio +@pytest.mark.parametrize('remove_in_policy', [False, True]) +@pytest.mark.parametrize('fail', [False, True]) +async def test_exact_count_retains_privacy_of_removed_relations_without_ambient_state(tmp_path, remove_in_policy, fail): + context, service, transport, entries = await fixture(tmp_path) + service.schema_provider.get_entity('CustomerOrder').audit_mask_fields([]) + service.schema_provider.get_entity('CustomerOrder').property_by_name('name').log_policy('plain') + service.schema_provider.get_entity('Payment').audit_mask_fields(['name']) + private = 'CHILD-COUNT-PRIVACY-CANARY' + original = QueryRequest(SelectQuery('CustomerOrder').project('id').limit(1) + .relation_query('children', SelectQuery('Payment').filter(Expr.eq('name', private)).limit(1)), + _comment='count graph mentioning ' + private, + _purpose='inspect total for ' + private) + if remove_in_policy: + def discard_relation(query): + query.relations = [] + return query + context.with_request_policy(discard_relation) + prepared = context.prepare_query_request(original) + counted = prepared.with_query(prepared.query.for_exact_count('__total')).comment( + original.intent.comment).purpose(original.intent.purpose) + if fail: + transport.fail_table = 'customerorder_data' + with pytest.raises(TransportError) as caught: + await service.query(context, counted) + assert caught.value.error is transport.failure + transport.fail_table = None + else: + result = await service.query(context, counted) + assert result.rows == [{'__total': 1}] + assert len(transport.reads) == len(entries) == 1 + assert 'COUNT(' in transport.reads[0].sql.upper() + assert 'payment_data' not in transport.reads[0].sql + assert entries[0].comment == 'count graph mentioning [REDACTED]' + assert entries[0].purpose == 'inspect total for [REDACTED]' + assert entries[0].execution_outcome == ('failure' if fail else 'success') + assert private not in repr(context.sql_logs()) + assert original.intent.comment.endswith(private) and len(original.query.relations) == 1 + await service.query(context, QueryRequest(SelectQuery('CustomerOrder') + .filter(Expr.eq('name', private)).limit(1), + _comment='independent query mentioning ' + private, _purpose='no inherited privacy')) + assert entries[-1].comment.endswith(private) + + @pytest.mark.asyncio @pytest.mark.parametrize('rollback', [False, True]) async def test_explicit_transaction_audit_waits_for_commit_and_rollback_discards(tmp_path, rollback): diff --git a/tests/runtime/test_sql_mask_lifecycle.py b/tests/runtime/test_sql_mask_lifecycle.py index 40c144a..e254e91 100644 --- a/tests/runtime/test_sql_mask_lifecycle.py +++ b/tests/runtime/test_sql_mask_lifecycle.py @@ -308,6 +308,37 @@ async def test_consumer_athrow_closes_stream(fixture): assert_log(fixture, 'failure', 1) +@pytest.mark.asyncio +async def test_stream_owns_request_before_delayed_consumption(fixture): + context, provider, _, entries = fixture + transport = FaultTransport(3) + supplied = request() + supplied.trace_chain.append(TraceNode(kind='relation', name='original', comment='Customer.original')) + original_params = ['Riverside', '1 Runtime Road', 'PASSWORD-CANARY'] + stream = SqlDataServiceExecutor(SqliteDialect(), transport, provider).query_stream(context, supplied, 1) + supplied.query.filter(Expr.eq('public_address', 'LATE-BUILDER-CANARY')) + supplied.trace_chain.clear() + chunks = [chunk async for chunk in stream] + assert len(chunks) == 3 and transport.closed + assert [getattr(value, 'val', value) for value in transport.params] == original_params + assert any(node.name == 'original' for node in entries[0].trace_path) + assert 'LATE-BUILDER-CANARY' not in repr(context.sql_logs()) + + +@pytest.mark.asyncio +async def test_live_stream_path_cannot_be_rewritten_before_close(fixture): + context, provider, _, entries = fixture + transport = FaultTransport(3) + supplied = request() + supplied.trace_chain.append(TraceNode(kind='relation', name='original', comment='Customer.original')) + stream = SqlDataServiceExecutor(SqliteDialect(), transport, provider).query_stream(context, supplied, 1) + await anext(stream) + supplied.trace_chain[:] = [TraceNode(kind='relation', name='late', comment='Customer.late')] + await stream.aclose() + assert transport.closed and entries[0].execution_outcome == 'cancelled' + assert [node.name for node in entries[0].trace_path if node.kind == 'relation'] == ['original'] + + @pytest.mark.asyncio async def test_cancelled_mutation_rolls_back_transaction(fixture): context, provider, _, _ = fixture From 8cbebd91c9da8b08892a3d5d16622afed6c9b197 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sat, 3 Oct 2026 10:26:56 +0800 Subject: [PATCH 06/40] fix(trace): retain Python mutation readbacks and canonical query origin (#43) Signed-off-by: Philip Z --- examples/conformance/app/masking_lifecycle.py | 3 +- examples/trace-chain/README.md | 11 +++ examples/trace-chain/main.py | 19 ++++- examples/trace-chain/shared_reference.py | 8 +- src/teaql/core/trace.py | 18 +++- src/teaql/data_service/__init__.py | 3 + src/teaql/sql/executor.py | 11 ++- tests/core/test_trace.py | 16 ++++ tests/provider/sqlite/test_sqlite.py | 8 +- tests/provider/sqlite/test_trace_chain.py | 82 ++++++++++++++++++- tests/runtime/test_sql_mask_lifecycle.py | 9 +- tests/runtime/test_sql_masking_policy.py | 4 +- 12 files changed, 175 insertions(+), 17 deletions(-) diff --git a/examples/conformance/app/masking_lifecycle.py b/examples/conformance/app/masking_lifecycle.py index 8e85468..9abd71d 100644 --- a/examples/conformance/app/masking_lifecycle.py +++ b/examples/conformance/app/masking_lifecycle.py @@ -109,7 +109,8 @@ async def partial_graph(graph): raise AssertionError('partial graph unexpectedly committed') except TransportError: pass - assert [entry.execution_outcome for entry in entries] == ['success','success','success'] + assert [entry.execution_outcome for entry in entries] == ['success','success','success','success'] + assert [entry.trace_path[-1].name for entry in entries] == ['insert','select','insert','select'] assert entries[-1].result_count == 0 assert not await service.transport.fetch_all_sql(CompiledQuery( 'SELECT id FROM mask_customer_data WHERE id IN (30,31,777)', [])) diff --git a/examples/trace-chain/README.md b/examples/trace-chain/README.md index a71ec9d..b577d2a 100644 --- a/examples/trace-chain/README.md +++ b/examples/trace-chain/README.md @@ -26,6 +26,17 @@ retained soft deletion, three relation levels with inherited query intent, two concurrent graph saves in one Context, failed mutation, and failed authoritative readback. Failures retain SQL evidence and emit no committed audits. +Successful mutations also retain their actually executed readback SELECTs. The +six-item mutation verifies six writes followed individually by six readbacks, +with six committed audit events, not twelve. Logical mutation metadata preserves +its operation and affected-row count; ordered `statements` contain the write and +readback leaves. Readback paths use `Operation(CustomerOrder, query)` and +`Request(CustomerOrder)` even for a PaymentAttempt, while typed target identity +and branch responsibility stay in the mutation lineage. Diagnostic SELECT +switches do not erase the physical result metadata. Native tests cover no-Context +results, grouped batches, zero affected rows, hard deletes and masked readback +intent. These checks do not establish whole-graph sibling-value privacy. + Four additional ownership cases use generated Q/E/save: two independent loaded roots share the same provider-returned Platform record without sharing mutable ledgers; adoption imports only a reached changed child; a clean ancestor emits diff --git a/examples/trace-chain/main.py b/examples/trace-chain/main.py index b4421fd..0f39446 100644 --- a/examples/trace-chain/main.py +++ b/examples/trace-chain/main.py @@ -183,7 +183,24 @@ def resolve_policy(request_key): assert record.trace_chain[0].name == 'CustomerOrder' assert [node.kind for node in record.trace_chain] == ['operation', 'entity', 'provider', 'sql'] assert all(node.kind == 'auditReason' for node in record.mutation_lineage) - assert len([entry for entry in context.sql_logs() if entry.mutation_lineage]) == 6 + assert len(record.statements) == 2 + write, read = record.statements + assert not write.statements and not read.statements + assert read.comment == write.comment == 'submit order' + assert read.mutation_lineage == write.mutation_lineage == record.mutation_lineage + assert read.result_count == 1 and read.affected_rows is None + assert read.execution_outcome == 'success' + assert [node.kind for node in read.trace_chain] == ['operation', 'request', 'provider', 'sql'] + assert [node.name for node in read.trace_chain] == ['CustomerOrder', 'CustomerOrder', 'sqlite', 'select'] + assert read.trace_chain[0].comment == 'query' + physical = [entry for entry in context.sql_logs() if entry.mutation_lineage] + assert len(physical) == 12 + for index, record in enumerate(service.results): + write, read = physical[index * 2:index * 2 + 2] + assert write.trace_path[-1].name == record.trace_chain[-1].name + assert read.trace_path[-1].name == 'select' + assert read.mutation_lineage == write.mutation_lineage + print('PASS: six writes plus six successful readbacks; root request paths; no duplicate SQL facts') print('PASS: normative six items; assigned typed identity; branch/deletion reasons; request/SQL/audit boundaries') loaded = await (Q.customer_orders().with_id_is(E.customer_order(order).id().eval()) diff --git a/examples/trace-chain/shared_reference.py b/examples/trace-chain/shared_reference.py index 120ad77..95cbbde 100644 --- a/examples/trace-chain/shared_reference.py +++ b/examples/trace-chain/shared_reference.py @@ -94,7 +94,13 @@ async def load(context, entity_id): def assert_writes(service, sink, context, count): - sql = [entry for entry in context.sql_logs() if entry.mutation_lineage] + sql = [entry for entry in context.sql_logs() if entry.mutation_lineage and not entry.operation.is_select()] + readbacks = [entry for entry in context.sql_logs() if entry.mutation_lineage and entry.operation.is_select()] + assert len(readbacks) == count + for write, read in zip(sql, readbacks): + assert read.mutation_lineage == write.mutation_lineage + assert read.trace_path[1].kind == 'request' + assert read.trace_path[1].name == 'CustomerOrder' assert len(service.requests) == len(sink.events) == len(sql) == count, 'only reached changed entities emit commands' for request, entry, event in zip(service.requests, sql, sink.events): entity, identity, lineage = request diff --git a/src/teaql/core/trace.py b/src/teaql/core/trace.py index ea7a14c..9c83bee 100644 --- a/src/teaql/core/trace.py +++ b/src/teaql/core/trace.py @@ -44,6 +44,18 @@ def trace_intent(source: Iterable[TraceNode]) -> dict[str, Optional[str]]: def physical_readback_path(write_path: Iterable[TraceNode]) -> List[TraceNode]: - """Retain originating mutation route, replace its sole physical SQL leaf.""" - return [replace(node, name='select', comment='') if _kind(node) == 'sql' - else deepcopy(node) for node in write_path] + """A real SELECT keeps its mutation origin but uses a query request frame.""" + nodes = list(write_path) + root = next((trace_name(node) for node in nodes if _kind(node) == 'operation'), 'unknown') + result = [] + for node in nodes: + kind = _kind(node) + if kind == 'operation': + result.append(replace(node, comment='query')) + elif kind == 'entity': + result.append(TraceNode(kind='request', name=root)) + elif kind == 'sql': + result.append(replace(node, name='select', comment='')) + else: + result.append(deepcopy(node)) + return result diff --git a/src/teaql/data_service/__init__.py b/src/teaql/data_service/__init__.py index 0e5f4e3..cc0d3ef 100644 --- a/src/teaql/data_service/__init__.py +++ b/src/teaql/data_service/__init__.py @@ -122,6 +122,9 @@ class ExecutionMetadata: sql_origin: Optional[str] = None # Statement/cursor termination only, not transaction commit. execution_outcome: Optional[str] = None + # Logical mutation summaries own ordered physical children. Batch summaries + # retain per-item grouping; leaves are empty and never point to a summary. + statements: tuple['ExecutionMetadata', ...] = field(default_factory=tuple) @dataclass diff --git a/src/teaql/sql/executor.py b/src/teaql/sql/executor.py index 658c42a..7caf8d2 100644 --- a/src/teaql/sql/executor.py +++ b/src/teaql/sql/executor.py @@ -819,7 +819,8 @@ async def _mutate(self, context: 'UserContext', request: MutationRequest) -> Mut return MutationResult(affected, {}, ExecutionMetadata( backend=self._backend_name(), operation=DataServiceOperation.Batch, started_at=start, ended_at=datetime.now(), affected_rows=affected, - comment=request.intent.comment, audit_reason=request.intent.comment)) + comment=request.intent.comment, audit_reason=request.intent.comment, + statements=tuple(result.metadata for result in results))) entity_desc = self.schema_provider.get_entity(req_data.entity) if not entity_desc and context: entities = context.get_resource("entities") @@ -956,6 +957,9 @@ async def _mutate(self, context: 'UserContext', request: MutationRequest) -> Mut persisted_rows, error) raise persisted_record = persisted_rows[0] + read_metadata = self._record_readback(context, readback, compiled, metadata, + read_start, persisted_rows, None) + metadata = replace(metadata, statements=(metadata, read_metadata)) if affected_rows > 0 and context is not None: from teaql.runtime.audit import AuditFieldChange, MutationAuditKind, RawAuditEvent @@ -994,8 +998,6 @@ async def _mutate(self, context: 'UserContext', request: MutationRequest) -> Mut ) def _record_readback(self, context, readback, source, write_metadata, started_at, rows, error): - if context is None: - return # A driver returning zero/multiple rows succeeded as SQL; validation of # the authoritative snapshot is a separate business failure. outcome = ('success' if rows is not None else 'cancelled' @@ -1007,12 +1009,15 @@ def _record_readback(self, context, readback, source, write_metadata, started_at parameter_log_policies=readback.parameter_log_policies, sql_origin=readback.sql_origin, affected_rows=None, result_count=len(rows) if rows is not None else None, trace_chain=physical_readback_path(write_metadata.trace_chain)) + if context is None: + return metadata try: context._record_metadata_log(metadata, intent_source=source, intent_values=tuple(readback.params[:1])) except BaseException: # An in-flight readback error must survive a diagnostic sink failure. pass + return metadata async def next_id(self, entity: str) -> int: await self.transport.execute_sql(CompiledQuery( diff --git a/tests/core/test_trace.py b/tests/core/test_trace.py index 6b9edac..f5f6e52 100644 --- a/tests/core/test_trace.py +++ b/tests/core/test_trace.py @@ -44,6 +44,22 @@ def test_derived_query_origin_is_owned_and_survives_intent_changes(): derived.origin_entity = 'Shipment' +def test_readback_path_keeps_origin_not_child_and_does_not_mutate_write(): + from copy import deepcopy + from teaql.core.trace import physical_readback_path + source = canonical_sql_trace_path([ + TraceNode(kind='auditReason', name='CustomerOrder', entity_id=7, comment='submit'), + TraceNode(kind='entity', name='PaymentAttempt', entity_id=7), + ], 'sqlite', 'update') + before = deepcopy(source) + read = physical_readback_path(source) + assert [(n.kind, n.name, n.comment) for n in read] == [ + ('operation', 'CustomerOrder', 'query'), ('request', 'CustomerOrder', ''), + ('provider', 'sqlite', ''), ('sql', 'select', '')] + read[0].name = 'consumer change' + assert source == before + + @pytest.mark.parametrize('kind', ['query', 'mutation']) def test_untyped_trace_is_rejected_without_echoing_payload(kind): from teaql.core.mutation import InsertCommand, MutationRequest diff --git a/tests/provider/sqlite/test_sqlite.py b/tests/provider/sqlite/test_sqlite.py index 6ee24d6..48fe160 100644 --- a/tests/provider/sqlite/test_sqlite.py +++ b/tests/provider/sqlite/test_sqlite.py @@ -675,12 +675,13 @@ async def test_structured_sql_evidence_is_parameterized_and_filterable(temp_db, await service.query(context, request) entries = context.sql_logs() - assert len(entries) == 2 + assert len(entries) == 3 # INSERT, authoritative readback, explicit query assert all(entry.sql and secret not in entry.sql for entry in entries) assert all(entry.params for entry in entries) assert any(entry.result_count is not None for entry in entries) assert any(entry.affected_rows is not None for entry in entries) - select_entry = next(entry for entry in entries if entry.operation.is_select()) + select_entry = entries[-1] + assert select_entry.operation.is_select() assert select_entry.comment == "what: load governed users" assert select_entry.purpose == "why: verify trace inheritance" assert [node.kind for node in select_entry.trace_path] == [ @@ -691,7 +692,8 @@ async def test_structured_sql_evidence_is_parameterized_and_filterable(temp_db, await service.mutate(context, MutationRequest(InsertCommand("User", { "id": Value.I64(2), "name": Value.Text("ignored"), "version": Value.I64(1) }), comment='what: runtime regression fixture')) - assert context.sql_logs() == [] + assert len(context.sql_logs()) == 1 + assert context.sql_logs()[0].operation.is_select() # the real readback context.enable_mutation_sql_log() await service.query(context, QueryRequest(SelectQuery("User"), _comment='what: runtime regression fixture', _purpose='why: verify runtime behavior')) assert context.sql_logs() == [] diff --git a/tests/provider/sqlite/test_trace_chain.py b/tests/provider/sqlite/test_trace_chain.py index bdf3ac6..6883377 100644 --- a/tests/provider/sqlite/test_trace_chain.py +++ b/tests/provider/sqlite/test_trace_chain.py @@ -89,6 +89,82 @@ def three_levels(): .relation_query('children', SelectQuery('Shipment').project('id').limit(1))))) +@pytest.mark.asyncio +@pytest.mark.parametrize('with_context', [False, True]) +async def test_successful_readback_retains_ordered_physical_metadata(tmp_path, with_context): + from teaql.data_service import DataServiceOperation + context, service, transport, entries = await fixture(tmp_path) + request = MutationRequest( + InsertCommand.new('Payment').value('name', 'new payment').value('parent_id', 1), + comment='persist payment and verify its authoritative snapshot') + result = await service.mutate(context if with_context else None, request) + assert result.affected_rows == result.metadata.affected_rows == 1 + assert result.metadata.operation == DataServiceOperation.Insert + assert result.persisted_record['name'] == 'new payment' + assert len(result.metadata.statements) == 2 + write, read = result.metadata.statements + assert write.operation == DataServiceOperation.Insert + assert read.operation == DataServiceOperation.Query + assert write.statements == read.statements == () + assert write.affected_rows == 1 and read.affected_rows is None + assert read.result_count == 1 and read.execution_outcome == 'success' + assert read.comment == write.comment == request.comment() + assert read.audit_reason == write.audit_reason == request.comment() + assert read.mutation_lineage == write.mutation_lineage + assert read.purpose == 'verify the persisted mutation result' + assert [node.kind for node in read.trace_chain] == ['operation', 'request', 'provider', 'sql'] + assert [node.name for node in read.trace_chain] == ['Payment', 'Payment', 'sqlite', 'select'] + assert read.trace_chain[0].comment == 'query' + assert len(entries) == (2 if with_context else 0) + if with_context: + assert [entry.execution_outcome for entry in entries] == ['success', 'success'] + assert len(context.sql_logs()) == 2 + + +@pytest.mark.asyncio +async def test_batch_keeps_each_write_readback_pair(tmp_path): + from teaql.data_service import DataServiceOperation + context, service, transport, entries = await fixture(tmp_path) + result = await service.mutate(context, MutationRequest.Batch([ + InsertCommand.new('Payment').value('name', 'batch payment').value('parent_id', 1), + InsertCommand.new('PaymentAttempt').value('name', 'batch attempt').value('parent_id', 1), + ], comment='persist atomic payment batch')) + assert result.affected_rows == result.metadata.affected_rows == 2 + assert result.metadata.operation == DataServiceOperation.Batch + assert len(result.metadata.statements) == 2 + for item in result.metadata.statements: + assert item.operation == DataServiceOperation.Insert + assert [part.operation for part in item.statements] == [ + DataServiceOperation.Insert, DataServiceOperation.Query] + assert len(entries) == 4 + + +@pytest.mark.asyncio +@pytest.mark.parametrize('mode', ['no-match', 'hard-delete']) +async def test_no_synthetic_readback_when_none_executed(tmp_path, mode): + from teaql.core.mutation import UpdateCommand, DeleteCommand + context, service, transport, entries = await fixture(tmp_path) + command = (UpdateCommand.new('Payment', 99999).expected_version(1).value('name', 'missing') + if mode == 'no-match' else DeleteCommand.new('Payment', 1).hard_delete()) + result = await service.mutate(context, MutationRequest(command, comment='verify physical work only')) + assert result.affected_rows == (0 if mode == 'no-match' else 1) + assert result.persisted_record is None and result.metadata.statements == () + assert len(entries) == 1 + assert transport.reads == [] + + +@pytest.mark.asyncio +async def test_query_log_switch_does_not_remove_physical_readback_result(tmp_path): + context, service, transport, entries = await fixture(tmp_path) + context.enable_mutation_sql_log() + result = await service.mutate(context, MutationRequest( + InsertCommand.new('Payment').value('name', 'log switch payment').value('parent_id', 1), + comment='persist despite query log switch')) + assert len(result.metadata.statements) == 2 + assert len(entries) == 1 and not entries[0].operation.is_select() + assert len(context.sql_logs()) == 1 + + @pytest.mark.asyncio @pytest.mark.parametrize('failure', [False, True]) async def test_three_real_relation_levels_keep_origin_and_qualified_frames(tmp_path, failure): @@ -132,7 +208,8 @@ async def test_successful_write_and_failed_readback_have_separate_canonical_path assert write.execution_outcome == 'success' and read.execution_outcome == 'failure' assert write.audit_reason == read.audit_reason == request.comment() for entry, operation in ((write, 'insert'), (read, 'select')): - assert [node.kind for node in entry.trace_path] == ['operation', 'entity', 'provider', 'sql'] + assert [node.kind for node in entry.trace_path] == [ + 'operation', 'request' if operation == 'select' else 'entity', 'provider', 'sql'] assert entry.trace_path[0].name == 'Payment' assert entry.trace_path[-1].name == operation async with aiosqlite.connect(transport.db_path) as database: @@ -250,7 +327,8 @@ async def test_automatic_batch_readback_failure_discards_prior_successful_audits await service.mutate(context, batch) assert caught.value is transport.failure assert audits == [] - assert [entry.execution_outcome for entry in entries] == ['success', 'success', 'failure'] + assert [entry.execution_outcome for entry in entries] == ['success', 'success', 'success', 'failure'] + assert [entry.trace_path[-1].name for entry in entries] == ['insert', 'select', 'insert', 'select'] async with aiosqlite.connect(transport.db_path) as database: for table in ('payment_data', 'paymentattempt_data'): cursor = await database.execute(f'SELECT COUNT(*) FROM {table}') diff --git a/tests/runtime/test_sql_mask_lifecycle.py b/tests/runtime/test_sql_mask_lifecycle.py index e254e91..fce1b6d 100644 --- a/tests/runtime/test_sql_mask_lifecycle.py +++ b/tests/runtime/test_sql_mask_lifecycle.py @@ -474,7 +474,11 @@ async def begin_sql(self): assert tx.commits == 0 await target.commit(context) assert tx.commits == 1 and tx.rollbacks == 0 - assert len(entries) == 1 + assert len(entries) == 2 + assert len(result.metadata.statements) == 2 + assert entries[1].execution_outcome == 'success' + assert entries[1].result_count == 1 + assert entries[1].trace_path[1].kind == 'request' else: with pytest.raises(BaseException) as caught: await target.mutate(context, readback_request()) @@ -559,6 +563,7 @@ async def work(graph): with pytest.raises(RuntimeError) as caught: await context.execute_graph_save(work, comment='what: runtime regression fixture') assert caught.value is failure - assert [entry.execution_outcome for entry in entries] == ['success','success','failure'] + assert [entry.execution_outcome for entry in entries] == ['success','success','success','failure'] + assert [entry.trace_path[-1].name for entry in entries] == ['update','select','update','select'] assert tx.writes == tx.reads == 2 and tx.rollbacks == 1 and tx.commits == 0 assert 'Riverside' not in repr(context.sql_logs()) diff --git a/tests/runtime/test_sql_masking_policy.py b/tests/runtime/test_sql_masking_policy.py index cc4c54e..b51800b 100644 --- a/tests/runtime/test_sql_masking_policy.py +++ b/tests/runtime/test_sql_masking_policy.py @@ -238,4 +238,6 @@ async def mutate(command): assert 'LIMIT 1' in logs and '1 rows returned' in logs assert 'Parameterized SQL:' not in logs and 'REDACTED SQL' not in logs assert 'Riverside' not in repr(context.sql_logs()) - assert len(entries) == 4 + assert len(entries) == 7 + assert [entry.trace_path[-1].name for entry in entries] == [ + 'insert', 'select', 'select', 'update', 'select', 'delete', 'select'] From a58fd87ab8752cc9bef83f1c4210f29821f08c37 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sat, 3 Oct 2026 10:45:18 +0800 Subject: [PATCH 07/40] fix(trace): own whole-mutation intent privacy and loaded scalar snapshots (#43) Signed-off-by: Philip Z --- examples/trace-chain/README.md | 15 ++- .../trace-chain/evidence/library-sha256.txt | 38 +++---- .../trace-chain/lib/models/customer_order.py | 12 +- examples/trace-chain/lib/models/order_item.py | 12 +- examples/trace-chain/lib/models/payment.py | 12 +- .../trace-chain/lib/models/payment_attempt.py | 12 +- examples/trace-chain/lib/models/platform.py | 12 +- examples/trace-chain/lib/models/shipment.py | 12 +- examples/trace-chain/mutation_privacy.py | 66 +++++++++++ examples/trace-chain/verify.sh | 2 + src/teaql/core/entity.py | 10 ++ src/teaql/runtime/audit.py | 3 + src/teaql/runtime/context.py | 10 +- src/teaql/runtime/graph_session.py | 4 + src/teaql/runtime/log_privacy.py | 57 +++++++++- src/teaql/sql/dialect.py | 11 +- src/teaql/sql/executor.py | 36 +++--- tests/provider/sqlite/test_trace_chain.py | 105 ++++++++++++++++++ tests/runtime/test_mutation_intent_privacy.py | 35 ++++++ tests/runtime/test_mutation_policy.py | 4 +- 20 files changed, 411 insertions(+), 57 deletions(-) create mode 100644 examples/trace-chain/mutation_privacy.py create mode 100644 tests/runtime/test_mutation_intent_privacy.py diff --git a/examples/trace-chain/README.md b/examples/trace-chain/README.md index b577d2a..edef065 100644 --- a/examples/trace-chain/README.md +++ b/examples/trace-chain/README.md @@ -12,7 +12,7 @@ cd /path/to/teaql-python bash examples/trace-chain/verify.sh ``` -The verifier uses this repository's runtime source and executes all three suites +The verifier uses this repository's runtime source and executes all four suites twice without cleanup, then checks the generated library hashes. The normative graph and ownership fixtures require separate SQLite files because their graph sizes differ. Set `TEAQL_TRACE_CHAIN_DB` and `TEAQL_TRACE_CHAIN_SHARED_DB` to retain @@ -35,7 +35,18 @@ readback leaves. Readback paths use `Operation(CustomerOrder, query)` and and branch responsibility stay in the mutation lineage. Diagnostic SELECT switches do not erase the physical result metadata. Native tests cover no-Context results, grouped batches, zero affected rows, hard deletes and masked readback -intent. These checks do not establish whole-graph sibling-value privacy. +intent. + +The mutation privacy suite configures Payment.reference_code as sensitive and +creates/updates a three-entity graph using generated Q/E/save. Root and sibling +write/readback intent plus committed safe audit must hide the future child's new +and old values. Alphabetic canaries avoid false positives from numeric identity +redaction. Generated E verifies original stored values and versions; the next +independent query proves there is no ambient privacy on shared Context. Original +scalar snapshot ownership is implemented in the runtime; generation supplies +loaded scalar data and advances the snapshot only after successful commit. +Native tests additionally cover batch/graph rollback, readback cancellation, +concurrent native batches, debug opt-in, credentials and safe reprojection. Four additional ownership cases use generated Q/E/save: two independent loaded roots share the same provider-returned Platform record without sharing mutable diff --git a/examples/trace-chain/evidence/library-sha256.txt b/examples/trace-chain/evidence/library-sha256.txt index 85a0ef5..9448bdd 100644 --- a/examples/trace-chain/evidence/library-sha256.txt +++ b/examples/trace-chain/evidence/library-sha256.txt @@ -1,19 +1,19 @@ -3dd62d0762032c4d90b64583a0eff75115a848b374a0ee2978bc6f7684380a6b ./E.py -555a60a9561499fb3ce7a6a3e40e7004d0a79b8ea01f2d80686c999db4873352 ./Q.py -e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ./models/__init__.py -09f4072731d5ac72a2f73ccdd78b23451629bbbf7b75e0912b2a577cdb87d3c6 ./models/customer_order.py -d24b15d953b5234c9442d6b4cbc5d748f31df1c0c2acb44bfe76cbd4428176b3 ./models/order_item.py -1ab512f57a22560047d868925b442e3c877c6f980d4490eaadad3f8f97c43c4f ./models/payment.py -5c6b1fecd541479f166fffd85d672f960383762e64174ff870ad8e5e04e9caef ./models/payment_attempt.py -e619ec2fe1471681f8870882329f832cb9988e149f41e7d2be40ab502028e23e ./models/platform.py -018738070ffbb9b209436a38fcb34e96859986ed9f434823983de0f3027a4124 ./models/shipment.py -f5820a2cbf1c4a29053e53e4d12e8304aee78eb52b6c1cda987daea3f22eff95 ./pyproject.toml -e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ./requests/__init__.py -dc0c2d4ff7c17c494fa76f3adee3094b2b27264c33116bb2b6f8483027868bc0 ./requests/customer_order_request.py -33dd1fb4b506f5dacc13ca60f85633da2b2a735e7d6c337f6046e41065bad4a2 ./requests/order_item_request.py -336ea012de43681dcca78443b633139ba63cfff349a5e17db7d8be177728011a ./requests/payment_attempt_request.py -cb8e22723f61f8f5c9e3b645044716fa09eb4d5c7fb67b9bbeed5cec69aa9131 ./requests/payment_request.py -9148ef76be0c6b176e2cb05a501c7d8b579e210aae617c2c76971ad6f64cb4a8 ./requests/platform_request.py -88bc9932f546e57328b323d1e421d357658dbf5e7fae2af17ae972ec59bb53fd ./requests/shipment_request.py -adf7af0e37f2d7b25d2fc709353bdfca207a2db3fe53989b1dbe37e0e2f2e63b ./runtime_module.py -5ff4a2f49563da43619a3bde0da4d3b73105e87824aef4851ba050b0509272d3 ./teaql-i18n.json +3dd62d0762032c4d90b64583a0eff75115a848b374a0ee2978bc6f7684380a6b E.py +555a60a9561499fb3ce7a6a3e40e7004d0a79b8ea01f2d80686c999db4873352 Q.py +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 models/__init__.py +dcbd451e2d2ea1551d902610d8aec47bc20f1a4a5c8e871beb31371f716dc6a5 models/customer_order.py +4d8b2a27d1538189ce71eb1a14ebb9c5eb3e9c607ea5b2b65a49ae9bed5b129f models/order_item.py +90e9e711ec80c16fdeed70393e4f43607554cb5a3bd148d4e7d1c562eb2313d5 models/payment.py +b6c35aa3753234b504ded8ccff85df0b1407fd3c77690b9df5d34e0d86d564eb models/payment_attempt.py +bea4d609d76cfd1738416cb17e7144b48173a7763c551f3eee59b7474bd886cb models/platform.py +318d0d9eb2f0bfefd6d2fd93375666d85999d72c35ce36049783cff84806b929 models/shipment.py +f5820a2cbf1c4a29053e53e4d12e8304aee78eb52b6c1cda987daea3f22eff95 pyproject.toml +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 requests/__init__.py +91f91c4faf84f8829b86febf274ac999d07715173734561b3ae7a31dc1987f29 requests/customer_order_request.py +3d75a7d34ed73aaf981f92a96ad378e28b4d70b5dc5bb6120e5972cfc8277f4c requests/order_item_request.py +4d707c1f2468a30ebb21fef597677535a7abb544c30608b72ce18267b0f3e918 requests/payment_attempt_request.py +b21db39354b156ac8a94b16407bdf92bfa9e6067038171cc69a2dfaa11a87982 requests/payment_request.py +3ff6c80155d072bb78dcd9f4cb72e915fe4eec184de0ae007c12500ad4fda38e requests/platform_request.py +83b59281f359eb215a8db358465dfb9ea574ed6001add38ab7b4e1a4d8431a1a requests/shipment_request.py +adf7af0e37f2d7b25d2fc709353bdfca207a2db3fe53989b1dbe37e0e2f2e63b runtime_module.py +5ff4a2f49563da43619a3bde0da4d3b73105e87824aef4851ba050b0509272d3 teaql-i18n.json diff --git a/examples/trace-chain/lib/models/customer_order.py b/examples/trace-chain/lib/models/customer_order.py index b0c411a..c430c98 100644 --- a/examples/trace-chain/lib/models/customer_order.py +++ b/examples/trace-chain/lib/models/customer_order.py @@ -1,4 +1,5 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core.entity import _LoadedScalarSnapshot from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation @@ -84,6 +85,8 @@ def __init__(self, **kwargs): self._entity_root.mark_as_new(key) elif getattr(self, "version", None) is not None: self._entity_root.set_original_version(key, int(self.version)) + self._teaql_loaded_snapshot = _LoadedScalarSnapshot( + self._teaql_scalar_payload() if self._action == "Update" else {}) def _teaql_entity_key(self): return EntityKey("CustomerOrder", self._ledger_id) @@ -162,7 +165,7 @@ async def _teaql_preflight_and_save(self, graph): self._teaql_preflight_graph(graph) return await self._teaql_save_within_graph(graph) - def _teaql_build_command(self): + def _teaql_scalar_payload(self): payload = {} if "id" in self._loaded_fields: payload["id"] = Value.I64(self.id) @@ -183,6 +186,10 @@ def _teaql_build_command(self): if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + return payload + + def _teaql_build_command(self): + payload = self._teaql_scalar_payload() action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -195,6 +202,7 @@ def _teaql_build_command(self): original_version if original_version is not None else getattr(self, "version", None)) for key, value in payload.items(): if key not in ("id", "version"): cmd.value(key, value) + cmd.old_values = self._teaql_loaded_snapshot.select(payload) else: original_version = self._entity_root.original_version(self._teaql_entity_key()) cmd = DeleteCommand("CustomerOrder", Value.from_any(getattr(self, "id", None)), @@ -325,6 +333,7 @@ async def _teaql_save_within_graph(self, graph, parent_scope=None): elif "version" in persisted: self.version = persisted["version"] self._loaded_fields.add("version") + committed_snapshot = _LoadedScalarSnapshot(self._teaql_scalar_payload()) self._ledger_id = getattr(self, "id", self._ledger_id) new_key = self._teaql_entity_key() if old_key != new_key: @@ -344,6 +353,7 @@ def rollback_entity(): if action != "Delete": await self._teaql_save_children(graph, scope) def commit_entity(): + self._teaql_loaded_snapshot = committed_snapshot self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.accept_committed_version(new_key, int(self.version)) diff --git a/examples/trace-chain/lib/models/order_item.py b/examples/trace-chain/lib/models/order_item.py index c1aab6e..9108a52 100644 --- a/examples/trace-chain/lib/models/order_item.py +++ b/examples/trace-chain/lib/models/order_item.py @@ -1,4 +1,5 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core.entity import _LoadedScalarSnapshot from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation @@ -53,6 +54,8 @@ def __init__(self, **kwargs): self._entity_root.mark_as_new(key) elif getattr(self, "version", None) is not None: self._entity_root.set_original_version(key, int(self.version)) + self._teaql_loaded_snapshot = _LoadedScalarSnapshot( + self._teaql_scalar_payload() if self._action == "Update" else {}) def _teaql_entity_key(self): return EntityKey("OrderItem", self._ledger_id) @@ -116,7 +119,7 @@ async def _teaql_preflight_and_save(self, graph): self._teaql_preflight_graph(graph) return await self._teaql_save_within_graph(graph) - def _teaql_build_command(self): + def _teaql_scalar_payload(self): payload = {} if "id" in self._loaded_fields: payload["id"] = Value.I64(self.id) @@ -134,6 +137,10 @@ def _teaql_build_command(self): if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + return payload + + def _teaql_build_command(self): + payload = self._teaql_scalar_payload() action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -146,6 +153,7 @@ def _teaql_build_command(self): original_version if original_version is not None else getattr(self, "version", None)) for key, value in payload.items(): if key not in ("id", "version"): cmd.value(key, value) + cmd.old_values = self._teaql_loaded_snapshot.select(payload) else: original_version = self._entity_root.original_version(self._teaql_entity_key()) cmd = DeleteCommand("OrderItem", Value.from_any(getattr(self, "id", None)), @@ -229,6 +237,7 @@ async def _teaql_save_within_graph(self, graph, parent_scope=None): elif "version" in persisted: self.version = persisted["version"] self._loaded_fields.add("version") + committed_snapshot = _LoadedScalarSnapshot(self._teaql_scalar_payload()) self._ledger_id = getattr(self, "id", self._ledger_id) new_key = self._teaql_entity_key() if old_key != new_key: @@ -248,6 +257,7 @@ def rollback_entity(): if action != "Delete": await self._teaql_save_children(graph, scope) def commit_entity(): + self._teaql_loaded_snapshot = committed_snapshot self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.accept_committed_version(new_key, int(self.version)) diff --git a/examples/trace-chain/lib/models/payment.py b/examples/trace-chain/lib/models/payment.py index e599b80..56db81e 100644 --- a/examples/trace-chain/lib/models/payment.py +++ b/examples/trace-chain/lib/models/payment.py @@ -1,4 +1,5 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core.entity import _LoadedScalarSnapshot from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation @@ -62,6 +63,8 @@ def __init__(self, **kwargs): self._entity_root.mark_as_new(key) elif getattr(self, "version", None) is not None: self._entity_root.set_original_version(key, int(self.version)) + self._teaql_loaded_snapshot = _LoadedScalarSnapshot( + self._teaql_scalar_payload() if self._action == "Update" else {}) def _teaql_entity_key(self): return EntityKey("Payment", self._ledger_id) @@ -130,7 +133,7 @@ async def _teaql_preflight_and_save(self, graph): self._teaql_preflight_graph(graph) return await self._teaql_save_within_graph(graph) - def _teaql_build_command(self): + def _teaql_scalar_payload(self): payload = {} if "id" in self._loaded_fields: payload["id"] = Value.I64(self.id) @@ -148,6 +151,10 @@ def _teaql_build_command(self): if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + return payload + + def _teaql_build_command(self): + payload = self._teaql_scalar_payload() action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -160,6 +167,7 @@ def _teaql_build_command(self): original_version if original_version is not None else getattr(self, "version", None)) for key, value in payload.items(): if key not in ("id", "version"): cmd.value(key, value) + cmd.old_values = self._teaql_loaded_snapshot.select(payload) else: original_version = self._entity_root.original_version(self._teaql_entity_key()) cmd = DeleteCommand("Payment", Value.from_any(getattr(self, "id", None)), @@ -256,6 +264,7 @@ async def _teaql_save_within_graph(self, graph, parent_scope=None): elif "version" in persisted: self.version = persisted["version"] self._loaded_fields.add("version") + committed_snapshot = _LoadedScalarSnapshot(self._teaql_scalar_payload()) self._ledger_id = getattr(self, "id", self._ledger_id) new_key = self._teaql_entity_key() if old_key != new_key: @@ -275,6 +284,7 @@ def rollback_entity(): if action != "Delete": await self._teaql_save_children(graph, scope) def commit_entity(): + self._teaql_loaded_snapshot = committed_snapshot self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.accept_committed_version(new_key, int(self.version)) diff --git a/examples/trace-chain/lib/models/payment_attempt.py b/examples/trace-chain/lib/models/payment_attempt.py index 422a706..087514b 100644 --- a/examples/trace-chain/lib/models/payment_attempt.py +++ b/examples/trace-chain/lib/models/payment_attempt.py @@ -1,4 +1,5 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core.entity import _LoadedScalarSnapshot from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation @@ -53,6 +54,8 @@ def __init__(self, **kwargs): self._entity_root.mark_as_new(key) elif getattr(self, "version", None) is not None: self._entity_root.set_original_version(key, int(self.version)) + self._teaql_loaded_snapshot = _LoadedScalarSnapshot( + self._teaql_scalar_payload() if self._action == "Update" else {}) def _teaql_entity_key(self): return EntityKey("PaymentAttempt", self._ledger_id) @@ -116,7 +119,7 @@ async def _teaql_preflight_and_save(self, graph): self._teaql_preflight_graph(graph) return await self._teaql_save_within_graph(graph) - def _teaql_build_command(self): + def _teaql_scalar_payload(self): payload = {} if "id" in self._loaded_fields: payload["id"] = Value.I64(self.id) @@ -134,6 +137,10 @@ def _teaql_build_command(self): if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + return payload + + def _teaql_build_command(self): + payload = self._teaql_scalar_payload() action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -146,6 +153,7 @@ def _teaql_build_command(self): original_version if original_version is not None else getattr(self, "version", None)) for key, value in payload.items(): if key not in ("id", "version"): cmd.value(key, value) + cmd.old_values = self._teaql_loaded_snapshot.select(payload) else: original_version = self._entity_root.original_version(self._teaql_entity_key()) cmd = DeleteCommand("PaymentAttempt", Value.from_any(getattr(self, "id", None)), @@ -229,6 +237,7 @@ async def _teaql_save_within_graph(self, graph, parent_scope=None): elif "version" in persisted: self.version = persisted["version"] self._loaded_fields.add("version") + committed_snapshot = _LoadedScalarSnapshot(self._teaql_scalar_payload()) self._ledger_id = getattr(self, "id", self._ledger_id) new_key = self._teaql_entity_key() if old_key != new_key: @@ -248,6 +257,7 @@ def rollback_entity(): if action != "Delete": await self._teaql_save_children(graph, scope) def commit_entity(): + self._teaql_loaded_snapshot = committed_snapshot self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.accept_committed_version(new_key, int(self.version)) diff --git a/examples/trace-chain/lib/models/platform.py b/examples/trace-chain/lib/models/platform.py index 9eb5819..cdf4311 100644 --- a/examples/trace-chain/lib/models/platform.py +++ b/examples/trace-chain/lib/models/platform.py @@ -1,4 +1,5 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core.entity import _LoadedScalarSnapshot from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation @@ -55,6 +56,8 @@ def __init__(self, **kwargs): self._entity_root.mark_as_new(key) elif getattr(self, "version", None) is not None: self._entity_root.set_original_version(key, int(self.version)) + self._teaql_loaded_snapshot = _LoadedScalarSnapshot( + self._teaql_scalar_payload() if self._action == "Update" else {}) def _teaql_entity_key(self): return EntityKey("Platform", self._ledger_id) @@ -123,7 +126,7 @@ async def _teaql_preflight_and_save(self, graph): self._teaql_preflight_graph(graph) return await self._teaql_save_within_graph(graph) - def _teaql_build_command(self): + def _teaql_scalar_payload(self): payload = {} if "id" in self._loaded_fields: payload["id"] = Value.I64(self.id) @@ -134,6 +137,10 @@ def _teaql_build_command(self): if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + return payload + + def _teaql_build_command(self): + payload = self._teaql_scalar_payload() action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -146,6 +153,7 @@ def _teaql_build_command(self): original_version if original_version is not None else getattr(self, "version", None)) for key, value in payload.items(): if key not in ("id", "version"): cmd.value(key, value) + cmd.old_values = self._teaql_loaded_snapshot.select(payload) else: original_version = self._entity_root.original_version(self._teaql_entity_key()) cmd = DeleteCommand("Platform", Value.from_any(getattr(self, "id", None)), @@ -234,6 +242,7 @@ async def _teaql_save_within_graph(self, graph, parent_scope=None): elif "version" in persisted: self.version = persisted["version"] self._loaded_fields.add("version") + committed_snapshot = _LoadedScalarSnapshot(self._teaql_scalar_payload()) self._ledger_id = getattr(self, "id", self._ledger_id) new_key = self._teaql_entity_key() if old_key != new_key: @@ -253,6 +262,7 @@ def rollback_entity(): if action != "Delete": await self._teaql_save_children(graph, scope) def commit_entity(): + self._teaql_loaded_snapshot = committed_snapshot self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.accept_committed_version(new_key, int(self.version)) diff --git a/examples/trace-chain/lib/models/shipment.py b/examples/trace-chain/lib/models/shipment.py index e70879b..192c4f7 100644 --- a/examples/trace-chain/lib/models/shipment.py +++ b/examples/trace-chain/lib/models/shipment.py @@ -1,4 +1,5 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core.entity import _LoadedScalarSnapshot from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation @@ -53,6 +54,8 @@ def __init__(self, **kwargs): self._entity_root.mark_as_new(key) elif getattr(self, "version", None) is not None: self._entity_root.set_original_version(key, int(self.version)) + self._teaql_loaded_snapshot = _LoadedScalarSnapshot( + self._teaql_scalar_payload() if self._action == "Update" else {}) def _teaql_entity_key(self): return EntityKey("Shipment", self._ledger_id) @@ -116,7 +119,7 @@ async def _teaql_preflight_and_save(self, graph): self._teaql_preflight_graph(graph) return await self._teaql_save_within_graph(graph) - def _teaql_build_command(self): + def _teaql_scalar_payload(self): payload = {} if "id" in self._loaded_fields: payload["id"] = Value.I64(self.id) @@ -134,6 +137,10 @@ def _teaql_build_command(self): if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + return payload + + def _teaql_build_command(self): + payload = self._teaql_scalar_payload() action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -146,6 +153,7 @@ def _teaql_build_command(self): original_version if original_version is not None else getattr(self, "version", None)) for key, value in payload.items(): if key not in ("id", "version"): cmd.value(key, value) + cmd.old_values = self._teaql_loaded_snapshot.select(payload) else: original_version = self._entity_root.original_version(self._teaql_entity_key()) cmd = DeleteCommand("Shipment", Value.from_any(getattr(self, "id", None)), @@ -229,6 +237,7 @@ async def _teaql_save_within_graph(self, graph, parent_scope=None): elif "version" in persisted: self.version = persisted["version"] self._loaded_fields.add("version") + committed_snapshot = _LoadedScalarSnapshot(self._teaql_scalar_payload()) self._ledger_id = getattr(self, "id", self._ledger_id) new_key = self._teaql_entity_key() if old_key != new_key: @@ -248,6 +257,7 @@ def rollback_entity(): if action != "Delete": await self._teaql_save_children(graph, scope) def commit_entity(): + self._teaql_loaded_snapshot = committed_snapshot self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.accept_committed_version(new_key, int(self.version)) diff --git a/examples/trace-chain/mutation_privacy.py b/examples/trace-chain/mutation_privacy.py new file mode 100644 index 0000000..80cf4e9 --- /dev/null +++ b/examples/trace-chain/mutation_privacy.py @@ -0,0 +1,66 @@ +"""Generated save privacy; no generated source inspection or synthetic traces.""" +import asyncio +import os +import uuid + +from E import E +from Q import Q +from runtime_module import GENERATED_RUNTIME_MODULE +from teaql.provider.sqlite import create_sqlite_service +from teaql.runtime import UserContext + +from main import AuditSink, ObservedService, new, reset + + +async def main(): + context = UserContext.new().install(GENERATED_RUNTIME_MODULE) + service = ObservedService(create_sqlite_service(os.environ['TEAQL_TRACE_CHAIN_DB'])) + sink = AuditSink(service) + context.insert_resource('dataService', service).with_app_audit_event_sink(sink) + context.set_diagnostic_sql_log_sink(type('Silent', (), {'write': lambda self, entry: None})()) + context.configure_audit_policy('Payment', ['reference_code']) + await context.ensure_schema() + platform = await Q.platforms().with_id_is(1).limit(1).comment('reuse seeded root').purpose( + 'attach generated privacy graph').execute_for_one(context) + label = 'PRIVACY-' + uuid.uuid4().hex[:16] + # Alphabetic canaries prevent numeric identity redaction from accidentally + # making a missing old-value snapshot appear private. + alphabetic_nonce = lambda: uuid.uuid4().hex[:16].translate(str.maketrans('0123456789', 'ghijklmnop')) + secret = 'PRIVATE-PAYMENT-' + alphabetic_nonce() + root = new(Q.customer_orders(), context).update_platform(platform) + root.update_order_number(label).update_description('privacy graph') + item = new(Q.order_items(), context).update_customer_order(root).update_name('ordinary sibling') + payment = new(Q.payments(), context).update_customer_order(root).update_reference_code(secret) + root.order_item_list().append(item) + root.payment_list().append(payment) + for phase in ('create', 'update'): + old = secret + if phase == 'update': + secret = 'PRIVATE-REVISED-' + alphabetic_nonce() + root.update_description('revised privacy graph') + item.update_name('revised ordinary sibling') + payment.update_reference_code(secret) + reset(service, sink, context) + reason = f'{phase} payment {secret} previous {old}' + await root.audit_as(reason).save(context) + assert len(service.requests) == len(sink.events) == 3 + logs = context.sql_logs() + assert len(logs) == 6 + for value in (secret, old): + assert value not in repr(logs) + assert value not in repr(sink.events) + for metadata in service.results: + assert len(metadata.statements) == 2 + assert metadata.comment == reason # raw owned intent is not rewritten + loaded = await Q.payments().with_id_is(E.payment(payment).id().eval()).limit(1).comment( + 'reload authoritative payment').purpose('verify safe logs do not alter business data').execute_for_one(context) + assert E.payment(loaded).reference_code().eval() == secret + assert E.payment(loaded).version().eval() == (1 if phase == 'create' else 2) + await Q.customer_orders().with_id_is(E.customer_order(root).id().eval()).limit(1).comment( + f'independent {secret} {old}').purpose('ensure no ambient redaction values').execute_for_one(context) + assert secret in context.sql_logs()[-1].comment and old in context.sql_logs()[-1].comment + print(f'PASS: Python generated mutation privacy {phase}; 3 writes/3 reads/3 audits; independent next request') + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/examples/trace-chain/verify.sh b/examples/trace-chain/verify.sh index 15520e9..9f2242d 100644 --- a/examples/trace-chain/verify.sh +++ b/examples/trace-chain/verify.sh @@ -29,6 +29,8 @@ snapshot="$(mktemp)" for attempt in first second; do echo "Run $attempt on $TEAQL_TRACE_CHAIN_DB without cleanup" python "$example/main.py" + echo "Mutation privacy $attempt on $TEAQL_TRACE_CHAIN_DB without cleanup" + python "$example/mutation_privacy.py" echo "Ownership $attempt on $TEAQL_TRACE_CHAIN_SHARED_DB without cleanup" run_log="$(mktemp -t teaql-python-shared.XXXXXX.log)" env -u TEAQL_TRACE_CHAIN_SCENARIO TEAQL_TRACE_CHAIN_DB="$TEAQL_TRACE_CHAIN_SHARED_DB" \ diff --git a/src/teaql/core/entity.py b/src/teaql/core/entity.py index a122c98..4d99cc2 100644 --- a/src/teaql/core/entity.py +++ b/src/teaql/core/entity.py @@ -15,6 +15,16 @@ def __post_init__(self): raise ValueError("entity type is required") +class _LoadedScalarSnapshot: + """Generated plumbing: owned original scalars, not a mutable graph ledger.""" + + def __init__(self, values): + self.__values = deepcopy(values) + + def select(self, fields): + return {name: deepcopy(self.__values[name]) for name in fields if name in self.__values} + + class EntityChangeSet: """Final pending field values grouped by stable entity identity.""" diff --git a/src/teaql/runtime/audit.py b/src/teaql/runtime/audit.py index 5e4d1ab..7e90bce 100644 --- a/src/teaql/runtime/audit.py +++ b/src/teaql/runtime/audit.py @@ -28,6 +28,7 @@ class RawAuditEvent: actor: Optional[str] = None category: Optional[str] = None mutation_governance: Any = None + _intent_privacy: Any = field(default=None, repr=False, compare=False) def safe(self, mask_fields: List[str], max_length: Optional[int]) -> "SafeAuditEvent": from .log_privacy import REDACTED, credential_name, payload_has_credentials, plaintext_enabled, scrub, value_strings @@ -50,6 +51,8 @@ def safe(self, mask_fields: List[str], max_length: Optional[int]) -> "SafeAuditE value = "*" * max_length if max_length <= 3 else value[:max_length - 3] + "..." fields.append(SafeAuditField(change.field, value, masked, truncated)) intent_values = secrets + value_strings(self.entity_id) + if self._intent_privacy is not None: + intent_values.extend(self._intent_privacy.secrets(allow)) return SafeAuditEvent( self.kind, self.entity, self.entity_id, scrub(tuple(fields), secrets), scrub(self.trace_chain, intent_values), scrub(self.actor, intent_values), self.category, self.mutation_governance, diff --git a/src/teaql/runtime/context.py b/src/teaql/runtime/context.py index 156da02..8e5d421 100644 --- a/src/teaql/runtime/context.py +++ b/src/teaql/runtime/context.py @@ -813,6 +813,11 @@ def preflight_mutation(self, mutation: Any) -> None: """Check/fix and snapshot one operation for whole-graph policy review.""" self.check_and_fix_mutation(mutation) self._mutation_policy.record_preflight(mutation) + if self._graph_session is not None: + from .log_privacy import _MutationIntentPrivacy + session = self._graph_session + session._intent_privacy = session._intent_privacy.merge( + _MutationIntentPrivacy.capture(mutation, self.entity)) def mutation_policy_execution(self, request: Any): """Provider boundary scope; entered after validation and before mutation.""" @@ -897,7 +902,7 @@ def language(self) -> Any: def record_metadata_log(self, metadata: Any): self._record_metadata_log(metadata) - def _record_metadata_log(self, metadata: Any, *, intent_source=None, intent_values=()): + def _record_metadata_log(self, metadata: Any, *, intent_source=None, intent_values=(), intent_privacy=None): """Internal statement plumbing: source bindings never reach sinks/buffers.""" op = SqlLogOperation.Select op_str = str(getattr(metadata, 'operation', '')).lower() @@ -939,7 +944,8 @@ def _record_metadata_log(self, metadata: Any, *, intent_source=None, intent_valu entry.result_summary = f"{entry.affected_rows} rows affected" from .log_privacy import sql_log_projection - entry = sql_log_projection(entry, _intent_source=intent_source, _intent_values=intent_values) + entry = sql_log_projection(entry, _intent_source=intent_source, _intent_values=intent_values, + _intent_privacy=intent_privacy) logs = self.sql_logs() logs.append(entry) self._resources["sql_logs"] = logs diff --git a/src/teaql/runtime/graph_session.py b/src/teaql/runtime/graph_session.py index be1c312..15b36ce 100644 --- a/src/teaql/runtime/graph_session.py +++ b/src/teaql/runtime/graph_session.py @@ -20,6 +20,8 @@ def __init__(self, failures): class GraphMutationSession: def __init__(self, context, transaction, intent: MutationIntent): + from .log_privacy import _MutationIntentPrivacy + self._intent_privacy = _MutationIntentPrivacy() self.intent = intent self.transaction = transaction self._owner = object() @@ -108,6 +110,8 @@ async def rolled_back(self): return tuple(failures) def close(self): + from .log_privacy import _MutationIntentPrivacy + self._intent_privacy = _MutationIntentPrivacy() self._active = False self.context._mutation_policy.end_graph() self.context.finish_fix_evidence() diff --git a/src/teaql/runtime/log_privacy.py b/src/teaql/runtime/log_privacy.py index bfc9f31..56026b6 100644 --- a/src/teaql/runtime/log_privacy.py +++ b/src/teaql/runtime/log_privacy.py @@ -5,7 +5,7 @@ import re import weakref from copy import deepcopy -from dataclasses import fields, is_dataclass, replace +from dataclasses import dataclass, fields, is_dataclass, replace from functools import lru_cache PLAINTEXT_ENV = "TEAQL_ALLOW_SENSITIVE_PLAINTEXT_LOGS" @@ -117,7 +117,52 @@ def _is_masked(policy, allow): return policy in ('credential', 'unknown') or (not allow and policy != 'plain') -def sql_log_projection(entry, *, _intent_source=None, _intent_values=()): +def field_log_policy(entity, name): + """One classification for SQL binds and invocation-owned intent snapshots.""" + prop = entity.property_by_name(name) if entity else None + if credential_name(name) or (prop and credential_name(prop.column_name_val)): + return 'credential' + if entity is None or not entity.audit_mask_fields_declared: + return 'unknown' + if name in entity.audit_mask_fields_val: + return 'masked' + return getattr(prop, 'log_policy_val', 'unknown') + + +@dataclass(frozen=True, repr=False) +class _MutationIntentPrivacy: + # Immutable strings only, no caller-owned Value/map references. This object + # belongs to a native invocation or graph session, never to shared Context. + bindings: tuple = () + + def merge(self, other): + return _MutationIntentPrivacy(self.bindings + other.bindings) + + def secrets(self, allow): + return [text for policy, text in self.bindings if _is_masked(policy, allow)] + + @classmethod + def capture(cls, data, resolve_entity): + from teaql.core.mutation import MutationRequest + if isinstance(data, MutationRequest): + data = data._data + if isinstance(data, list): + result = cls() + for child in data: + result = result.merge(cls.capture(child, resolve_entity)) + return result + descriptor = resolve_entity(data.entity) + bindings = [] + for values in (getattr(data, 'values', {}), getattr(data, 'old_values', None) or {}): + for name, value in values.items(): + policy = ('credential' if payload_has_credentials(value) + else field_log_policy(descriptor, name)) + bindings.extend((policy, text) for text in value_strings(value)) + bindings.extend(('unknown', text) for text in value_strings(getattr(data, 'id', None))) + return cls(tuple(bindings)) + + +def sql_log_projection(entry, *, _intent_source=None, _intent_values=(), _intent_privacy=None): """Source bindings are call-local runtime plumbing, never stored on a log entry.""" allow = plaintext_enabled() and entry.log_mode != 'masked' prior = _projections.get(id(entry)) @@ -129,12 +174,12 @@ def sql_log_projection(entry, *, _intent_source=None, _intent_values=()): # Entries are mutable. Do not expose the cached safe alternative itself. if prior[3] is not None: return _remember_projection(deepcopy(prior[3]), False) - projected = _project_with_policy(entry, allow, _intent_source, _intent_values) - alternative = _project_with_policy(entry, False, _intent_source, _intent_values) if allow else None + projected = _project_with_policy(entry, allow, _intent_source, _intent_values, _intent_privacy) + alternative = _project_with_policy(entry, False, _intent_source, _intent_values, _intent_privacy) if allow else None return _remember_projection(projected, allow, alternative) -def _project_with_policy(entry, allow, intent_source, intent_values): +def _project_with_policy(entry, allow, intent_source, intent_values, intent_privacy=None): from teaql.sql.types import DatabaseKind, render_log_sql, _sql_literal from teaql.core.value import Value supplied = entry.parameter_log_policies @@ -155,6 +200,8 @@ def _project_with_policy(entry, allow, intent_source, intent_values): secrets.extend(text for index, value in enumerate(intent_source.params) if _is_masked(source_policies[index], allow) for text in value_strings(value)) intent_secrets = secrets + [text for value in intent_values for text in value_strings(value)] + if intent_privacy is not None: + intent_secrets.extend(intent_privacy.secrets(allow)) # A copied/changed debug record has lost its reliable private alternative. # Its inherited intent may mention values absent from its own SQL bindings. unknown_debug_intent = not allow and entry.log_mode == 'debug-plaintext' and intent_source is None diff --git a/src/teaql/sql/dialect.py b/src/teaql/sql/dialect.py index b29d901..46def59 100644 --- a/src/teaql/sql/dialect.py +++ b/src/teaql/sql/dialect.py @@ -399,15 +399,8 @@ def compile_projection(self, entity: EntityDescriptor, query: SelectQuery, param return self.aggregate_projection(entity, query, params) def field_log_policy(self, entity, field): - from teaql.runtime.log_privacy import credential_name - prop = entity.property_by_name(field) - if credential_name(field) or (prop and credential_name(prop.column_name_val)): - return 'credential' - if not entity.audit_mask_fields_declared: - return 'unknown' - if field in entity.audit_mask_fields_val: - return 'masked' - return getattr(prop, 'log_policy_val', 'unknown') + from teaql.runtime.log_privacy import field_log_policy + return field_log_policy(entity, field) def bind_field(self, params, value, entity, field): if isinstance(params, SQLBindings): diff --git a/src/teaql/sql/executor.py b/src/teaql/sql/executor.py index 7caf8d2..b2cd05c 100644 --- a/src/teaql/sql/executor.py +++ b/src/teaql/sql/executor.py @@ -248,7 +248,7 @@ def capabilities(self) -> DataServiceCapabilities: ) def _record_statement(self, context, request, compiled, started_at, operation, - outcome, result_count=None, affected_rows=None): + outcome, result_count=None, affected_rows=None, intent_privacy=None): """Project through context; never attach driver exceptions to diagnostics.""" query = operation == DataServiceOperation.Query entity = request.query.entity if query else request._data.entity @@ -294,11 +294,12 @@ def _record_statement(self, context, request, compiled, started_at, operation, if getattr(prop, '_is_id', False) or getattr(prop, 'is_id_val', False)), None) if id_property is not None: target_id = getattr(request._data, 'values', {}).get(id_property.name) - if source is None and target_id is None: + if source is None and target_id is None and intent_privacy is None: context.record_metadata_log(metadata) else: context._record_metadata_log(metadata, intent_source=source, - intent_values=() if target_id is None else (target_id,)) + intent_values=() if target_id is None else (target_id,), + intent_privacy=intent_privacy) except Exception: # A broken diagnostic destination must not replace an in-flight # driver failure, cancellation or generator close. @@ -772,16 +773,23 @@ def check(data): else _NoopContextManager() ) with scope: + from teaql.runtime.log_privacy import _MutationIntentPrivacy + def descriptor(name): + return self.schema_provider.get_entity(name) or (context.entity(name) if context else None) + privacy = _MutationIntentPrivacy.capture(request, descriptor) + session = getattr(context, '_graph_session', None) + if session is not None: + privacy = session._intent_privacy.merge(privacy) return await observe_runtime_operation( telemetry, RuntimeOperation("mutation", f"{entity}.{kind}", { "teaql.entity.type": entity, "teaql.mutation.kind": kind, }), - lambda: self._mutate(context, request), + lambda: self._mutate(context, request, privacy), ) - async def _mutate(self, context: 'UserContext', request: MutationRequest) -> MutationResult: + async def _mutate(self, context: 'UserContext', request: MutationRequest, privacy=None) -> MutationResult: request.validate() if isinstance(self.transport, SqlTransactionTransport): transaction = await self.transport.begin_sql() @@ -789,7 +797,7 @@ async def _mutate(self, context: 'UserContext', request: MutationRequest) -> Mut from teaql.runtime.audit import _CommittedAuditJournal journal = _CommittedAuditJournal(context) if context is not None else None try: - result = await executor._mutate(journal.context if journal else context, request) + result = await executor._mutate(journal.context if journal else context, request, privacy) await transaction.commit_sql() except BaseException: if journal: @@ -814,7 +822,7 @@ async def _mutate(self, context: 'UserContext', request: MutationRequest) -> Mut child_request = (child.with_root_intent(request.intent) if isinstance(child, MutationRequest) else MutationRequest(child, comment=request.intent.comment)) - results.append(await self._mutate(context, child_request)) + results.append(await self._mutate(context, child_request, privacy)) affected = sum(result.affected_rows for result in results) return MutationResult(affected, {}, ExecutionMetadata( backend=self._backend_name(), operation=DataServiceOperation.Batch, @@ -896,12 +904,13 @@ async def _mutate(self, context: 'UserContext', request: MutationRequest) -> Mut ) except BaseException as e: self._record_statement(context, request, compiled, start, operation, - 'cancelled' if isinstance(e, asyncio.CancelledError) else 'failure') + 'cancelled' if isinstance(e, asyncio.CancelledError) else 'failure', + intent_privacy=privacy) if isinstance(e, Exception): raise TransportError(e) from e raise metadata = self._record_statement(context, request, compiled, start, operation, - 'success', affected_rows=affected_rows) + 'success', affected_rows=affected_rows, intent_privacy=privacy) generated_values = {} if op == "insert" and last_insert_id: @@ -954,11 +963,11 @@ async def _mutate(self, context: 'UserContext', request: MutationRequest) -> Mut f"expected one authoritative persisted row for {req_data.entity}, got {len(persisted_rows)}")) except BaseException as error: self._record_readback(context, readback, compiled, metadata, read_start, - persisted_rows, error) + persisted_rows, error, privacy) raise persisted_record = persisted_rows[0] read_metadata = self._record_readback(context, readback, compiled, metadata, - read_start, persisted_rows, None) + read_start, persisted_rows, None, privacy) metadata = replace(metadata, statements=(metadata, read_metadata)) if affected_rows > 0 and context is not None: @@ -989,6 +998,7 @@ async def _mutate(self, context: 'UserContext', request: MutationRequest) -> Mut context.user_identifier(), context.get_resource("bootstrapCategory"), context.current_mutation_governance(), + _intent_privacy=privacy, )) return MutationResult( affected_rows=affected_rows, @@ -997,7 +1007,7 @@ async def _mutate(self, context: 'UserContext', request: MutationRequest) -> Mut persisted_record=persisted_record, ) - def _record_readback(self, context, readback, source, write_metadata, started_at, rows, error): + def _record_readback(self, context, readback, source, write_metadata, started_at, rows, error, privacy=None): # A driver returning zero/multiple rows succeeded as SQL; validation of # the authoritative snapshot is a separate business failure. outcome = ('success' if rows is not None else 'cancelled' @@ -1013,7 +1023,7 @@ def _record_readback(self, context, readback, source, write_metadata, started_at return metadata try: context._record_metadata_log(metadata, intent_source=source, - intent_values=tuple(readback.params[:1])) + intent_values=tuple(readback.params[:1]), intent_privacy=privacy) except BaseException: # An in-flight readback error must survive a diagnostic sink failure. pass diff --git a/tests/provider/sqlite/test_trace_chain.py b/tests/provider/sqlite/test_trace_chain.py index 6883377..bd9dff5 100644 --- a/tests/provider/sqlite/test_trace_chain.py +++ b/tests/provider/sqlite/test_trace_chain.py @@ -165,6 +165,111 @@ async def test_query_log_switch_does_not_remove_physical_readback_result(tmp_pat assert len(context.sql_logs()) == 1 +@pytest.mark.asyncio +@pytest.mark.parametrize('graph', [False, True]) +@pytest.mark.parametrize('failure', [None, 'read-error', 'cancelled']) +async def test_future_child_values_cannot_escape_root_intent(tmp_path, graph, failure): + import asyncio + from teaql.core.mutation import UpdateCommand + from teaql.core.value import Value + context, service, transport, entries = await fixture(tmp_path) + context.entity('Payment').audit_mask_fields(['name']) + service.schema_provider.get_entity('Payment').audit_mask_fields(['name']) + audits = [] + context.with_app_audit_event_sink(SimpleNamespace(on_safe_event=lambda _, event: audits.append(event))) + secret, old = 'PRIVATE-FUTURE-CHILD-VALUE', 'PRIVATE-OLD-CHILD-VALUE' + comment = f'authorize graph containing {secret} replacing {old}' + root = InsertCommand.new('CustomerOrder').value('name', 'public root') + child = UpdateCommand.new('Payment', 1).expected_version(1).value('name', secret) + child.old_values = {'name': Value.from_any(old)} + async with aiosqlite.connect(transport.db_path) as database: + await database.execute('UPDATE payment_data SET name=? WHERE id=1', (old,)) + await database.commit() + if failure: + transport.fail_table = 'payment_data' + if failure == 'cancelled': + transport.failure = asyncio.CancelledError() + async def execute(): + if graph: + context.insert_resource('dataService', service) + async def work(session): + for command in (root, child): + session.context.preflight_mutation(command) + for command in (root, child): + await session.transaction.mutate(session.context, MutationRequest(command, comment=comment)) + await context.execute_graph_save(work, comment=comment) + else: + await service.mutate(context, MutationRequest.Batch([root, child], comment=comment)) + if failure: + with pytest.raises(type(transport.failure)) as caught: + await execute() + assert caught.value is transport.failure + else: + await execute() + assert len(entries) == 4 and len(audits) == (0 if failure else 2) + assert entries[-1].execution_outcome == ( + 'cancelled' if failure == 'cancelled' else 'failure' if failure else 'success') + for value in (secret, old): + assert value not in repr(entries) + assert value not in repr(context.sql_logs()) + assert value not in repr(audits) + async with aiosqlite.connect(transport.db_path) as database: + row = await (await database.execute('SELECT name, version FROM payment_data WHERE id=1')).fetchone() + assert row == ((old, 1) if failure else (secret, 2)) + await service.query(context, QueryRequest(SelectQuery('CustomerOrder').limit(1), + _comment=f'independent {secret} {old}', _purpose='verify privacy does not outlive its invocation')) + assert secret in entries[-1].comment and old in entries[-1].comment + + +@pytest.mark.asyncio +async def test_concurrent_native_batches_own_distinct_privacy(tmp_path): + import asyncio + context, service, transport, entries = await fixture(tmp_path) + context.configure_audit_policy('Payment', ['name']) + secrets = ['FIRST-PRIVATE-PAYMENT', 'SECOND-PRIVATE-PAYMENT'] + groups = ['alpha', 'beta'] + async def save(index): + own, other = secrets[index], secrets[1 - index] + return await service.mutate(context, MutationRequest.Batch([ + InsertCommand.new('CustomerOrder').value('name', f'public root {index}'), + InsertCommand.new('Payment').value('name', own).value('parent_id', 1), + ], comment=f'group-{groups[index]} protects {own} unrelated {other}')) + results = await asyncio.gather(save(0), save(1)) + assert all(result.affected_rows == 2 for result in results) + assert len(entries) == 8 + for index in range(2): + own_logs = [entry for entry in entries if entry.comment.startswith(f'group-{groups[index]}')] + assert len(own_logs) == 4 + assert all(secrets[index] not in entry.comment and secrets[1-index] in entry.comment for entry in own_logs) + + +@pytest.mark.asyncio +async def test_inherited_debug_opt_in_keeps_credentials_hidden_and_can_reproject(tmp_path, monkeypatch): + from teaql.runtime.log_privacy import PLAINTEXT_ENV, PLAINTEXT_ACK, sql_log_projection + context, service, transport, entries = await fixture(tmp_path) + descriptor = context.entity('Payment') + descriptor.audit_mask_fields(['name']) + descriptor.property(PropertyDescriptor('password_hash', DataType.Text)) + async with aiosqlite.connect(transport.db_path) as database: + await database.execute('ALTER TABLE payment_data ADD COLUMN password_hash TEXT') + await database.commit() + audits = [] + context.with_app_audit_event_sink(SimpleNamespace(on_safe_event=lambda _, event: audits.append(event))) + business, credential = 'DEBUG-BUSINESS-CANARY', 'CREDENTIAL-NEVER-PUBLIC' + monkeypatch.setenv(PLAINTEXT_ENV, PLAINTEXT_ACK) + await service.mutate(context, MutationRequest.Batch([ + InsertCommand.new('CustomerOrder').value('name', 'root'), + InsertCommand.new('Payment').value('name', business).value('password_hash', credential).value('parent_id', 1), + ], comment=f'authorize {business} with {credential}')) + assert len(entries) == 4 and len(audits) == 2 + assert all(business in entry.comment and 'EXPLICIT OPT-IN' in entry.debug_sql for entry in entries) + assert credential not in repr(entries) + repr(audits) + monkeypatch.delenv(PLAINTEXT_ENV) + for entry in entries: + safe = sql_log_projection(entry) + assert business not in repr(safe) and credential not in repr(safe) + + @pytest.mark.asyncio @pytest.mark.parametrize('failure', [False, True]) async def test_three_real_relation_levels_keep_origin_and_qualified_frames(tmp_path, failure): diff --git a/tests/runtime/test_mutation_intent_privacy.py b/tests/runtime/test_mutation_intent_privacy.py new file mode 100644 index 0000000..ea4ca09 --- /dev/null +++ b/tests/runtime/test_mutation_intent_privacy.py @@ -0,0 +1,35 @@ +"""Owned provenance and debug rules, independent from graph acceptance.""" +from teaql.core.meta import EntityDescriptor, PropertyDescriptor +from teaql.core.mutation import UpdateCommand +from teaql.core.value import DataType, Value +from teaql.runtime.log_privacy import _MutationIntentPrivacy + + +def test_snapshot_owns_old_and_new_values_and_debug_never_reveals_credentials(): + descriptor = EntityDescriptor('Payment').audit_mask_fields(['name']) + descriptor.property(PropertyDescriptor('name', DataType.Text).log_policy('plain')) + descriptor.property(PropertyDescriptor('password_hash', DataType.Text).log_policy('plain')) + command = UpdateCommand.new('Payment', 73).value('name', 'PRIVATE-NEW') + command.value('password_hash', 'CREDENTIAL-CANARY') + command.old_values = {'name': Value.from_any('PRIVATE-OLD')} + privacy = _MutationIntentPrivacy.capture(command, lambda _: descriptor) + command.values['name'] = Value.from_any('changed after snapshot') + command.old_values.clear() + descriptor.audit_mask_fields([]) + assert set(privacy.secrets(False)) == {'PRIVATE-NEW', 'PRIVATE-OLD', 'CREDENTIAL-CANARY', '73'} + assert set(privacy.secrets(True)) == {'CREDENTIAL-CANARY', '73'} + assert 'PRIVATE-NEW' not in repr(privacy) + + +def test_loaded_scalar_snapshot_owns_values_and_returns_detached_changed_fields(): + from teaql.core.entity import _LoadedScalarSnapshot + source = {'name': Value.from_any('original'), 'config': Value.from_any({'nested': ['original']})} + snapshot = _LoadedScalarSnapshot(source) + source['name'] = Value.from_any('caller changed') + source['config'].val['nested'].append('caller changed') + selected = snapshot.select(['name', 'config', 'not_loaded']) + assert selected['name'].val == 'original' + assert selected['config'].val == {'nested': ['original']} + assert 'not_loaded' not in selected + selected['config'].val['nested'].clear() + assert snapshot.select(['config'])['config'].val == {'nested': ['original']} diff --git a/tests/runtime/test_mutation_policy.py b/tests/runtime/test_mutation_policy.py index 4bf635e..ab26b06 100644 --- a/tests/runtime/test_mutation_policy.py +++ b/tests/runtime/test_mutation_policy.py @@ -65,13 +65,15 @@ class RecordingTransaction: def __init__(self, owner): self.owner = owner self.executor = object.__new__(SqlDataServiceExecutor) + from teaql.provider.sqlite import SimpleSchemaProvider + self.executor.schema_provider = SimpleSchemaProvider() self.executor._sync_generated_schema = lambda context: None self.executor._mutate = self._mutate async def mutate(self, context, request): return await self.executor.mutate(context, request) - async def _mutate(self, context, request): + async def _mutate(self, context, request, privacy=None): self.owner.mutations += 1 command = request._data await context.send_audit_event(RawAuditEvent( From e38b21863d455b62587bb1309904784d011df4da Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sat, 3 Oct 2026 15:36:43 +0800 Subject: [PATCH 08/40] fix(trace): preserve loaded delete and unchanged scalar privacy (#43) Signed-off-by: Philip Z --- examples/conformance/models/platform.py | 13 +++++- examples/conformance/models/work_item.py | 13 +++++- .../models/commerce_platform.py | 13 +++++- .../python-lib-core/models/customer.py | 13 +++++- .../python-lib-core/models/customer_order.py | 13 +++++- .../python-lib-core/models/order_line.py | 13 +++++- .../models/order_search_preset.py | 13 +++++- .../python-lib-core/models/order_status.py | 13 +++++- .../python-lib-core/models/product.py | 13 +++++- examples/school-management/models/platform.py | 13 +++++- examples/school-management/models/school.py | 13 +++++- .../school-management/models/school_type.py | 13 +++++- .../task_board/generated/models/platform.py | 13 +++++- examples/task_board/generated/models/task.py | 13 +++++- .../generated/models/task_execution_log.py | 13 +++++- .../generated/models/task_status.py | 13 +++++- examples/trace-chain/README.md | 6 +++ .../trace-chain/lib/models/customer_order.py | 3 +- examples/trace-chain/lib/models/order_item.py | 3 +- examples/trace-chain/lib/models/payment.py | 3 +- .../trace-chain/lib/models/payment_attempt.py | 3 +- examples/trace-chain/lib/models/platform.py | 3 +- examples/trace-chain/lib/models/shipment.py | 3 +- examples/trace-chain/mutation_privacy.py | 43 +++++++++++++++++++ examples/trace-chain/verify.sh | 5 ++- src/teaql/core/entity.py | 4 ++ src/teaql/core/mutation.py | 2 + tests/runtime/test_mutation_intent_privacy.py | 27 ++++++++++++ 28 files changed, 290 insertions(+), 23 deletions(-) diff --git a/examples/conformance/models/platform.py b/examples/conformance/models/platform.py index 3ba5b49..84ba4e5 100644 --- a/examples/conformance/models/platform.py +++ b/examples/conformance/models/platform.py @@ -1,4 +1,5 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core.entity import _LoadedScalarSnapshot from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation @@ -55,6 +56,8 @@ def __init__(self, **kwargs): self._entity_root.mark_as_new(key) elif getattr(self, "version", None) is not None: self._entity_root.set_original_version(key, int(self.version)) + self._teaql_loaded_snapshot = _LoadedScalarSnapshot( + self._teaql_scalar_payload() if self._action == "Update" else {}) def _teaql_entity_key(self): return EntityKey("Platform", self._ledger_id) @@ -123,7 +126,7 @@ async def _teaql_preflight_and_save(self, graph): self._teaql_preflight_graph(graph) return await self._teaql_save_within_graph(graph) - def _teaql_build_command(self): + def _teaql_scalar_payload(self): payload = {} if "id" in self._loaded_fields: payload["id"] = Value.I64(self.id) @@ -134,6 +137,10 @@ def _teaql_build_command(self): if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + return payload + + def _teaql_build_command(self): + payload = self._teaql_scalar_payload() action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -150,6 +157,8 @@ def _teaql_build_command(self): original_version = self._entity_root.original_version(self._teaql_entity_key()) cmd = DeleteCommand("Platform", Value.from_any(getattr(self, "id", None)), original_version if original_version is not None else getattr(self, "version", None)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() return action, cmd def _teaql_preflight_graph(self, graph): @@ -234,6 +243,7 @@ async def _teaql_save_within_graph(self, graph, parent_scope=None): elif "version" in persisted: self.version = persisted["version"] self._loaded_fields.add("version") + committed_snapshot = _LoadedScalarSnapshot(self._teaql_scalar_payload()) self._ledger_id = getattr(self, "id", self._ledger_id) new_key = self._teaql_entity_key() if old_key != new_key: @@ -253,6 +263,7 @@ def rollback_entity(): if action != "Delete": await self._teaql_save_children(graph, scope) def commit_entity(): + self._teaql_loaded_snapshot = committed_snapshot self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.accept_committed_version(new_key, int(self.version)) diff --git a/examples/conformance/models/work_item.py b/examples/conformance/models/work_item.py index 6d2e85b..cec96ce 100644 --- a/examples/conformance/models/work_item.py +++ b/examples/conformance/models/work_item.py @@ -1,4 +1,5 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core.entity import _LoadedScalarSnapshot from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation @@ -57,6 +58,8 @@ def __init__(self, **kwargs): self._entity_root.mark_as_new(key) elif getattr(self, "version", None) is not None: self._entity_root.set_original_version(key, int(self.version)) + self._teaql_loaded_snapshot = _LoadedScalarSnapshot( + self._teaql_scalar_payload() if self._action == "Update" else {}) def _teaql_entity_key(self): return EntityKey("WorkItem", self._ledger_id) @@ -120,7 +123,7 @@ async def _teaql_preflight_and_save(self, graph): self._teaql_preflight_graph(graph) return await self._teaql_save_within_graph(graph) - def _teaql_build_command(self): + def _teaql_scalar_payload(self): payload = {} if "id" in self._loaded_fields: payload["id"] = Value.I64(self.id) @@ -141,6 +144,10 @@ def _teaql_build_command(self): if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + return payload + + def _teaql_build_command(self): + payload = self._teaql_scalar_payload() action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -157,6 +164,8 @@ def _teaql_build_command(self): original_version = self._entity_root.original_version(self._teaql_entity_key()) cmd = DeleteCommand("WorkItem", Value.from_any(getattr(self, "id", None)), original_version if original_version is not None else getattr(self, "version", None)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() return action, cmd def _teaql_preflight_graph(self, graph): @@ -244,6 +253,7 @@ async def _teaql_save_within_graph(self, graph, parent_scope=None): elif "version" in persisted: self.version = persisted["version"] self._loaded_fields.add("version") + committed_snapshot = _LoadedScalarSnapshot(self._teaql_scalar_payload()) self._ledger_id = getattr(self, "id", self._ledger_id) new_key = self._teaql_entity_key() if old_key != new_key: @@ -263,6 +273,7 @@ def rollback_entity(): if action != "Delete": await self._teaql_save_children(graph, scope) def commit_entity(): + self._teaql_loaded_snapshot = committed_snapshot self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.accept_committed_version(new_key, int(self.version)) diff --git a/examples/order-management/python-lib-core/models/commerce_platform.py b/examples/order-management/python-lib-core/models/commerce_platform.py index 397a980..6ac5876 100644 --- a/examples/order-management/python-lib-core/models/commerce_platform.py +++ b/examples/order-management/python-lib-core/models/commerce_platform.py @@ -1,4 +1,5 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core.entity import _LoadedScalarSnapshot from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation @@ -108,6 +109,8 @@ def __init__(self, **kwargs): self._entity_root.mark_as_new(key) elif getattr(self, "version", None) is not None: self._entity_root.set_original_version(key, int(self.version)) + self._teaql_loaded_snapshot = _LoadedScalarSnapshot( + self._teaql_scalar_payload() if self._action == "Update" else {}) def _teaql_entity_key(self): return EntityKey("CommercePlatform", self._ledger_id) @@ -201,7 +204,7 @@ async def _teaql_preflight_and_save(self, graph): self._teaql_preflight_graph(graph) return await self._teaql_save_within_graph(graph) - def _teaql_build_command(self): + def _teaql_scalar_payload(self): payload = {} if "id" in self._loaded_fields: payload["id"] = Value.I64(self.id) @@ -218,6 +221,10 @@ def _teaql_build_command(self): if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + return payload + + def _teaql_build_command(self): + payload = self._teaql_scalar_payload() action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -234,6 +241,8 @@ def _teaql_build_command(self): original_version = self._entity_root.original_version(self._teaql_entity_key()) cmd = DeleteCommand("CommercePlatform", Value.from_any(getattr(self, "id", None)), original_version if original_version is not None else getattr(self, "version", None)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() return action, cmd def _teaql_preflight_graph(self, graph): @@ -399,6 +408,7 @@ async def _teaql_save_within_graph(self, graph, parent_scope=None): elif "version" in persisted: self.version = persisted["version"] self._loaded_fields.add("version") + committed_snapshot = _LoadedScalarSnapshot(self._teaql_scalar_payload()) self._ledger_id = getattr(self, "id", self._ledger_id) new_key = self._teaql_entity_key() if old_key != new_key: @@ -418,6 +428,7 @@ def rollback_entity(): if action != "Delete": await self._teaql_save_children(graph, scope) def commit_entity(): + self._teaql_loaded_snapshot = committed_snapshot self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.accept_committed_version(new_key, int(self.version)) diff --git a/examples/order-management/python-lib-core/models/customer.py b/examples/order-management/python-lib-core/models/customer.py index 0a3e343..ff0b9c5 100644 --- a/examples/order-management/python-lib-core/models/customer.py +++ b/examples/order-management/python-lib-core/models/customer.py @@ -1,4 +1,5 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core.entity import _LoadedScalarSnapshot from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation @@ -74,6 +75,8 @@ def __init__(self, **kwargs): self._entity_root.mark_as_new(key) elif getattr(self, "version", None) is not None: self._entity_root.set_original_version(key, int(self.version)) + self._teaql_loaded_snapshot = _LoadedScalarSnapshot( + self._teaql_scalar_payload() if self._action == "Update" else {}) def _teaql_entity_key(self): return EntityKey("Customer", self._ledger_id) @@ -142,7 +145,7 @@ async def _teaql_preflight_and_save(self, graph): self._teaql_preflight_graph(graph) return await self._teaql_save_within_graph(graph) - def _teaql_build_command(self): + def _teaql_scalar_payload(self): payload = {} if "id" in self._loaded_fields: payload["id"] = Value.I64(self.id) @@ -169,6 +172,10 @@ def _teaql_build_command(self): if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + return payload + + def _teaql_build_command(self): + payload = self._teaql_scalar_payload() action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -185,6 +192,8 @@ def _teaql_build_command(self): original_version = self._entity_root.original_version(self._teaql_entity_key()) cmd = DeleteCommand("Customer", Value.from_any(getattr(self, "id", None)), original_version if original_version is not None else getattr(self, "version", None)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() return action, cmd def _teaql_preflight_graph(self, graph): @@ -301,6 +310,7 @@ async def _teaql_save_within_graph(self, graph, parent_scope=None): elif "version" in persisted: self.version = persisted["version"] self._loaded_fields.add("version") + committed_snapshot = _LoadedScalarSnapshot(self._teaql_scalar_payload()) self._ledger_id = getattr(self, "id", self._ledger_id) new_key = self._teaql_entity_key() if old_key != new_key: @@ -320,6 +330,7 @@ def rollback_entity(): if action != "Delete": await self._teaql_save_children(graph, scope) def commit_entity(): + self._teaql_loaded_snapshot = committed_snapshot self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.accept_committed_version(new_key, int(self.version)) diff --git a/examples/order-management/python-lib-core/models/customer_order.py b/examples/order-management/python-lib-core/models/customer_order.py index 1a004d8..c188d64 100644 --- a/examples/order-management/python-lib-core/models/customer_order.py +++ b/examples/order-management/python-lib-core/models/customer_order.py @@ -1,4 +1,5 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core.entity import _LoadedScalarSnapshot from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation @@ -92,6 +93,8 @@ def __init__(self, **kwargs): self._entity_root.mark_as_new(key) elif getattr(self, "version", None) is not None: self._entity_root.set_original_version(key, int(self.version)) + self._teaql_loaded_snapshot = _LoadedScalarSnapshot( + self._teaql_scalar_payload() if self._action == "Update" else {}) def _teaql_entity_key(self): return EntityKey("CustomerOrder", self._ledger_id) @@ -160,7 +163,7 @@ async def _teaql_preflight_and_save(self, graph): self._teaql_preflight_graph(graph) return await self._teaql_save_within_graph(graph) - def _teaql_build_command(self): + def _teaql_scalar_payload(self): payload = {} if "id" in self._loaded_fields: payload["id"] = Value.I64(self.id) @@ -204,6 +207,10 @@ def _teaql_build_command(self): if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + return payload + + def _teaql_build_command(self): + payload = self._teaql_scalar_payload() action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -220,6 +227,8 @@ def _teaql_build_command(self): original_version = self._entity_root.original_version(self._teaql_entity_key()) cmd = DeleteCommand("CustomerOrder", Value.from_any(getattr(self, "id", None)), original_version if original_version is not None else getattr(self, "version", None)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() return action, cmd def _teaql_preflight_graph(self, graph): @@ -360,6 +369,7 @@ async def _teaql_save_within_graph(self, graph, parent_scope=None): elif "version" in persisted: self.version = persisted["version"] self._loaded_fields.add("version") + committed_snapshot = _LoadedScalarSnapshot(self._teaql_scalar_payload()) self._ledger_id = getattr(self, "id", self._ledger_id) new_key = self._teaql_entity_key() if old_key != new_key: @@ -379,6 +389,7 @@ def rollback_entity(): if action != "Delete": await self._teaql_save_children(graph, scope) def commit_entity(): + self._teaql_loaded_snapshot = committed_snapshot self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.accept_committed_version(new_key, int(self.version)) diff --git a/examples/order-management/python-lib-core/models/order_line.py b/examples/order-management/python-lib-core/models/order_line.py index 2a49c37..5abf5d0 100644 --- a/examples/order-management/python-lib-core/models/order_line.py +++ b/examples/order-management/python-lib-core/models/order_line.py @@ -1,4 +1,5 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core.entity import _LoadedScalarSnapshot from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation @@ -79,6 +80,8 @@ def __init__(self, **kwargs): self._entity_root.mark_as_new(key) elif getattr(self, "version", None) is not None: self._entity_root.set_original_version(key, int(self.version)) + self._teaql_loaded_snapshot = _LoadedScalarSnapshot( + self._teaql_scalar_payload() if self._action == "Update" else {}) def _teaql_entity_key(self): return EntityKey("OrderLine", self._ledger_id) @@ -142,7 +145,7 @@ async def _teaql_preflight_and_save(self, graph): self._teaql_preflight_graph(graph) return await self._teaql_save_within_graph(graph) - def _teaql_build_command(self): + def _teaql_scalar_payload(self): payload = {} if "id" in self._loaded_fields: payload["id"] = Value.I64(self.id) @@ -183,6 +186,10 @@ def _teaql_build_command(self): if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + return payload + + def _teaql_build_command(self): + payload = self._teaql_scalar_payload() action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -199,6 +206,8 @@ def _teaql_build_command(self): original_version = self._entity_root.original_version(self._teaql_entity_key()) cmd = DeleteCommand("OrderLine", Value.from_any(getattr(self, "id", None)), original_version if original_version is not None else getattr(self, "version", None)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() return action, cmd def _teaql_preflight_graph(self, graph): @@ -318,6 +327,7 @@ async def _teaql_save_within_graph(self, graph, parent_scope=None): elif "version" in persisted: self.version = persisted["version"] self._loaded_fields.add("version") + committed_snapshot = _LoadedScalarSnapshot(self._teaql_scalar_payload()) self._ledger_id = getattr(self, "id", self._ledger_id) new_key = self._teaql_entity_key() if old_key != new_key: @@ -337,6 +347,7 @@ def rollback_entity(): if action != "Delete": await self._teaql_save_children(graph, scope) def commit_entity(): + self._teaql_loaded_snapshot = committed_snapshot self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.accept_committed_version(new_key, int(self.version)) diff --git a/examples/order-management/python-lib-core/models/order_search_preset.py b/examples/order-management/python-lib-core/models/order_search_preset.py index 0405842..418f3c3 100644 --- a/examples/order-management/python-lib-core/models/order_search_preset.py +++ b/examples/order-management/python-lib-core/models/order_search_preset.py @@ -1,4 +1,5 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core.entity import _LoadedScalarSnapshot from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation @@ -73,6 +74,8 @@ def __init__(self, **kwargs): self._entity_root.mark_as_new(key) elif getattr(self, "version", None) is not None: self._entity_root.set_original_version(key, int(self.version)) + self._teaql_loaded_snapshot = _LoadedScalarSnapshot( + self._teaql_scalar_payload() if self._action == "Update" else {}) def _teaql_entity_key(self): return EntityKey("OrderSearchPreset", self._ledger_id) @@ -136,7 +139,7 @@ async def _teaql_preflight_and_save(self, graph): self._teaql_preflight_graph(graph) return await self._teaql_save_within_graph(graph) - def _teaql_build_command(self): + def _teaql_scalar_payload(self): payload = {} if "id" in self._loaded_fields: payload["id"] = Value.I64(self.id) @@ -169,6 +172,10 @@ def _teaql_build_command(self): if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + return payload + + def _teaql_build_command(self): + payload = self._teaql_scalar_payload() action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -185,6 +192,8 @@ def _teaql_build_command(self): original_version = self._entity_root.original_version(self._teaql_entity_key()) cmd = DeleteCommand("OrderSearchPreset", Value.from_any(getattr(self, "id", None)), original_version if original_version is not None else getattr(self, "version", None)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() return action, cmd def _teaql_preflight_graph(self, graph): @@ -304,6 +313,7 @@ async def _teaql_save_within_graph(self, graph, parent_scope=None): elif "version" in persisted: self.version = persisted["version"] self._loaded_fields.add("version") + committed_snapshot = _LoadedScalarSnapshot(self._teaql_scalar_payload()) self._ledger_id = getattr(self, "id", self._ledger_id) new_key = self._teaql_entity_key() if old_key != new_key: @@ -323,6 +333,7 @@ def rollback_entity(): if action != "Delete": await self._teaql_save_children(graph, scope) def commit_entity(): + self._teaql_loaded_snapshot = committed_snapshot self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.accept_committed_version(new_key, int(self.version)) diff --git a/examples/order-management/python-lib-core/models/order_status.py b/examples/order-management/python-lib-core/models/order_status.py index 3db1740..39eb96f 100644 --- a/examples/order-management/python-lib-core/models/order_status.py +++ b/examples/order-management/python-lib-core/models/order_status.py @@ -1,4 +1,5 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core.entity import _LoadedScalarSnapshot from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation @@ -74,6 +75,8 @@ def __init__(self, **kwargs): self._entity_root.mark_as_new(key) elif getattr(self, "version", None) is not None: self._entity_root.set_original_version(key, int(self.version)) + self._teaql_loaded_snapshot = _LoadedScalarSnapshot( + self._teaql_scalar_payload() if self._action == "Update" else {}) def _teaql_entity_key(self): return EntityKey("OrderStatus", self._ledger_id) @@ -142,7 +145,7 @@ async def _teaql_preflight_and_save(self, graph): self._teaql_preflight_graph(graph) return await self._teaql_save_within_graph(graph) - def _teaql_build_command(self): + def _teaql_scalar_payload(self): payload = {} if "id" in self._loaded_fields: payload["id"] = Value.I64(self.id) @@ -169,6 +172,10 @@ def _teaql_build_command(self): if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + return payload + + def _teaql_build_command(self): + payload = self._teaql_scalar_payload() action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -185,6 +192,8 @@ def _teaql_build_command(self): original_version = self._entity_root.original_version(self._teaql_entity_key()) cmd = DeleteCommand("OrderStatus", Value.from_any(getattr(self, "id", None)), original_version if original_version is not None else getattr(self, "version", None)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() return action, cmd def _teaql_preflight_graph(self, graph): @@ -301,6 +310,7 @@ async def _teaql_save_within_graph(self, graph, parent_scope=None): elif "version" in persisted: self.version = persisted["version"] self._loaded_fields.add("version") + committed_snapshot = _LoadedScalarSnapshot(self._teaql_scalar_payload()) self._ledger_id = getattr(self, "id", self._ledger_id) new_key = self._teaql_entity_key() if old_key != new_key: @@ -320,6 +330,7 @@ def rollback_entity(): if action != "Delete": await self._teaql_save_children(graph, scope) def commit_entity(): + self._teaql_loaded_snapshot = committed_snapshot self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.accept_committed_version(new_key, int(self.version)) diff --git a/examples/order-management/python-lib-core/models/product.py b/examples/order-management/python-lib-core/models/product.py index 05c2d23..ea563c3 100644 --- a/examples/order-management/python-lib-core/models/product.py +++ b/examples/order-management/python-lib-core/models/product.py @@ -1,4 +1,5 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core.entity import _LoadedScalarSnapshot from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation @@ -78,6 +79,8 @@ def __init__(self, **kwargs): self._entity_root.mark_as_new(key) elif getattr(self, "version", None) is not None: self._entity_root.set_original_version(key, int(self.version)) + self._teaql_loaded_snapshot = _LoadedScalarSnapshot( + self._teaql_scalar_payload() if self._action == "Update" else {}) def _teaql_entity_key(self): return EntityKey("Product", self._ledger_id) @@ -146,7 +149,7 @@ async def _teaql_preflight_and_save(self, graph): self._teaql_preflight_graph(graph) return await self._teaql_save_within_graph(graph) - def _teaql_build_command(self): + def _teaql_scalar_payload(self): payload = {} if "id" in self._loaded_fields: payload["id"] = Value.I64(self.id) @@ -176,6 +179,10 @@ def _teaql_build_command(self): if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + return payload + + def _teaql_build_command(self): + payload = self._teaql_scalar_payload() action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -192,6 +199,8 @@ def _teaql_build_command(self): original_version = self._entity_root.original_version(self._teaql_entity_key()) cmd = DeleteCommand("Product", Value.from_any(getattr(self, "id", None)), original_version if original_version is not None else getattr(self, "version", None)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() return action, cmd def _teaql_preflight_graph(self, graph): @@ -316,6 +325,7 @@ async def _teaql_save_within_graph(self, graph, parent_scope=None): elif "version" in persisted: self.version = persisted["version"] self._loaded_fields.add("version") + committed_snapshot = _LoadedScalarSnapshot(self._teaql_scalar_payload()) self._ledger_id = getattr(self, "id", self._ledger_id) new_key = self._teaql_entity_key() if old_key != new_key: @@ -335,6 +345,7 @@ def rollback_entity(): if action != "Delete": await self._teaql_save_children(graph, scope) def commit_entity(): + self._teaql_loaded_snapshot = committed_snapshot self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.accept_committed_version(new_key, int(self.version)) diff --git a/examples/school-management/models/platform.py b/examples/school-management/models/platform.py index 41aa778..bc3dc18 100644 --- a/examples/school-management/models/platform.py +++ b/examples/school-management/models/platform.py @@ -1,4 +1,5 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core.entity import _LoadedScalarSnapshot from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation @@ -76,6 +77,8 @@ def __init__(self, **kwargs): self._entity_root.mark_as_new(key) elif getattr(self, "version", None) is not None: self._entity_root.set_original_version(key, int(self.version)) + self._teaql_loaded_snapshot = _LoadedScalarSnapshot( + self._teaql_scalar_payload() if self._action == "Update" else {}) def _teaql_entity_key(self): return EntityKey("Platform", self._ledger_id) @@ -149,7 +152,7 @@ async def _teaql_preflight_and_save(self, graph): self._teaql_preflight_graph(graph) return await self._teaql_save_within_graph(graph) - def _teaql_build_command(self): + def _teaql_scalar_payload(self): payload = {} if "id" in self._loaded_fields: payload["id"] = Value.I64(self.id) @@ -169,6 +172,10 @@ def _teaql_build_command(self): if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + return payload + + def _teaql_build_command(self): + payload = self._teaql_scalar_payload() action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -185,6 +192,8 @@ def _teaql_build_command(self): original_version = self._entity_root.original_version(self._teaql_entity_key()) cmd = DeleteCommand("Platform", Value.from_any(getattr(self, "id", None)), original_version if original_version is not None else getattr(self, "version", None)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() return action, cmd def _teaql_preflight_graph(self, graph): @@ -306,6 +315,7 @@ async def _teaql_save_within_graph(self, graph, parent_scope=None): elif "version" in persisted: self.version = persisted["version"] self._loaded_fields.add("version") + committed_snapshot = _LoadedScalarSnapshot(self._teaql_scalar_payload()) self._ledger_id = getattr(self, "id", self._ledger_id) new_key = self._teaql_entity_key() if old_key != new_key: @@ -325,6 +335,7 @@ def rollback_entity(): if action != "Delete": await self._teaql_save_children(graph, scope) def commit_entity(): + self._teaql_loaded_snapshot = committed_snapshot self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.accept_committed_version(new_key, int(self.version)) diff --git a/examples/school-management/models/school.py b/examples/school-management/models/school.py index 84825fc..514a34c 100644 --- a/examples/school-management/models/school.py +++ b/examples/school-management/models/school.py @@ -1,4 +1,5 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core.entity import _LoadedScalarSnapshot from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation @@ -84,6 +85,8 @@ def __init__(self, **kwargs): self._entity_root.mark_as_new(key) elif getattr(self, "version", None) is not None: self._entity_root.set_original_version(key, int(self.version)) + self._teaql_loaded_snapshot = _LoadedScalarSnapshot( + self._teaql_scalar_payload() if self._action == "Update" else {}) def _teaql_entity_key(self): return EntityKey("School", self._ledger_id) @@ -147,7 +150,7 @@ async def _teaql_preflight_and_save(self, graph): self._teaql_preflight_graph(graph) return await self._teaql_save_within_graph(graph) - def _teaql_build_command(self): + def _teaql_scalar_payload(self): payload = {} if "id" in self._loaded_fields: payload["id"] = Value.I64(self.id) @@ -190,6 +193,10 @@ def _teaql_build_command(self): if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + return payload + + def _teaql_build_command(self): + payload = self._teaql_scalar_payload() action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -206,6 +213,8 @@ def _teaql_build_command(self): original_version = self._entity_root.original_version(self._teaql_entity_key()) cmd = DeleteCommand("School", Value.from_any(getattr(self, "id", None)), original_version if original_version is not None else getattr(self, "version", None)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() return action, cmd def _teaql_preflight_graph(self, graph): @@ -341,6 +350,7 @@ async def _teaql_save_within_graph(self, graph, parent_scope=None): elif "version" in persisted: self.version = persisted["version"] self._loaded_fields.add("version") + committed_snapshot = _LoadedScalarSnapshot(self._teaql_scalar_payload()) self._ledger_id = getattr(self, "id", self._ledger_id) new_key = self._teaql_entity_key() if old_key != new_key: @@ -360,6 +370,7 @@ def rollback_entity(): if action != "Delete": await self._teaql_save_children(graph, scope) def commit_entity(): + self._teaql_loaded_snapshot = committed_snapshot self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.accept_committed_version(new_key, int(self.version)) diff --git a/examples/school-management/models/school_type.py b/examples/school-management/models/school_type.py index 0358694..b2ecef5 100644 --- a/examples/school-management/models/school_type.py +++ b/examples/school-management/models/school_type.py @@ -1,4 +1,5 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core.entity import _LoadedScalarSnapshot from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation @@ -70,6 +71,8 @@ def __init__(self, **kwargs): self._entity_root.mark_as_new(key) elif getattr(self, "version", None) is not None: self._entity_root.set_original_version(key, int(self.version)) + self._teaql_loaded_snapshot = _LoadedScalarSnapshot( + self._teaql_scalar_payload() if self._action == "Update" else {}) def _teaql_entity_key(self): return EntityKey("SchoolType", self._ledger_id) @@ -138,7 +141,7 @@ async def _teaql_preflight_and_save(self, graph): self._teaql_preflight_graph(graph) return await self._teaql_save_within_graph(graph) - def _teaql_build_command(self): + def _teaql_scalar_payload(self): payload = {} if "platform" in self._loaded_fields: reference = self.platform @@ -162,6 +165,10 @@ def _teaql_build_command(self): if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + return payload + + def _teaql_build_command(self): + payload = self._teaql_scalar_payload() action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -178,6 +185,8 @@ def _teaql_build_command(self): original_version = self._entity_root.original_version(self._teaql_entity_key()) cmd = DeleteCommand("SchoolType", Value.from_any(getattr(self, "id", None)), original_version if original_version is not None else getattr(self, "version", None)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() return action, cmd def _teaql_preflight_graph(self, graph): @@ -286,6 +295,7 @@ async def _teaql_save_within_graph(self, graph, parent_scope=None): elif "version" in persisted: self.version = persisted["version"] self._loaded_fields.add("version") + committed_snapshot = _LoadedScalarSnapshot(self._teaql_scalar_payload()) self._ledger_id = getattr(self, "id", self._ledger_id) new_key = self._teaql_entity_key() if old_key != new_key: @@ -305,6 +315,7 @@ def rollback_entity(): if action != "Delete": await self._teaql_save_children(graph, scope) def commit_entity(): + self._teaql_loaded_snapshot = committed_snapshot self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.accept_committed_version(new_key, int(self.version)) diff --git a/examples/task_board/generated/models/platform.py b/examples/task_board/generated/models/platform.py index 4214919..176e1b4 100644 --- a/examples/task_board/generated/models/platform.py +++ b/examples/task_board/generated/models/platform.py @@ -1,4 +1,5 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core.entity import _LoadedScalarSnapshot from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation @@ -72,6 +73,8 @@ def __init__(self, **kwargs): self._entity_root.mark_as_new(key) elif getattr(self, "version", None) is not None: self._entity_root.set_original_version(key, int(self.version)) + self._teaql_loaded_snapshot = _LoadedScalarSnapshot( + self._teaql_scalar_payload() if self._action == "Update" else {}) def _teaql_entity_key(self): return EntityKey("Platform", self._ledger_id) @@ -145,7 +148,7 @@ async def _teaql_preflight_and_save(self, graph): self._teaql_preflight_graph(graph) return await self._teaql_save_within_graph(graph) - def _teaql_build_command(self): + def _teaql_scalar_payload(self): payload = {} if "id" in self._loaded_fields: payload["id"] = Value.I64(self.id) @@ -162,6 +165,10 @@ def _teaql_build_command(self): if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + return payload + + def _teaql_build_command(self): + payload = self._teaql_scalar_payload() action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -178,6 +185,8 @@ def _teaql_build_command(self): original_version = self._entity_root.original_version(self._teaql_entity_key()) cmd = DeleteCommand("Platform", Value.from_any(getattr(self, "id", None)), original_version if original_version is not None else getattr(self, "version", None)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() return action, cmd def _teaql_preflight_graph(self, graph): @@ -291,6 +300,7 @@ async def _teaql_save_within_graph(self, graph, parent_scope=None): elif "version" in persisted: self.version = persisted["version"] self._loaded_fields.add("version") + committed_snapshot = _LoadedScalarSnapshot(self._teaql_scalar_payload()) self._ledger_id = getattr(self, "id", self._ledger_id) new_key = self._teaql_entity_key() if old_key != new_key: @@ -310,6 +320,7 @@ def rollback_entity(): if action != "Delete": await self._teaql_save_children(graph, scope) def commit_entity(): + self._teaql_loaded_snapshot = committed_snapshot self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.accept_committed_version(new_key, int(self.version)) diff --git a/examples/task_board/generated/models/task.py b/examples/task_board/generated/models/task.py index e43d664..c022a34 100644 --- a/examples/task_board/generated/models/task.py +++ b/examples/task_board/generated/models/task.py @@ -1,4 +1,5 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core.entity import _LoadedScalarSnapshot from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation @@ -69,6 +70,8 @@ def __init__(self, **kwargs): self._entity_root.mark_as_new(key) elif getattr(self, "version", None) is not None: self._entity_root.set_original_version(key, int(self.version)) + self._teaql_loaded_snapshot = _LoadedScalarSnapshot( + self._teaql_scalar_payload() if self._action == "Update" else {}) def _teaql_entity_key(self): return EntityKey("Task", self._ledger_id) @@ -137,7 +140,7 @@ async def _teaql_preflight_and_save(self, graph): self._teaql_preflight_graph(graph) return await self._teaql_save_within_graph(graph) - def _teaql_build_command(self): + def _teaql_scalar_payload(self): payload = {} if "id" in self._loaded_fields: payload["id"] = Value.I64(self.id) @@ -162,6 +165,10 @@ def _teaql_build_command(self): if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + return payload + + def _teaql_build_command(self): + payload = self._teaql_scalar_payload() action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -178,6 +185,8 @@ def _teaql_build_command(self): original_version = self._entity_root.original_version(self._teaql_entity_key()) cmd = DeleteCommand("Task", Value.from_any(getattr(self, "id", None)), original_version if original_version is not None else getattr(self, "version", None)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() return action, cmd def _teaql_preflight_graph(self, graph): @@ -278,6 +287,7 @@ async def _teaql_save_within_graph(self, graph, parent_scope=None): elif "version" in persisted: self.version = persisted["version"] self._loaded_fields.add("version") + committed_snapshot = _LoadedScalarSnapshot(self._teaql_scalar_payload()) self._ledger_id = getattr(self, "id", self._ledger_id) new_key = self._teaql_entity_key() if old_key != new_key: @@ -297,6 +307,7 @@ def rollback_entity(): if action != "Delete": await self._teaql_save_children(graph, scope) def commit_entity(): + self._teaql_loaded_snapshot = committed_snapshot self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.accept_committed_version(new_key, int(self.version)) diff --git a/examples/task_board/generated/models/task_execution_log.py b/examples/task_board/generated/models/task_execution_log.py index be6c897..c9063dc 100644 --- a/examples/task_board/generated/models/task_execution_log.py +++ b/examples/task_board/generated/models/task_execution_log.py @@ -1,4 +1,5 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core.entity import _LoadedScalarSnapshot from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation @@ -57,6 +58,8 @@ def __init__(self, **kwargs): self._entity_root.mark_as_new(key) elif getattr(self, "version", None) is not None: self._entity_root.set_original_version(key, int(self.version)) + self._teaql_loaded_snapshot = _LoadedScalarSnapshot( + self._teaql_scalar_payload() if self._action == "Update" else {}) def _teaql_entity_key(self): return EntityKey("TaskExecutionLog", self._ledger_id) @@ -120,7 +123,7 @@ async def _teaql_preflight_and_save(self, graph): self._teaql_preflight_graph(graph) return await self._teaql_save_within_graph(graph) - def _teaql_build_command(self): + def _teaql_scalar_payload(self): payload = {} if "id" in self._loaded_fields: payload["id"] = Value.I64(self.id) @@ -141,6 +144,10 @@ def _teaql_build_command(self): if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + return payload + + def _teaql_build_command(self): + payload = self._teaql_scalar_payload() action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -157,6 +164,8 @@ def _teaql_build_command(self): original_version = self._entity_root.original_version(self._teaql_entity_key()) cmd = DeleteCommand("TaskExecutionLog", Value.from_any(getattr(self, "id", None)), original_version if original_version is not None else getattr(self, "version", None)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() return action, cmd def _teaql_preflight_graph(self, graph): @@ -244,6 +253,7 @@ async def _teaql_save_within_graph(self, graph, parent_scope=None): elif "version" in persisted: self.version = persisted["version"] self._loaded_fields.add("version") + committed_snapshot = _LoadedScalarSnapshot(self._teaql_scalar_payload()) self._ledger_id = getattr(self, "id", self._ledger_id) new_key = self._teaql_entity_key() if old_key != new_key: @@ -263,6 +273,7 @@ def rollback_entity(): if action != "Delete": await self._teaql_save_children(graph, scope) def commit_entity(): + self._teaql_loaded_snapshot = committed_snapshot self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.accept_committed_version(new_key, int(self.version)) diff --git a/examples/task_board/generated/models/task_status.py b/examples/task_board/generated/models/task_status.py index f029623..91e2afd 100644 --- a/examples/task_board/generated/models/task_status.py +++ b/examples/task_board/generated/models/task_status.py @@ -1,4 +1,5 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core.entity import _LoadedScalarSnapshot from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation @@ -78,6 +79,8 @@ def __init__(self, **kwargs): self._entity_root.mark_as_new(key) elif getattr(self, "version", None) is not None: self._entity_root.set_original_version(key, int(self.version)) + self._teaql_loaded_snapshot = _LoadedScalarSnapshot( + self._teaql_scalar_payload() if self._action == "Update" else {}) def _teaql_entity_key(self): return EntityKey("TaskStatus", self._ledger_id) @@ -146,7 +149,7 @@ async def _teaql_preflight_and_save(self, graph): self._teaql_preflight_graph(graph) return await self._teaql_save_within_graph(graph) - def _teaql_build_command(self): + def _teaql_scalar_payload(self): payload = {} if "id" in self._loaded_fields: payload["id"] = Value.I64(self.id) @@ -176,6 +179,10 @@ def _teaql_build_command(self): if "version" in self._loaded_fields: payload["version"] = Value.I64(self.version) + return payload + + def _teaql_build_command(self): + payload = self._teaql_scalar_payload() action = self._action if action == "Update": ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) @@ -192,6 +199,8 @@ def _teaql_build_command(self): original_version = self._entity_root.original_version(self._teaql_entity_key()) cmd = DeleteCommand("TaskStatus", Value.from_any(getattr(self, "id", None)), original_version if original_version is not None else getattr(self, "version", None)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() return action, cmd def _teaql_preflight_graph(self, graph): @@ -316,6 +325,7 @@ async def _teaql_save_within_graph(self, graph, parent_scope=None): elif "version" in persisted: self.version = persisted["version"] self._loaded_fields.add("version") + committed_snapshot = _LoadedScalarSnapshot(self._teaql_scalar_payload()) self._ledger_id = getattr(self, "id", self._ledger_id) new_key = self._teaql_entity_key() if old_key != new_key: @@ -335,6 +345,7 @@ def rollback_entity(): if action != "Delete": await self._teaql_save_children(graph, scope) def commit_entity(): + self._teaql_loaded_snapshot = committed_snapshot self._entity_root.clear_entity(new_key) if getattr(self, "version", None) is not None: self._entity_root.accept_committed_version(new_key, int(self.version)) diff --git a/examples/trace-chain/README.md b/examples/trace-chain/README.md index edef065..9b109bc 100644 --- a/examples/trace-chain/README.md +++ b/examples/trace-chain/README.md @@ -45,6 +45,12 @@ redaction. Generated E verifies original stored values and versions; the next independent query proves there is no ambient privacy on shared Context. Original scalar snapshot ownership is implemented in the runtime; generation supplies loaded scalar data and advances the snapshot only after successful commit. +Loaded deletion and updates of another field also retain private old scalar +provenance, including unchanged business fields but excluding structural ID and +version. The generated command passes this detached snapshot without adding +fields to SQL writes. These cases require regeneration with the matching local +runtime. Native RecoverCommand capture is tested separately; this example does +not claim an end-to-end generated recovery flow. Native tests additionally cover batch/graph rollback, readback cancellation, concurrent native batches, debug opt-in, credentials and safe reprojection. diff --git a/examples/trace-chain/lib/models/customer_order.py b/examples/trace-chain/lib/models/customer_order.py index c430c98..d3781fd 100644 --- a/examples/trace-chain/lib/models/customer_order.py +++ b/examples/trace-chain/lib/models/customer_order.py @@ -202,11 +202,12 @@ def _teaql_build_command(self): original_version if original_version is not None else getattr(self, "version", None)) for key, value in payload.items(): if key not in ("id", "version"): cmd.value(key, value) - cmd.old_values = self._teaql_loaded_snapshot.select(payload) else: original_version = self._entity_root.original_version(self._teaql_entity_key()) cmd = DeleteCommand("CustomerOrder", Value.from_any(getattr(self, "id", None)), original_version if original_version is not None else getattr(self, "version", None)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() return action, cmd def _teaql_preflight_graph(self, graph): diff --git a/examples/trace-chain/lib/models/order_item.py b/examples/trace-chain/lib/models/order_item.py index 9108a52..2180e1c 100644 --- a/examples/trace-chain/lib/models/order_item.py +++ b/examples/trace-chain/lib/models/order_item.py @@ -153,11 +153,12 @@ def _teaql_build_command(self): original_version if original_version is not None else getattr(self, "version", None)) for key, value in payload.items(): if key not in ("id", "version"): cmd.value(key, value) - cmd.old_values = self._teaql_loaded_snapshot.select(payload) else: original_version = self._entity_root.original_version(self._teaql_entity_key()) cmd = DeleteCommand("OrderItem", Value.from_any(getattr(self, "id", None)), original_version if original_version is not None else getattr(self, "version", None)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() return action, cmd def _teaql_preflight_graph(self, graph): diff --git a/examples/trace-chain/lib/models/payment.py b/examples/trace-chain/lib/models/payment.py index 56db81e..13b4a8b 100644 --- a/examples/trace-chain/lib/models/payment.py +++ b/examples/trace-chain/lib/models/payment.py @@ -167,11 +167,12 @@ def _teaql_build_command(self): original_version if original_version is not None else getattr(self, "version", None)) for key, value in payload.items(): if key not in ("id", "version"): cmd.value(key, value) - cmd.old_values = self._teaql_loaded_snapshot.select(payload) else: original_version = self._entity_root.original_version(self._teaql_entity_key()) cmd = DeleteCommand("Payment", Value.from_any(getattr(self, "id", None)), original_version if original_version is not None else getattr(self, "version", None)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() return action, cmd def _teaql_preflight_graph(self, graph): diff --git a/examples/trace-chain/lib/models/payment_attempt.py b/examples/trace-chain/lib/models/payment_attempt.py index 087514b..30d4fe3 100644 --- a/examples/trace-chain/lib/models/payment_attempt.py +++ b/examples/trace-chain/lib/models/payment_attempt.py @@ -153,11 +153,12 @@ def _teaql_build_command(self): original_version if original_version is not None else getattr(self, "version", None)) for key, value in payload.items(): if key not in ("id", "version"): cmd.value(key, value) - cmd.old_values = self._teaql_loaded_snapshot.select(payload) else: original_version = self._entity_root.original_version(self._teaql_entity_key()) cmd = DeleteCommand("PaymentAttempt", Value.from_any(getattr(self, "id", None)), original_version if original_version is not None else getattr(self, "version", None)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() return action, cmd def _teaql_preflight_graph(self, graph): diff --git a/examples/trace-chain/lib/models/platform.py b/examples/trace-chain/lib/models/platform.py index cdf4311..94f3f22 100644 --- a/examples/trace-chain/lib/models/platform.py +++ b/examples/trace-chain/lib/models/platform.py @@ -153,11 +153,12 @@ def _teaql_build_command(self): original_version if original_version is not None else getattr(self, "version", None)) for key, value in payload.items(): if key not in ("id", "version"): cmd.value(key, value) - cmd.old_values = self._teaql_loaded_snapshot.select(payload) else: original_version = self._entity_root.original_version(self._teaql_entity_key()) cmd = DeleteCommand("Platform", Value.from_any(getattr(self, "id", None)), original_version if original_version is not None else getattr(self, "version", None)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() return action, cmd def _teaql_preflight_graph(self, graph): diff --git a/examples/trace-chain/lib/models/shipment.py b/examples/trace-chain/lib/models/shipment.py index 192c4f7..f8e237e 100644 --- a/examples/trace-chain/lib/models/shipment.py +++ b/examples/trace-chain/lib/models/shipment.py @@ -153,11 +153,12 @@ def _teaql_build_command(self): original_version if original_version is not None else getattr(self, "version", None)) for key, value in payload.items(): if key not in ("id", "version"): cmd.value(key, value) - cmd.old_values = self._teaql_loaded_snapshot.select(payload) else: original_version = self._entity_root.original_version(self._teaql_entity_key()) cmd = DeleteCommand("Shipment", Value.from_any(getattr(self, "id", None)), original_version if original_version is not None else getattr(self, "version", None)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() return action, cmd def _teaql_preflight_graph(self, graph): diff --git a/examples/trace-chain/mutation_privacy.py b/examples/trace-chain/mutation_privacy.py index 80cf4e9..78951bb 100644 --- a/examples/trace-chain/mutation_privacy.py +++ b/examples/trace-chain/mutation_privacy.py @@ -61,6 +61,49 @@ async def main(): assert secret in context.sql_logs()[-1].comment and old in context.sql_logs()[-1].comment print(f'PASS: Python generated mutation privacy {phase}; 3 writes/3 reads/3 audits; independent next request') + # Deletion binds identity/version, not the private business scalar. Its + # loaded original must still protect the whole graph's audit prose. + root = await Q.customer_orders().with_id_is(E.customer_order(root).id().eval()).limit(1).select_payment_list_with( + Q.payments().limit(2)).comment('reload deletion graph').purpose('retain loaded private scalars').execute_for_one(context) + payment = root.payment_list()[0] + payment_id = E.payment(payment).id().eval() + root.update_description('delete the private payment') + payment.mark_for_deletion() + reset(service, sink, context) + reason = f'delete payment {secret}' + await root.audit_as(reason).save(context) + assert len(service.requests) == len(sink.events) == 2 + assert len(context.sql_logs()) == 4 + assert secret not in repr(context.sql_logs()), 'loaded delete value leaked into SQL intent' + assert secret not in repr(sink.events), 'loaded delete value leaked into committed audit' + assert all(metadata.comment == reason for metadata in service.results) + missing = await Q.payments().with_id_is(payment_id).limit(1).comment('verify deleted payment').purpose( + 'prove soft delete hides the row').execute_for_one(context) + assert missing is None + await Q.customer_orders().with_id_is(E.customer_order(root).id().eval()).limit(1).comment( + f'independent {secret}').purpose('ensure delete privacy stays invocation-local').execute_for_one(context) + assert secret in context.sql_logs()[-1].comment + print('PASS: Python generated loaded delete privacy; 2 writes/2 reads/2 audits; independent next request') + + # Changing one field must not lose privacy for another loaded scalar. + context.configure_audit_policy('CustomerOrder', ['description']) + private_description = 'PRIVATE-DESCRIPTION-' + alphabetic_nonce() + root.update_description(private_description) + await root.audit_as('prepare unchanged scalar privacy').save(context) + root = await Q.customer_orders().with_id_is(E.customer_order(root).id().eval()).limit(1).comment( + 'load all order fields').purpose('retain an unchanged private description').execute_for_one(context) + root.update_order_number(label + '-revised') + reset(service, sink, context) + await root.audit_as(f'renumber {private_description}').save(context) + assert len(service.requests) == len(sink.events) == 1 + assert private_description not in repr(context.sql_logs()), 'unchanged loaded scalar leaked into SQL intent' + assert private_description not in repr(sink.events), 'unchanged loaded scalar leaked into audit' + reloaded = await Q.customer_orders().with_id_is(E.customer_order(root).id().eval()).limit(1).comment( + 'verify unchanged description').purpose('prove provenance did not alter business values').execute_for_one(context) + assert E.customer_order(reloaded).description().eval() == private_description + assert E.customer_order(reloaded).order_number().eval() == label + '-revised' + print('PASS: Python generated unchanged scalar privacy; 1 write/1 read/1 audit') + if __name__ == '__main__': asyncio.run(main()) diff --git a/examples/trace-chain/verify.sh b/examples/trace-chain/verify.sh index 9f2242d..f2a3ad6 100644 --- a/examples/trace-chain/verify.sh +++ b/examples/trace-chain/verify.sh @@ -30,7 +30,10 @@ for attempt in first second; do echo "Run $attempt on $TEAQL_TRACE_CHAIN_DB without cleanup" python "$example/main.py" echo "Mutation privacy $attempt on $TEAQL_TRACE_CHAIN_DB without cleanup" - python "$example/mutation_privacy.py" + privacy_log="$(mktemp -t teaql-python-privacy.XXXXXX.log)" + python "$example/mutation_privacy.py" | tee "$privacy_log" + rg -Fq 'PASS: Python generated loaded delete privacy; 2 writes/2 reads/2 audits; independent next request' "$privacy_log" + rg -Fq 'PASS: Python generated unchanged scalar privacy; 1 write/1 read/1 audit' "$privacy_log" echo "Ownership $attempt on $TEAQL_TRACE_CHAIN_SHARED_DB without cleanup" run_log="$(mktemp -t teaql-python-shared.XXXXXX.log)" env -u TEAQL_TRACE_CHAIN_SCENARIO TEAQL_TRACE_CHAIN_DB="$TEAQL_TRACE_CHAIN_SHARED_DB" \ diff --git a/src/teaql/core/entity.py b/src/teaql/core/entity.py index 4d99cc2..d756185 100644 --- a/src/teaql/core/entity.py +++ b/src/teaql/core/entity.py @@ -24,6 +24,10 @@ def __init__(self, values): def select(self, fields): return {name: deepcopy(self.__values[name]) for name in fields if name in self.__values} + def business_values(self): + """Private provenance includes unchanged loaded scalars, never structural version/ID.""" + return self.select(name for name in self.__values if name not in ('id', 'version')) + class EntityChangeSet: """Final pending field values grouped by stable entity identity.""" diff --git a/src/teaql/core/mutation.py b/src/teaql/core/mutation.py index a599e91..1f3daa9 100644 --- a/src/teaql/core/mutation.py +++ b/src/teaql/core/mutation.py @@ -79,6 +79,7 @@ class DeleteCommand: expected_version_val: Optional[int] = None soft_delete: bool = True trace_chain: List[TraceNode] = field(default_factory=list) + old_values: Optional[Dict[str, Value]] = field(default=None, repr=False) @classmethod def new(cls, entity: str, id_val: Any) -> 'DeleteCommand': @@ -98,6 +99,7 @@ class RecoverCommand: id: Value expected_version_val: int trace_chain: List[TraceNode] = field(default_factory=list) + old_values: Optional[Dict[str, Value]] = field(default=None, repr=False) @classmethod def new(cls, entity: str, id_val: Any, expected_version: int) -> 'RecoverCommand': diff --git a/tests/runtime/test_mutation_intent_privacy.py b/tests/runtime/test_mutation_intent_privacy.py index ea4ca09..1464380 100644 --- a/tests/runtime/test_mutation_intent_privacy.py +++ b/tests/runtime/test_mutation_intent_privacy.py @@ -33,3 +33,30 @@ def test_loaded_scalar_snapshot_owns_values_and_returns_detached_changed_fields( assert 'not_loaded' not in selected selected['config'].val['nested'].clear() assert snapshot.select(['config'])['config'].val == {'nested': ['original']} + + +def test_business_snapshot_includes_unchanged_scalars_without_identity_or_version(): + from teaql.core.entity import _LoadedScalarSnapshot + snapshot = _LoadedScalarSnapshot({'id': Value.from_any(1), 'version': Value.from_any(2), + 'name': Value.from_any('PRIVATE-UNCHANGED'), + 'config': Value.from_any({'secret': ['original']})}) + values = snapshot.business_values() + assert set(values) == {'name', 'config'} + values['config'].val['secret'].clear() + assert snapshot.business_values()['config'].val == {'secret': ['original']} + + +def test_delete_and_recover_capture_old_private_values_without_altering_bindings(): + from teaql.core.mutation import DeleteCommand, RecoverCommand + descriptor = EntityDescriptor('Payment').audit_mask_fields(['name']) + descriptor.property(PropertyDescriptor('name', DataType.Text).log_policy('plain')) + descriptor.property(PropertyDescriptor('public_note', DataType.Text).log_policy('plain')) + for command in (DeleteCommand.new('Payment', 73), RecoverCommand.new('Payment', 73, -2)): + command.old_values = {'name': Value.from_any('PRIVATE-OLD'), + 'public_note': Value.from_any('ordinary prose')} + privacy = _MutationIntentPrivacy.capture(command, lambda _: descriptor) + command.old_values.clear() + assert set(privacy.secrets(False)) == {'PRIVATE-OLD', '73'} + assert set(privacy.secrets(True)) == {'73'} + assert command.id.val == 73 + assert not hasattr(command, 'values') From 1e41efa33109449910e6cd9e8abed79c0b77fec5 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sat, 3 Oct 2026 17:54:39 +0800 Subject: [PATCH 09/40] fix(trace): preserve Python aggregate membership and reject incomplete scalar streams (#43) Signed-off-by: Philip Z --- examples/trace-chain/README.md | 5 ++ src/teaql/sql/executor.py | 25 ++++++-- tests/provider/sqlite/test_trace_chain.py | 75 +++++++++++++++++++++++ tests/runtime/test_relation_masking.py | 17 +++-- 4 files changed, 113 insertions(+), 9 deletions(-) diff --git a/examples/trace-chain/README.md b/examples/trace-chain/README.md index 9b109bc..4febb1f 100644 --- a/examples/trace-chain/README.md +++ b/examples/trace-chain/README.md @@ -80,6 +80,11 @@ iterators immediately; successful exhaustion and early cancellation retain distinct outcomes/cardinalities. Streamed rows own independent ledgers; saving one row does not write or clear another row's pending changes. Relation hydration is not supported by scalar streaming; use list or page for relation graphs. +This rejection includes related aggregate enhancements and occurs before opening +a provider cursor, even with SQL logging disabled. Native SQLite trace tests also +cover loading and aggregating the same forward relation, nested scalar-key +recovery and aggregate failure/privacy. These native tests do not yet constitute +generated Q/E acceptance of related aggregates in this example. `PAGE_STREAM_OBSERVED` records actual SQL, policy-reviewed operations, commands, optimistic versions and safe audits. Native tests additionally cover COUNT diff --git a/src/teaql/sql/executor.py b/src/teaql/sql/executor.py index b2cd05c..de9ff0c 100644 --- a/src/teaql/sql/executor.py +++ b/src/teaql/sql/executor.py @@ -316,7 +316,7 @@ def query_stream(self, context, request: QueryRequest, chunk_size: int): return self._query_stream(context, owned, chunk_size) async def _query_stream(self, context, request: QueryRequest, chunk_size: int): - if (request.query.relations or request.query.child_enhancements + if (request.query.relations or request.query.relation_aggregates or request.query.child_enhancements or request.query.object_group_bys or request.query.facets): raise ValueError( "streaming relation or aggregate enhancement is not supported; " @@ -441,8 +441,10 @@ async def _query(self, context: 'UserContext', request: QueryRequest) -> QueryRe metadata = self._record_statement(context, request, compiled, start, DataServiceOperation.Query, 'success', result_count=len(rows)) - await self._enhance_relations(context, rows, request, source) + # Forward hydration may replace a scalar membership key with an object. + # Compute related aggregates while those keys still identify their rows. await self._enhance_relation_aggregates(context, rows, request, source) + await self._enhance_relations(context, rows, request, source) if retained_order: by_id = {int(row["id"]): row for row in rows if row.get("id") is not None} rows = [by_id[entity_id] for entity_id in retained_order if entity_id in by_id] @@ -591,6 +593,17 @@ def _id_set_query_key(context, query: SelectQuery, namespace: str) -> str: _canonical_id_set_value(normalized)) return "teaql:id-set:v1:" + hashlib.sha256(repr(scope).encode("utf-8")).hexdigest() + @staticmethod + def _relation_key(row, descriptor, field): + value = row.get(field) + # A selected forward relation can occupy its scalar storage field. Read + # the declared target key, never guess that every referenced key is id. + relation = descriptor.relation_by_name(field) if descriptor else None + if (isinstance(value, dict) and relation is not None + and not relation.is_many and relation.local_key == field): + return value.get(relation.foreign_key) + return value + async def _enhance_relations(self, context, parents: List[Dict[str, Any]], request: QueryRequest, intent_source=None) -> None: query = request.query @@ -611,7 +624,8 @@ async def _enhance_relations(self, context, parents: List[Dict[str, Any]], reque relation = parent_desc.relation_by_name(load.name) if relation is None: raise CompileError(SqlCompileError(f"missing relation: {query.entity}.{load.name}")) - parent_ids = [row[relation.local_key] for row in parents if relation.local_key in row] + parent_ids = [self._relation_key(row, parent_desc, relation.local_key) + for row in parents if relation.local_key in row] child_query = deepcopy(load.query) if load.query is not None else SelectQuery(relation.target_entity) child_query.entity = relation.target_entity if relation.foreign_key not in child_query.projection: @@ -658,10 +672,11 @@ async def _enhance_relations(self, context, parents: List[Dict[str, Any]], reque for child in children: child.pop("__teaql_partition_rank", None) buckets: Dict[Any, List[Dict[str, Any]]] = {} + child_desc = self.schema_provider.get_entity(relation.target_entity) for child in children: - buckets.setdefault(child.get(relation.foreign_key), []).append(child) + buckets.setdefault(self._relation_key(child, child_desc, relation.foreign_key), []).append(child) for parent in parents: - related = buckets.get(parent.get(relation.local_key), []) + related = buckets.get(self._relation_key(parent, parent_desc, relation.local_key), []) parent[load.name] = related if relation.is_many else (related[0] if related else None) relation_scope.success({ "teaql.result.cardinality": len(children), diff --git a/tests/provider/sqlite/test_trace_chain.py b/tests/provider/sqlite/test_trace_chain.py index bd9dff5..9a986ee 100644 --- a/tests/provider/sqlite/test_trace_chain.py +++ b/tests/provider/sqlite/test_trace_chain.py @@ -21,6 +21,7 @@ class RecordingTransport(SqliteTransport): def __init__(self, path): super().__init__(path) self.reads = [] + self.streams = [] self.fail_table = None self.failure = RuntimeError('synthetic readback failure') @@ -30,6 +31,11 @@ async def fetch_all_sql(self, compiled): raise self.failure return await super().fetch_all_sql(compiled) + async def stream_sql(self, compiled, chunk_size): + self.streams.append(compiled) + async for chunk in super().stream_sql(compiled, chunk_size): + yield chunk + async def begin_sql(self): transaction = await super().begin_sql() fetch = transaction.fetch_all_sql @@ -352,6 +358,75 @@ async def test_unexecuted_aggregate_provenance_does_not_modify_query_builders(tm assert child.relations[0].query.slice is None +@pytest.mark.asyncio +@pytest.mark.parametrize('nested', [False, True]) +@pytest.mark.parametrize('logging', [False, True]) +async def test_loaded_forward_key_remains_usable_by_relation_aggregate(tmp_path, nested, logging): + from teaql.core.query import RelationAggregate + from teaql.core.mutation import UpdateCommand + context, service, transport, entries = await fixture(tmp_path) + descriptor = service.schema_provider.get_entity('Payment') + descriptor.relation(RelationDescriptor('parent_id', 'CustomerOrder').local('parent_id').foreign('id')) + service.schema_provider.get_entity('CustomerOrder').audit_mask_fields(['name']) + secret = 'PRIVATE-AGGREGATE-PARENT' + await service.mutate(context, MutationRequest(UpdateCommand.new('CustomerOrder', 1) + .expected_version(1).value('name', secret), comment='prepare private relation fixture')) + entries.clear() + context.clear_sql_logs() + transport.reads.clear() + if not logging: + context.disable_select_sql_log() + child = (SelectQuery('Payment').project('id', 'parent_id').limit(1) + .relation_query('parent_id', SelectQuery('CustomerOrder').project('id', 'name').limit(1))) + child.relation_aggregates.append(RelationAggregate('parent_id', 'parent_count', + SelectQuery('CustomerOrder').filter(Expr.eq('name', secret)).count('n'), True)) + query = (SelectQuery('CustomerOrder').project('id').limit(1).relation_query('children', child) + if nested else child) + result = await service.query(context, QueryRequest(query, _comment='inspect ' + secret, + _purpose='verify composed aggregate ancestry')) + row = result.rows[0]['children'][0] if nested else result.rows[0] + assert row['parent_count'] == 1 + assert row['parent_id']['name'] == secret + assert len(transport.reads) == (4 if nested else 3) + if logging: + assert len(entries) == len(transport.reads) + assert all(secret not in entry.comment for entry in entries) + assert all(entry.purpose == 'verify composed aggregate ancestry' for entry in entries) + expected_root = 'CustomerOrder' if nested else 'Payment' + assert all(entry.trace_path[0].name == expected_root for entry in entries) + aggregate = next(entry for entry in entries if 'COUNT(' in entry.sql.upper()) + assert [node.name for node in aggregate.trace_path if node.kind == 'relation'] == ( + ['children', 'parent_id'] if nested else ['parent_id']) + else: + assert entries == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize('logging', [False, True]) +async def test_scalar_stream_rejects_relation_aggregate_before_provider(tmp_path, logging): + from teaql.core.query import RelationAggregate + context, service, transport, entries = await fixture(tmp_path) + if not logging: + context.disable_select_sql_log() + query = SelectQuery('CustomerOrder').project('id').limit(1) + query.relation_aggregates.append(RelationAggregate('children', 'child_count', SelectQuery('Payment').count('n'), True)) + stream = service.query_stream(context, QueryRequest(query, _comment='stream child counts', + _purpose='reject unsupported enhancement'), 1) + with pytest.raises(ValueError, match='streaming relation or aggregate enhancement is not supported'): + async for _ in stream: + pytest.fail('unsupported aggregate stream must not yield incomplete rows') + assert transport.streams == [] and transport.reads == [] and entries == [] + + +def test_hydrated_relation_key_uses_declared_target_not_assumed_id(): + descriptor = EntityDescriptor('Stock') + descriptor.relation(RelationDescriptor('sku', 'Product').local('sku').foreign('code')) + row = {'sku': {'id': 999, 'code': 'SKU-A'}} + assert SqlDataServiceExecutor._relation_key(row, descriptor, 'sku') == 'SKU-A' + assert row == {'sku': {'id': 999, 'code': 'SKU-A'}} + assert SqlDataServiceExecutor._relation_key({'sku': 'SKU-A'}, descriptor, 'sku') == 'SKU-A' + + @pytest.mark.asyncio @pytest.mark.parametrize('remove_in_policy', [False, True]) @pytest.mark.parametrize('fail', [False, True]) diff --git a/tests/runtime/test_relation_masking.py b/tests/runtime/test_relation_masking.py index 595471f..352a633 100644 --- a/tests/runtime/test_relation_masking.py +++ b/tests/runtime/test_relation_masking.py @@ -43,7 +43,7 @@ async def query(self, context, request): @pytest.mark.asyncio -@pytest.mark.parametrize('shape', ['batch', 'probe', 'window', 'aggregate', 'nested']) +@pytest.mark.parametrize('shape', ['batch', 'probe', 'window', 'aggregate', 'nested', 'nested-aggregate']) @pytest.mark.parametrize('debug', [False, True]) @pytest.mark.parametrize('failure', [False, True]) async def test_derived_relation_intent(tmp_path, monkeypatch, shape, debug, failure): @@ -88,7 +88,7 @@ def capture(entry): if debug: monkeypatch.setenv(PLAINTEXT_ENV, PLAINTEXT_ACK) if failure: - transport.failing_table = 'line_data' if shape == 'nested' else 'order_data' + transport.failing_table = 'line_data' if shape in ('nested', 'nested-aggregate') else 'order_data' query = (SelectQuery('Customer').project('id', 'name') .filter(Expr.eq('name', 'Riverside')).and_filter(Expr.eq('password', 'PASSWORD-CANARY')).limit(1)) child = SelectQuery('Order').project('id', 'name') @@ -98,6 +98,9 @@ def capture(entry): child.limit(1).top_n_probe_parent_threshold(0) if shape == 'nested': child.filter(Expr.eq('name', 'Lakeside')).relation_query('lines', SelectQuery('Line').project('id').limit(2)) + if shape == 'nested-aggregate': + child.filter(Expr.eq('name', 'Lakeside')) + child.relation_aggregates.append(RelationAggregate('lines', 'line_count', SelectQuery('Line').count('n'), True)) if shape == 'aggregate': query.relation_aggregates.append(RelationAggregate('orders', 'record_count', SelectQuery('Order').count('n'), True)) elif shape == 'batch': @@ -122,7 +125,9 @@ def capture(entry): assert result.rows[0]['orders'][0]['id'] == 1 if shape == 'nested': assert result.rows[0]['orders'][0]['lines'][0]['id'] == 1 - assert service.dispatched == (['Customer', 'Order', 'Line'] if shape == 'nested' else ['Customer', 'Order']) + if shape == 'nested-aggregate': + assert result.rows[0]['orders'][0]['line_count'] == 1 + assert service.dispatched == (['Customer', 'Order', 'Line'] if shape in ('nested', 'nested-aggregate') else ['Customer', 'Order']) assert len(logs) == len(service.dispatched) entry = logs[-1] assert entry.execution_outcome == ('failure' if failure else 'success') @@ -132,9 +137,13 @@ def capture(entry): assert 'PASSWORD-CANARY' not in repr(logs) + '\n'.join(output) + repr(context.sql_logs()) if not debug: assert 'Riverside' not in repr(logs) + '\n'.join(output) - if shape == 'nested': + if shape in ('nested', 'nested-aggregate'): assert 'Lakeside' not in entry.comment assert 'Lakeside' not in repr(logs) + '\n'.join(output) + repr(context.sql_logs()) + if shape == 'nested-aggregate': + assert [(node.kind, node.name) for node in entry.trace_path] == [ + ('operation', 'Customer'), ('request', 'Customer'), ('relation', 'orders'), + ('relation', 'lines'), ('provider', 'sqlite'), ('sql', 'select')] assert len(entry.params) == len(transport.reads[-1].params) assert 'PASSWORD-CANARY' in [v.val for v in transport.reads[0].params] if shape == 'batch': From 1bb80ffb27ff242aafd7b79219dbd5eb559bb8fb Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sat, 3 Oct 2026 18:12:00 +0800 Subject: [PATCH 10/40] fix: retain generated aggregate projections outside mutation ledger (#43) Signed-off-by: Philip Z --- README.md | 10 ++ .../customer_order.order_item_list.md | 6 + .../customer_order.payment_list.md | 6 + .../customer_order.shipment_list.md | 6 + .../payment.payment_attempt_list.md | 6 + .../trace-chain/lib/models/customer_order.py | 19 ++- examples/trace-chain/lib/models/order_item.py | 15 +- examples/trace-chain/lib/models/payment.py | 16 +- .../trace-chain/lib/models/payment_attempt.py | 15 +- examples/trace-chain/lib/models/platform.py | 15 +- examples/trace-chain/lib/models/shipment.py | 15 +- examples/trace-chain/relation_aggregate.py | 152 ++++++++++++++++++ examples/trace-chain/verify.sh | 12 ++ src/teaql/core/entity.py | 16 ++ tests/core/test_query_projection.py | 29 ++++ 15 files changed, 332 insertions(+), 6 deletions(-) create mode 100644 examples/trace-chain/relation_aggregate.py create mode 100644 tests/core/test_query_projection.py diff --git a/README.md b/README.md index 92a313b..c28e207 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,16 @@ rollback. Reentrant independent root saves fail instead of silently joining. The private generated save implementation has changed; old generated libraries must be regenerated, while public `audit_as(...).save(context)` stays unchanged. +The aggregate follow-up adds runtime-owned query projection snapshots and generated +`query_projection(alias)` / `has_query_projection(alias)` accessors. Missing aliases +raise `KeyError`; zero and null are present values. Projections are owned snapshots, +not writable model fields, live calculations or part of a mutation payload. The +generated example covers root/nested counts, original Trace Chain ancestry, +masked descendant intent, logging on/off, provider failure recovery and saving +only a modeled field. An alias named `save` cannot replace the save method. +Regenerate libraries to acquire this projection API; related aggregate streaming +remains unsupported and rejects before provider I/O. + This is local-source/generated-consumer evidence, not complete Trace Chain. Prepared-batch grouping, generated ledger overrides, complete entry-point and inherited mutation privacy coverage, successful readback diagnostic alignment, diff --git a/examples/trace-chain/evidence/assist/python-assist-query/customer_order.order_item_list.md b/examples/trace-chain/evidence/assist/python-assist-query/customer_order.order_item_list.md index f119a08..62d8423 100644 --- a/examples/trace-chain/evidence/assist/python-assist-query/customer_order.order_item_list.md +++ b/examples/trace-chain/evidence/assist/python-assist-query/customer_order.order_item_list.md @@ -9,6 +9,12 @@ Child entity: `order_item` | Capability | Generated API | | --- | --- | | Select | `.select_order_item_list()`, `.select_order_item_list_with(child_request)` | +| Related count | `.count_order_items_as(alias)` | +| Filtered related count | `.count_order_items_with(alias, child_request)` | +| Read related count | `entity.query_projection(alias)`; missing raises `KeyError` | +| Check projection presence | `entity.has_query_projection(alias)`; null and zero remain present | + +Related counts are query-only projections, not modeled fields or mutation setters. Use a dedicated child request for the aggregate; do not reuse it as a row selection. An alias has no generated E accessor; use E for modeled fields and loaded relations. List and nested loaded entities carry projections; scalar-only streaming rejects relation aggregates. Choose aliases that do not collide with model fields or relations. Projection reads never perform database I/O and returned mutable values are isolated copies. Reverse relations are model-derived and never perform an implicit database query. Select the relation explicitly before expression access. Apply query methods before the executable purpose stage, then execute with exactly one trusted context. diff --git a/examples/trace-chain/evidence/assist/python-assist-query/customer_order.payment_list.md b/examples/trace-chain/evidence/assist/python-assist-query/customer_order.payment_list.md index e077c8c..fe6833a 100644 --- a/examples/trace-chain/evidence/assist/python-assist-query/customer_order.payment_list.md +++ b/examples/trace-chain/evidence/assist/python-assist-query/customer_order.payment_list.md @@ -9,6 +9,12 @@ Child entity: `payment` | Capability | Generated API | | --- | --- | | Select | `.select_payment_list()`, `.select_payment_list_with(child_request)` | +| Related count | `.count_payments_as(alias)` | +| Filtered related count | `.count_payments_with(alias, child_request)` | +| Read related count | `entity.query_projection(alias)`; missing raises `KeyError` | +| Check projection presence | `entity.has_query_projection(alias)`; null and zero remain present | + +Related counts are query-only projections, not modeled fields or mutation setters. Use a dedicated child request for the aggregate; do not reuse it as a row selection. An alias has no generated E accessor; use E for modeled fields and loaded relations. List and nested loaded entities carry projections; scalar-only streaming rejects relation aggregates. Choose aliases that do not collide with model fields or relations. Projection reads never perform database I/O and returned mutable values are isolated copies. Reverse relations are model-derived and never perform an implicit database query. Select the relation explicitly before expression access. Apply query methods before the executable purpose stage, then execute with exactly one trusted context. diff --git a/examples/trace-chain/evidence/assist/python-assist-query/customer_order.shipment_list.md b/examples/trace-chain/evidence/assist/python-assist-query/customer_order.shipment_list.md index 422f4f6..9a4f03c 100644 --- a/examples/trace-chain/evidence/assist/python-assist-query/customer_order.shipment_list.md +++ b/examples/trace-chain/evidence/assist/python-assist-query/customer_order.shipment_list.md @@ -9,6 +9,12 @@ Child entity: `shipment` | Capability | Generated API | | --- | --- | | Select | `.select_shipment_list()`, `.select_shipment_list_with(child_request)` | +| Related count | `.count_shipments_as(alias)` | +| Filtered related count | `.count_shipments_with(alias, child_request)` | +| Read related count | `entity.query_projection(alias)`; missing raises `KeyError` | +| Check projection presence | `entity.has_query_projection(alias)`; null and zero remain present | + +Related counts are query-only projections, not modeled fields or mutation setters. Use a dedicated child request for the aggregate; do not reuse it as a row selection. An alias has no generated E accessor; use E for modeled fields and loaded relations. List and nested loaded entities carry projections; scalar-only streaming rejects relation aggregates. Choose aliases that do not collide with model fields or relations. Projection reads never perform database I/O and returned mutable values are isolated copies. Reverse relations are model-derived and never perform an implicit database query. Select the relation explicitly before expression access. Apply query methods before the executable purpose stage, then execute with exactly one trusted context. diff --git a/examples/trace-chain/evidence/assist/python-assist-query/payment.payment_attempt_list.md b/examples/trace-chain/evidence/assist/python-assist-query/payment.payment_attempt_list.md index 3a325a4..e5b0830 100644 --- a/examples/trace-chain/evidence/assist/python-assist-query/payment.payment_attempt_list.md +++ b/examples/trace-chain/evidence/assist/python-assist-query/payment.payment_attempt_list.md @@ -9,6 +9,12 @@ Child entity: `payment_attempt` | Capability | Generated API | | --- | --- | | Select | `.select_payment_attempt_list()`, `.select_payment_attempt_list_with(child_request)` | +| Related count | `.count_payment_attempts_as(alias)` | +| Filtered related count | `.count_payment_attempts_with(alias, child_request)` | +| Read related count | `entity.query_projection(alias)`; missing raises `KeyError` | +| Check projection presence | `entity.has_query_projection(alias)`; null and zero remain present | + +Related counts are query-only projections, not modeled fields or mutation setters. Use a dedicated child request for the aggregate; do not reuse it as a row selection. An alias has no generated E accessor; use E for modeled fields and loaded relations. List and nested loaded entities carry projections; scalar-only streaming rejects relation aggregates. Choose aliases that do not collide with model fields or relations. Projection reads never perform database I/O and returned mutable values are isolated copies. Reverse relations are model-derived and never perform an implicit database query. Select the relation explicitly before expression access. Apply query methods before the executable purpose stage, then execute with exactly one trusted context. diff --git a/examples/trace-chain/lib/models/customer_order.py b/examples/trace-chain/lib/models/customer_order.py index d3781fd..34459e4 100644 --- a/examples/trace-chain/lib/models/customer_order.py +++ b/examples/trace-chain/lib/models/customer_order.py @@ -1,5 +1,5 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest -from teaql.core.entity import _LoadedScalarSnapshot +from teaql.core.entity import _LoadedScalarSnapshot, _QueryProjectionSnapshot from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation @@ -35,6 +35,16 @@ def __init__(self, **kwargs): kwargs["description"] = kwargs.pop("description") if "version" in kwargs and "version" not in kwargs: kwargs["version"] = kwargs.pop("version") + self._teaql_query_projections = _QueryProjectionSnapshot(kwargs, ( + "id", "id", + "platform", "platform", + "orderNumber", "order_number", + "description", "description", + "version", "version", + "order_item_list", + "payment_list", + "shipment_list", + )) self._action = "Update" if kwargs.get("id") else "Create" self._comment = None self._loaded_fields = set(kwargs.keys()) @@ -88,6 +98,13 @@ def __init__(self, **kwargs): self._teaql_loaded_snapshot = _LoadedScalarSnapshot( self._teaql_scalar_payload() if self._action == "Update" else {}) + def query_projection(self, alias): + """Read a query-only alias; raises KeyError when it was not returned.""" + return self._teaql_query_projections.get(alias) + + def has_query_projection(self, alias): + return self._teaql_query_projections.contains(alias) + def _teaql_entity_key(self): return EntityKey("CustomerOrder", self._ledger_id) diff --git a/examples/trace-chain/lib/models/order_item.py b/examples/trace-chain/lib/models/order_item.py index 2180e1c..b6fd2a5 100644 --- a/examples/trace-chain/lib/models/order_item.py +++ b/examples/trace-chain/lib/models/order_item.py @@ -1,5 +1,5 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest -from teaql.core.entity import _LoadedScalarSnapshot +from teaql.core.entity import _LoadedScalarSnapshot, _QueryProjectionSnapshot from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation @@ -33,6 +33,12 @@ def __init__(self, **kwargs): kwargs["name"] = kwargs.pop("name") if "version" in kwargs and "version" not in kwargs: kwargs["version"] = kwargs.pop("version") + self._teaql_query_projections = _QueryProjectionSnapshot(kwargs, ( + "id", "id", + "customerOrder", "customer_order", + "name", "name", + "version", "version", + )) self._action = "Update" if kwargs.get("id") else "Create" self._comment = None self._loaded_fields = set(kwargs.keys()) @@ -57,6 +63,13 @@ def __init__(self, **kwargs): self._teaql_loaded_snapshot = _LoadedScalarSnapshot( self._teaql_scalar_payload() if self._action == "Update" else {}) + def query_projection(self, alias): + """Read a query-only alias; raises KeyError when it was not returned.""" + return self._teaql_query_projections.get(alias) + + def has_query_projection(self, alias): + return self._teaql_query_projections.contains(alias) + def _teaql_entity_key(self): return EntityKey("OrderItem", self._ledger_id) diff --git a/examples/trace-chain/lib/models/payment.py b/examples/trace-chain/lib/models/payment.py index 13b4a8b..36256e0 100644 --- a/examples/trace-chain/lib/models/payment.py +++ b/examples/trace-chain/lib/models/payment.py @@ -1,5 +1,5 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest -from teaql.core.entity import _LoadedScalarSnapshot +from teaql.core.entity import _LoadedScalarSnapshot, _QueryProjectionSnapshot from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation @@ -33,6 +33,13 @@ def __init__(self, **kwargs): kwargs["referenceCode"] = kwargs.pop("reference_code") if "version" in kwargs and "version" not in kwargs: kwargs["version"] = kwargs.pop("version") + self._teaql_query_projections = _QueryProjectionSnapshot(kwargs, ( + "id", "id", + "customerOrder", "customer_order", + "referenceCode", "reference_code", + "version", "version", + "payment_attempt_list", + )) self._action = "Update" if kwargs.get("id") else "Create" self._comment = None self._loaded_fields = set(kwargs.keys()) @@ -66,6 +73,13 @@ def __init__(self, **kwargs): self._teaql_loaded_snapshot = _LoadedScalarSnapshot( self._teaql_scalar_payload() if self._action == "Update" else {}) + def query_projection(self, alias): + """Read a query-only alias; raises KeyError when it was not returned.""" + return self._teaql_query_projections.get(alias) + + def has_query_projection(self, alias): + return self._teaql_query_projections.contains(alias) + def _teaql_entity_key(self): return EntityKey("Payment", self._ledger_id) diff --git a/examples/trace-chain/lib/models/payment_attempt.py b/examples/trace-chain/lib/models/payment_attempt.py index 30d4fe3..8aeeac0 100644 --- a/examples/trace-chain/lib/models/payment_attempt.py +++ b/examples/trace-chain/lib/models/payment_attempt.py @@ -1,5 +1,5 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest -from teaql.core.entity import _LoadedScalarSnapshot +from teaql.core.entity import _LoadedScalarSnapshot, _QueryProjectionSnapshot from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation @@ -33,6 +33,12 @@ def __init__(self, **kwargs): kwargs["referenceCode"] = kwargs.pop("reference_code") if "version" in kwargs and "version" not in kwargs: kwargs["version"] = kwargs.pop("version") + self._teaql_query_projections = _QueryProjectionSnapshot(kwargs, ( + "id", "id", + "payment", "payment", + "referenceCode", "reference_code", + "version", "version", + )) self._action = "Update" if kwargs.get("id") else "Create" self._comment = None self._loaded_fields = set(kwargs.keys()) @@ -57,6 +63,13 @@ def __init__(self, **kwargs): self._teaql_loaded_snapshot = _LoadedScalarSnapshot( self._teaql_scalar_payload() if self._action == "Update" else {}) + def query_projection(self, alias): + """Read a query-only alias; raises KeyError when it was not returned.""" + return self._teaql_query_projections.get(alias) + + def has_query_projection(self, alias): + return self._teaql_query_projections.contains(alias) + def _teaql_entity_key(self): return EntityKey("PaymentAttempt", self._ledger_id) diff --git a/examples/trace-chain/lib/models/platform.py b/examples/trace-chain/lib/models/platform.py index 94f3f22..b1cf88e 100644 --- a/examples/trace-chain/lib/models/platform.py +++ b/examples/trace-chain/lib/models/platform.py @@ -1,5 +1,5 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest -from teaql.core.entity import _LoadedScalarSnapshot +from teaql.core.entity import _LoadedScalarSnapshot, _QueryProjectionSnapshot from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation @@ -30,6 +30,12 @@ def __init__(self, **kwargs): kwargs["name"] = kwargs.pop("name") if "version" in kwargs and "version" not in kwargs: kwargs["version"] = kwargs.pop("version") + self._teaql_query_projections = _QueryProjectionSnapshot(kwargs, ( + "id", "id", + "name", "name", + "version", "version", + "customer_order_list", + )) self._action = "Update" if kwargs.get("id") else "Create" self._comment = None self._loaded_fields = set(kwargs.keys()) @@ -59,6 +65,13 @@ def __init__(self, **kwargs): self._teaql_loaded_snapshot = _LoadedScalarSnapshot( self._teaql_scalar_payload() if self._action == "Update" else {}) + def query_projection(self, alias): + """Read a query-only alias; raises KeyError when it was not returned.""" + return self._teaql_query_projections.get(alias) + + def has_query_projection(self, alias): + return self._teaql_query_projections.contains(alias) + def _teaql_entity_key(self): return EntityKey("Platform", self._ledger_id) diff --git a/examples/trace-chain/lib/models/shipment.py b/examples/trace-chain/lib/models/shipment.py index f8e237e..406d1c6 100644 --- a/examples/trace-chain/lib/models/shipment.py +++ b/examples/trace-chain/lib/models/shipment.py @@ -1,5 +1,5 @@ from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest -from teaql.core.entity import _LoadedScalarSnapshot +from teaql.core.entity import _LoadedScalarSnapshot, _QueryProjectionSnapshot from teaql.core import MutationIntent from teaql.core.value import Value from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation @@ -33,6 +33,12 @@ def __init__(self, **kwargs): kwargs["referenceCode"] = kwargs.pop("reference_code") if "version" in kwargs and "version" not in kwargs: kwargs["version"] = kwargs.pop("version") + self._teaql_query_projections = _QueryProjectionSnapshot(kwargs, ( + "id", "id", + "customerOrder", "customer_order", + "referenceCode", "reference_code", + "version", "version", + )) self._action = "Update" if kwargs.get("id") else "Create" self._comment = None self._loaded_fields = set(kwargs.keys()) @@ -57,6 +63,13 @@ def __init__(self, **kwargs): self._teaql_loaded_snapshot = _LoadedScalarSnapshot( self._teaql_scalar_payload() if self._action == "Update" else {}) + def query_projection(self, alias): + """Read a query-only alias; raises KeyError when it was not returned.""" + return self._teaql_query_projections.get(alias) + + def has_query_projection(self, alias): + return self._teaql_query_projections.contains(alias) + def _teaql_entity_key(self): return EntityKey("Shipment", self._ledger_id) diff --git a/examples/trace-chain/relation_aggregate.py b/examples/trace-chain/relation_aggregate.py new file mode 100644 index 0000000..e54233e --- /dev/null +++ b/examples/trace-chain/relation_aggregate.py @@ -0,0 +1,152 @@ +"""Assist-guided generated Q/E/save: root and nested aggregate provenance.""" +import asyncio +import json +import os +import uuid +from types import SimpleNamespace + +from E import E +from Q import Q +from runtime_module import GENERATED_RUNTIME_MODULE +from teaql.provider.sqlite import create_sqlite_service +from teaql.runtime import UserContext, DelegatingMutationPolicyRegistry + +from main import AuditSink, new, reset +from page_stream import RecordingPolicy, log_row +from shared_reference import SharedService + + +async def main(): + context = UserContext.new().install(GENERATED_RUNTIME_MODULE) + native = create_sqlite_service(os.environ['TEAQL_TRACE_CHAIN_AGGREGATE_DB']) + service = SharedService(native) + sink, policy = AuditSink(service), RecordingPolicy() + context.insert_resource('dataService', service).with_app_audit_event_sink(sink) + context.with_mutation_policy_registry(DelegatingMutationPolicyRegistry(lambda _: policy)) + diagnostic = [] + context.set_diagnostic_sql_log_sink(SimpleNamespace(write=diagnostic.append)) + await context.ensure_schema() + native.schema_provider.get_entity('OrderItem').audit_mask_fields(['name']) + platform = await (Q.platforms().with_id_is(1).limit(1).comment('reuse bootstrap root') + .purpose('prepare aggregate example').execute_for_one(context)) + secret = 'PRIVATE-AGGREGATE-' + uuid.uuid4().hex + order = new(Q.customer_orders(), context).update_platform(platform) + order.update_order_number('AGG-' + uuid.uuid4().hex).update_description('aggregate original') + for name in (secret, 'public item'): + order.order_item_list().append(new(Q.order_items(), context) + .update_customer_order(order).update_name(name)) + payment = (new(Q.payments(), context).update_customer_order(order) + .update_reference_code('AGG-PAY-' + uuid.uuid4().hex)) + order.payment_list().append(payment) + await order.audit_as('seed aggregate graph').save(context) + order_id, payment_id = E.customer_order(order).id().eval(), E.payment(payment).id().eval() + reads, fault = [], [False] + original_fetch = native.transport.fetch_all_sql + + async def capture(compiled): + reads.append(compiled.sql) + if fault[0] and 'COUNT(' in compiled.sql.upper(): + raise RuntimeError('synthetic aggregate failure') + return await original_fetch(compiled) + + native.transport.fetch_all_sql = capture + + def query(nested, filtered=secret): + # Each aggregate has its own child builder; no hand-written application SQL. + request = (Q.customer_orders().with_id_is(order_id).limit(1) + .count_order_items_with('filtered_count', Q.order_items().with_name_is(filtered).limit(10)) + .count_order_items_as('save') + .select_order_item_list_with(Q.order_items().limit(10))) + if nested: + request = (Q.payments().with_id_is(payment_id).limit(1) + .select_customer_order_with(request)) + return request.comment('inspect ' + secret).purpose('verify aggregate provenance') + + def clear(): + reset(service, sink, context) + diagnostic.clear() + reads.clear() + policy.plans.clear() + + for logging in (True, False): + context.enable_all_sql_log() if logging else context.disable_sql_log() + for nested in (False, True): + clear() + rows = await query(nested).execute_for_list(context) + assert len(rows) == 1 + loaded = E.payment(rows[0]).customer_order().eval() if nested else rows[0] + assert E.customer_order(loaded).id().eval() == order_id + assert E.customer_order(loaded).order_item_list().size().eval() == 2 + assert loaded.has_query_projection('filtered_count') + assert loaded.query_projection('filtered_count') == 1 + assert loaded.query_projection('save') == 2 and callable(loaded.save) + assert not loaded.has_query_projection('id') + try: + loaded.query_projection('absent') + except KeyError: + pass + else: + raise AssertionError('missing aggregate silently became a value') + assert len(reads) == (5 if nested else 4), reads + assert service.requests == [] and policy.plans == [] and sink.events == [] + if logging: + assert len(context.sql_logs()) == len(diagnostic) == len(reads) + for entry in context.sql_logs(): + assert entry.trace_path[0].name == ('Payment' if nested else 'CustomerOrder') + assert entry.purpose == 'verify aggregate provenance' + assert secret not in json.dumps(log_row(entry)) + aggregates = [entry for entry in context.sql_logs() if 'COUNT(' in entry.sql.upper()] + assert len(aggregates) == 2 + for entry in aggregates: + relations = [node.name for node in entry.trace_path if node.kind == 'relation'] + assert relations == (['customer_order', 'order_item_list'] if nested else ['order_item_list']), relations + assert entry.trace_path[-1].kind == 'sql' + else: + assert context.sql_logs() == diagnostic == [] + print('AGGREGATE_OBSERVED ' + json.dumps({'nested': nested, 'logging': logging, + 'count': loaded.query_projection('filtered_count'), 'provider_calls': len(reads), + 'sql': [log_row(entry) for entry in context.sql_logs()]})) + clear() + await loaded.update_description('aggregate saved ' + uuid.uuid4().hex).audit_as( + 'save model field without projection aliases').save(context) + assert len(service.requests) == len(sink.events) == len(policy.plans) == 1 + operation = policy.plans[0].operations[0] + assert set(operation.changed_values) == {'description'}, operation.changed_values + assert loaded.query_projection('save') == 2 + print('AGGREGATE_SAVE ' + json.dumps({'nested': nested, 'logging': logging, + 'commands': len(service.requests), 'audits': len(sink.events), + 'fields': sorted(operation.changed_values), 'projection': loaded.query_projection('save')})) + fresh = (await query(nested, 'NO-MATCH-' + uuid.uuid4().hex).execute_for_list(context))[0] + fresh = E.payment(fresh).customer_order().eval() if nested else fresh + assert E.customer_order(fresh).description().eval() == E.customer_order(loaded).description().eval() + assert fresh.has_query_projection('filtered_count') and fresh.query_projection('filtered_count') == 0 + + clear() + fault[0] = True + try: + await query(False).execute_for_list(context) + except Exception as error: + assert 'synthetic aggregate failure' in str(error), error + else: + raise AssertionError('aggregate provider failure was swallowed') + finally: + fault[0] = False + assert len(reads) == 2 and service.requests == [] and sink.events == [] + if logging: + assert context.sql_logs()[-1].execution_outcome == 'failure' + assert all(secret not in json.dumps(log_row(entry)) for entry in context.sql_logs()) + else: + assert context.sql_logs() == diagnostic == [] + print('AGGREGATE_FAILURE ' + json.dumps({'logging': logging, 'provider_calls': len(reads), + 'sql': [log_row(entry) for entry in context.sql_logs()]})) + clear() + await (Q.platforms().with_id_is(1).limit(1).comment('independent next query') + .purpose('prove scope restored').execute_for_list(context)) + assert len(reads) == 1 + assert all(entry.comment == 'independent next query' and entry.trace_path[0].name == 'Platform' + for entry in context.sql_logs()) + print('PASS: Python generated aggregates; 4 root/nested/logging cases; isolated save; 2 failures restored') + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/examples/trace-chain/verify.sh b/examples/trace-chain/verify.sh index f2a3ad6..e0f9776 100644 --- a/examples/trace-chain/verify.sh +++ b/examples/trace-chain/verify.sh @@ -23,6 +23,16 @@ if [[ "$TEAQL_TRACE_CHAIN_SHARED_DB" == "$TEAQL_TRACE_CHAIN_DB" ]]; then exit 1 fi export PYTHONDONTWRITEBYTECODE=1 +if [[ -z "${TEAQL_TRACE_CHAIN_AGGREGATE_DB:-}" ]]; then + aggregate_directory="$(mktemp -d -t teaql-python-aggregate.XXXXXX)" + export TEAQL_TRACE_CHAIN_AGGREGATE_DB="$aggregate_directory/aggregate.sqlite" +fi +for other_db in "$TEAQL_TRACE_CHAIN_DB" "$TEAQL_TRACE_CHAIN_SHARED_DB" "$TEAQL_TRACE_CHAIN_PAGE_STREAM_DB"; do + if [[ "$TEAQL_TRACE_CHAIN_AGGREGATE_DB" == "$other_db" ]]; then + echo 'FAIL: aggregate fixture requires its own retained database' >&2 + exit 1 + fi +done export PYTHONPATH="$example/lib:$repo/src${PYTHONPATH:+:$PYTHONPATH}" snapshot="$(mktemp)" (cd "$example/lib" && rg --files --hidden -g '!**/__pycache__/**' | LC_ALL=C sort | xargs -d '\n' sha256sum) > "$snapshot" @@ -41,6 +51,8 @@ for attempt in first second; do rg -Fq 'PASS: Python shared ownership 4 scenarios' "$run_log" echo "Page/stream $attempt on $TEAQL_TRACE_CHAIN_PAGE_STREAM_DB without cleanup" python "$example/page_stream.py" + echo "Aggregate $attempt on $TEAQL_TRACE_CHAIN_AGGREGATE_DB without cleanup" + python "$example/relation_aggregate.py" done (cd "$example/lib" && sha256sum --check "$snapshot") echo "PASS: two runs on the same database; generated library hashes unchanged" diff --git a/src/teaql/core/entity.py b/src/teaql/core/entity.py index d756185..e1d79e2 100644 --- a/src/teaql/core/entity.py +++ b/src/teaql/core/entity.py @@ -29,6 +29,22 @@ def business_values(self): return self.select(name for name in self.__values if name not in ('id', 'version')) +class _QueryProjectionSnapshot: + """Owned query-only aliases; never entity attributes or mutation payloads.""" + + def __init__(self, values, modeled_names): + excluded = frozenset(modeled_names) + self.__values = {name: deepcopy(value) for name, value in values.items() + if name not in excluded} + + def contains(self, alias): + return alias in self.__values + + def get(self, alias): + # Missing is not zero/None. Return an owned value, not a writable alias. + return deepcopy(self.__values[alias]) + + class EntityChangeSet: """Final pending field values grouped by stable entity identity.""" diff --git a/tests/core/test_query_projection.py b/tests/core/test_query_projection.py new file mode 100644 index 0000000..9c2a6c3 --- /dev/null +++ b/tests/core/test_query_projection.py @@ -0,0 +1,29 @@ +import pytest + +from teaql.core.entity import _QueryProjectionSnapshot + + +def test_query_projections_are_owned_and_not_entity_attributes(): + source = {'id': 7, 'order_number': 'private', 'orderNumber': 'private', + 'items': [{'id': 8}], 'summary': {'counts': [2]}, 'save': 3} + snapshot = _QueryProjectionSnapshot(source, ('id', 'order_number', 'orderNumber', 'items')) + source['summary']['counts'].append(99) + returned = snapshot.get('summary') + returned['counts'].append(100) + assert snapshot.get('summary') == {'counts': [2]} + assert snapshot.get('save') == 3 + assert not hasattr(snapshot, 'save') + for field in ('id', 'order_number', 'orderNumber', 'items'): + assert not snapshot.contains(field) + with pytest.raises(KeyError): + snapshot.get(field) + assert 'private' not in repr(snapshot) + + +def test_query_projection_missing_is_distinct_from_null_and_zero(): + snapshot = _QueryProjectionSnapshot({'zero': 0, 'null': None}, ()) + assert snapshot.contains('zero') and snapshot.get('zero') == 0 + assert snapshot.contains('null') and snapshot.get('null') is None + assert not snapshot.contains('missing') + with pytest.raises(KeyError): + snapshot.get('missing') From b34585a1183ef6b100e27c7181cce6d658f35575 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sat, 3 Oct 2026 19:18:43 +0800 Subject: [PATCH 11/40] fix(trace): preserve Python membership across filtered reference loads (#43) Signed-off-by: Philip Z --- README.md | 7 +++ src/teaql/sql/executor.py | 66 ++++++++++++++--------- tests/provider/sqlite/test_trace_chain.py | 37 +++++++++---- 3 files changed, 75 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index c28e207..749bb00 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,13 @@ only a modeled field. An alias named `save` cannot replace the save method. Regenerate libraries to acquire this projection API; related aggregate streaming remains unsupported and rejects before provider I/O. +Relation attachment retains original scalar keys in invocation-local runtime +state. It does not infer membership from hydrated objects: a forward reference +filtered to `None` must not remove its child from a separately loaded parent +list, and a later sibling can still load using the same FK. Keys are not added +to returned records or mutation payloads. Native SQLite cases exercise these +boundaries with related counts, nested ancestry and logging both on and off. + This is local-source/generated-consumer evidence, not complete Trace Chain. Prepared-batch grouping, generated ledger overrides, complete entry-point and inherited mutation privacy coverage, successful readback diagnostic alignment, diff --git a/src/teaql/sql/executor.py b/src/teaql/sql/executor.py index de9ff0c..790fd47 100644 --- a/src/teaql/sql/executor.py +++ b/src/teaql/sql/executor.py @@ -37,9 +37,25 @@ class _QueryWithLogIntent(QueryRequest): """Invocation-local compiler plumbing, excluded from dataclass/wire fields.""" - def __init__(self, query, trace_chain, comment, purpose, source, origin_entity=None): + def __init__(self, query, trace_chain, comment, purpose, source, origin_entity=None, assembly=None): super().__init__(query, trace_chain, comment, purpose, _origin_entity=origin_entity) self._log_intent_source = source + self._relation_assembly = assembly + + +class _RelationAssembly: + """One derived load owns scalar keys; never attach them to result records.""" + def __init__(self, field): + self.field = field + self.keys = {} + + def capture(self, rows): + self.keys = {id(row): row.get(self.field) for row in rows} + + def key(self, row): + # Hydration mutates these same row dictionaries. Reordering (ID-set + # paging) is fine, but a replacement must not silently drop membership. + return self.keys[id(row)] def _intent_bindings(compiled, request): @@ -379,6 +395,7 @@ async def query(self, context: 'UserContext', request: QueryRequest) -> QueryRes async def _query(self, context: 'UserContext', request: QueryRequest) -> QueryResult: request.validate() + assembly = getattr(request, '_relation_assembly', None) request = request.with_query(request.query) self._sync_generated_schema(context) request.query.prepare_for_list() @@ -441,10 +458,17 @@ async def _query(self, context: 'UserContext', request: QueryRequest) -> QueryRe metadata = self._record_statement(context, request, compiled, start, DataServiceOperation.Query, 'success', result_count=len(rows)) + if assembly is not None: + assembly.capture(rows) + # All local identities are captured before aggregate aliases or sibling + # hydration can replace record fields. + parent_keys = {relation.local_key: [row.get(relation.local_key) for row in rows] + for load in request.query.relations + if (relation := entity_desc.relation_by_name(load.name)) is not None} # Forward hydration may replace a scalar membership key with an object. # Compute related aggregates while those keys still identify their rows. await self._enhance_relation_aggregates(context, rows, request, source) - await self._enhance_relations(context, rows, request, source) + await self._enhance_relations(context, rows, request, source, parent_keys) if retained_order: by_id = {int(row["id"]): row for row in rows if row.get("id") is not None} rows = [by_id[entity_id] for entity_id in retained_order if entity_id in by_id] @@ -593,19 +617,8 @@ def _id_set_query_key(context, query: SelectQuery, namespace: str) -> str: _canonical_id_set_value(normalized)) return "teaql:id-set:v1:" + hashlib.sha256(repr(scope).encode("utf-8")).hexdigest() - @staticmethod - def _relation_key(row, descriptor, field): - value = row.get(field) - # A selected forward relation can occupy its scalar storage field. Read - # the declared target key, never guess that every referenced key is id. - relation = descriptor.relation_by_name(field) if descriptor else None - if (isinstance(value, dict) and relation is not None - and not relation.is_many and relation.local_key == field): - return value.get(relation.foreign_key) - return value - async def _enhance_relations(self, context, parents: List[Dict[str, Any]], request: QueryRequest, - intent_source=None) -> None: + intent_source, parent_keys) -> None: query = request.query if not parents or not query.relations: return @@ -624,8 +637,8 @@ async def _enhance_relations(self, context, parents: List[Dict[str, Any]], reque relation = parent_desc.relation_by_name(load.name) if relation is None: raise CompileError(SqlCompileError(f"missing relation: {query.entity}.{load.name}")) - parent_ids = [self._relation_key(row, parent_desc, relation.local_key) - for row in parents if relation.local_key in row] + local_keys = parent_keys[relation.local_key] + parent_ids = [key for key in local_keys if key is not None] child_query = deepcopy(load.query) if load.query is not None else SelectQuery(relation.target_entity) child_query.entity = relation.target_entity if relation.foreign_key not in child_query.projection: @@ -641,6 +654,7 @@ async def _enhance_relations(self, context, parents: List[Dict[str, Any]], reque ) if use_probes: children = [] + child_keys = [] for parent_id in parent_ids: probe = deepcopy(child_query) probe.partition_by = None @@ -650,9 +664,12 @@ async def _enhance_relations(self, context, parents: List[Dict[str, Any]], reque child_trace = [*request.trace_chain, TraceNode( kind="relation", name=load.name, comment=f"{query.entity}.{load.name}")] - children.extend((await self.query(context, _QueryWithLogIntent( + assembly = _RelationAssembly(relation.foreign_key) + loaded = (await self.query(context, _QueryWithLogIntent( probe, child_trace, request._comment, request._purpose, intent_source, - request.origin_entity))).rows) + request.origin_entity, assembly))).rows + children.extend(loaded) + child_keys.extend(assembly.key(row) for row in loaded) selected_plan = "bounded_probes" probe_count = len(parent_ids) else: @@ -664,19 +681,20 @@ async def _enhance_relations(self, context, parents: List[Dict[str, Any]], reque child_trace = [*request.trace_chain, TraceNode( kind="relation", name=load.name, comment=f"{query.entity}.{load.name}")] + assembly = _RelationAssembly(relation.foreign_key) children = (await self.query(context, _QueryWithLogIntent( child_query, child_trace, request._comment, request._purpose, intent_source, - request.origin_entity))).rows + request.origin_entity, assembly))).rows + child_keys = [assembly.key(row) for row in children] selected_plan = "window" if limited else "batch" probe_count = 0 for child in children: child.pop("__teaql_partition_rank", None) buckets: Dict[Any, List[Dict[str, Any]]] = {} - child_desc = self.schema_provider.get_entity(relation.target_entity) - for child in children: - buckets.setdefault(self._relation_key(child, child_desc, relation.foreign_key), []).append(child) - for parent in parents: - related = buckets.get(self._relation_key(parent, parent_desc, relation.local_key), []) + for key, child in zip(child_keys, children): + buckets.setdefault(key, []).append(child) + for key, parent in zip(local_keys, parents): + related = buckets.get(key, []) parent[load.name] = related if relation.is_many else (related[0] if related else None) relation_scope.success({ "teaql.result.cardinality": len(children), diff --git a/tests/provider/sqlite/test_trace_chain.py b/tests/provider/sqlite/test_trace_chain.py index 9a986ee..156a7a2 100644 --- a/tests/provider/sqlite/test_trace_chain.py +++ b/tests/provider/sqlite/test_trace_chain.py @@ -361,12 +361,14 @@ async def test_unexecuted_aggregate_provenance_does_not_modify_query_builders(tm @pytest.mark.asyncio @pytest.mark.parametrize('nested', [False, True]) @pytest.mark.parametrize('logging', [False, True]) -async def test_loaded_forward_key_remains_usable_by_relation_aggregate(tmp_path, nested, logging): +@pytest.mark.parametrize('reference', ['visible', 'filtered', 'filtered_with_sibling']) +async def test_loaded_forward_key_remains_usable_by_relation_aggregate(tmp_path, nested, logging, reference): from teaql.core.query import RelationAggregate from teaql.core.mutation import UpdateCommand context, service, transport, entries = await fixture(tmp_path) descriptor = service.schema_provider.get_entity('Payment') descriptor.relation(RelationDescriptor('parent_id', 'CustomerOrder').local('parent_id').foreign('id')) + descriptor.relation(RelationDescriptor('parent_again', 'CustomerOrder').local('parent_id').foreign('id')) service.schema_provider.get_entity('CustomerOrder').audit_mask_fields(['name']) secret = 'PRIVATE-AGGREGATE-PARENT' await service.mutate(context, MutationRequest(UpdateCommand.new('CustomerOrder', 1) @@ -376,8 +378,13 @@ async def test_loaded_forward_key_remains_usable_by_relation_aggregate(tmp_path, transport.reads.clear() if not logging: context.disable_select_sql_log() + forward = SelectQuery('CustomerOrder').project('id', 'name').limit(1) + if reference != 'visible': + forward.filter(Expr.eq('name', 'not-visible')) child = (SelectQuery('Payment').project('id', 'parent_id').limit(1) - .relation_query('parent_id', SelectQuery('CustomerOrder').project('id', 'name').limit(1))) + .relation_query('parent_id', forward)) + if reference == 'filtered_with_sibling': + child.relation_query('parent_again', SelectQuery('CustomerOrder').project('id', 'name').limit(1)) child.relation_aggregates.append(RelationAggregate('parent_id', 'parent_count', SelectQuery('CustomerOrder').filter(Expr.eq('name', secret)).count('n'), True)) query = (SelectQuery('CustomerOrder').project('id').limit(1).relation_query('children', child) @@ -386,8 +393,13 @@ async def test_loaded_forward_key_remains_usable_by_relation_aggregate(tmp_path, _purpose='verify composed aggregate ancestry')) row = result.rows[0]['children'][0] if nested else result.rows[0] assert row['parent_count'] == 1 - assert row['parent_id']['name'] == secret - assert len(transport.reads) == (4 if nested else 3) + if reference == 'visible': + assert row['parent_id']['name'] == secret + else: + assert row['parent_id'] is None + if reference == 'filtered_with_sibling': + assert row['parent_again']['name'] == secret + assert len(transport.reads) == (4 if nested else 3) + (reference == 'filtered_with_sibling') if logging: assert len(entries) == len(transport.reads) assert all(secret not in entry.comment for entry in entries) @@ -418,13 +430,16 @@ async def test_scalar_stream_rejects_relation_aggregate_before_provider(tmp_path assert transport.streams == [] and transport.reads == [] and entries == [] -def test_hydrated_relation_key_uses_declared_target_not_assumed_id(): - descriptor = EntityDescriptor('Stock') - descriptor.relation(RelationDescriptor('sku', 'Product').local('sku').foreign('code')) - row = {'sku': {'id': 999, 'code': 'SKU-A'}} - assert SqlDataServiceExecutor._relation_key(row, descriptor, 'sku') == 'SKU-A' - assert row == {'sku': {'id': 999, 'code': 'SKU-A'}} - assert SqlDataServiceExecutor._relation_key({'sku': 'SKU-A'}, descriptor, 'sku') == 'SKU-A' +def test_relation_assembly_keeps_original_text_key_after_hydration_or_filtering(): + from teaql.sql.executor import _RelationAssembly + row = {'sku': 'SKU-A'} + capture = _RelationAssembly('sku') + capture.capture([row]) + row['sku'] = {'id': 999, 'code': 'SKU-A'} + assert capture.key(row) == 'SKU-A' + row['sku'] = None + assert capture.key(row) == 'SKU-A' + assert row == {'sku': None} @pytest.mark.asyncio From 967000844d97d1d4a620d4c4e3113d1c3e6bc678 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sat, 3 Oct 2026 21:22:36 +0800 Subject: [PATCH 12/40] test: assert provider request intent diagnostics in both log modes (#43) Signed-off-by: Philip Z --- tests/core/test_request_intent.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/tests/core/test_request_intent.py b/tests/core/test_request_intent.py index 80da8bd..9d31837 100644 --- a/tests/core/test_request_intent.py +++ b/tests/core/test_request_intent.py @@ -102,11 +102,14 @@ async def begin(self, _): @pytest.mark.asyncio -async def test_direct_provider_gates_before_checker_schema_and_stream_with_logs_off(): +@pytest.mark.parametrize('logging', [False, True]) +async def test_direct_provider_gates_before_checker_schema_and_stream(logging): from teaql.runtime import UserContext from teaql.runtime.context import SqlLogOptions from teaql.sql.executor import SqlDataServiceExecutor - context = UserContext().with_sql_log_options(SqlLogOptions.disabled()) + context = UserContext() + if not logging: + context.with_sql_log_options(SqlLogOptions.disabled()) calls = [] context.check_and_fix_mutation = lambda _: calls.append('checker') context.with_request_policy(lambda _: calls.append('policy')) @@ -116,15 +119,22 @@ async def test_direct_provider_gates_before_checker_schema_and_stream_with_logs_ query.query = SelectQuery('MissingTable') mutation = object.__new__(MutationRequest) mutation._data = InsertCommand('MissingTable').value('secret', 'PAYLOAD-CANARY') - for execute in [provider.query(context, query), provider.mutate(context, mutation)]: + def diagnostic(error, kind): + assert error.code == 'REQUEST_COMMENT_REQUIRED' + assert error.field == 'comment' + assert error.request_kind == kind + assert 'PAYLOAD-CANARY' not in str(error) + for execute, kind in [(provider.query(context, query), 'query'), (provider.mutate(context, mutation), 'mutation')]: with pytest.raises(RequestIntentError) as caught: await execute - assert 'PAYLOAD-CANARY' not in str(caught.value) - with pytest.raises(RequestIntentError): + diagnostic(caught.value, kind) + with pytest.raises(RequestIntentError) as caught: await context.prepare_query_request(query) - with pytest.raises(RequestIntentError): + diagnostic(caught.value, 'query') + with pytest.raises(RequestIntentError) as caught: async for _ in provider.query_stream(context, query, 10): raise AssertionError('stream must not open') + diagnostic(caught.value, 'query') assert calls == [] From ec99c25270832518b1e6d763c79db68b06c2dc09 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sat, 3 Oct 2026 21:56:24 +0800 Subject: [PATCH 13/40] test: prove live query trace isolation through SQLite statements (#43) Signed-off-by: Philip Z --- tests/provider/sqlite/test_trace_chain.py | 71 +++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/tests/provider/sqlite/test_trace_chain.py b/tests/provider/sqlite/test_trace_chain.py index 156a7a2..506d974 100644 --- a/tests/provider/sqlite/test_trace_chain.py +++ b/tests/provider/sqlite/test_trace_chain.py @@ -1,4 +1,6 @@ """Native SQLite observations; no expected trace frames are supplied to requests.""" +import asyncio +import re from types import SimpleNamespace import aiosqlite @@ -276,6 +278,75 @@ async def test_inherited_debug_opt_in_keeps_credentials_hidden_and_can_reproject assert business not in repr(safe) and credential not in repr(safe) +@pytest.mark.asyncio +@pytest.mark.parametrize('logging', [False, True]) +async def test_two_live_query_graphs_keep_independent_intent(tmp_path, monkeypatch, logging): + context, service, transport, entries = await fixture(tmp_path) + if not logging: + context.disable_select_sql_log() + entered, release = asyncio.Event(), asyncio.Event() + roots = 0 + metadata = [] + fetch = transport.fetch_all_sql + record = context._record_metadata_log + + async def pause_real_roots(compiled): + nonlocal roots + rows = await fetch(compiled) + if re.search(r'\bFROM\s+customerorder_data\b', compiled.sql.replace('"', ''), re.IGNORECASE): + roots += 1 + if roots == 2: + entered.set() + await release.wait() + return rows + + def observe(actual, **kwargs): + # Observe the runtime's physical-statement metadata unchanged, including + # when the diagnostic sink is disabled. Do not supply expected frames. + metadata.append(actual) + record(actual, **kwargs) + + monkeypatch.setattr(transport, 'fetch_all_sql', pause_real_roots) + monkeypatch.setattr(context, '_record_metadata_log', observe) + requests = [QueryRequest(three_levels(), _comment=f'load {label} graph', + _purpose=f'render {label} graph') for label in ('alpha', 'beta')] + pending = [asyncio.create_task(service.query(context, request)) for request in requests] + ready = asyncio.create_task(entered.wait()) + try: + done, _ = await asyncio.wait([ready, *pending], timeout=5, + return_when=asyncio.FIRST_COMPLETED) + assert ready in done, 'both root SQL calls must overlap before either query finishes' + assert roots == 2 and not any(task.done() for task in pending) + assert metadata == entries == [] + assert [request.intent.comment for request in requests] == ['load alpha graph', 'load beta graph'] + release.set() + results = await asyncio.wait_for(asyncio.gather(*pending), timeout=5) + assert all(result.rows[0]['children'][0]['children'][0]['children'][0]['id'] == 1 + for result in results) + assert len(transport.reads) == len(metadata) == 8 + assert len(entries) == (8 if logging else 0) + for observed, path_attr in [(metadata, 'trace_chain'), (entries, 'trace_path')]: + for label in ('alpha', 'beta'): + own = [entry for entry in observed if entry.comment == f'load {label} graph'] + assert len(own) == (4 if observed is metadata or logging else 0) + for depth, entry in enumerate(own): + assert entry.purpose == f'render {label} graph' + assert entry.execution_outcome == 'success' + nodes = getattr(entry, path_attr) + assert [(node.kind, node.name) for node in nodes] == [ + ('operation', 'CustomerOrder'), ('request', 'CustomerOrder'), + *[('relation', 'children')] * depth, + ('provider', 'sqlite'), ('sql', 'select')] + assert [node.comment for node in nodes[2:-2]] == [ + 'CustomerOrder.children', 'Payment.children', 'PaymentAttempt.children'][:depth] + finally: + release.set() + for task in [ready, *pending]: + if not task.done(): + task.cancel() + await asyncio.gather(ready, *pending, return_exceptions=True) + + @pytest.mark.asyncio @pytest.mark.parametrize('failure', [False, True]) async def test_three_real_relation_levels_keep_origin_and_qualified_frames(tmp_path, failure): From 736d22defb849ec980d6e63220f3f6aba3d6bf80 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sat, 3 Oct 2026 23:36:21 +0800 Subject: [PATCH 14/40] test: verify generated bootstrap trace and committed visibility (#43) Signed-off-by: Philip Z --- examples/school-management/README.md | 12 ++ .../school-management/app/bootstrap_trace.py | 114 ++++++++++++++++++ scripts/verify-examples.sh | 1 + scripts/verify-school-bootstrap-example.sh | 25 ++++ 4 files changed, 152 insertions(+) create mode 100644 examples/school-management/app/bootstrap_trace.py create mode 100644 scripts/verify-school-bootstrap-example.sh diff --git a/examples/school-management/README.md b/examples/school-management/README.md index d74a9d9..89bf8a6 100644 --- a/examples/school-management/README.md +++ b/examples/school-management/README.md @@ -18,3 +18,15 @@ Expected result: ```text PASS Python School Management: idempotent bootstrap, multi-word hydration, and forward relations ``` + +## Generated bootstrap Trace Chain gate + +`bash scripts/verify-school-bootstrap-example.sh` (from the runtime root) runs +the actual generated School bootstrap twice per retained SQLite file, with SQL +logs on/off. No trace nodes are injected. It checks request intent, canonical +physical paths, matching typed mutation/audit lineage, caller identity +restoration, no-op reseeding and an audited constant edit/reconciliation. An +independent read-only connection must see the expected version at audit +delivery. Generated-library hashes must stay unchanged. The script uses local +runtime source and disables bytecode writes; all evidence and databases remain +available at the printed path. diff --git a/examples/school-management/app/bootstrap_trace.py b/examples/school-management/app/bootstrap_trace.py new file mode 100644 index 0000000..1ba362c --- /dev/null +++ b/examples/school-management/app/bootstrap_trace.py @@ -0,0 +1,114 @@ +"""Generated bootstrap acceptance; observe outputs, never inject trace frames.""" +import asyncio +import os +from pathlib import Path +import sqlite3 + +from Q import Q +from runtime_module import GENERATED_RUNTIME_MODULE +from teaql.data_service import SQLiteTeaQLClient +from teaql.runtime import UserContext +from teaql.runtime.context import SqlLogOptions + + +def identity(node): + return node.name or node.entity_type, node.entity_id + + +class Evidence: + def __init__(self, database): + self.database, self.events, self.sql = database, [], [] + self.versions = {('Platform', 1): 1, ('SchoolType', 1001): 1, ('SchoolType', 1002): 1} + + def write(self, entry): + self.sql.append(entry) + + def on_safe_event(self, context, event): + key = event.entity, getattr(event.entity_id, 'val', event.entity_id) + table = {'Platform': 'platform_data', 'SchoolType': 'school_type_data'}[event.entity] + # Read-only, independent connection: audit must follow durable commit. + with sqlite3.connect(self.database.as_uri() + '?mode=ro', uri=True, timeout=1) as connection: + row = connection.execute(f'SELECT version FROM {table} WHERE id = ?', (key[1],)).fetchone() + assert row == (self.versions[key],), (key, row, self.versions[key]) + self.events.append(event) + + def clear(self, context): + self.sql.clear() + self.events.clear() + context.clear_sql_logs() + + def verify(self, writes, reads, logging): + assert len(self.events) == writes, self.events + audits = {} + for event in self.events: + assert event.actor == 'teaql-generated-bootstrap', event.actor + assert event.category == 'runtime-bootstrap', event.category + assert len(event.trace_chain) == 1, event.trace_chain + node = event.trace_chain[0] + assert node.kind == 'auditReason' and node.comment.strip(), node + key = event.entity, getattr(event.entity_id, 'val', event.entity_id) + assert identity(node) == key, (node, key) + audits[key] = node + if not logging: + assert self.sql == [], self.sql + return + mutations = [entry for entry in self.sql if entry.operation.name.lower() != 'select'] + assert len(mutations) == writes, mutations + assert len(self.sql) - writes == reads, len(self.sql) + for entry in self.sql: + select = entry.operation.name.lower() == 'select' + assert [node.kind for node in entry.trace_path] == [ + 'operation', 'request' if select else 'entity', 'provider', 'sql'], entry.trace_path + if select: + assert entry.comment and entry.purpose, entry + else: + assert len(entry.mutation_lineage) == 1, entry + node = entry.mutation_lineage[0] + assert node == audits[identity(node)], (node, audits) + assert entry.audit_reason == node.comment, (entry.audit_reason, node) + + +async def main(): + database = Path(os.environ['TEAQL_SCHOOL_BOOTSTRAP_DB']).resolve() + logging = os.environ['TEAQL_SCHOOL_BOOTSTRAP_LOGGING'] == 'on' + evidence = Evidence(database) + client = SQLiteTeaQLClient(str(database)) + context = (UserContext.new().install(GENERATED_RUNTIME_MODULE) + .insert_resource('dataService', client).with_user_identifier('school-example-user') + .with_app_audit_event_sink(evidence)) + context.set_diagnostic_sql_log_sink(evidence) + context.with_sql_log_options(SqlLogOptions.all() if logging else SqlLogOptions.disabled()) + await context.ensure_schema() + fresh = len(evidence.events) == 3 + evidence.verify(3 if fresh else 0, 6 if fresh else 3, logging) + assert context.user_identifier() == 'school-example-user' + evidence.clear(context) + await context.ensure_schema() + evidence.verify(0, 3, logging) + primary = await (Q.school_types().with_id_is(1001).limit(1) + .comment('load Primary for audited drift').purpose('verify bootstrap reconciliation') + .execute_for_one(context)) + original_version = primary.version + evidence.versions[('SchoolType', 1001)] = original_version + 1 + primary.update_name('Drifted Primary') + await primary.audit_as('simulate constant drift').save(context) + assert len(evidence.events) == 1 + assert evidence.events[0].actor == 'school-example-user' + assert evidence.events[0].category != 'runtime-bootstrap' + evidence.clear(context) + evidence.versions[('SchoolType', 1001)] = original_version + 2 + await context.ensure_schema() + evidence.verify(1, 4, logging) + restored = await (Q.school_types().with_id_is(1001).limit(1) + .comment('verify corrected constant').purpose('verify audited bootstrap persistence') + .execute_for_one(context)) + assert restored.name == 'Primary' and restored.version == original_version + 2 + evidence.clear(context) + await context.ensure_schema() + evidence.verify(0, 3, logging) + assert context.user_identifier() == 'school-example-user' + print(f'PASS Python generated bootstrap trace logging={logging} fresh={fresh} originalVersion={original_version}') + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/scripts/verify-examples.sh b/scripts/verify-examples.sh index 0238f2d..00a304d 100755 --- a/scripts/verify-examples.sh +++ b/scripts/verify-examples.sh @@ -23,6 +23,7 @@ fi PYTHONPATH="$repo/examples/conformance:$repo/src" python -m app.main PYTHONPATH="$repo/src" python -m unittest discover -s "$repo/examples/conformance" -p 'test_sql_log_intent.py' -v PYTHONPATH="$repo/examples/school-management:$repo/src" python -m app.main +bash "$repo/scripts/verify-school-bootstrap-example.sh" PYTHONPATH="$repo/src" python -m unittest discover -s "$repo/examples/school-management" -p 'test_sql_log_intent.py' -v PYTHONPATH="$repo/src" python "$repo/examples/mutation-policy/main.py" PYTHONPATH="$repo/src" python "$repo/examples/business-clock/main.py" diff --git a/scripts/verify-school-bootstrap-example.sh b/scripts/verify-school-bootstrap-example.sh new file mode 100644 index 0000000..d98342c --- /dev/null +++ b/scripts/verify-school-bootstrap-example.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail +repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +example="$repo/examples/school-management" +evidence="$(mktemp -d -t teaql-python-bootstrap.XXXXXXXX)" +export PYTHONDONTWRITEBYTECODE=1 +export PYTHONPATH="$example:$repo/src" +fingerprint() { + { rg --files "$example/models" "$example/requests" -g '*.py'; printf '%s\n' "$example/Q.py" "$example/E.py" "$example/runtime_module.py"; } | + LC_ALL=C sort | xargs -d '\n' sha256sum +} +fingerprint > "$evidence/library-before.sha256" +for logging in on off; do + for round in 1 2; do + TEAQL_SCHOOL_BOOTSTRAP_DB="$evidence/$logging.sqlite" TEAQL_SCHOOL_BOOTSTRAP_LOGGING="$logging" \ + python -m app.bootstrap_trace | tee "$evidence/$logging-$round.log" + expected_logging=True; [[ "$logging" == off ]] && expected_logging=False + fresh=True; version=1 + if [[ "$round" == 2 ]]; then fresh=False; version=3; fi + rg -Fq "PASS Python generated bootstrap trace logging=$expected_logging fresh=$fresh originalVersion=$version" "$evidence/$logging-$round.log" + fingerprint > "$evidence/library-after.sha256" + cmp "$evidence/library-before.sha256" "$evidence/library-after.sha256" + done +done +echo "PASS Python generated bootstrap twice per logging mode; retained evidence: $evidence" From fd4606873a974ce9ac378f51349c560a9720924f Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sun, 4 Oct 2026 06:34:14 +0800 Subject: [PATCH 15/40] fix: preserve nested and loaded relation facet trace (#43) Signed-off-by: Philip Z --- src/teaql/sql/executor.py | 78 +++++++++++---- tests/provider/sqlite/test_trace_chain.py | 116 ++++++++++++++++++++++ 2 files changed, 174 insertions(+), 20 deletions(-) diff --git a/src/teaql/sql/executor.py b/src/teaql/sql/executor.py index 790fd47..4a7e4c4 100644 --- a/src/teaql/sql/executor.py +++ b/src/teaql/sql/executor.py @@ -473,50 +473,71 @@ async def _query(self, context: 'UserContext', request: QueryRequest) -> QueryRe by_id = {int(row["id"]): row for row in rows if row.get("id") is not None} rows = [by_id[entity_id] for entity_id in retained_order if entity_id in by_id] + facets = await self._query_facets(context, request, entity_desc) + return QueryResult(rows=rows, metadata=metadata, facets=facets) + + async def _query_facets(self, context, request, entity_desc): + """Count the current membership, then traverse each owned Facet selection.""" + from teaql.core.list import SmartList facets = {} for facet in getattr(request.query, 'facets', []): + relation = entity_desc.relation_by_name(facet.relation_name) + # Legacy numeric partitions still work, but only model metadata can + # identify a relationship or add a logical traversal to the trace. + member_field = relation.local_key if relation else facet.relation_name + target_field = relation.foreign_key if relation else 'id' + if relation and relation.target_entity != facet.query.entity: + raise CompileError(SqlCompileError( + f'facet target differs from relation: {request.query.entity}.{facet.relation_name}')) membership_query = deepcopy(request.query) membership_query.facets = [] membership_query.relations = [] + membership_query.relation_aggregates = [] + membership_query.child_enhancements = [] + membership_query.object_group_bys = [] + membership_query.dynamic_properties = [] + membership_query.raw_projections = [] membership_query.order_by_items = [] membership_query.slice = None + membership_query.partition_by = None membership_query.projection = [] + membership_query.expr_projection = [] membership_query.aggregates = [Aggregate( AggregateFunction.Count, "id", "__teaql_facet_count")] - membership_query.group_by_items = [facet.relation_name] + membership_query.group_by_items = [member_field] membership_result = await self._query(context, request.with_query(membership_query)) + member_property = entity_desc.property_by_name(member_field) + member_column = member_property.column_name_val if member_property else member_field counts = { - str(row[facet.relation_name]): int(row["__teaql_facet_count"]) + str(row[member_column]): int(row["__teaql_facet_count"]) for row in membership_result.rows - if row.get(facet.relation_name) is not None + if row.get(member_column) is not None } nested_query = deepcopy(facet.query) - nested_query.facets = [] count_aliases = [ aggregate.alias for aggregate in nested_query.aggregates if aggregate.function == AggregateFunction.Count ] nested_query.aggregates = [] nested_query.group_by_items = [] - nested_result = await self._query(context, request.with_query(nested_query)) + nested_request = request.with_query(nested_query) + if relation: + nested_request.trace_chain.append(TraceNode( + kind='relation', name=facet.relation_name, + comment=f'{request.query.entity}.{facet.relation_name}')) + nested_result = await self._query(context, nested_request) facet_rows = [] for row in nested_result.rows: - count = counts.get(str(row.get("id")), 0) + count = counts.get(str(row.get(target_field)), 0) if not facet.include_all_facets and count == 0: continue decorated = dict(row) for alias in count_aliases or ["count"]: decorated[alias] = count facet_rows.append(decorated) - from teaql.core.list import SmartList - facets[facet.name] = SmartList(facet_rows) - - return QueryResult( - rows=rows, - metadata=metadata, - facets=facets - ) + facets[facet.name] = SmartList(facet_rows, facets=nested_result.facets) + return facets async def _prepare_id_set_page(self, context, query: SelectQuery): options = getattr(query, "id_set_pagination", None) @@ -641,6 +662,13 @@ async def _enhance_relations(self, context, parents: List[Dict[str, Any]], reque parent_ids = [key for key in local_keys if key is not None] child_query = deepcopy(load.query) if load.query is not None else SelectQuery(relation.target_entity) child_query.entity = relation.target_entity + # A batched relation SELECT can serve several parents, but its + # Facet counts belong to each parent's full filtered membership. + # Preserve the finite selection tree while deferring that work + # until scalar parent keys have been used to assemble the rows. + relation_facets = child_query.facets if relation.is_many else [] + if relation_facets: + child_query.facets = [] if relation.foreign_key not in child_query.projection: child_query.projection.append(relation.foreign_key) limited = child_query.slice is not None and child_query.slice.limit is not None @@ -652,6 +680,9 @@ async def _enhance_relations(self, context, parents: List[Dict[str, Any]], reque provider_policy == "always_probe" and threshold is None or threshold is not None and threshold > 0 and len(parent_ids) <= threshold ) + child_trace = [*request.trace_chain, TraceNode( + kind="relation", name=load.name, + comment=f"{query.entity}.{load.name}")] if use_probes: children = [] child_keys = [] @@ -661,9 +692,6 @@ async def _enhance_relations(self, context, parents: List[Dict[str, Any]], reque probe.and_filter(BinaryExpr( ColumnExpr(relation.foreign_key), BinaryOp.Eq, ValueExpr(Value.from_any(parent_id)))) - child_trace = [*request.trace_chain, TraceNode( - kind="relation", name=load.name, - comment=f"{query.entity}.{load.name}")] assembly = _RelationAssembly(relation.foreign_key) loaded = (await self.query(context, _QueryWithLogIntent( probe, child_trace, request._comment, request._purpose, intent_source, @@ -678,9 +706,6 @@ async def _enhance_relations(self, context, parents: List[Dict[str, Any]], reque ColumnExpr(relation.foreign_key), BinaryOp.In, ValueExpr(values))) if limited: child_query.partition_by_field(relation.foreign_key) - child_trace = [*request.trace_chain, TraceNode( - kind="relation", name=load.name, - comment=f"{query.entity}.{load.name}")] assembly = _RelationAssembly(relation.foreign_key) children = (await self.query(context, _QueryWithLogIntent( child_query, child_trace, request._comment, request._purpose, intent_source, @@ -695,6 +720,19 @@ async def _enhance_relations(self, context, parents: List[Dict[str, Any]], reque buckets.setdefault(key, []).append(child) for key, parent in zip(local_keys, parents): related = buckets.get(key, []) + if relation_facets: + from teaql.core.list import SmartList + per_parent = deepcopy(child_query) + per_parent.facets = deepcopy(relation_facets) + per_parent.and_filter(BinaryExpr( + ColumnExpr(relation.foreign_key), BinaryOp.Eq, + ValueExpr(Value.from_any(key)))) + facet_request = _QueryWithLogIntent( + per_parent, child_trace, request._comment, request._purpose, + intent_source, request.origin_entity) + child_desc = self.schema_provider.get_entity(relation.target_entity) + related = SmartList(related, facets=await self._query_facets( + context, facet_request, child_desc)) parent[load.name] = related if relation.is_many else (related[0] if related else None) relation_scope.success({ "teaql.result.cardinality": len(children), diff --git a/tests/provider/sqlite/test_trace_chain.py b/tests/provider/sqlite/test_trace_chain.py index 506d974..91f857f 100644 --- a/tests/provider/sqlite/test_trace_chain.py +++ b/tests/provider/sqlite/test_trace_chain.py @@ -97,6 +97,122 @@ def three_levels(): .relation_query('children', SelectQuery('Shipment').project('id').limit(1))))) +def assert_facet_paths(entries, origin, routes, comment='inspect native facets'): + assert len(entries) == len(routes) + for entry, route in zip(entries, routes): + assert entry.comment == comment + assert entry.purpose == 'verify original facet ancestry' + assert entry.execution_outcome == 'success' + assert [(node.kind, node.name, node.comment) for node in entry.trace_path] == [ + ('operation', origin, 'query'), ('request', origin, ''), + *[('relation', name, detail) for name, detail in route], + ('provider', 'sqlite', ''), ('sql', 'select', '')] + + +@pytest.mark.asyncio +@pytest.mark.parametrize('relation_name', ['parent_id', 'owner']) +async def test_nested_facets_keep_original_route_and_nested_materialization(tmp_path, relation_name): + context, service, transport, entries = await fixture(tmp_path) + for owner, target in [('PaymentAttempt', 'Payment'), ('Payment', 'CustomerOrder')]: + service.schema_provider.get_entity(owner).relation( + RelationDescriptor(relation_name, target).local('parent_id').foreign('id')) + orders = SelectQuery('CustomerOrder').project('id').limit(10).count('order_count') + payments = (SelectQuery('Payment').project('id', 'parent_id').limit(10).count('attempt_count') + .facet_by('orders', relation_name, orders)) + query = (SelectQuery('PaymentAttempt').project('id', 'parent_id').limit(10) + .facet_by('payments', relation_name, payments)) + result = await service.query(context, QueryRequest(query, _comment='inspect native facets', + _purpose='verify original facet ancestry')) + payment_route = [(relation_name, 'PaymentAttempt.' + relation_name)] + order_route = [*payment_route, (relation_name, 'Payment.' + relation_name)] + assert len(transport.reads) == 5, 'the nested Facet must actually count and materialize' + assert_facet_paths(entries, 'PaymentAttempt', [[], [], payment_route, payment_route, order_route]) + assert [(row['id'], row['attempt_count']) for row in result.facets['payments']] == [(1, 1)] + assert [(row['id'], row['order_count']) for row in + result.facets['payments'].facet('orders')] == [(1, 1)] + assert query.facets[0].query.facets[0].name == 'orders', 'execution changed the caller Facet tree' + + +@pytest.mark.asyncio +@pytest.mark.parametrize('logging', [False, True]) +@pytest.mark.parametrize('probes', [False, True]) +async def test_loaded_relation_facets_keep_parent_counts_and_empty_results(tmp_path, logging, probes): + context, service, transport, entries = await fixture(tmp_path) + service.schema_provider.get_entity('PaymentAttempt').relation( + RelationDescriptor('parent_id', 'Payment').local('parent_id').foreign('id')) + for entity, name, parent in [('Payment', 'empty payment', 1), ('PaymentAttempt', 'second attempt', 1)]: + await service.mutate(context, MutationRequest( + InsertCommand.new(entity).value('name', name).value('parent_id', parent), comment='seed facet counts')) + entries.clear() + transport.reads.clear() + if not logging: + context.disable_sql_log() + attempts = (SelectQuery('PaymentAttempt').project('id', 'parent_id').limit(1) + .facet_by('payments', 'parent_id', SelectQuery('Payment').project('id').limit(10).count('attempt_count'))) + attempts.top_n_probe_parent_threshold(10 if probes else 0) + query = SelectQuery('Payment').project('id').limit(10).relation_query('children', attempts) + result = await service.query(context, QueryRequest(query, _comment='inspect native facets', + _purpose='verify original facet ancestry')) + assert [row['id'] for row in result.rows] == [1, 2] + first, empty = [row['children'] for row in result.rows] + assert [row['id'] for row in first] == [1] and empty == [] + assert hasattr(first, 'facet') and hasattr(empty, 'facet'), 'loaded lists must retain their Facet results' + assert [(row['id'], row['attempt_count']) for row in first.facet('payments')] == [(1, 2), (2, 0)] + assert [(row['id'], row['attempt_count']) for row in empty.facet('payments')] == [(1, 0), (2, 0)] + assert first.facets is not empty.facets + child_route = [('children', 'Payment.children')] + facet_route = [*child_route, ('parent_id', 'PaymentAttempt.parent_id')] + # One relation load (or two bounded probes), then count/materialize once per parent. + routes = [[], *([child_route] * (2 if probes else 1)), + child_route, facet_route, child_route, facet_route] + assert len(transport.reads) == len(routes) + if logging: + assert_facet_paths(entries, 'Payment', routes) + else: + assert entries == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize('failure', [False, True]) +async def test_nested_facet_intent_privacy_survives_failure_and_next_request(tmp_path, failure): + from teaql.core.mutation import UpdateCommand + context, service, transport, entries = await fixture(tmp_path) + secret = 'PRIVATE-NESTED-FACET' + service.schema_provider.get_entity('CustomerOrder').audit_mask_fields(['name']) + await service.mutate(context, MutationRequest(UpdateCommand.new('CustomerOrder', 1) + .expected_version(1).value('name', secret), comment='seed private facet value')) + for owner, target in [('PaymentAttempt', 'Payment'), ('Payment', 'CustomerOrder')]: + service.schema_provider.get_entity(owner).relation( + RelationDescriptor('owner', target).local('parent_id').foreign('id')) + nested = (SelectQuery('Payment').project('id', 'parent_id').limit(10) + .facet_by('orders', 'owner', SelectQuery('CustomerOrder').project('id', 'name') + .filter(Expr.eq('name', secret)).limit(10))) + query = SelectQuery('PaymentAttempt').project('id').limit(10).facet_by('payments', 'owner', nested) + request = QueryRequest(query, _comment='inspect ' + secret, _purpose='verify facet privacy') + entries.clear() + transport.reads.clear() + if failure: + transport.fail_table = 'customerorder_data' + with pytest.raises(TransportError): + await service.query(context, request) + else: + result = await service.query(context, request) + assert result.facets['payments'].facet('orders')[0]['name'] == secret + assert len(transport.reads) == len(entries) == 5 + assert all(secret not in repr(entry) for entry in entries) + assert all(entry.trace_path[0].name == 'PaymentAttempt' for entry in entries) + assert [(node.name, node.comment) for node in entries[-1].trace_path if node.kind == 'relation'] == [ + ('owner', 'PaymentAttempt.owner'), ('owner', 'Payment.owner')] + assert entries[-1].execution_outcome == ('failure' if failure else 'success') + assert request.intent.comment == 'inspect ' + secret + assert [value.val for value in transport.reads[-1].params] == [secret], 'diagnostic masking changed a database binding' + transport.fail_table = None + await service.query(context, QueryRequest(SelectQuery('CustomerOrder').project('id').limit(1), + _comment='independent ' + secret, _purpose='verify no inherited Facet privacy')) + assert entries[-1].comment == 'independent ' + secret + assert len(entries[-1].trace_path) == 4 + + @pytest.mark.asyncio @pytest.mark.parametrize('with_context', [False, True]) async def test_successful_readback_retains_ordered_physical_metadata(tmp_path, with_context): From fe291e86eeafbeb3b25902c3c16f207ecc759307 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sun, 4 Oct 2026 06:44:29 +0800 Subject: [PATCH 16/40] fix: preserve empty facet membership for absent parent keys (#43) Signed-off-by: Philip Z --- src/teaql/sql/executor.py | 7 +++- tests/provider/sqlite/test_trace_chain.py | 44 +++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/teaql/sql/executor.py b/src/teaql/sql/executor.py index 4a7e4c4..099997d 100644 --- a/src/teaql/sql/executor.py +++ b/src/teaql/sql/executor.py @@ -683,7 +683,12 @@ async def _enhance_relations(self, context, parents: List[Dict[str, Any]], reque child_trace = [*request.trace_chain, TraceNode( kind="relation", name=load.name, comment=f"{query.entity}.{load.name}")] - if use_probes: + if not parent_ids: + # Missing/NULL local keys have no relation membership. + # Do not compile IN [] or accidentally load orphan rows. + children, child_keys = [], [] + selected_plan, probe_count = "empty", 0 + elif use_probes: children = [] child_keys = [] for parent_id in parent_ids: diff --git a/tests/provider/sqlite/test_trace_chain.py b/tests/provider/sqlite/test_trace_chain.py index 91f857f..7d4acb6 100644 --- a/tests/provider/sqlite/test_trace_chain.py +++ b/tests/provider/sqlite/test_trace_chain.py @@ -213,6 +213,50 @@ async def test_nested_facet_intent_privacy_survives_failure_and_next_request(tmp assert len(entries[-1].trace_path) == 4 +@pytest.mark.asyncio +@pytest.mark.parametrize('missing_key', [False, True]) +@pytest.mark.parametrize('probes', [False, True]) +async def test_loaded_relation_facets_do_not_count_orphans_for_absent_parent_key(tmp_path, missing_key, probes): + context, service, transport, entries = await fixture(tmp_path) + service.schema_provider.get_entity('Payment').relation( + RelationDescriptor('attempts_by_owner', 'PaymentAttempt').many() + .local('parent_id').foreign('parent_id')) + # A separate, non-null facet key ensures an incorrectly matched orphan + # would contribute a visible count, rather than a discarded NULL group. + service.schema_provider.get_entity('PaymentAttempt').relation( + RelationDescriptor('category', 'CustomerOrder').local('version').foreign('id')) + for entity, name in [('Payment', 'parent without owner'), ('PaymentAttempt', 'orphan attempt')]: + await service.mutate(context, MutationRequest( + InsertCommand.new(entity).value('name', name), comment='seed absent relation key')) + attempts = (SelectQuery('PaymentAttempt').project('id', 'parent_id').limit(10) + .top_n_probe_parent_threshold(10 if probes else 0) + .facet_by('categories', 'category', + SelectQuery('CustomerOrder').project('id').limit(10).count('attempt_count'))) + query = (SelectQuery('Payment').project('id').limit(10).order_asc('id') + .relation_query('attempts_by_owner', attempts)) + if not missing_key: + query.project('parent_id') + entries.clear() + transport.reads.clear() + result = await service.query(context, QueryRequest(query, _comment='inspect absent parent keys', + _purpose='orphan rows never belong to a missing relation')) + assert [row['id'] for row in result.rows] == [1, 2] + if missing_key: + assert all('parent_id' not in row for row in result.rows) + else: + assert [row['parent_id'] for row in result.rows] == [1, None] + for index, row in enumerate(result.rows): + related = row['attempts_by_owner'] + expected_count = int(index == 0 and not missing_key) + assert [child['id'] for child in related] == ([1] if expected_count else []) + assert [(facet['id'], facet['attempt_count']) for facet in related.facet('categories')] == [ + (1, expected_count)] + assert len(transport.reads) == (5 if missing_key else 6) + assert sum('COUNT(' in statement.sql.upper() for statement in transport.reads) == 2 + assert all(entry.trace_path[0].name == 'Payment' for entry in entries) + assert all(entry.execution_outcome == 'success' for entry in entries) + + @pytest.mark.asyncio @pytest.mark.parametrize('with_context', [False, True]) async def test_successful_readback_retains_ordered_physical_metadata(tmp_path, with_context): From 4518e59aa602ea893e6eaac0bc939e37d1c751b3 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sun, 4 Oct 2026 08:28:25 +0800 Subject: [PATCH 17/40] fix: scope nested Facet membership before execution (#43) Signed-off-by: Philip Z --- src/teaql/sql/executor.py | 11 +++++++ tests/provider/sqlite/test_trace_chain.py | 39 +++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/teaql/sql/executor.py b/src/teaql/sql/executor.py index 099997d..e136a5e 100644 --- a/src/teaql/sql/executor.py +++ b/src/teaql/sql/executor.py @@ -521,6 +521,17 @@ async def _query_facets(self, context, request, entity_desc): ] nested_query.aggregates = [] nested_query.group_by_items = [] + if not facet.include_all_facets: + # The target selection (and any Facets nested inside it) must + # see only matching candidates before pagination/recursion. + # Keep original key types and make empty membership explicit. + member_values = [Value.from_any(row[member_column]) + for row in membership_result.rows + if row.get(member_column) is not None] + membership = (BinaryExpr(ColumnExpr(target_field), BinaryOp.In, + ValueExpr(Value.List(member_values))) + if member_values else ValueExpr(Value.Bool(False))) + nested_query.and_filter(membership) nested_request = request.with_query(nested_query) if relation: nested_request.trace_chain.append(TraceNode( diff --git a/tests/provider/sqlite/test_trace_chain.py b/tests/provider/sqlite/test_trace_chain.py index 7d4acb6..87e23cb 100644 --- a/tests/provider/sqlite/test_trace_chain.py +++ b/tests/provider/sqlite/test_trace_chain.py @@ -133,6 +133,45 @@ async def test_nested_facets_keep_original_route_and_nested_materialization(tmp_ assert query.facets[0].query.facets[0].name == 'orders', 'execution changed the caller Facet tree' +@pytest.mark.asyncio +@pytest.mark.parametrize('include_all', [False, True]) +@pytest.mark.parametrize('empty', [False, True]) +@pytest.mark.parametrize('logging', [False, True]) +async def test_matched_only_nested_facets_filter_membership_before_recursion(tmp_path, include_all, empty, logging): + context, service, transport, entries = await fixture(tmp_path) + for owner, target in [('PaymentAttempt', 'Payment'), ('Payment', 'CustomerOrder')]: + service.schema_provider.get_entity(owner).relation( + RelationDescriptor('owner', target).local('parent_id').foreign('id')) + # This candidate belongs to the same order but has no matching attempt. + await service.mutate(context, MutationRequest(InsertCommand.new('Payment') + .value('name', 'unused candidate').value('parent_id', 1), comment='seed unmatched facet candidate')) + orders = SelectQuery('CustomerOrder').project('id').limit(10).count('payment_count') + payments = (SelectQuery('Payment').project('id', 'parent_id').limit(10).count('attempt_count') + .facet_by('orders', 'owner', orders, include_all)) + query = (SelectQuery('PaymentAttempt').project('id', 'parent_id').limit(1) + .facet_by('payments', 'owner', payments, include_all)) + if empty: + query.filter(Expr.eq('id', 999)) + entries.clear() + transport.reads.clear() + if not logging: + context.disable_sql_log() + result = await service.query(context, QueryRequest(query, _comment='inspect native facets', + _purpose='verify original facet ancestry')) + facets = result.facets['payments'] + expected = [(1, int(not empty)), (2, 0)] if include_all else ([] if empty else [(1, 1)]) + assert [(row['id'], row['attempt_count']) for row in facets] == expected + expected_nested = [(1, 2)] if include_all else ([] if empty else [(1, 1)]) + assert [(row['id'], row['payment_count']) for row in facets.facet('orders')] == expected_nested + assert len(transport.reads) == 5 + if logging: + assert_facet_paths(entries, 'PaymentAttempt', [[], [], [('owner', 'PaymentAttempt.owner')], + [('owner', 'PaymentAttempt.owner')], + [('owner', 'PaymentAttempt.owner'), ('owner', 'Payment.owner')]]) + else: + assert entries == [] + + @pytest.mark.asyncio @pytest.mark.parametrize('logging', [False, True]) @pytest.mark.parametrize('probes', [False, True]) From 8465f77a4169057db61dc8304a4d870c8eaee729 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sun, 4 Oct 2026 09:10:49 +0800 Subject: [PATCH 18/40] test: gate Python request intent before policy and SQLite IO (#43) Signed-off-by: Philip Z --- examples/trace-chain/README.md | 16 +- examples/trace-chain/verify.sh | 12 ++ tests/provider/sqlite/test_trace_chain.py | 197 ++++++++++++++++++++++ 3 files changed, 223 insertions(+), 2 deletions(-) diff --git a/examples/trace-chain/README.md b/examples/trace-chain/README.md index 4febb1f..426d5ff 100644 --- a/examples/trace-chain/README.md +++ b/examples/trace-chain/README.md @@ -12,14 +12,26 @@ cd /path/to/teaql-python bash examples/trace-chain/verify.sh ``` -The verifier uses this repository's runtime source and executes all four suites -twice without cleanup, then checks the generated library hashes. The normative +The verifier uses this repository's runtime source. It executes the native +request-intent gate twice on fresh fixtures and every application suite twice +on retained databases without cleanup, then checks generated library hashes. The normative graph and ownership fixtures require separate SQLite files because their graph sizes differ. Set `TEAQL_TRACE_CHAIN_DB` and `TEAQL_TRACE_CHAIN_SHARED_DB` to retain and replay those files. The page/stream suite has its own `TEAQL_TRACE_CHAIN_PAGE_STREAM_DB` file. Each suite reuses its file across both runs. Unique business labels avoid deleting existing demo records. +The native intent gate runs 90 SQLite cases: all seventeen shared rejection +vectors across transaction/direct execution and logging on/off, ten missing-root +graph-save cases, and twelve explicit-comment/blank-route-tail positive cases. +Rejections assert the exact code, field, request kind and repair hint, zero +Query/Mutation Policy and Checker calls, no physical reads/writes/stream opens, +no invalid-input transaction begin and no SQL/audit events. Positive controls +prove the observed policies and real SQLite reads/writes remain functional. +Blank Entity/Provider/SQL tail nodes cannot erase the explicit root comment; +actual statement metadata and committed safe audit retain it. These native +fixtures are not a substitute for generated-entry or protocol-decoder coverage. + Seven scenario groups verify missing root reason before transaction access, six-item branch/deletion lineage at request/SQL/audit boundaries, Q/E reload and retained soft deletion, three relation levels with inherited query intent, two diff --git a/examples/trace-chain/verify.sh b/examples/trace-chain/verify.sh index e0f9776..b262cc0 100644 --- a/examples/trace-chain/verify.sh +++ b/examples/trace-chain/verify.sh @@ -36,7 +36,19 @@ done export PYTHONPATH="$example/lib:$repo/src${PYTHONPATH:+:$PYTHONPATH}" snapshot="$(mktemp)" (cd "$example/lib" && rg --files --hidden -g '!**/__pycache__/**' | LC_ALL=C sort | xargs -d '\n' sha256sum) > "$snapshot" +echo "Generated library manifest: $snapshot" for attempt in first second; do + intent_log="$(mktemp -t teaql-python-intent.XXXXXX.log)" + if ! python -m pytest -q "$repo/tests/provider/sqlite/test_trace_chain.py" \ + -k 'request_intent_matrix or graph_intent_gate or explicit_mutation_comment_survives_blank_route' \ + -s > "$intent_log" 2>&1; then + sed -n '1,200p' "$intent_log" >&2 + echo "FAIL: native request intent gate; evidence $intent_log" >&2 + exit 1 + fi + rg -Fq 'INTENT_GATE_PASS' "$intent_log" + rg -Fq 'INTENT_TAIL_PASS' "$intent_log" + echo "PASS: native intent matrix $attempt; evidence $intent_log" echo "Run $attempt on $TEAQL_TRACE_CHAIN_DB without cleanup" python "$example/main.py" echo "Mutation privacy $attempt on $TEAQL_TRACE_CHAIN_DB without cleanup" diff --git a/tests/provider/sqlite/test_trace_chain.py b/tests/provider/sqlite/test_trace_chain.py index 87e23cb..c6cbbb5 100644 --- a/tests/provider/sqlite/test_trace_chain.py +++ b/tests/provider/sqlite/test_trace_chain.py @@ -1,6 +1,8 @@ """Native SQLite observations; no expected trace frames are supplied to requests.""" import asyncio +import json import re +from pathlib import Path from types import SimpleNamespace import aiosqlite @@ -24,6 +26,8 @@ def __init__(self, path): super().__init__(path) self.reads = [] self.streams = [] + self.writes = [] + self.begins = 0 self.fail_table = None self.failure = RuntimeError('synthetic readback failure') @@ -38,9 +42,15 @@ async def stream_sql(self, compiled, chunk_size): async for chunk in super().stream_sql(compiled, chunk_size): yield chunk + async def execute_sql(self, compiled): + self.writes.append(compiled) + return await super().execute_sql(compiled) + async def begin_sql(self): + self.begins += 1 transaction = await super().begin_sql() fetch = transaction.fetch_all_sql + execute = transaction.execute_sql async def capture(compiled): self.reads.append(compiled) @@ -49,6 +59,10 @@ async def capture(compiled): return await fetch(compiled) transaction.fetch_all_sql = capture + async def capture_write(compiled): + self.writes.append(compiled) + return await execute(compiled) + transaction.execute_sql = capture_write return transaction @@ -87,6 +101,8 @@ async def fixture(tmp_path): entries.clear() context.clear_sql_logs() transport.reads.clear() + transport.writes.clear() + transport.begins = 0 return context, service, transport, entries @@ -97,6 +113,187 @@ def three_levels(): .relation_query('children', SelectQuery('Shipment').project('id').limit(1))))) +INTENT_FAILURES = [case for case in json.loads( + (Path(__file__).parents[2] / 'fixtures/request-intent-v1.json').read_text())['cases'] + if 'error' in case] + + +def unchecked_request(case): + """Exercise defensive adapter validation, not a public construction bypass. + + A decoder/custom adapter can present a malformed object without having run + its constructor. Trace-only and child-only inputs must still be rejected. + These supplied frames are invalid-input stimuli, not generated trace proof. + """ + from teaql.core import TraceNode + data = case['input'] + nodes = [TraceNode(kind=node['kind'], comment=node['detail']) + for node in data.get('trace', [])] + if case['kind'] == 'query': + request = object.__new__(QueryRequest) + request.query = SelectQuery('CustomerOrder').limit(1).filter( + Expr.eq('name', 'INTENT-PAYLOAD-CANARY')) + request.trace_chain = nodes + request._QueryRequest__intent = SimpleNamespace(**data) + else: + request = object.__new__(MutationRequest) + request._data = InsertCommand.new('CustomerOrder').value('name', 'INTENT-PAYLOAD-CANARY') + request._data.trace_chain = nodes + if 'children' in data: + request._data = [MutationRequest(InsertCommand.new('Payment').value('name', 'INTENT-PAYLOAD-CANARY'), + comment=child['comment']) for child in data['children']] + request._MutationRequest__intent = SimpleNamespace(**data) + return request + + +@pytest.mark.asyncio +@pytest.mark.parametrize('case', INTENT_FAILURES, ids=lambda case: case['id']) +@pytest.mark.parametrize('logging', [False, True]) +@pytest.mark.parametrize('transaction', [False, True]) +async def test_request_intent_matrix_rejects_before_real_sqlite_policy_and_io( + tmp_path, case, logging, transaction): + from teaql.core import RequestIntentError + from teaql.runtime import DelegatingMutationPolicyRegistry + from teaql.runtime.context import SqlLogOptions + context, service, transport, entries = await fixture(tmp_path) + context.insert_resource('dataService', service) + if not logging: + context.with_sql_log_options(SqlLogOptions.disabled()) + calls, audits = [], [] + def query_policy(query): + calls.append('query-policy') + return query + def mutation_policy(key): + calls.append('mutation-policy') + return None + checker = context.check_and_fix_mutation + def check(command): + calls.append('checker') + return checker(command) + context.with_request_policy(query_policy) + context.with_mutation_policy_registry(DelegatingMutationPolicyRegistry(mutation_policy)) + context.check_and_fix_mutation = check + context.with_app_audit_event_sink(SimpleNamespace(on_safe_event=lambda _, event: audits.append(event))) + tx = await service.begin(context) if transaction else None + execution = tx if tx is not None else service + # An explicitly opened transaction is setup, not IO caused by invalid input. + transport.begins = 0 + request = unchecked_request(case) + def assert_diagnostic(error): + assert (error.code, error.field, error.request_kind) == ( + case['error']['code'], case['error']['field'], case['kind']) + assert 'supply ' + error.field in str(error) + assert 'INTENT-PAYLOAD-CANARY' not in str(error) + try: + if case['kind'] == 'query': + with pytest.raises(RequestIntentError) as caught: + context.prepare_query_request(request) + assert_diagnostic(caught.value) + with pytest.raises(RequestIntentError) as caught: + await execution.query(context, request) + assert_diagnostic(caught.value) + # Cursor creation itself must reject; no polling is required. + with pytest.raises(RequestIntentError) as caught: + service.query_stream(context, request, 1) + assert_diagnostic(caught.value) + else: + with pytest.raises(RequestIntentError) as caught: + await execution.mutate(context, request) + assert_diagnostic(caught.value) + assert calls == [] + assert transport.reads == transport.streams == transport.writes == [] + assert transport.begins == 0 + assert entries == audits == context.sql_logs() == [] + finally: + if tx is not None: + await tx.rollback(context) + + # Positive controls prove these are live hooks and a functioning SQLite + # provider, not a fixture that unconditionally refuses all operations. + valid = context.prepare_query_request(QueryRequest(SelectQuery('CustomerOrder').limit(1), + _comment='read validation control', _purpose='verify real provider and policy probes')) + result = await service.query(context, valid) + assert len(result.rows) == 1 and 'query-policy' in calls and transport.reads + await service.mutate(context, MutationRequest(InsertCommand.new('CustomerOrder').value('name', 'validation control'), + comment='persist validation control')) + assert 'checker' in calls and 'mutation-policy' in calls + assert transport.writes and transport.begins and len(audits) == 1 + assert bool(entries) == logging + print(f"INTENT_GATE_PASS {case['id']} logging={logging} transaction={transaction} invalid_io=0 invalid_policy=0") + + +@pytest.mark.asyncio +@pytest.mark.parametrize('comment', [None, '', ' \t\r\n', '\u0085', '\u2003']) +@pytest.mark.parametrize('logging', [False, True]) +async def test_graph_intent_gate_precedes_begin_and_annotated_child(tmp_path, comment, logging): + from teaql.core import RequestIntentError + from teaql.runtime import DelegatingMutationPolicyRegistry + from teaql.runtime.context import SqlLogOptions + context, service, transport, entries = await fixture(tmp_path) + context.insert_resource('dataService', service) + if not logging: + context.with_sql_log_options(SqlLogOptions.disabled()) + calls, audits = [], [] + context.with_mutation_policy_registry(DelegatingMutationPolicyRegistry(lambda _: calls.append('policy'))) + context.check_and_fix_mutation = lambda _: calls.append('checker') + context.with_app_audit_event_sink(SimpleNamespace(on_safe_event=lambda _, event: audits.append(event))) + child = MutationRequest(InsertCommand.new('Payment').value('name', 'GRAPH-PAYLOAD-CANARY'), + comment='valid local child reason') + async def work(session): + calls.append('work') + return await session.transaction.mutate(session.context, child) + with pytest.raises(RequestIntentError) as caught: + await context.execute_graph_save(work, comment=comment) + error = caught.value + assert (error.code, error.field, error.request_kind) == ('REQUEST_COMMENT_REQUIRED', 'comment', 'mutation') + assert 'supply comment' in str(error) and 'GRAPH-PAYLOAD-CANARY' not in str(error) + assert calls == [] and transport.begins == 0 + assert transport.reads == transport.streams == transport.writes == [] + assert entries == audits == context.sql_logs() == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize('tail', ['entity', 'provider', 'sql']) +@pytest.mark.parametrize('logging', [False, True]) +@pytest.mark.parametrize('transaction', [False, True]) +async def test_explicit_mutation_comment_survives_blank_route_tail_at_real_sinks( + tmp_path, tail, logging, transaction): + from teaql.core import TraceNode + from teaql.runtime.context import SqlLogOptions + context, service, transport, entries = await fixture(tmp_path) + if not logging: + context.with_sql_log_options(SqlLogOptions.disabled()) + audits = [] + context.with_app_audit_event_sink(SimpleNamespace(on_safe_event=lambda _, event: audits.append(event))) + command = InsertCommand.new('CustomerOrder').value('name', 'explicit reason control') + command.trace_chain = [TraceNode(kind=tail, name='CustomerOrder', comment='')] + request = MutationRequest(command, comment=' explicit root reason ') + tx = await service.begin(context) if transaction else None + try: + result = await (tx if tx is not None else service).mutate(context, request) + if tx is not None: + assert audits == [], 'explicit transaction must not publish precommit' + await tx.commit(context) + except BaseException: + if tx is not None: + await tx.rollback(context) + raise + assert result.affected_rows == 1 and len(audits) == 1 + assert request.comment() == result.metadata.audit_reason == ' explicit root reason ' + assert result.metadata.statements + for statement in result.metadata.statements: + assert statement.comment == statement.audit_reason == request.comment() + assert [node.comment for node in audits[0].trace_chain] == [request.comment()] + assert audits[0].trace_chain[0].entity_id is not None + assert transport.writes and transport.reads + if logging: + assert len(entries) == 2, 'physical INSERT plus authoritative readback' + assert all(entry.audit_reason == request.comment() for entry in entries) + else: + assert entries == context.sql_logs() == [] + print(f"INTENT_TAIL_PASS {tail} logging={logging} transaction={transaction} committed_audit=1") + + def assert_facet_paths(entries, origin, routes, comment='inspect native facets'): assert len(entries) == len(routes) for entry, route in zip(entries, routes): From e4595487220d79499f2eb087948d1ed385b73698 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sun, 4 Oct 2026 09:35:21 +0800 Subject: [PATCH 19/40] fix: retain exact LIKE operands for invocation privacy (#43) Signed-off-by: Philip Z --- examples/trace-chain/README.md | 9 + examples/trace-chain/verify.sh | 9 + src/teaql/core/expr.py | 20 +- src/teaql/sql/dialect.py | 7 + src/teaql/sql/executor.py | 30 ++- src/teaql/sql/types.py | 5 + tests/provider/sqlite/test_like_intent.py | 243 ++++++++++++++++++++++ 7 files changed, 307 insertions(+), 16 deletions(-) create mode 100644 tests/provider/sqlite/test_like_intent.py diff --git a/examples/trace-chain/README.md b/examples/trace-chain/README.md index 426d5ff..09eb9b6 100644 --- a/examples/trace-chain/README.md +++ b/examples/trace-chain/README.md @@ -32,6 +32,15 @@ Blank Entity/Provider/SQL tail nodes cannot erase the explicit root comment; actual statement metadata and committed safe audit retain it. These native fixtures are not a substitute for generated-entry or protocol-decoder coverage. +The verifier also runs 66 native LIKE privacy cases twice: typed contains, +starts/ends and negations retain exact original operands for safe intent logging, +without changing executable patterns or caller/policy inputs. Tests cover direct +and transaction queries, count, future-child secrets before first-root SQL, +failure/next-request isolation, logging off, raw/literal wildcard controls, +rewritten ASTs and scalar streaming with debug opt-in/revocation. Private source +provenance is not a wire field. These are native small fixtures, not a new +generated-query scenario or an all-provider acceptance claim. + Seven scenario groups verify missing root reason before transaction access, six-item branch/deletion lineage at request/SQL/audit boundaries, Q/E reload and retained soft deletion, three relation levels with inherited query intent, two diff --git a/examples/trace-chain/verify.sh b/examples/trace-chain/verify.sh index b262cc0..4a9f085 100644 --- a/examples/trace-chain/verify.sh +++ b/examples/trace-chain/verify.sh @@ -49,6 +49,15 @@ for attempt in first second; do rg -Fq 'INTENT_GATE_PASS' "$intent_log" rg -Fq 'INTENT_TAIL_PASS' "$intent_log" echo "PASS: native intent matrix $attempt; evidence $intent_log" + like_log="$(mktemp -t teaql-python-like.XXXXXX.log)" + if ! python -m pytest -q -p no:cacheprovider "$repo/tests/provider/sqlite/test_like_intent.py" \ + > "$like_log" 2>&1; then + sed -n '1,200p' "$like_log" >&2 + echo "FAIL: native LIKE intent privacy; evidence $like_log" >&2 + exit 1 + fi + rg -q '^66 passed in ' "$like_log" + echo "PASS: native LIKE privacy $attempt; evidence $like_log" echo "Run $attempt on $TEAQL_TRACE_CHAIN_DB without cleanup" python "$example/main.py" echo "Mutation privacy $attempt on $TEAQL_TRACE_CHAIN_DB without cleanup" diff --git a/src/teaql/core/expr.py b/src/teaql/core/expr.py index 808f74b..1e414bd 100644 --- a/src/teaql/core/expr.py +++ b/src/teaql/core/expr.py @@ -77,6 +77,14 @@ class ColumnExpr(Expr): class ValueExpr(Expr): value: Value +def _like_pattern(original, prefix, suffix): + """Retain lowering provenance without changing dataclass/wire contracts.""" + original = str(original) + pattern = prefix + original + suffix + result = ValueExpr(Value.Text(pattern)) + result._like_operand = (Value.Text(original), Value.Text(pattern)) + return result + @dataclass class FunctionExpr(Expr): function: ExprFunction @@ -277,22 +285,22 @@ def not_like(field: str, val: Any) -> Expr: return Expr.not_like(field, val) def contain(field: str, val: Any) -> Expr: - return Expr.like(field, f"%{val}%") + return Expr.like(field, _like_pattern(val, '%', '%')) def not_contain(field: str, val: Any) -> Expr: - return Expr.not_like(field, f"%{val}%") + return Expr.not_like(field, _like_pattern(val, '%', '%')) def begin_with(field: str, val: Any) -> Expr: - return Expr.like(field, f"{val}%") + return Expr.like(field, _like_pattern(val, '', '%')) def not_begin_with(field: str, val: Any) -> Expr: - return Expr.not_like(field, f"{val}%") + return Expr.not_like(field, _like_pattern(val, '', '%')) def end_with(field: str, val: Any) -> Expr: - return Expr.like(field, f"%{val}") + return Expr.like(field, _like_pattern(val, '%', '')) def not_end_with(field: str, val: Any) -> Expr: - return Expr.not_like(field, f"%{val}") + return Expr.not_like(field, _like_pattern(val, '%', '')) def sound_like(field: str, val: Any) -> Expr: return eq(soundex(column(field)), soundex(value(val))) diff --git a/src/teaql/sql/dialect.py b/src/teaql/sql/dialect.py index 46def59..94b2abb 100644 --- a/src/teaql/sql/dialect.py +++ b/src/teaql/sql/dialect.py @@ -436,6 +436,13 @@ def _compile_expr(self, entity: EntityDescriptor, expr: Expr, params: List[Value elif isinstance(expr, BinaryExpr): if expr.op in (BinaryOp.In, BinaryOp.NotIn, BinaryOp.InLarge, BinaryOp.NotInLarge): return self.compile_in(entity, expr.left, expr.op, expr.right, params) + original = getattr(expr.right, '_like_operand', None) + if (isinstance(params, SQLBindings) and expr.op in (BinaryOp.Like, BinaryOp.NotLike) + and isinstance(expr.right, ValueExpr) and original is not None + and expr.right.value == original[1]): + # Retain only an intact typed lowering, never infer from raw SQL + # wildcards or reuse stale provenance after an AST rewrite. + params._retain_intent_operand(original[0]) lhs = self.compile_expr(entity, expr.left, params) rhs = self.compile_expr(entity, expr.right, params) op_str = { diff --git a/src/teaql/sql/executor.py b/src/teaql/sql/executor.py index e136a5e..c1e98de 100644 --- a/src/teaql/sql/executor.py +++ b/src/teaql/sql/executor.py @@ -61,13 +61,26 @@ def key(self, row): def _intent_bindings(compiled, request): # Resolve each source's policy before flattening: credential detection and # malformed-policy handling depend on the original statement, not the child. - from teaql.runtime.log_privacy import _binding_policies inherited = getattr(request, '_log_intent_source', None) sources = [inherited, compiled] if inherited is not None else [compiled] - return CompiledQuery('', [deepcopy(value) for source in sources for value in source.params], - parameter_log_policies=[policy for source in sources - for policy in _binding_policies(source)], - sql_origin='generated') + result = CompiledQuery('', [], parameter_log_policies=[], sql_origin='generated') + for source in sources: + _append_intent_bindings(result, source) + return result + + +def _append_intent_bindings(target, source): + from teaql.runtime.log_privacy import _binding_policies + target.params.extend(deepcopy(source.params)) + target.parameter_log_policies.extend(_binding_policies(source)) + operands = getattr(source, '_intent_operands', ()) + if operands: + # This is a private provenance copy, not the executable statement. Apply + # the same credential/unknown fail-closed policy before combining it. + original = CompiledQuery('', [deepcopy(value) for value, _ in operands], + parameter_log_policies=[policy for _, policy in operands], sql_origin='generated') + target.params.extend(original.params) + target.parameter_log_policies.extend(_binding_policies(original)) class _NoopContextManager: @@ -160,7 +173,6 @@ def _query_intent_bindings(self, compiled, request): Compile copies only for policy provenance: no SQL, list preparation, query-builder mutation or Context-owned redaction state. """ - from teaql.runtime.log_privacy import _binding_policies source = _intent_bindings(compiled, request) pending = [(request.query, request.query.entity)] for original in getattr(request, '_log_intent_queries', ()): @@ -170,8 +182,7 @@ def _query_intent_bindings(self, compiled, request): candidate = deepcopy(original) self._resolve_subquery_entities(candidate.filter_expr) bindings = self.dialect.compile_select(descriptor, candidate) - source.params.extend(deepcopy(bindings.params)) - source.parameter_log_policies.extend(_binding_policies(bindings)) + _append_intent_bindings(source, bindings) pending.append((original, original.entity)) visited = set() while pending: @@ -202,8 +213,7 @@ def _query_intent_bindings(self, compiled, request): candidate.entity = entity self._resolve_subquery_entities(candidate.filter_expr) bindings = self.dialect.compile_select(child_descriptor, candidate) - source.params.extend(deepcopy(bindings.params)) - source.parameter_log_policies.extend(_binding_policies(bindings)) + _append_intent_bindings(source, bindings) pending.append((child, entity)) return source diff --git a/src/teaql/sql/types.py b/src/teaql/sql/types.py index abf93e2..ccd82c0 100644 --- a/src/teaql/sql/types.py +++ b/src/teaql/sql/types.py @@ -19,11 +19,15 @@ def __init__(self): self.policies = [] self.current_policy = 'unknown' self.trusted = True + self._intent_operands = [] def append(self, value, policy=None): super().append(value) self.policies.append(policy or self.current_policy) + def _retain_intent_operand(self, value): + self._intent_operands.append((value, self.current_policy)) + @contextmanager def policy(self, policy): previous = self.current_policy @@ -45,6 +49,7 @@ def __post_init__(self): if isinstance(self.params, SQLBindings): self.parameter_log_policies = list(self.params.policies) self.sql_origin = 'generated' if self.params.trusted else None + self._intent_operands = tuple(self.params._intent_operands) def sql_with_comment(self) -> str: if self.comment: diff --git a/tests/provider/sqlite/test_like_intent.py b/tests/provider/sqlite/test_like_intent.py new file mode 100644 index 0000000..ada6893 --- /dev/null +++ b/tests/provider/sqlite/test_like_intent.py @@ -0,0 +1,243 @@ +"""Exact typed LIKE operands at real SQLite boundaries; never strip wildcards.""" +from copy import deepcopy +from dataclasses import asdict, replace + +import pytest + +from test_trace_chain import fixture +from teaql.core import expr +from teaql.core.mutation import MutationRequest, UpdateCommand +from teaql.core.query import SelectQuery +from teaql.core.value import Value +from teaql.data_service import QueryRequest +from teaql.runtime.context import SqlLogOptions +from teaql.sql.executor import TransportError + + +OPERATIONS = [ + (expr.contain, '%', '%', False), + (expr.not_contain, '%', '%', True), + (expr.begin_with, '', '%', False), + (expr.not_begin_with, '', '%', True), + (expr.end_with, '%', '', False), + (expr.not_end_with, '%', '', True), +] + + +async def set_name(context, service, transport, entries, entity, value): + await service.mutate(context, MutationRequest(UpdateCommand.new(entity, 1) + .expected_version(1).value('name', value), comment='prepare LIKE operand fixture')) + entries.clear() + context.clear_sql_logs() + transport.reads.clear() + + +def assert_logs(entries, logging, private, operand, root): + if not logging: + assert entries == [] + return + assert entries + for entry in entries: + assert entry.trace_path[0].name == root + assert entry.trace_path[-1].kind == 'sql' + if private: + assert operand not in repr(entry), 'original operand leaked to safe SQL evidence' + assert '[REDACTED]' in entry.comment + assert '[REDACTED]' in entry.purpose + else: + assert operand in entry.comment and operand in entry.purpose + + +@pytest.mark.asyncio +@pytest.mark.parametrize('operation,prefix,suffix,negated', OPERATIONS, + ids=[op[0].__name__ for op in OPERATIONS]) +@pytest.mark.parametrize('logging', [False, True]) +@pytest.mark.parametrize('private', [False, True]) +@pytest.mark.parametrize('transaction', [False, True]) +async def test_like_original_operand_at_sqlite_boundaries( + tmp_path, monkeypatch, operation, prefix, suffix, negated, logging, private, transaction): + monkeypatch.delenv('TEAQL_ALLOW_SENSITIVE_PLAINTEXT_LOGS', raising=False) + context, service, transport, entries = await fixture(tmp_path) + descriptor = service.schema_provider.get_entity('CustomerOrder') + descriptor.audit_mask_fields(['name'] if private else []) + descriptor.property_by_name('name').log_policy('plain') + operand = 'PRIVATE-ROOT-OPERAND' + await set_name(context, service, transport, entries, 'CustomerOrder', operand) + if not logging: + context.with_sql_log_options(SqlLogOptions.disabled()) + comment, purpose = 'inspect ' + operand, 'justify ' + operand + policies = [] + context.with_request_policy(lambda query: policies.append(deepcopy(query)) or query) + tx = await service.begin(context) if transaction else None + execution = tx or service + try: + for count in [False, True]: + query = SelectQuery('CustomerOrder').filter(operation('name', operand)).limit(2) + query = query.count('n') if count else query.project('id') + request = QueryRequest(query, _comment=comment, _purpose=purpose) + before = deepcopy(request.query) + result = await execution.query(context, context.prepare_query_request(request)) + assert (result.rows[0]['n'] if count else len(result.rows)) == (0 if negated else 1) + assert request.query == before + assert (request.intent.comment, request.intent.purpose) == (comment, purpose) + assert policies[-1].filter_expr == query.filter_expr + assert policies[-1].comment_text == comment and policies[-1].purpose_text == purpose + assert [value.val for value in transport.reads[-1].params] == [prefix + operand + suffix] + assert transport.reads[-1].parameter_log_policies == ['masked' if private else 'plain'] + assert_logs(entries, logging, private, operand, 'CustomerOrder') + assert len(transport.reads) == 2 + # An independent invocation on the same Context must not retain this secret. + await execution.query(context, QueryRequest(SelectQuery('CustomerOrder').project('id').limit(1), + _comment=comment, _purpose=purpose)) + if logging: + assert entries[-1].comment == comment and entries[-1].purpose == purpose + finally: + if tx: + await tx.rollback(context) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('logging', [False, True]) +@pytest.mark.parametrize('transaction', [False, True]) +@pytest.mark.parametrize('failure', [False, True]) +async def test_like_future_child_masks_first_root_and_failure( + tmp_path, monkeypatch, logging, transaction, failure): + monkeypatch.delenv('TEAQL_ALLOW_SENSITIVE_PLAINTEXT_LOGS', raising=False) + context, service, transport, entries = await fixture(tmp_path) + service.schema_provider.get_entity('Payment').audit_mask_fields(['name']) + operand = 'PRIVATE-CHILD-OPERAND' + await set_name(context, service, transport, entries, 'Payment', operand) + if not logging: + context.with_sql_log_options(SqlLogOptions.disabled()) + child = SelectQuery('Payment').project('id', 'parent_id').filter(expr.contain('name', operand)).limit(1) + query = SelectQuery('CustomerOrder').project('id').limit(1).relation_query('children', child) + request = QueryRequest(query, _comment='inspect ' + operand, _purpose='justify ' + operand) + before = deepcopy(request.query) + tx = await service.begin(context) if transaction else None + execution = tx or service + try: + if failure: + transport.fail_table = 'payment_data' + with pytest.raises(TransportError): + await execution.query(context, request) + else: + result = await execution.query(context, request) + assert len(result.rows[0]['children']) == 1 + assert len(transport.reads) == 2 + assert any(value.val == '%' + operand + '%' for value in transport.reads[1].params) + assert request.query == before + assert_logs(entries, logging, True, operand, 'CustomerOrder') + if logging: + assert len(entries) == 2 + assert [(n.name, n.comment) for n in entries[-1].trace_path if n.kind == 'relation'] == [ + ('children', 'CustomerOrder.children')] + assert entries[-1].execution_outcome == ('failure' if failure else 'success') + transport.fail_table = None + await execution.query(context, QueryRequest(SelectQuery('CustomerOrder').project('id').limit(1), + _comment='independent ' + operand, _purpose='no inherited source')) + if logging: + assert entries[-1].comment == 'independent ' + operand + finally: + if tx: + await tx.rollback(context) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('logging', [False, True]) +async def test_like_literal_operands_and_raw_patterns_are_not_inferred(tmp_path, monkeypatch, logging): + monkeypatch.delenv('TEAQL_ALLOW_SENSITIVE_PLAINTEXT_LOGS', raising=False) + context, service, transport, entries = await fixture(tmp_path) + service.schema_provider.get_entity('CustomerOrder').audit_mask_fields(['name']) + operand = '%_\\fragment%' + await service.mutate(context, MutationRequest(UpdateCommand.new('CustomerOrder', 1) + .expected_version(1).value('name', operand), comment='prepare literal fixture')) + entries.clear() + transport.reads.clear() + if not logging: + context.with_sql_log_options(SqlLogOptions.disabled()) + result = await service.query(context, QueryRequest( + SelectQuery('CustomerOrder').project('id').filter(expr.begin_with('name', operand)).limit(1), + _comment='inspect ' + operand + ' public fragment', _purpose='justify ' + operand)) + assert len(result.rows) == 1 + assert [value.val for value in transport.reads[-1].params] == [operand + '%'] + assert_logs(entries, logging, True, operand, 'CustomerOrder') + if logging: + assert 'public fragment' in entries[-1].comment + # Raw LIKE accepts a complete pattern. It has no original unwrapped operand. + pattern = '%fragment%' + result = await service.query(context, QueryRequest( + SelectQuery('CustomerOrder').project('id').filter(expr.like('name', pattern)).limit(1), + _comment='literal fragment; pattern ' + pattern, _purpose='raw pattern ' + pattern)) + assert len(result.rows) == 1 + assert [value.val for value in transport.reads[-1].params] == [pattern] + if logging: + assert entries[-1].comment == 'literal fragment; pattern [REDACTED]' + assert entries[-1].purpose == 'raw pattern [REDACTED]' + + +@pytest.mark.asyncio +@pytest.mark.parametrize('logging', [False, True]) +@pytest.mark.parametrize('rewrite', ['operator', 'pattern', 'replace']) +async def test_like_rewritten_ast_does_not_retain_stale_original(tmp_path, monkeypatch, logging, rewrite): + monkeypatch.delenv('TEAQL_ALLOW_SENSITIVE_PLAINTEXT_LOGS', raising=False) + context, service, transport, entries = await fixture(tmp_path) + service.schema_provider.get_entity('CustomerOrder').audit_mask_fields(['name']) + original = 'STALE-ORIGINAL-OPERAND' + condition = expr.contain('name', original) + assert set(asdict(condition.right)) == {'value'}, 'private provenance became a wire field' + assert condition.right == expr.ValueExpr(Value.Text('%' + original + '%')) + assert deepcopy(condition.right) == condition.right + if rewrite == 'operator': + condition.op = expr.BinaryOp.Eq + pattern = '%' + original + '%' + elif rewrite == 'pattern': + pattern = '%CURRENT-PATTERN%' + condition.right.value = Value.Text(pattern) + else: + pattern = '%REPLACEMENT-PATTERN%' + condition.right = replace(condition.right, value=Value.Text(pattern)) + assert condition.right == replace(expr.ValueExpr(Value.Text('old')), value=Value.Text(pattern)) + assert not hasattr(condition.right, '_like_operand') + if not logging: + context.with_sql_log_options(SqlLogOptions.disabled()) + comment = 'old ' + original + ' current ' + pattern + request = QueryRequest(SelectQuery('CustomerOrder').project('id').filter(condition).limit(1), + _comment=comment, _purpose='do not infer a rewritten operand') + result = await service.query(context, request) + assert result.rows == [] + assert [value.val for value in transport.reads[-1].params] == [pattern] + if logging: + assert entries[-1].comment == 'old ' + original + ' current [REDACTED]' + else: + assert entries == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize('policy', ['masked', 'credential']) +async def test_like_stream_debug_opt_in_and_revocation(tmp_path, monkeypatch, policy): + from teaql.runtime.log_privacy import PLAINTEXT_ENV, PLAINTEXT_ACK + monkeypatch.delenv(PLAINTEXT_ENV, raising=False) + context, service, transport, entries = await fixture(tmp_path) + descriptor = service.schema_provider.get_entity('CustomerOrder') + descriptor.audit_mask_fields([]) + descriptor.property_by_name('name').log_policy(policy) + operand = 'PRIVATE-STREAM-OPERAND' + await set_name(context, service, transport, entries, 'CustomerOrder', operand) + request = QueryRequest(SelectQuery('CustomerOrder').project('id') + .filter(expr.contain('name', operand)).limit(1), + _comment='inspect ' + operand, _purpose='justify ' + operand) + for mode in ['safe', 'debug', 'revoked']: + if mode == 'debug': + monkeypatch.setenv(PLAINTEXT_ENV, PLAINTEXT_ACK) + else: + monkeypatch.delenv(PLAINTEXT_ENV, raising=False) + entries.clear() + chunks = [chunk async for chunk in service.query_stream(context, request, 1)] + assert sum(len(chunk.rows) for chunk in chunks) == 1 + assert [value.val for value in transport.streams[-1].params] == ['%' + operand + '%'] + assert len(entries) == 1 + private = mode != 'debug' or policy == 'credential' + assert_logs(entries, True, private, operand, 'CustomerOrder') + if not private: + assert 'DEBUG PLAINTEXT' in entries[0].debug_sql + assert request.intent.comment == 'inspect ' + operand From 4da53afb073c4787cb849dbb146d72c00b2c2c45 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sun, 4 Oct 2026 10:12:09 +0800 Subject: [PATCH 20/40] test: exercise overlapping generated Checker save outcomes (#43) Signed-off-by: Philip Z --- examples/trace-chain/checker_overlap.py | 285 ++++++++++++++++++++++++ examples/trace-chain/verify.sh | 16 ++ 2 files changed, 301 insertions(+) create mode 100644 examples/trace-chain/checker_overlap.py diff --git a/examples/trace-chain/checker_overlap.py b/examples/trace-chain/checker_overlap.py new file mode 100644 index 0000000..002f060 --- /dev/null +++ b/examples/trace-chain/checker_overlap.py @@ -0,0 +1,285 @@ +"""Real generated Checker overlap; app observers never synthesize validation or traces.""" +import asyncio +import copy +import json +import os +import uuid + +from E import E +from Q import Q +from runtime_module import GENERATED_RUNTIME_MODULE +from teaql.core.entity import EntityKey +from teaql.provider.sqlite import create_sqlite_service +from teaql.runtime import UserContext +from teaql.runtime.i18n import CheckException + +from main import AuditSink, ObservedTransaction, chain, reset, scalar +from shared_reference import SharedService, pending, seed + + +class ObservedContext(UserContext): + def __init__(self): + super().__init__() + self.observation = dict(active=0, peak=0, callbacks=0, callback_peak=0, checks=[]) + + async def execute_graph_save(self, work, *, comment=None): + state = self.observation + state['active'] += 1 + state['peak'] = max(state['peak'], state['active']) + + async def observed(session): + state['callbacks'] += 1 + state['callback_peak'] = max(state['callback_peak'], state['callbacks']) + session.transaction.entry['reason'] = comment + try: + return await work(session) + finally: + state['callbacks'] -= 1 + + try: + return await super().execute_graph_save(observed, comment=comment) + finally: + state['active'] -= 1 + + +class ObservedRegistry: + def __init__(self, delegate, state): + self.delegate, self.state = delegate, state + + def checker(self, entity): + delegate = self.delegate.checker(entity) + if delegate is None: + return None + state = self.state + + class Checker: + def check_and_fix(self, context, record, location, results): + before = len(results) + delegate.check_and_fix(context, record, location, results) + state['checks'].append({ + 'entity': entity, + 'reason': context._graph_session.intent.comment, + 'violations': [result.to_wire() for result in results[before:]], + }) + + return Checker() + + +class Transaction(ObservedTransaction): + def __init__(self, owner, transaction): + super().__init__(owner, transaction) + self.entry = dict(reason=None, outcome='pending', physical=[]) + owner.transactions.append(self.entry) + for name in ('execute_sql', 'fetch_all_sql'): + delegate = getattr(transaction.transport, name) + + async def observed(compiled, delegate=delegate, name=name): + self.entry['physical'].append(dict( + method=name, sql=compiled.sql, params=[scalar(value) for value in compiled.params])) + return await delegate(compiled) + + setattr(transaction.transport, name, observed) + + async def mutate(self, context, request): + self.owner.request_comments.append(request.comment()) + return await super().mutate(context, request) + + async def commit(self, context): + await super().commit(context) + self.entry['outcome'] = 'commit' + + async def rollback(self, context): + await super().rollback(context) + self.entry['outcome'] = 'rollback' + + +class Service(SharedService): + def __init__(self, service): + super().__init__(service) + self.transactions = [] + self.request_comments = [] + + async def begin(self, context): + pause, self.pause = self.pause, None + if pause: + entered, release = pause + entered.set() + await asyncio.wait_for(release.wait(), 10) + self.begins += 1 + return Transaction(self, await self.service.begin(context)) + + +async def load(context, identity): + return await (Q.customer_orders().with_id_is(identity) + .select_platform_with(Q.platforms().limit(1)) + .select_order_item_list_with(Q.order_items().limit(10)).limit(1) + .comment('load complete Checker graph').purpose('preserve versions and readonly references') + .execute_for_one(context)) + + +def clear(context, service, sink): + reset(service, sink, context) + service.transactions.clear() + service.request_comments.clear() + state = context.observation + assert state['active'] == state['callbacks'] == 0 + state['peak'] = state['callback_peak'] = 0 + state['checks'].clear() + + +async def verify(context, platform, service, sink, logging, invalid_first): + if logging: + context.enable_all_sql_log() + else: + context.disable_sql_log() + accepted, rejected = await seed(context, platform), await seed(context, platform) + accepted_id, rejected_id = E.customer_order(accepted).id().eval(), E.customer_order(rejected).id().eval() + service.platform_snapshot = None + service.snapshot_uses = 0 + service.reuse_platform = True + try: + accepted, rejected = await load(context, accepted_id), await load(context, rejected_id) + finally: + service.reuse_platform = False + assert service.snapshot_uses == 2 + snapshot = copy.deepcopy(service.platform_snapshot) + left = E.customer_order(accepted).platform().eval() + right = E.customer_order(rejected).platform().eval() + assert accepted._entity_root is not rejected._entity_root + assert left is not right and left._entity_root is not right._entity_root + platform_version = E.platform(left).version().eval() + original_description = E.customer_order(rejected).description().eval() + original_version = E.customer_order(rejected).version().eval() + nonce = uuid.uuid4().hex.translate(str.maketrans('0123456789', 'ghijklmnop')) + accepted_secret, rejected_secret = 'PRIVATE-ACCEPT-' + nonce, 'PRIVATE-REJECT-' + nonce + accepted_reason = f'accept {accepted_secret}; unrelated {rejected_secret}' + rejected_reason = f'reject {rejected_secret}' + accepted.update_description(accepted_secret) + child = accepted.order_item_list()[0] + child.update_name('accepted-child-' + nonce).audit_as('accepted child responsibility') + rejected.update_description(rejected_secret) + # An existing child avoids unrelated ID allocation before validation. + rejected.order_item_list()[0].update_name(None).audit_as('rejected child responsibility') + clear(context, service, sink) + entered, release = asyncio.Event(), asyncio.Event() + service.pause = (entered, release) + ordered = [(rejected, rejected_reason), (accepted, accepted_reason)] if invalid_first else [ + (accepted, accepted_reason), (rejected, rejected_reason)] + first = asyncio.create_task(ordered[0][0].audit_as(ordered[0][1]).save(context)) + await asyncio.wait_for(entered.wait(), 10) + second = asyncio.create_task(ordered[1][0].audit_as(ordered[1][1]).save(context)) + await asyncio.sleep(0) + assert not first.done() and not second.done() + assert context._graph_save_lock.locked() + assert context.observation['peak'] == 2, 'two real public Save calls must be live' + release.set() + results = await asyncio.wait_for(asyncio.gather(first, second, return_exceptions=True), 15) + valid_result, invalid_result = (results[1], results[0]) if invalid_first else results + assert not isinstance(valid_result, BaseException), repr(valid_result) + assert isinstance(invalid_result, CheckException), repr(invalid_result) + assert any(result.rule_id.upper() == 'REQUIRED' and result.location.native_path == 'order_item_list[0].name' + for result in invalid_result.violations), str(invalid_result) + assert context.observation['callback_peak'] == 1, 'the existing graph gate serializes Checker callbacks' + checks = context.observation['checks'] + assert any(check['reason'] == rejected_reason and check['entity'] == 'OrderItem' + and check['violations'] for check in checks) + assert any(check['reason'] == accepted_reason and check['entity'] == 'OrderItem' + and not check['violations'] for check in checks) + expected = [[('CustomerOrder', accepted_id, accepted_reason)], + [('CustomerOrder', accepted_id, accepted_reason), + ('OrderItem', E.order_item(child).id().eval(), 'accepted child responsibility')]] + assert service.requests == [('CustomerOrder', accepted_id, expected[0]), + ('OrderItem', E.order_item(child).id().eval(), expected[1])] + assert service.request_comments == [accepted_reason, accepted_reason] + assert len(service.results) == len(sink.events) == 2 + physical = [statement for result in service.results for statement in result.statements] + assert len(physical) == 4 + assert [chain(statement.mutation_lineage) for statement in physical] == [ + expected[0], expected[0], expected[1], expected[1]] + assert all(statement.comment == accepted_reason and statement.execution_outcome == 'success' + for statement in physical) + for index, statement in enumerate(physical): + assert [node.kind for node in statement.trace_chain] == [ + 'operation', 'request' if index % 2 else 'entity', 'provider', 'sql'] + assert statement.trace_chain[0].name == 'CustomerOrder' + assert [node.name for node in statement.trace_chain[-2:]] == ['sqlite', 'select' if index % 2 else 'update'] + assert statement.result_count == 1 if index % 2 else statement.affected_rows == 1 + assert len(service.transactions) == 2 + failed = next(item for item in service.transactions if item['reason'] == rejected_reason) + committed = next(item for item in service.transactions if item['reason'] == accepted_reason) + assert failed['outcome'] == 'rollback' and failed['physical'] == [], failed + assert committed['outcome'] == 'commit' and len(committed['physical']) == 4 + assert accepted_secret in committed['physical'][0]['params'], 'real SQL values are unchanged' + assert [(event.entity, scalar(event.entity_id)) for event in sink.events] == [ + ('CustomerOrder', accepted_id), ('OrderItem', E.order_item(child).id().eval())] + safe_expected = [] + for event, lineage in zip(sink.events, expected): + reason = chain(event.trace_chain)[0][2] + assert accepted_secret not in repr(event) and rejected_secret in reason + safe_lineage = [('CustomerOrder', accepted_id, reason), *lineage[1:]] + assert chain(event.trace_chain) == safe_lineage + safe_expected.append(safe_lineage) + logs = list(context.sql_logs()) + assert len(logs) == (4 if logging else 0) + for index, entry in enumerate(logs): + assert accepted_secret not in repr(entry) and rejected_secret in entry.comment + assert entry.trace_path[0].name == 'CustomerOrder' + assert chain(entry.mutation_lineage) == safe_expected[index // 2] + assert service.platform_snapshot == snapshot + assert E.platform(left).version().eval() == E.platform(right).version().eval() == platform_version + for reference in (left, right): + assert not pending(reference._entity_root, EntityKey('Platform', E.platform(reference).id().eval())) + assert context.get_resource('fix_time') is None and context._graph_session is None + assert context._checked_mutations == set() and context._fix_evidence_current == [] + proof = dict(logging=logging, invalidFirst=invalid_first, publicOverlap=context.observation['peak'], + serializedCallbacks=context.observation['callback_peak'], checks=copy.deepcopy(checks), + commands=copy.deepcopy(service.requests), transactions=copy.deepcopy(service.transactions), + committedAudit=[dict(entity=event.entity, identity=scalar(event.entity_id), + lineage=chain(event.trace_chain)) for event in sink.events], + sql=[dict(comment=entry.comment, lineage=chain(entry.mutation_lineage)) for entry in logs]) + stored_valid, stored_invalid = await load(context, accepted_id), await load(context, rejected_id) + assert E.customer_order(stored_valid).description().eval() == accepted_secret + assert E.order_item(stored_valid.order_item_list()[0]).name().eval() == 'accepted-child-' + nonce + assert E.customer_order(stored_invalid).description().eval() == original_description + assert E.customer_order(stored_invalid).version().eval() == original_version + assert len(stored_invalid.order_item_list()) == 1 + assert E.platform(E.customer_order(stored_invalid).platform().eval()).version().eval() == platform_version + assert pending(rejected._entity_root, EntityKey('CustomerOrder', rejected_id)) + assert E.customer_order(rejected).description().eval() == rejected_secret + clear(context, service, sink) + next_reason = f'independent after rejection {accepted_secret} {rejected_secret}' + stored_invalid.update_description('independent committed value') + await stored_invalid.audit_as(next_reason).save(context) + next_lineage = [('CustomerOrder', rejected_id, next_reason)] + assert service.requests == [('CustomerOrder', rejected_id, next_lineage)] + assert service.request_comments == [next_reason] + assert len(sink.events) == 1 and chain(sink.events[0].trace_chain) == next_lineage + assert [chain(item.mutation_lineage) for item in service.results[0].statements] == [next_lineage, next_lineage] + assert len(context.sql_logs()) == (2 if logging else 0) + for entry in context.sql_logs(): + assert entry.comment == next_reason + assert E.customer_order(await load(context, rejected_id)).description().eval() == 'independent committed value' + print('CHECKER EVIDENCE ' + json.dumps(dict(proof, nextIndependentSave='committed', sharedReadonlyReference=True))) + + +async def main(): + context = ObservedContext().install(GENERATED_RUNTIME_MODULE) + registry = context.get_resource('checker_registry') + assert registry.checker('CustomerOrder') is not None and registry.checker('OrderItem') is not None + context.set_checker_registry(ObservedRegistry(registry, context.observation)) + service = Service(create_sqlite_service(os.environ['TEAQL_TRACE_CHAIN_DB'])) + sink = AuditSink(service) + context.insert_resource('dataService', service).with_app_audit_event_sink(sink) + context.set_diagnostic_sql_log_sink(type('Silent', (), {'write': lambda self, entry: None})()) + context.configure_audit_policy('CustomerOrder', ['description']) + await context.ensure_schema() + platform = await Q.platforms().with_id_is(1).limit(1).comment('reuse generated root').purpose( + 'attach generated Checker fixtures').execute_for_one(context) + for logging in (False, True): + for invalid_first in (False, True): + await verify(context, platform, service, sink, logging, invalid_first) + print('PASS: Python generated Checker overlap 4 scenarios; accepted-only command/SQL/audit; serialized callbacks') + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/examples/trace-chain/verify.sh b/examples/trace-chain/verify.sh index 4a9f085..09955ec 100644 --- a/examples/trace-chain/verify.sh +++ b/examples/trace-chain/verify.sh @@ -33,6 +33,16 @@ for other_db in "$TEAQL_TRACE_CHAIN_DB" "$TEAQL_TRACE_CHAIN_SHARED_DB" "$TEAQL_T exit 1 fi done +if [[ -z "${TEAQL_TRACE_CHAIN_CHECKER_DB:-}" ]]; then + checker_directory="$(mktemp -d -t teaql-python-checker.XXXXXX)" + export TEAQL_TRACE_CHAIN_CHECKER_DB="$checker_directory/checker.sqlite" +fi +for other_db in "$TEAQL_TRACE_CHAIN_DB" "$TEAQL_TRACE_CHAIN_SHARED_DB" "$TEAQL_TRACE_CHAIN_PAGE_STREAM_DB" "$TEAQL_TRACE_CHAIN_AGGREGATE_DB"; do + if [[ "$TEAQL_TRACE_CHAIN_CHECKER_DB" == "$other_db" ]]; then + echo 'FAIL: Checker overlap fixture requires its own retained database' >&2 + exit 1 + fi +done export PYTHONPATH="$example/lib:$repo/src${PYTHONPATH:+:$PYTHONPATH}" snapshot="$(mktemp)" (cd "$example/lib" && rg --files --hidden -g '!**/__pycache__/**' | LC_ALL=C sort | xargs -d '\n' sha256sum) > "$snapshot" @@ -74,6 +84,12 @@ for attempt in first second; do python "$example/page_stream.py" echo "Aggregate $attempt on $TEAQL_TRACE_CHAIN_AGGREGATE_DB without cleanup" python "$example/relation_aggregate.py" + echo "Checker overlap $attempt on $TEAQL_TRACE_CHAIN_CHECKER_DB without cleanup" + checker_log="$(mktemp -t teaql-python-checker.XXXXXX.log)" + TEAQL_TRACE_CHAIN_DB="$TEAQL_TRACE_CHAIN_CHECKER_DB" \ + python "$example/checker_overlap.py" | tee "$checker_log" + rg -Fq 'PASS: Python generated Checker overlap 4 scenarios; accepted-only command/SQL/audit; serialized callbacks' "$checker_log" + echo "PASS: generated Checker overlap $attempt; evidence $checker_log" done (cd "$example/lib" && sha256sum --check "$snapshot") echo "PASS: two runs on the same database; generated library hashes unchanged" From 94e869721a1a0c70ee51600878d4c4af6684936d Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sun, 4 Oct 2026 10:37:03 +0800 Subject: [PATCH 21/40] test: verify allocated child identities at generated save sinks (#43) Signed-off-by: Philip Z --- examples/trace-chain/README.md | 12 ++ examples/trace-chain/assigned_identity.py | 146 ++++++++++++++++++++++ examples/trace-chain/verify.sh | 5 + 3 files changed, 163 insertions(+) create mode 100644 examples/trace-chain/assigned_identity.py diff --git a/examples/trace-chain/README.md b/examples/trace-chain/README.md index 09eb9b6..4dd5881 100644 --- a/examples/trace-chain/README.md +++ b/examples/trace-chain/README.md @@ -119,6 +119,18 @@ under `evidence/`. `AGENTS.md` governs application implementation. The library i regenerated only by the upstream `PythonTraceChainExampleGenerationTest`; do not edit generated files to fix application usage. +The assigned-identity suite verifies `TC-MUT-07` through actual generated saves. +New root/annotated child/unannotated sibling start with negative temporary typed +ledger keys; no explicit IDs or expected trace nodes are injected. Saving +allocates positive IDs which appear in emitted commands, every actual +write/readback metadata entry and committed safe audit. Q/E reload verifies +stored names, versions and parent IDs. A freshly loaded update carries only +its own reason. Both diagnostic logging modes run twice using the retained +ownership database, not the normative fixture's equal-ID sequence. The verifier +requires GNU coreutils `timeout` (60-second deadline, 5-second kill grace) and +the exact completion marker. This proves allocation and lineage, not batch +grouping or a complete-ledger override. + Prepared batch grouping, generated ledger override, complete mutation privacy, external database acceptance and immutable Registry consumer replay are not established by this example. Local tests do not prove any published version. diff --git a/examples/trace-chain/assigned_identity.py b/examples/trace-chain/assigned_identity.py new file mode 100644 index 0000000..ee676cb --- /dev/null +++ b/examples/trace-chain/assigned_identity.py @@ -0,0 +1,146 @@ +"""TC-MUT-07: observe assigned identities from generated graph saves, not fixtures.""" +import asyncio +import json +import os +import uuid + +from E import E +from Q import Q +from runtime_module import GENERATED_RUNTIME_MODULE +from teaql.core.entity import EntityKey +from teaql.provider.sqlite import create_sqlite_service +from teaql.runtime import UserContext + +from main import AuditSink, ObservedService, chain, new, reset, scalar + + +async def load(context, identity): + return await (Q.customer_orders().with_id_is(identity).limit(1) + .select_order_item_list_with(Q.order_items().limit(10)) + .comment('load assigned identity graph').purpose('verify generated persistence and lineage') + .execute_for_one(context)) + + +def verify_sinks(service, sink, context, expected, reason, operation, logging): + assert service.finishes == ['commit'] + assert len(service.requests) == len(service.results) == len(sink.events) == len(expected) + assert {(entity, identity): nodes for entity, identity, nodes in service.requests} == expected + assert {(event.entity, scalar(event.entity_id)): chain(event.trace_chain) + for event in sink.events} == expected + for (entity, identity, nodes), metadata in zip(service.requests, service.results): + assert identity > 0 and chain(metadata.mutation_lineage) == expected[(entity, identity)] + assert metadata.comment == reason and len(metadata.statements) == 2 + for index, statement in enumerate(metadata.statements): + assert statement.execution_outcome == 'success' + assert statement.comment == reason and chain(statement.mutation_lineage) == nodes + assert statement.result_count == 1 if index else statement.affected_rows == 1 + assert all(node.kind == 'auditReason' and node.entity_id > 0 + for node in statement.mutation_lineage) + path = statement.trace_chain + assert [node.kind for node in path] == [ + 'operation', 'request' if index else 'entity', 'provider', 'sql'] + assert [node.name for node in path] == [ + 'CustomerOrder', 'CustomerOrder' if index else entity, 'sqlite', + 'select' if index else operation] + logs = list(context.sql_logs()) + assert len(logs) == (2 * len(expected) if logging else 0) + if logging: + assert [chain(entry.mutation_lineage) for entry in logs] == [ + nodes for _, _, nodes in service.requests for _ in range(2)] + assert all(entry.comment == reason and entry.execution_outcome == 'success' for entry in logs) + + +async def verify(context, platform, service, sink, logging): + if logging: + context.enable_all_sql_log() + else: + context.disable_sql_log() + label = 'ASSIGNED-' + uuid.uuid4().hex + root = new(Q.customer_orders(), context).update_platform(platform) + root.update_order_number(label).update_description('new assigned graph') + annotated = new(Q.order_items(), context).update_customer_order(root).update_name('annotated ' + label) + sibling = new(Q.order_items(), context).update_customer_order(root).update_name('ordinary ' + label) + annotated.audit_as('annotated newly allocated child') + root.order_item_list().extend([annotated, sibling]) + # Observe only native ledger state. No explicit ID assignment or trace injection. + temporary = [entity._teaql_entity_key() for entity in (root, annotated, sibling)] + assert all(isinstance(key.id, int) and key.id < 0 for key in temporary) + assert len(set(temporary)) == 3 + assert all(key in entity._entity_root.new_keys() + for entity, key in zip((root, annotated, sibling), temporary)) + reset(service, sink, context) + await root.audit_as('create graph with allocated identities').save(context) + identities = [E.customer_order(root).id().eval(), E.order_item(annotated).id().eval(), + E.order_item(sibling).id().eval()] + assert all(isinstance(identity, int) and identity > 0 for identity in identities) + assert identities[1] != identities[2] + reason = 'create graph with allocated identities' + root_node = ('CustomerOrder', identities[0], reason) + expected = { + ('CustomerOrder', identities[0]): [root_node], + ('OrderItem', identities[1]): [root_node, ('OrderItem', identities[1], 'annotated newly allocated child')], + ('OrderItem', identities[2]): [root_node], + } + verify_sinks(service, sink, context, expected, reason, 'insert', logging) + for entity, old, identity in zip((root, annotated, sibling), temporary, identities): + ledger = entity._entity_root + assert old not in ledger.new_keys() and not ledger.has_pending(old) + assert not ledger.has_pending(EntityKey(old.entity, identity)) + print('ASSIGNED_IDENTITY_OBSERVED ' + json.dumps(dict( + logging=logging, temporary=[(key.entity, key.id) for key in temporary], + commands=service.requests, + sql=[dict(comment=item.comment, sql=item.parameterized_sql, + parameters=[scalar(value) for value in item.parameters], + outcome=item.execution_outcome, result_count=item.result_count, + affected_rows=item.affected_rows, lineage=chain(item.mutation_lineage), + path=[(node.kind, node.name) for node in item.trace_chain]) + for metadata in service.results for item in metadata.statements], + audit=[dict(entity=event.entity, id=scalar(event.entity_id), lineage=chain(event.trace_chain)) + for event in sink.events]), sort_keys=True)) + loaded = await load(context, identities[0]) + assert E.customer_order(loaded).order_number().eval() == label + assert E.customer_order(loaded).version().eval() == 1 + children = {E.order_item(item).id().eval(): item for item in loaded.order_item_list()} + assert set(children) == set(identities[1:]) + for identity in identities[1:]: + assert E.order_item(children[identity]).version().eval() == 1 + assert E.order_item(children[identity]).customer_order_id().eval() == identities[0] + assert E.order_item(children[identities[1]]).name().eval() == 'annotated ' + label + assert E.order_item(children[identities[2]]).name().eval() == 'ordinary ' + label + + # A fresh loaded graph has no previous operation's local comments. + loaded.update_description('independent loaded update') + for item in loaded.order_item_list(): + item.update_name('revised ' + label) + reset(service, sink, context) + reason = 'update independently loaded graph' + await loaded.audit_as(reason).save(context) + next_expected = {key: [('CustomerOrder', identities[0], reason)] for key in expected} + verify_sinks(service, sink, context, next_expected, reason, 'update', logging) + reloaded = await load(context, identities[0]) + assert E.customer_order(reloaded).version().eval() == 2 + assert E.customer_order(reloaded).description().eval() == 'independent loaded update' + assert {E.order_item(item).id().eval() for item in reloaded.order_item_list()} == set(identities[1:]) + assert all(E.order_item(item).version().eval() == 2 and + E.order_item(item).name().eval() == 'revised ' + label + for item in reloaded.order_item_list()) + print(f'PASS: assigned identity logging={logging}; 3 inserts then 3 independent updates') + + +async def main(): + context = UserContext.new().install(GENERATED_RUNTIME_MODULE) + service = ObservedService(create_sqlite_service(os.environ['TEAQL_TRACE_CHAIN_DB'])) + sink = AuditSink(service) + context.insert_resource('dataService', service).with_app_audit_event_sink(sink) + context.set_diagnostic_sql_log_sink(type('Silent', (), {'write': lambda self, entry: None})()) + await context.ensure_schema() + platform = await Q.platforms().with_id_is(1).limit(1).comment('reuse bootstrap root').purpose( + 'attach generated assigned identity graph').execute_for_one(context) + assert platform is not None + for logging in (False, True): + await verify(context, platform, service, sink, logging) + print('PASS: Python generated assigned identity 2 scenarios; command/SQL/audit and independent reload') + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/examples/trace-chain/verify.sh b/examples/trace-chain/verify.sh index 09955ec..e25da19 100644 --- a/examples/trace-chain/verify.sh +++ b/examples/trace-chain/verify.sh @@ -75,6 +75,11 @@ for attempt in first second; do python "$example/mutation_privacy.py" | tee "$privacy_log" rg -Fq 'PASS: Python generated loaded delete privacy; 2 writes/2 reads/2 audits; independent next request' "$privacy_log" rg -Fq 'PASS: Python generated unchanged scalar privacy; 1 write/1 read/1 audit' "$privacy_log" + echo "Assigned identities $attempt on $TEAQL_TRACE_CHAIN_SHARED_DB without cleanup" + assigned_log="$(mktemp -t teaql-python-assigned.XXXXXX.log)" + TEAQL_TRACE_CHAIN_DB="$TEAQL_TRACE_CHAIN_SHARED_DB" \ + timeout --kill-after=5s 60s python "$example/assigned_identity.py" | tee "$assigned_log" + rg -Fxq 'PASS: Python generated assigned identity 2 scenarios; command/SQL/audit and independent reload' "$assigned_log" echo "Ownership $attempt on $TEAQL_TRACE_CHAIN_SHARED_DB without cleanup" run_log="$(mktemp -t teaql-python-shared.XXXXXX.log)" env -u TEAQL_TRACE_CHAIN_SCENARIO TEAQL_TRACE_CHAIN_DB="$TEAQL_TRACE_CHAIN_SHARED_DB" \ From 79e6360f4cbc0a1dd31bc1258461dc437e12ced9 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sun, 4 Oct 2026 11:12:23 +0800 Subject: [PATCH 22/40] fix: retain grouped window clauses and numeric trace evidence (#43) Signed-off-by: Philip Z --- src/teaql/sql/dialect.py | 19 +- .../sqlite/test_numeric_partition_trace.py | 197 ++++++++++++++++++ 2 files changed, 208 insertions(+), 8 deletions(-) create mode 100644 tests/provider/sqlite/test_numeric_partition_trace.py diff --git a/src/teaql/sql/dialect.py b/src/teaql/sql/dialect.py index 94b2abb..4f3f158 100644 --- a/src/teaql/sql/dialect.py +++ b/src/teaql/sql/dialect.py @@ -161,6 +161,17 @@ def compile_select_sql(self, entity: EntityDescriptor, query: SelectQuery, param if where_parts: sql += " WHERE " + " AND ".join(where_parts) + # Window ranks apply to the grouped, HAVING-filtered result. Returning + # the partition wrapper first silently collapses aggregate rows and + # drops HAVING bindings on both root and loaded-relation queries. + if query.group_by_items: + group_by = ", ".join(self.column_sql(entity, field) for field in query.group_by_items) + sql += f" GROUP BY {group_by}" + + if query.having_expr is not None: + having_sql = self.compile_expr(entity, query.having_expr, params) + sql += f" HAVING {having_sql}" + if partitioned: rank = self.quote_ident("__teaql_partition_rank") predicates = [f"{rank} > {query.slice.offset}"] @@ -169,14 +180,6 @@ def compile_select_sql(self, entity: EntityDescriptor, query: SelectQuery, param alias = self.quote_ident("__teaql_partitioned") return f"SELECT * FROM ({sql}) AS {alias} WHERE {' AND '.join(predicates)} ORDER BY {rank}" - if query.group_by_items: - group_by = ", ".join(self.column_sql(entity, field) for field in query.group_by_items) - sql += f" GROUP BY {group_by}" - - if query.having_expr is not None: - having_sql = self.compile_expr(entity, query.having_expr, params) - sql += f" HAVING {having_sql}" - if query.order_by_items: order_by = ", ".join(self.order_by_sql(entity, order, params) for order in query.order_by_items) sql += f" ORDER BY {order_by}" diff --git a/tests/provider/sqlite/test_numeric_partition_trace.py b/tests/provider/sqlite/test_numeric_partition_trace.py new file mode 100644 index 0000000..d4d796f --- /dev/null +++ b/tests/provider/sqlite/test_numeric_partition_trace.py @@ -0,0 +1,197 @@ +"""Native numeric grouping/window evidence, not generated aggregation acceptance.""" +from copy import deepcopy +import json +from types import SimpleNamespace + +import aiosqlite +import pytest + +from test_trace_chain import RecordingTransport +from teaql.core.expr import Expr, begin_with +from teaql.core.meta import EntityDescriptor, PropertyDescriptor, RelationDescriptor +from teaql.core.query import SelectQuery +from teaql.core.value import DataType +from teaql.data_service import QueryRequest +from teaql.provider.sqlite import SimpleSchemaProvider +from teaql.provider.sqlite.dialect import SqliteDialect +from teaql.runtime import RuntimeModule +from teaql.runtime.context import SqlLogOptions +from teaql.sql.executor import SqlDataServiceExecutor + + +OPERANDS = ('%NUMERIC_FIRST_', '%NUMERIC_SECOND_') + + +async def numeric_fixture(tmp_path, monkeypatch, logging): + monkeypatch.delenv('TEAQL_ALLOW_SENSITIVE_PLAINTEXT_LOGS', raising=False) + module, schema = RuntimeModule.new(), SimpleSchemaProvider() + for name, fields in ( + ('NumericRoot', ()), ('NumericParent', ('root_id',)), + ('NumericItem', ('parent_id', 'bucket', 'private_name', 'public_name'))): + descriptor = EntityDescriptor(name).table_name(name.lower()) + descriptor.property(PropertyDescriptor('id', DataType.I64).is_id().log_policy('plain')) + descriptor.property(PropertyDescriptor('version', DataType.I64).is_version().log_policy('plain')) + for field in fields: + descriptor.property(PropertyDescriptor(field, + DataType.Text if field.endswith('_name') else DataType.I64).log_policy('plain')) + descriptor.audit_mask_fields(['private_name'] if name == 'NumericItem' else []) + if name == 'NumericRoot': + descriptor.relation(RelationDescriptor('parents', 'NumericParent') + .local('id').foreign('root_id').many()) + elif name == 'NumericParent': + descriptor.relation(RelationDescriptor('items', 'NumericItem') + .local('id').foreign('parent_id').many()) + module.entity(descriptor) + schema.register_entity(descriptor) + context = module.into_context() + entries, raw = [], [] + context.set_diagnostic_sql_log_sink(SimpleNamespace(write=entries.append)) + if not logging: + context.with_sql_log_options(SqlLogOptions.disabled()) + record = context._record_metadata_log + + def observe(metadata, **kwargs): + raw.append(deepcopy(metadata)) + return record(metadata, **kwargs) + + monkeypatch.setattr(context, '_record_metadata_log', observe) + transport = RecordingTransport(str(tmp_path / 'numeric.sqlite')) + service = SqlDataServiceExecutor(SqliteDialect(), transport, schema) + async with aiosqlite.connect(transport.db_path) as db: + await db.execute('CREATE TABLE numericroot (id INTEGER PRIMARY KEY, version INTEGER)') + await db.execute('CREATE TABLE numericparent ' + '(id INTEGER PRIMARY KEY, version INTEGER, root_id INTEGER)') + await db.execute('CREATE TABLE numericitem (id INTEGER PRIMARY KEY, version INTEGER, ' + 'parent_id INTEGER, bucket INTEGER, private_name TEXT, public_name TEXT)') + await db.execute('INSERT INTO numericroot VALUES (1, 1)') + await db.executemany('INSERT INTO numericparent VALUES (?, 1, 1)', [(1,), (2,), (3,)]) + for index, operand in enumerate(OPERANDS): + rows = [(identity + index * 100, 1, parent, bucket, + operand + str(identity), operand + str(identity)) + for identity, parent, bucket in ( + (11, 1, 10), (12, 1, 10), (13, 1, 20), + (21, 2, 10), (22, 2, 30), (23, 2, 30))] + await db.executemany('INSERT INTO numericitem VALUES (?, ?, ?, ?, ?, ?)', rows) + await db.commit() + return context, service, transport, entries, raw + + +def assert_path(nodes, root, relations): + assert [node.kind for node in nodes] == [ + 'operation', 'request', *['relation'] * len(relations), 'provider', 'sql'] + assert [node.name for node in nodes] == [root, root, *relations, 'sqlite', 'select'] + assert not any(node.kind == 'relation' and node.name in ('bucket', 'n', 'parent_id') + for node in nodes) + + +def assert_observations(request, before, transport, entries, raw, logging, field, operand, + root, routes, counts, expected_params): + assert request.query == before and request.query.trace_chain == [] + assert len(transport.reads) == len(raw) == len(routes) + assert [item.result_count for item in raw] == counts + assert [[value.val for value in query.params] for query in transport.reads] == expected_params + assert [item.parameters for item in raw] == [list(query.params) for query in transport.reads] + assert all((item.comment, item.purpose) == (request.intent.comment, request.intent.purpose) + for item in raw) + for item, relations in zip(raw, routes): + assert_path(item.trace_chain, root, relations) + assert len(entries) == (len(raw) if logging else 0) + for entry, relations in zip(entries, routes): + assert_path(entry.trace_path, root, relations) + if field == 'private_name': + assert operand not in repr(entry) + assert entry.comment == request.intent.comment.replace(operand, '[REDACTED]') + assert entry.purpose == request.intent.purpose.replace(operand, '[REDACTED]') + else: + assert entry.comment == request.intent.comment and entry.purpose == request.intent.purpose + print('NUMERIC_OBSERVATION ' + json.dumps({ + 'root': root, 'logging': logging, 'field': field, 'routes': routes, 'counts': counts, + 'sql': [query.sql for query in transport.reads], 'parameters': expected_params, + 'safeIntent': [(entry.comment, entry.purpose) for entry in entries], + })) + + +async def independent(context, service, entries, logging): + reason = 'independent ' + ' and '.join(OPERANDS) + result = await service.query(context, QueryRequest(SelectQuery('NumericRoot').project('id').limit(1), + _comment=reason, _purpose='no numeric query provenance')) + assert [row['id'] for row in result.rows] == [1] + assert_path(result.metadata.trace_chain, 'NumericRoot', []) + if logging: + assert entries[-1].comment == reason + assert entries[-1].purpose == 'no numeric query provenance' + + +@pytest.mark.asyncio +@pytest.mark.parametrize('logging', [False, True]) +@pytest.mark.parametrize('field', ['private_name', 'public_name']) +@pytest.mark.parametrize('shape', ['group', 'window', 'grouped-window']) +async def test_root_numeric_grouping_and_partition_keep_real_sql_semantics( + tmp_path, monkeypatch, logging, field, shape): + context, service, transport, entries, raw = await numeric_fixture(tmp_path, monkeypatch, logging) + for index, operand in enumerate(OPERANDS): + entries.clear(); raw.clear(); transport.reads.clear() + minimum = 15 if index == 0 else 25 + query = SelectQuery('NumericItem').filter(begin_with(field, operand)) + if shape == 'window': + query.project('id', 'parent_id', 'bucket').partition_by_field('bucket').order_asc('id').limit(1) + else: + query.group_by('parent_id', 'bucket').count('n').having(Expr.gte('bucket', minimum)) + query.order_desc('bucket').limit(10 if shape == 'group' else 1) + if shape == 'grouped-window': + query.partition_by_field('parent_id') + request = QueryRequest(query, _comment='inspect ' + operand, _purpose='justify ' + operand) + before = deepcopy(request.query) + result = await service.query(context, request) + if shape == 'window': + assert sorted((row['id'], row['parent_id'], row['bucket']) for row in result.rows) == [ + (11 + index * 100, 1, 10), (13 + index * 100, 1, 20), (22 + index * 100, 2, 30)] + else: + expected = [(1, 20, 1), (2, 30, 2)] if index == 0 else [(2, 30, 2)] + assert sorted((row['parent_id'], row['bucket'], row['n']) for row in result.rows) == expected + sql = transport.reads[0].sql + assert ('ROW_NUMBER()' in sql) == (shape != 'group') + assert ('GROUP BY' in sql and 'HAVING' in sql) == (shape != 'window') + assert transport.reads[0].parameter_log_policies == [ + 'masked' if field == 'private_name' else 'plain', *(['plain'] if shape != 'window' else [])] + assert_observations(request, before, transport, entries, raw, logging, field, operand, + 'NumericItem', [[]], [len(result.rows)], [[operand + '%'] + ([] if shape == 'window' else [minimum])]) + await independent(context, service, entries, logging) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('logging', [False, True]) +@pytest.mark.parametrize('field', ['private_name', 'public_name']) +@pytest.mark.parametrize('plan', ['window', 'probe']) +async def test_loaded_grouped_relation_keeps_ancestors_membership_and_future_privacy( + tmp_path, monkeypatch, logging, field, plan): + context, service, transport, entries, raw = await numeric_fixture(tmp_path, monkeypatch, logging) + for index, operand in enumerate(OPERANDS): + entries.clear(); raw.clear(); transport.reads.clear() + minimum = 15 if index == 0 else 25 + child = (SelectQuery('NumericItem').filter(begin_with(field, operand)) + .group_by('parent_id', 'bucket').count('n').having(Expr.gte('bucket', minimum)) + .order_desc('bucket').limit(1).top_n_probe_parent_threshold(0 if plan == 'window' else 10)) + parents = (SelectQuery('NumericParent').project('id').order_asc('id').limit(3) + .top_n_probe_parent_threshold(0).relation_query('items', child)) + query = SelectQuery('NumericRoot').project('id').limit(1).relation_query('parents', parents) + request = QueryRequest(query, _comment='inspect future ' + operand, _purpose='justify future ' + operand) + before = deepcopy(request.query) + result = await service.query(context, request) + assert len(result.rows) == 1 + loaded = result.rows[0]['parents'] + assert [parent['id'] for parent in loaded] == [1, 2, 3] + assert {parent['id']: [(row['parent_id'], row['bucket'], row['n']) for row in parent['items']] + for parent in loaded} == { + 1: [(1, 20, 1)] if index == 0 else [], 2: [(2, 30, 2)], 3: []} + assert all('__teaql_partition_rank' not in row for parent in loaded for row in parent['items']) + child_reads = transport.reads[2:] + assert all('GROUP BY' in item.sql and 'HAVING' in item.sql for item in child_reads) + assert all(('ROW_NUMBER()' in item.sql) == (plan == 'window') for item in child_reads) + expected_params = [[], [1]] + ( + [[operand + '%', 1, 2, 3, minimum]] if plan == 'window' else + [[operand + '%', parent, minimum] for parent in (1, 2, 3)]) + counts = [1, 3] + ([2 if index == 0 else 1] if plan == 'window' else [1 if index == 0 else 0, 1, 0]) + assert_observations(request, before, transport, entries, raw, logging, field, operand, + 'NumericRoot', [[], ['parents']] + [['parents', 'items']] * len(child_reads), counts, expected_params) + await independent(context, service, entries, logging) From be8d5b285486fbe1980cc006b698aade7bcc9182 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sun, 4 Oct 2026 11:32:49 +0800 Subject: [PATCH 23/40] fix: retain native batch root lineage on each executed item (#43) Signed-off-by: Philip Z --- src/teaql/sql/executor.py | 10 + tests/provider/sqlite/test_trace_chain.py | 225 ++++++++++++++++++++++ 2 files changed, 235 insertions(+) diff --git a/src/teaql/sql/executor.py b/src/teaql/sql/executor.py index c1e98de..15bc001 100644 --- a/src/teaql/sql/executor.py +++ b/src/teaql/sql/executor.py @@ -919,6 +919,16 @@ async def _mutate(self, context: 'UserContext', request: MutationRequest, privac child_request = (child.with_root_intent(request.intent) if isinstance(child, MutationRequest) else MutationRequest(child, comment=request.intent.comment)) + lineage = child_request.mutation_lineage + if (not isinstance(child_request._data, list) and lineage + and lineage[0].comment != request.intent.comment): + # A native batch has no aggregate entity/ID. Its reason + # prefixes each item's own responsibility type, without + # replacing the local chain or duplicating an existing root. + entity = lineage[0].entity_type or lineage[0].name or child_request._data.entity + child_request = child_request.with_mutation_lineage((TraceNode( + kind='auditReason', name=entity, entity_type=entity, + comment=request.intent.comment), *lineage)) results.append(await self._mutate(context, child_request, privacy)) affected = sum(result.affected_rows for result in results) return MutationResult(affected, {}, ExecutionMetadata( diff --git a/tests/provider/sqlite/test_trace_chain.py b/tests/provider/sqlite/test_trace_chain.py index c6cbbb5..048e31e 100644 --- a/tests/provider/sqlite/test_trace_chain.py +++ b/tests/provider/sqlite/test_trace_chain.py @@ -543,6 +543,231 @@ async def test_batch_keeps_each_write_readback_pair(tmp_path): assert len(entries) == 4 +async def ordered_batch_fixture(tmp_path, monkeypatch, logging): + """Observe real native leaf execution and driver commit, never supply SQL traces.""" + from copy import deepcopy + from teaql.runtime.context import SqlLogOptions + monkeypatch.delenv('TEAQL_ALLOW_SENSITIVE_PLAINTEXT_LOGS', raising=False) + context, service, transport, entries = await fixture(tmp_path) + for entity in ('Payment', 'PaymentAttempt'): + context.entity(entity).audit_mask_fields(['name']) + if not logging: + context.with_sql_log_options(SqlLogOptions.disabled()) + observed = SimpleNamespace(commands=[], physical=[], audits=[], lifecycle=[]) + original_mutate = SqlDataServiceExecutor._mutate + original_record = type(context)._record_metadata_log + original_begin = transport.begin_sql + + async def mutate(executor, invocation, request, privacy=None): + if not isinstance(request._data, list): + observed.commands.append(deepcopy(request)) + return await original_mutate(executor, invocation, request, privacy) + + def record(invocation, metadata, **kwargs): + observed.physical.append(deepcopy(metadata)) + return original_record(invocation, metadata, **kwargs) + + async def begin(): + transaction = await original_begin() + observed.lifecycle.append('begin') + commit, rollback = transaction.commit_sql, transaction.rollback_sql + + async def committed(): + assert observed.audits == [], 'no committed audit before driver commit' + await commit() + observed.lifecycle.append('commit') + + async def rolled_back(): + await rollback() + observed.lifecycle.append('rollback') + + transaction.commit_sql, transaction.rollback_sql = committed, rolled_back + return transaction + + def audit(invocation, event): + assert observed.lifecycle[-1] == 'commit' + observed.audits.append(event) + + monkeypatch.setattr(SqlDataServiceExecutor, '_mutate', mutate) + monkeypatch.setattr(type(context), '_record_metadata_log', record) + monkeypatch.setattr(transport, 'begin_sql', begin) + context.with_app_audit_event_sink(SimpleNamespace(on_safe_event=audit)) + return context, service, transport, entries, observed + + +def native_batch_lineage(nodes): + return [(node.kind, node.name or node.entity_type, node.entity_id, node.comment) for node in nodes] + + +def native_batch_identity(request): + target = getattr(request._data, 'id', None) + if target is None: + target = request._data.values['id'] + return request._data.entity, target.val + + +def native_batch_statements(metadata): + if not metadata.statements: + return [metadata] + return [leaf for child in metadata.statements for leaf in native_batch_statements(child)] + + +def native_batch_item(entity, identity, value, reason, update=False, old=None): + from teaql.core.entity import EntityKey + from teaql.core.mutation import UpdateCommand + from teaql.core.trace_scope import TraceScope + from teaql.core.value import Value + command = (UpdateCommand.new(entity, identity).expected_version(1).value('name', value) + if update else InsertCommand.new(entity).value('id', identity).value('version', 1) + .value('name', value).value('parent_id', 1)) + if update: + command.old_values = {'name': Value.from_any(old)} if old is not None else {} + scope = TraceScope.root(object(), EntityKey(entity, identity), reason) + return MutationRequest(command, comment=reason).with_mutation_lineage(scope.recover()) + + +def nested_native_batch(items, comment, nested): + return MutationRequest.Batch([ + MutationRequest.Batch(items[:2], comment='inner first container'), + MutationRequest.Batch(items[2:], comment='inner second container'), + ] if nested else items, comment=comment) + + +def native_batch_witness(observed, logging, nested, phase): + print('NATIVE_BATCH_OBSERVED ' + json.dumps({ + 'logging': logging, 'nested': nested, 'phase': phase, 'lifecycle': observed.lifecycle, + 'commands': [{'entity': item._data.entity, 'id': native_batch_identity(item)[1], 'comment': item.comment(), + 'lineage': native_batch_lineage(item.mutation_lineage)} for item in observed.commands], + 'physical': [{'sql': item.parameterized_sql, 'parameters': [value.val for value in item.parameters], + 'affected': item.affected_rows, 'count': item.result_count, + 'outcome': item.execution_outcome, 'lineage': native_batch_lineage(item.mutation_lineage)} + for item in observed.physical], + 'committedAudit': [{'entity': item.entity, 'id': getattr(item.entity_id, 'val', item.entity_id), + 'lineage': native_batch_lineage(item.trace_chain)} for item in observed.audits], + })) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('logging', [False, True]) +@pytest.mark.parametrize('nested', [False, True]) +@pytest.mark.parametrize('same_root', [False, True]) +async def test_native_batch_ordered_typed_commands_sql_and_committed_audit( + tmp_path, monkeypatch, logging, nested, same_root): + from copy import deepcopy + from teaql.core.mutation import UpdateCommand + from teaql.data_service import DataServiceOperation + context, service, transport, entries, seen = await ordered_batch_fixture(tmp_path, monkeypatch, logging) + identities = [('Payment', 3), ('Payment', 2), ('PaymentAttempt', 3)] + previous = ['FIRST-PRIVATE', 'SECOND-PRIVATE', 'FUTURE-PRIVATE'] + for update in (False, True): + values = ['UPDATED-' + value for value in previous] if update else previous + reason = 'batch ' + ' '.join(values) + ' public marker' + local = [reason] * 4 if same_root else ['alpha responsibility', 'beta responsibility', + 'gamma responsibility', 'zero responsibility'] + items = [native_batch_item(entity, identity, value, local[index], update, previous[index]) + for index, ((entity, identity), value) in enumerate(zip(identities, values))] + items.append(native_batch_item('Payment', 999, 'unmatched private name', local[3], True)) + before = [(deepcopy(item._data), item.comment(), item.mutation_lineage) for item in items] + seen.commands.clear(); seen.physical.clear(); seen.audits.clear(); seen.lifecycle.clear() + entries.clear(); transport.reads.clear(); transport.writes.clear() + result = await service.mutate(context, nested_native_batch(items, reason, nested)) + native_batch_witness(seen, logging, nested, 'update' if update else 'insert') + assert result.affected_rows == result.metadata.affected_rows == 3 + assert result.metadata.operation == DataServiceOperation.Batch + assert result.generated_values == {} and result.persisted_record is None + assert seen.lifecycle == ['begin', 'commit'] + assert len(seen.commands) == 4 and len(seen.physical) == 7 and len(seen.audits) == 3 + assert [native_batch_identity(item) for item in seen.commands] == identities + [('Payment', 999)] + returned = native_batch_statements(result.metadata) + assert [(item.operation, item.parameters, item.mutation_lineage, item.affected_rows, item.result_count) + for item in returned] == [ + (item.operation, item.parameters, item.mutation_lineage, item.affected_rows, item.result_count) + for item in seen.physical] + assert [(event.entity, event.entity_id.val) for event in seen.audits] == identities + assert [event.kind.value for event in seen.audits] == ['updated' if update else 'created'] * 3 + for index, ((entity, identity), value) in enumerate(zip(identities, values)): + expected = ([('auditReason', entity, identity, reason)] if same_root else + [('auditReason', entity, None, reason), ('auditReason', entity, identity, local[index])]) + assert seen.commands[index].comment() == reason + assert seen.commands[index]._data.values['name'].val == value + assert native_batch_lineage(seen.commands[index].mutation_lineage) == expected + write, read = seen.physical[index * 2:index * 2 + 2] + assert write.operation == (DataServiceOperation.Update if update else DataServiceOperation.Insert) + assert write.affected_rows == 1 and read.operation == DataServiceOperation.Query and read.result_count == 1 + assert value in [param.val for param in write.parameters] + assert [param.val for param in read.parameters] == [identity] + for statement in (write, read): + assert native_batch_lineage(statement.mutation_lineage) == expected + assert statement.comment == statement.audit_reason == reason + assert [node.kind for node in statement.trace_chain] == [ + 'operation', 'request' if statement is read else 'entity', 'provider', 'sql'] + assert [node.name for node in statement.trace_chain] == [ + entity, entity, 'sqlite', 'select' if statement is read else 'update' if update else 'insert'] + safe_reason = 'batch [REDACTED] [REDACTED] [REDACTED] public marker' + safe_expected = [(kind, name, key, safe_reason if text == reason else text) + for kind, name, key, text in expected] + assert native_batch_lineage(seen.audits[index].trace_chain) == safe_expected + assert seen.physical[-1].affected_rows == 0 and seen.physical[-1].statements == () + assert len(entries) == (7 if logging else 0) + assert all(value not in repr(entries) + repr(seen.audits) for value in values) + assert [(item._data, item.comment(), item.mutation_lineage) for item in items] == before + # Compare actual transport bindings only for modeled business statements; + # explicit INSERT IDs also execute genuine teaql_id_space maintenance. + writes = [item for item in transport.writes if 'teaql_id_space' not in item.sql] + reads = [item for item in transport.reads if 'teaql_id_space' not in item.sql] + assert [item.params for item in writes] == [item.parameters for item in seen.physical[::2]] + assert [item.params for item in reads] == [item.parameters for item in seen.physical[1:6:2]] + async with aiosqlite.connect(transport.db_path) as db: + for (entity, identity), value in zip(identities, values): + row = await (await db.execute(f'SELECT name, version FROM {entity.lower()}_data WHERE id=?', + (identity,))).fetchone() + assert row == (value, 2 if update else 1) + seen.commands.clear(); seen.physical.clear(); seen.audits.clear(); seen.lifecycle.clear(); entries.clear() + independent = 'independent ' + ' '.join(previous + values) + await service.mutate(context, MutationRequest(UpdateCommand.new('CustomerOrder', 1).expected_version(1) + .value('name', 'ordinary next root'), comment=independent)) + assert len(seen.audits) == 1 and len(seen.physical) == 2 + assert native_batch_lineage(seen.audits[0].trace_chain) == [('auditReason', 'CustomerOrder', 1, independent)] + if logging: + assert all(entry.audit_reason == independent for entry in entries) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('logging', [False, True]) +@pytest.mark.parametrize('nested', [False, True]) +async def test_native_batch_failed_write_retains_item_lineage_but_no_committed_audit( + tmp_path, monkeypatch, logging, nested): + context, service, transport, entries, seen = await ordered_batch_fixture(tmp_path, monkeypatch, logging) + items = [native_batch_item('Payment', 3, 'FIRST-PRIVATE', 'alpha responsibility'), + native_batch_item('Payment', 2, 'SECOND-PRIVATE', 'beta responsibility'), + native_batch_item('PaymentAttempt', 3, 'FUTURE-PRIVATE', 'gamma responsibility'), + native_batch_item('Payment', 3, 'duplicate private value', 'failed responsibility')] + reason = 'batch FIRST-PRIVATE SECOND-PRIVATE FUTURE-PRIVATE public marker' + with pytest.raises(TransportError, match='UNIQUE constraint failed'): + await service.mutate(context, nested_native_batch(items, reason, nested)) + native_batch_witness(seen, logging, nested, 'rollback') + assert seen.lifecycle == ['begin', 'rollback'] and seen.audits == [] + assert len(seen.commands) == 4 and len(seen.physical) == 7 + assert [native_batch_identity(item) for item in seen.commands] == [ + ('Payment', 3), ('Payment', 2), ('PaymentAttempt', 3), ('Payment', 3)] + assert [item.execution_outcome for item in seen.physical] == ['success'] * 6 + ['failure'] + assert native_batch_lineage(seen.physical[-1].mutation_lineage) == [ + ('auditReason', 'Payment', None, reason), ('auditReason', 'Payment', 3, 'failed responsibility')] + assert len(entries) == (7 if logging else 0) + assert all(value not in repr(entries) for value in ('FIRST-PRIVATE', 'SECOND-PRIVATE', 'FUTURE-PRIVATE')) + async with aiosqlite.connect(transport.db_path) as db: + for table in ('payment_data', 'paymentattempt_data'): + assert (await (await db.execute(f'SELECT COUNT(*) FROM {table} WHERE id IN (2,3)')).fetchone())[0] == 0 + seen.commands.clear(); seen.physical.clear(); seen.audits.clear(); seen.lifecycle.clear(); entries.clear() + independent = 'independent FIRST-PRIVATE SECOND-PRIVATE FUTURE-PRIVATE' + await service.mutate(context, MutationRequest(InsertCommand.new('Payment').value('name', 'independent row') + .value('parent_id', 1), comment=independent)) + assert len(seen.audits) == 1 and seen.lifecycle == ['begin', 'commit'] + assert seen.audits[0].trace_chain[0].comment == independent + if logging: + assert all(entry.audit_reason == independent for entry in entries) + + @pytest.mark.asyncio @pytest.mark.parametrize('mode', ['no-match', 'hard-delete']) async def test_no_synthetic_readback_when_none_executed(tmp_path, mode): From 09e885362857301b1aec0c9abcc6d9c436ed5a6b Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sun, 4 Oct 2026 11:36:30 +0800 Subject: [PATCH 24/40] test: observe native ledger precedence at execution boundaries (#43) Signed-off-by: Philip Z --- .../sqlite/test_ledger_trace_override.py | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 tests/provider/sqlite/test_ledger_trace_override.py diff --git a/tests/provider/sqlite/test_ledger_trace_override.py b/tests/provider/sqlite/test_ledger_trace_override.py new file mode 100644 index 0000000..3edda31 --- /dev/null +++ b/tests/provider/sqlite/test_ledger_trace_override.py @@ -0,0 +1,139 @@ +"""Explicit native ledger input, not a generated automatic-writer assertion.""" +from copy import deepcopy +from types import SimpleNamespace + +import aiosqlite +import pytest + +from test_trace_chain import fixture +from teaql.core.entity import EntityKey, EntityRoot +from teaql.core.mutation import UpdateCommand + + +def scalar(value): + return getattr(value, 'val', value) + + +def lineage(nodes): + return [(node.name or node.entity_type, node.entity_id, node.comment) for node in nodes] + + +@pytest.mark.asyncio +@pytest.mark.parametrize('logging', [False, True]) +@pytest.mark.parametrize('empty', [False, True]) +async def test_native_ledger_preference_reaches_sql_and_committed_audit( + tmp_path, monkeypatch, logging, empty): + monkeypatch.delenv('TEAQL_ALLOW_SENSITIVE_PLAINTEXT_LOGS', raising=False) + context, service, transport, diagnostics = await fixture(tmp_path) + context.insert_resource('dataService', service) + if not logging: + context.disable_sql_log() + context.entity('Payment').audit_mask_fields(['name']) + service.schema_provider.get_entity('Payment').audit_mask_fields(['name']) + secret = 'OVERRIDEPRIVATEPAYMENT' + reason = 'apply ' + secret + ledger = EntityRoot() + keys = [EntityKey(entity, 1) for entity in ('CustomerOrder', 'Payment', 'Shipment')] + names = ['changed root', secret, 'changed shipment'] + commands, results, events, phases = [], [], [], [] + begin = transport.begin_sql + + async def observed_begin(): + tx = await begin() + phases.append('begin') + commit = tx.commit_sql + + async def observed_commit(): + await commit() + phases.append('commit') + tx.commit_sql = observed_commit + return tx + + monkeypatch.setattr(transport, 'begin_sql', observed_begin) + + def audit(_context, event): + assert phases[-1] == 'commit' + events.append(event) + context.with_app_audit_event_sink(SimpleNamespace(on_safe_event=audit)) + + async def work(graph): + root = graph.scope(keys[0]) + # A native caller supplies the complete chain using real scope recovery. + # No observer inserts expected frames into requests or execution results. + supplied = graph.scope(keys[1], root, 'review ' + secret).recover() + before = deepcopy(supplied) + ledger.set_trace_chain(keys[1], () if empty else supplied) + ledger.set_trace_chain(keys[2], ()) + scope = [root, graph.scope(keys[1], root, 'fallback charge'), root] + updates = [UpdateCommand.new(key.entity, key.id).expected_version(1).value('name', name) + for key, name in zip(keys, names)] + for command in updates: + graph.context.preflight_mutation(command) + for key, command, local in zip(keys, updates, scope): + request = graph.request(command, local, ledger, key) + commands.append((key, deepcopy(request.mutation_lineage))) + results.append(await graph.transaction.mutate(graph.context, request)) + assert not events + assert supplied == before + assert ledger.trace_chain(keys[1]) == (() if empty else before) + + await context.execute_graph_save(work, comment=reason) + root_node = ('CustomerOrder', 1, reason) + local_reason = 'fallback charge' if empty else 'review ' + secret + expected = [[root_node], [root_node, ('Payment', 1, local_reason)], [root_node]] + assert phases == ['begin', 'commit'] + assert [key for key, _ in commands] == keys + assert [lineage(nodes) for _, nodes in commands] == expected + assert len(transport.writes) == len(transport.reads) == len(results) == len(events) == 3 + for index, result in enumerate(results): + assert scalar(result.persisted_record['name']) == names[index] + assert scalar(result.persisted_record['version']) == 2 + assert result.affected_rows == 1 + assert lineage(result.metadata.mutation_lineage) == expected[index] + assert len(result.metadata.statements) == 2 + for read, metadata in enumerate(result.metadata.statements): + compiled = (transport.reads if read else transport.writes)[index] + assert metadata.parameterized_sql == compiled.sql + assert metadata.parameters == compiled.params + assert metadata.comment == reason + assert metadata.execution_outcome == 'success' + assert lineage(metadata.mutation_lineage) == expected[index] + assert metadata.result_count == 1 if read else metadata.affected_rows == 1 + assert [node.kind for node in metadata.trace_chain] == [ + 'operation', 'request' if read else 'entity', 'provider', 'sql'] + assert metadata.trace_chain[0].name == 'CustomerOrder' + safe = [[(kind, identity, comment.replace(secret, '[REDACTED]')) + for kind, identity, comment in nodes] for nodes in expected] + assert [(event.entity, scalar(event.entity_id)) for event in events] == [(key.entity, key.id) for key in keys] + assert [lineage(event.trace_chain) for event in events] == safe + assert secret not in repr(events) + assert len(diagnostics) == (6 if logging else 0) + if logging: + assert [lineage(entry.mutation_lineage) for entry in diagnostics] == [nodes for nodes in safe for _ in range(2)] + assert secret not in repr(diagnostics) + async with aiosqlite.connect(transport.db_path) as db: + for key, name in zip(keys, names): + row = await (await db.execute( + f'SELECT name, version FROM {key.entity.lower()}_data WHERE id = ?', (key.id,))).fetchone() + assert row == (name, 2) + + # Native ownership is explicit; this does not claim generated auto-cleanup. + ledger.clear_committed() + assert ledger.trace_chain(keys[1]) == () + later_reason = 'independent mention ' + secret + + async def later(graph): + command = UpdateCommand.new('Payment', 1).expected_version(2).value('name', 'next value') + graph.context.preflight_mutation(command) + request = graph.request(command, graph.scope(keys[1]), ledger, keys[1]) + result = await graph.transaction.mutate(graph.context, request) + assert lineage(result.metadata.mutation_lineage) == [('Payment', 1, later_reason)] + assert scalar(result.persisted_record['version']) == 3 + await context.execute_graph_save(later, comment=later_reason) + assert phases == ['begin', 'commit', 'begin', 'commit'] + assert len(events) == 4 + assert lineage(events[-1].trace_chain) == [('Payment', 1, later_reason)] + assert len(transport.writes) == len(transport.reads) == 4 + if logging: + assert len(diagnostics) == 8 + assert all(entry.comment == later_reason for entry in diagnostics[-2:]) From 2c578d359103af79ca861614f0b27f87d2636682 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sun, 4 Oct 2026 15:18:02 +0800 Subject: [PATCH 25/40] fix: preserve known FK when forward details are filtered (#43) Signed-off-by: Philip Z --- examples/trace-chain/relation_aggregate.py | 26 ++++++++++++++++++++++ examples/trace-chain/verify.sh | 5 ++++- src/teaql/sql/dialect.py | 12 +++++++++- src/teaql/sql/executor.py | 6 ++++- tests/provider/sqlite/test_trace_chain.py | 24 +++++++++++++++++++- 5 files changed, 69 insertions(+), 4 deletions(-) diff --git a/examples/trace-chain/relation_aggregate.py b/examples/trace-chain/relation_aggregate.py index e54233e..afefd7e 100644 --- a/examples/trace-chain/relation_aggregate.py +++ b/examples/trace-chain/relation_aggregate.py @@ -70,6 +70,32 @@ def clear(): for logging in (True, False): context.enable_all_sql_log() if logging else context.disable_sql_log() + hidden = (await (Q.payments().with_id_is(payment_id).limit(1) + .select_customer_order_with(Q.customer_orders().with_id_is(0).limit(1)) + .comment('load filtered forward identity').purpose('distinguish unfetched detail from null') + .execute_for_list(context)))[0] + identity = E.payment(hidden).customer_order().eval() + assert identity is not None + assert E.customer_order(identity).id().eval() == order_id + + def assert_unfetched(): + try: + E.customer_order(identity).description().eval() + except RuntimeError as error: + assert type(error).__name__ == 'TeaQLNotLoadedError', error + assert 'description' in str(error), error + else: + raise AssertionError('unfetched description became loaded-null') + + assert_unfetched() + visible = (await (Q.payments().with_id_is(payment_id).limit(1) + .select_customer_order_with(Q.customer_orders().with_id_is(order_id).limit(1)) + .comment('load independent full reference').purpose('verify edge-owned load boundaries') + .execute_for_list(context)))[0] + full = E.payment(visible).customer_order().eval() + assert E.customer_order(full).description().eval() is not None + assert_unfetched() + print('FORWARD_NOTLOADED_OBSERVED ' + json.dumps({'logging': logging, 'id': order_id})) for nested in (False, True): clear() rows = await query(nested).execute_for_list(context) diff --git a/examples/trace-chain/verify.sh b/examples/trace-chain/verify.sh index e25da19..7f49967 100644 --- a/examples/trace-chain/verify.sh +++ b/examples/trace-chain/verify.sh @@ -88,7 +88,10 @@ for attempt in first second; do echo "Page/stream $attempt on $TEAQL_TRACE_CHAIN_PAGE_STREAM_DB without cleanup" python "$example/page_stream.py" echo "Aggregate $attempt on $TEAQL_TRACE_CHAIN_AGGREGATE_DB without cleanup" - python "$example/relation_aggregate.py" + aggregate_log="$(mktemp -t teaql-python-aggregate.XXXXXX.log)" + python "$example/relation_aggregate.py" | tee "$aggregate_log" + rg -Fq 'FORWARD_NOTLOADED_OBSERVED {"logging": true' "$aggregate_log" + rg -Fq 'FORWARD_NOTLOADED_OBSERVED {"logging": false' "$aggregate_log" echo "Checker overlap $attempt on $TEAQL_TRACE_CHAIN_CHECKER_DB without cleanup" checker_log="$(mktemp -t teaql-python-checker.XXXXXX.log)" TEAQL_TRACE_CHAIN_DB="$TEAQL_TRACE_CHAIN_CHECKER_DB" \ diff --git a/src/teaql/sql/dialect.py b/src/teaql/sql/dialect.py index 4f3f158..82e5af0 100644 --- a/src/teaql/sql/dialect.py +++ b/src/teaql/sql/dialect.py @@ -341,7 +341,17 @@ def property_projection(p): return ", ".join(property_projection(p) for p in getattr(entity, 'properties', [])) parts = [] - for field in query.projection: + projected_fields = list(query.projection) + # Hydration must use an actual FK, not treat an omitted column as NULL. + # Keep caller-owned query/projection unchanged. + if not query.group_by_items: + relation_names = [load.name for load in query.relations] + relation_names += [aggregate.relation_name for aggregate in query.relation_aggregates] + for name in relation_names: + relation = entity.relation_by_name(name) + if relation and not relation.is_many and relation.local_key not in projected_fields: + projected_fields.append(relation.local_key) + for field in projected_fields: prop = next((p for p in getattr(entity, 'properties', []) if getattr(p, 'name', None) == field), None) if not prop: raise UnknownFieldError(field) diff --git a/src/teaql/sql/executor.py b/src/teaql/sql/executor.py index 15bc001..0613ceb 100644 --- a/src/teaql/sql/executor.py +++ b/src/teaql/sql/executor.py @@ -759,7 +759,11 @@ async def _enhance_relations(self, context, parents: List[Dict[str, Any]], reque child_desc = self.schema_provider.get_entity(relation.target_entity) related = SmartList(related, facets=await self._query_facets( context, facet_request, child_desc)) - parent[load.name] = related if relation.is_many else (related[0] if related else None) + # Preserve the provider's known FK even if target detail is + # filtered out. Unfetched properties are absent/NotLoaded. + parent[load.name] = related if relation.is_many else ( + related[0] if related else + {relation.foreign_key: key} if key is not None else None) relation_scope.success({ "teaql.result.cardinality": len(children), "teaql.relation.parent_count": len(parent_ids), diff --git a/tests/provider/sqlite/test_trace_chain.py b/tests/provider/sqlite/test_trace_chain.py index 048e31e..7f44162 100644 --- a/tests/provider/sqlite/test_trace_chain.py +++ b/tests/provider/sqlite/test_trace_chain.py @@ -1088,7 +1088,8 @@ async def test_loaded_forward_key_remains_usable_by_relation_aggregate(tmp_path, if reference == 'visible': assert row['parent_id']['name'] == secret else: - assert row['parent_id'] is None + assert row['parent_id'] == {'id': 1} + assert 'name' not in row['parent_id'] if reference == 'filtered_with_sibling': assert row['parent_again']['name'] == secret assert len(transport.reads) == (4 if nested else 3) + (reference == 'filtered_with_sibling') @@ -1122,6 +1123,27 @@ async def test_scalar_stream_rejects_relation_aggregate_before_provider(tmp_path assert transport.streams == [] and transport.reads == [] and entries == [] +@pytest.mark.asyncio +async def test_forward_id_detail_is_distinct_from_actual_null_and_unrequested_relation(tmp_path): + from teaql.core.mutation import UpdateCommand + context, service, transport, entries = await fixture(tmp_path) + descriptor = service.schema_provider.get_entity('Payment') + descriptor.relation(RelationDescriptor('parent', 'CustomerOrder').local('parent_id').foreign('id')) + plain = await service.query(context, QueryRequest(SelectQuery('Payment').project('id').limit(1), + _comment='identity only', _purpose='do not fabricate an unrequested relation')) + assert 'parent' not in plain.rows[0] + query = (SelectQuery('Payment').project('id').limit(1) + .relation_query('parent', SelectQuery('CustomerOrder').filter(Expr.eq('name', 'absent')))) + loaded = await service.query(context, QueryRequest(query, _comment='filtered parent', + _purpose='keep actual ID with unloaded details')) + assert loaded.rows[0]['parent'] == {'id': 1} + await service.mutate(context, MutationRequest(UpdateCommand.new('Payment', 1) + .expected_version(1).value('parent_id', None), comment='clear nullable fixture FK')) + cleared = await service.query(context, QueryRequest(query, _comment='nullable parent', + _purpose='SQL NULL is not a hidden target')) + assert cleared.rows[0]['parent'] is None + + def test_relation_assembly_keeps_original_text_key_after_hydration_or_filtering(): from teaql.sql.executor import _RelationAssembly row = {'sku': 'SKU-A'} From 22369beb73b9b3efecb391edd674d59eb72c8441 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sun, 4 Oct 2026 22:25:06 +0800 Subject: [PATCH 26/40] test: retain generated Facet trace acceptance in runtime examples (#43) Signed-off-by: Philip Z --- README.md | 6 ++ examples/facet-trace/README.md | 28 ++++++ examples/facet-trace/main.py | 168 +++++++++++++++++++++++++++++++++ examples/facet-trace/model.xml | 41 ++++++++ examples/facet-trace/verify.sh | 24 +++++ scripts/verify-examples.sh | 3 +- 6 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 examples/facet-trace/README.md create mode 100644 examples/facet-trace/main.py create mode 100644 examples/facet-trace/model.xml create mode 100644 examples/facet-trace/verify.sh diff --git a/README.md b/README.md index 749bb00..2f65adb 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,12 @@ list, and a later sibling can still load using the same FK. Keys are not added to returned records or mutation payloads. Native SQLite cases exercise these boundaries with related counts, nested ancestry and logging both on and off. +The reusable [generated Facet example](examples/facet-trace/) exercises 20 +root/nested/loaded-relation scenarios twice on one retained SQLite database. +It checks actual counts and returned metadata, original root/ancestor routes, +future-binding masking, logging on/off, and independent next-query state. The +all-examples gate now includes it and fingerprints the generated library. + This is local-source/generated-consumer evidence, not complete Trace Chain. Prepared-batch grouping, generated ledger overrides, complete entry-point and inherited mutation privacy coverage, successful readback diagnostic alignment, diff --git a/examples/facet-trace/README.md b/examples/facet-trace/README.md new file mode 100644 index 0000000..47c1822 --- /dev/null +++ b/examples/facet-trace/README.md @@ -0,0 +1,28 @@ +# Generated Facet Trace Chain acceptance + +Run `bash examples/facet-trace/verify.sh` from the runtime repository. The script +uses local `src`, retains a fresh SQLite database and logs, executes twice without +cleanup, and verifies that the 13 generated library files remain unchanged. +It is included in `scripts/verify-examples.sh`. + +This three-object School model isolates `TC-SQL-09`: root Facets, nested Facets, +and nested Facets inside an already-loaded reverse relation. Twenty scenarios +exercise include-all/matched-only, empty parents/results, limit-one display with +full matching counts, and SQL logging on/off. Generated Q/E and audited mutation +APIs seed and traverse the graph. Real database reads, count SQL, safe diagnostics, +request immutability, raw policy intent, future-binding masking, and independent +next-request `NotLoaded` semantics are asserted. `FACET_OBSERVED` records actual +counts/results and diagnostic routes, not just a success marker. + +`lib/` is read-only generated output retained from producer commit +`c7407c93c186fa9f0e2ff9599952f333a5963020` (Python issue #43 / producer #251). +Do not inspect generated source for API discovery. For new operations, use +model-aware `python-assist-query/school.school_type` or other action/entity/field +Assist against `model.xml`; report `MISSING_ASSIST` rather than guessing. + +To test a downloaded artifact, copy this example into a separate workspace and +invoke `main.py` with `PYTHONPATH` containing only that workspace's `lib`, the +artifact's interpreter, and an explicit `TEAQL_FACET_TRACE_DB`. Verify actual +runtime module paths and package hashes. Do not change this local-source gate +to silently consume an installed release. This fixture does not prove all Trace +Chain cases, external database support, or public-release parity. diff --git a/examples/facet-trace/main.py b/examples/facet-trace/main.py new file mode 100644 index 0000000..b5a2723 --- /dev/null +++ b/examples/facet-trace/main.py @@ -0,0 +1,168 @@ +import asyncio +import copy +import json +import os +from types import SimpleNamespace +from Q import Q +from E import E, TeaQLNotLoadedError +from models.school import School +from runtime_module import GENERATED_RUNTIME_MODULE +from teaql.core.list import SmartList +from teaql.provider.sqlite import create_sqlite_service +from teaql.runtime import UserContext + +PRIVATE = 'FACET-PRIVATE-SCHOOL' +FUTURE = 'Campus Learning Platform' +PURPOSE = 'verify generated Facet ownership' + +def school_facets(include_all, nested): + types = Q.school_types().order_by_id_ascending().limit(10).count_as('school_count') + if nested: + types.facet_by_platform_as('platforms', Q.platforms().with_name_is(FUTURE) + .order_by_id_ascending().limit(10).count_as('type_count'), include_all) + return (Q.schools().with_name_in(PRIVATE, PRIVATE + '-B') + .order_by_id_ascending().limit(1) + .facet_by_school_type_as('types', types, include_all)) + +def assert_facets(rows, include_all, count, nested): + assert isinstance(rows, SmartList), 'loaded relation must remain SmartList' + types = rows.facet('types') + assert isinstance(types, SmartList), 'requested empty Facet must remain present' + pairs = [(row['id'], row['school_count']) for row in types] + assert all(type(row['school_count']) is int for row in types), 'missing count is not zero' + expected = [(1001, count), (1002, 0)] if include_all else ([(1001, count)] if count else []) + assert pairs == expected, (pairs, expected) + observation = {'types': pairs} + if nested: + platforms = types.facet('platforms') + assert isinstance(platforms, SmartList), 'nested Facet was dropped' + expected = [(1, 2)] if include_all else ([(1, 1)] if count else []) + actual = [(row['id'], row['type_count']) for row in platforms] + assert actual == expected, ('nested membership', actual, expected) + observation['platforms'] = actual + return observation + +def log_row(entry): + return {'comment': entry.comment, 'purpose': entry.purpose, + 'outcome': entry.execution_outcome, 'sql': entry.sql, + 'debug_sql': entry.debug_sql, 'params': [getattr(value, 'val', value) for value in entry.params], + 'path': [(n.kind, n.name, n.comment) for n in entry.trace_path]} + +def assert_observed(context, diagnostics, reads, loaded, nested, logging): + child = [('school_list', 'SchoolType.school_list')] if loaded else [] + first = child + [('school_type', 'School.school_type')] + second = first + [('platform', 'SchoolType.platform')] + branch = [child, first] + ([first, second] if nested else []) + routes = [[], child, *branch, *branch] if loaded else [[], [], first] + ([first, second] if nested else []) + assert len(reads) == len(routes), ('physical SQL count', len(reads), routes) + assert sum('COUNT(' in sql.upper() for sql, _ in reads) == (4 if loaded else 2 if nested else 1) + entries = context.sql_logs() + assert len(entries) == len(diagnostics) == (len(reads) if logging else 0) + if not logging: + return + root = 'SchoolType' if loaded else 'School' + for entry, route in zip(entries, routes): + assert entry.execution_outcome == 'success' + assert entry.purpose == PURPOSE + assert entry.comment.startswith('load ') + assert [(n.kind, n.name, n.comment) for n in entry.trace_path] == [ + ('operation', root, 'query'), ('request', root, ''), + *[('relation', name, detail) for name, detail in route], + ('provider', 'sqlite', ''), ('sql', 'select', '')] + safe = json.dumps([log_row(entry) for entry in entries + diagnostics]) + assert PRIVATE not in safe, 'private root binding leaked' + if nested: + assert FUTURE not in safe, 'future-only Facet binding leaked before first root SQL' + +async def main(): + service = create_sqlite_service(os.environ['TEAQL_FACET_TRACE_DB']) + context = UserContext.new().install(GENERATED_RUNTIME_MODULE).insert_resource('dataService', service) + diagnostics, reads, policies = [], [], [] + context.set_diagnostic_sql_log_sink(SimpleNamespace(write=diagnostics.append)) + await context.ensure_schema() + for suffix in ('', '-B'): + name = PRIVATE + suffix + existing = await (Q.schools().with_name_is(name).limit(1).comment('find retained fixture') + .purpose('idempotent seed').execute_for_list(context)) + if not existing: + school = Q.schools().comment('initialize fixture').purpose('idempotent seed').new_entity(context) + (school.update_platform(1).update_school_type(1001).update_name(name) + .update_address('12 River Road').update_established_date('1995-09-01') + .update_student_capacity(800).update_active(True)) + await school.audit_as('seed generated Facet acceptance').save(context) + original_fetch = service.transport.fetch_all_sql + async def capture(compiled): + reads.append((compiled.sql, [value.val for value in compiled.params])) + return await original_fetch(compiled) + service.transport.fetch_all_sql = capture + def policy(query): + policies.append(query.comment_text) + return query + context.with_request_policy(policy) + failures, cases = [], 0 + for mode in ('root', 'nested', 'loaded'): + for include_all in (True, False): + for logging in (True, False): + for empty in ((False,) if mode == 'loaded' else (False, True)): + label = (mode, include_all, logging, empty) + context.enable_all_sql_log() if logging else context.disable_sql_log() + context.clear_sql_logs(); diagnostics.clear(); reads.clear(); policies.clear() + nested = mode != 'root' + request = school_facets(include_all, nested) + if empty: request.with_name_is('absent') + if mode == 'loaded': + request.top_n_probe_parent_threshold(0) + request = (Q.school_types().order_by_id_ascending().limit(10) + .select_school_list_with(request)) + comment = 'load ' + PRIVATE + (' via ' + FUTURE if nested else '') + executable = request.comment(comment).purpose(PURPOSE) + before = copy.deepcopy(request.query) + try: + rows = await executable.execute_for_list(context) + assert request.query == before, 'execution mutated caller query' + assert policies[0] == comment, 'safe projection changed policy intent' + assert_observed(context, diagnostics, reads, mode == 'loaded', nested, logging) + if mode == 'loaded': + assert [E.school_type(parent).id().eval() for parent in rows] == [1001, 1002] + results = [] + for parent in rows: + own_count = 2 if E.school_type(parent).id().eval() == 1001 else 0 + assert E.school_type(parent).school_list().size().eval() == int(bool(own_count)) + children = parent.school_list() + if children: + assert isinstance(children[0], School) + assert E.school(children[0]).name().eval() == PRIVATE + results.append({'parent_id': E.school_type(parent).id().eval(), + 'visible_rows': len(children), + 'facets': assert_facets(children, include_all, own_count, True)}) + else: + assert len(rows) == (0 if empty else 1) + if rows: + assert isinstance(rows[0], School) + assert E.school(rows[0]).name().eval() == PRIVATE + results = [{'visible_rows': len(rows), + 'facets': assert_facets(rows, include_all, 0 if empty else 2, nested)}] + assert any(PRIVATE in params for _, params in reads), 'real root bind was changed' + if nested: + assert any(FUTURE in params for _, params in reads), 'real future bind was changed' + print('FACET_OBSERVED ' + json.dumps({'case': label, 'calls': len(reads), + 'counts': sum('COUNT(' in sql.upper() for sql, _ in reads), + 'results': results, + 'sql': [log_row(entry) for entry in context.sql_logs()]})) + cases += 1 + except Exception as error: + failures.append((label, type(error).__name__, str(error))) + print('FACET_FAILURE', failures[-1]) + context.enable_all_sql_log(); context.clear_sql_logs(); diagnostics.clear(); reads.clear() + independent = await (Q.school_types().with_id_is(1001).limit(1) + .comment('independent ' + PRIVATE).purpose('no inherited Facet redaction').execute_for_list(context)) + assert context.sql_logs()[0].comment == 'independent ' + PRIVATE + try: E.school_type(independent[0]).school_list().size().eval() + except TeaQLNotLoadedError: pass + else: raise AssertionError('unselected relation stopped being NotLoaded') + await service.close() + assert not failures, failures + assert cases == 20, cases + print('PASS: Python generated Facets; 20 root/nested/loaded/empty/includeAll/logging scenarios; Q/E/save; retained SQLite') + +asyncio.run(main()) diff --git a/examples/facet-trace/model.xml b/examples/facet-trace/model.xml new file mode 100644 index 0000000..f114b40 --- /dev/null +++ b/examples/facet-trace/model.xml @@ -0,0 +1,41 @@ + + + + + + <_value id="1001" name="Primary" code="PRIMARY" display_order="1"/> + <_value id="1002" name="Secondary" code="SECONDARY" display_order="2"/> + + + + diff --git a/examples/facet-trace/verify.sh b/examples/facet-trace/verify.sh new file mode 100644 index 0000000..90d9d7f --- /dev/null +++ b/examples/facet-trace/verify.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail +example="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo="$(cd "$example/../.." && pwd)" +facet_run_directory="$(mktemp -d -t teaql-python-facet.XXXXXX)" +export TEAQL_FACET_TRACE_DB="$facet_run_directory/school.sqlite" +export PYTHONDONTWRITEBYTECODE=1 +# Runtime examples always exercise this repository, never an installed release. +export PYTHONPATH="$example/lib:$repo/src" +unset TEAQL_ALLOW_SENSITIVE_PLAINTEXT_LOGS +(cd "$example/lib" && rg --files --hidden -g '!**/__pycache__/**' | LC_ALL=C sort | xargs -d '\n' sha256sum) > "$facet_run_directory/library-before.sha256" +for round in first second; do + log="$facet_run_directory/$round.log" + if ! timeout --kill-after=5s 60s python -B "$example/main.py" > "$log" 2>&1; then + tail -80 "$log" >&2 + echo "FAIL: generated Facet $round; retained evidence $facet_run_directory" >&2 + exit 1 + fi + [[ "$(rg -c '^FACET_OBSERVED ' "$log")" == 20 ]] + rg -Fxq 'PASS: Python generated Facets; 20 root/nested/loaded/empty/includeAll/logging scenarios; Q/E/save; retained SQLite' "$log" + echo "PASS: generated Facet $round, 20 scenarios; same database $TEAQL_FACET_TRACE_DB; log $log" +done +(cd "$example/lib" && sha256sum --check "$facet_run_directory/library-before.sha256") +echo "PASS: generated Facet library unchanged; retained evidence $facet_run_directory" diff --git a/scripts/verify-examples.sh b/scripts/verify-examples.sh index 00a304d..5199bd9 100755 --- a/scripts/verify-examples.sh +++ b/scripts/verify-examples.sh @@ -2,7 +2,7 @@ set -euo pipefail repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -expected=(business-clock business-id conformance mutation-policy opaque-entity-reference order-management query-policy school-management task_board trace-chain) +expected=(business-clock business-id conformance facet-trace mutation-policy opaque-entity-reference order-management query-policy school-management task_board trace-chain) mapfile -t actual < <(find "$repo/examples" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort) if [[ "${actual[*]}" != "${expected[*]}" ]]; then echo "example inventory changed; update scripts/verify-examples.sh: ${actual[*]}" >&2 @@ -40,4 +40,5 @@ PYTHONPATH="$repo/src" python -m unittest discover -s "$repo/examples/order-mana TEAQL_TASK_BOARD_DB="$task_board_tmp/task_board.db" PYTHONPATH="$repo/examples/task_board:$repo/src" python "$repo/examples/task_board/main.py" PYTHONPATH="$repo/src" python -m unittest discover -s "$repo/examples/task_board" -p 'test_task_board.py' -v bash "$repo/examples/trace-chain/verify.sh" +bash "$repo/examples/facet-trace/verify.sh" echo "PASS: all Python examples" From 65c57c7aff117ef4f840d43c50ec4e38144fc57e Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sun, 4 Oct 2026 22:25:29 +0800 Subject: [PATCH 27/40] test: include read-only generated School fixture in Facet example (#43) Signed-off-by: Philip Z --- examples/facet-trace/lib/E.py | 198 ++++ examples/facet-trace/lib/Q.py | 31 + examples/facet-trace/lib/models/__init__.py | 0 examples/facet-trace/lib/models/platform.py | 435 ++++++++ examples/facet-trace/lib/models/school.py | 493 ++++++++++ .../facet-trace/lib/models/school_type.py | 406 ++++++++ examples/facet-trace/lib/pyproject.toml | 16 + examples/facet-trace/lib/requests/__init__.py | 0 .../lib/requests/platform_request.py | 858 ++++++++++++++++ .../lib/requests/school_request.py | 927 ++++++++++++++++++ .../lib/requests/school_type_request.py | 754 ++++++++++++++ examples/facet-trace/lib/runtime_module.py | 274 ++++++ examples/facet-trace/lib/teaql-i18n.json | 122 +++ 13 files changed, 4514 insertions(+) create mode 100644 examples/facet-trace/lib/E.py create mode 100644 examples/facet-trace/lib/Q.py create mode 100644 examples/facet-trace/lib/models/__init__.py create mode 100644 examples/facet-trace/lib/models/platform.py create mode 100644 examples/facet-trace/lib/models/school.py create mode 100644 examples/facet-trace/lib/models/school_type.py create mode 100644 examples/facet-trace/lib/pyproject.toml create mode 100644 examples/facet-trace/lib/requests/__init__.py create mode 100644 examples/facet-trace/lib/requests/platform_request.py create mode 100644 examples/facet-trace/lib/requests/school_request.py create mode 100644 examples/facet-trace/lib/requests/school_type_request.py create mode 100644 examples/facet-trace/lib/runtime_module.py create mode 100644 examples/facet-trace/lib/teaql-i18n.json diff --git a/examples/facet-trace/lib/E.py b/examples/facet-trace/lib/E.py new file mode 100644 index 0000000..a9acbec --- /dev/null +++ b/examples/facet-trace/lib/E.py @@ -0,0 +1,198 @@ +class TeaQLNotLoadedError(RuntimeError): + def __init__(self, root, access_path, break_point): + self.root = root + self.access_path = access_path + self.break_point = break_point + super().__init__( + f"TeaQLNotLoadedError: root={root} access_path={access_path} " + f"break_point={break_point} suggested_fix=select_{break_point}(...)" + ) + + +class ValueExpression: + def __init__(self, value=None, error=None): + self._value = value + self._error = error + + def eval(self): + if self._error is not None: + raise self._error + return self._value + + def or_if_null(self, fallback): + value = self.eval() + return fallback if value is None else value + + +class EntityExpression: + def __init__(self, value, root=None, path="", error=None): + self._value = value + self._root = root or f"{type(value).__name__ if value is not None else 'Entity'}(null)" + self._path = path + self._error = error + + def eval(self): + if self._error is not None: + raise self._error + return self._value + + def _path_for(self, field): + return f"{self._path}.{field}" if self._path else field + + def _not_loaded(self, field): + path = self._path_for(field) + return TeaQLNotLoadedError(self._root, path, field) + + def _scalar(self, field, relation_id=False): + if self._error is not None: + return ValueExpression(error=self._error) + if self._value is None: + return ValueExpression(None) + if field not in getattr(self._value, "_loaded_fields", set()): + return ValueExpression(error=self._not_loaded(field)) + value = getattr(self._value, field) + if relation_id and value is not None and not isinstance(value, (int, str)): + value = getattr(value, "id", None) + return ValueExpression(value) + + def _relation(self, field, expression_type): + path = self._path_for(field) + if self._error is not None: + return expression_type(None, self._root, path, self._error) + if self._value is None: + return expression_type(None, self._root, path) + if field not in getattr(self._value, "_loaded_fields", set()): + return expression_type(None, self._root, path, self._not_loaded(field)) + value = getattr(self._value, field) + if value is not None and isinstance(value, (int, str)): + return expression_type(None, self._root, path, self._not_loaded(field)) + return expression_type(value, self._root, path) + + +class ListExpression: + def __init__(self, values, root, path, item_expression, error=None): + self._values = values + self._root = root + self._path = path + self._item_expression = item_expression + self._error = error + + def size(self): + return ValueExpression(error=self._error) if self._error else ValueExpression(len(self._values)) + + def first(self): + return self.get(0) + + def get(self, index): + path = f"{self._path}.get({index})" + if self._error is not None: + return self._item_expression(None, self._root, path, self._error) + value = self._values[index] if 0 <= index < len(self._values) else None + return self._item_expression(value, self._root, path) + + +class PlatformExpression(EntityExpression): + def id(self): + return self._scalar("id") + def name(self): + return self._scalar("name") + def base_url(self): + return self._scalar("baseUrl") + def create_time(self): + return self._scalar("createTime") + def update_time(self): + return self._scalar("updateTime") + def version(self): + return self._scalar("version") + def school_type_list(self): + path = self._path_for("school_type_list") + if self._error is not None: + return ListExpression([], self._root, path, SchoolTypeExpression, self._error) + if self._value is None: + return ListExpression([], self._root, path, SchoolTypeExpression) + if "school_type_list" not in getattr(self._value, "_loaded_fields", set()): + return ListExpression([], self._root, path, SchoolTypeExpression, self._not_loaded("school_type_list")) + return ListExpression(getattr(self._value, "_school_type_list"), self._root, path, SchoolTypeExpression) + def school_list(self): + path = self._path_for("school_list") + if self._error is not None: + return ListExpression([], self._root, path, SchoolExpression, self._error) + if self._value is None: + return ListExpression([], self._root, path, SchoolExpression) + if "school_list" not in getattr(self._value, "_loaded_fields", set()): + return ListExpression([], self._root, path, SchoolExpression, self._not_loaded("school_list")) + return ListExpression(getattr(self._value, "_school_list"), self._root, path, SchoolExpression) + pass + +class SchoolTypeExpression(EntityExpression): + def id(self): + return self._scalar("id") + def name(self): + return self._scalar("name") + def code(self): + return self._scalar("code") + def display_order(self): + return self._scalar("displayOrder") + def version(self): + return self._scalar("version") + def platform_id(self): + return self._scalar("platform", relation_id=True) + + def platform(self): + return self._relation("platform", PlatformExpression) + def school_list(self): + path = self._path_for("school_list") + if self._error is not None: + return ListExpression([], self._root, path, SchoolExpression, self._error) + if self._value is None: + return ListExpression([], self._root, path, SchoolExpression) + if "school_list" not in getattr(self._value, "_loaded_fields", set()): + return ListExpression([], self._root, path, SchoolExpression, self._not_loaded("school_list")) + return ListExpression(getattr(self._value, "_school_list"), self._root, path, SchoolExpression) + pass + +class SchoolExpression(EntityExpression): + def id(self): + return self._scalar("id") + def name(self): + return self._scalar("name") + def address(self): + return self._scalar("address") + def established_date(self): + return self._scalar("establishedDate") + def student_capacity(self): + return self._scalar("studentCapacity") + def active(self): + return self._scalar("active") + def create_time(self): + return self._scalar("createTime") + def update_time(self): + return self._scalar("updateTime") + def version(self): + return self._scalar("version") + def platform_id(self): + return self._scalar("platform", relation_id=True) + + def platform(self): + return self._relation("platform", PlatformExpression) + def school_type_id(self): + return self._scalar("schoolType", relation_id=True) + + def school_type(self): + return self._relation("schoolType", SchoolTypeExpression) + pass + +class E: + @staticmethod + def platform(value): + entity_id = getattr(value, "id", None) + return PlatformExpression(value, "Platform(id={})".format(entity_id)) + @staticmethod + def school_type(value): + entity_id = getattr(value, "id", None) + return SchoolTypeExpression(value, "SchoolType(id={})".format(entity_id)) + @staticmethod + def school(value): + entity_id = getattr(value, "id", None) + return SchoolExpression(value, "School(id={})".format(entity_id)) + pass \ No newline at end of file diff --git a/examples/facet-trace/lib/Q.py b/examples/facet-trace/lib/Q.py new file mode 100644 index 0000000..fa189cd --- /dev/null +++ b/examples/facet-trace/lib/Q.py @@ -0,0 +1,31 @@ +# Generated by teaql-code-gen +from requests.platform_request import PlatformRequest +from requests.school_type_request import SchoolTypeRequest +from requests.school_request import SchoolRequest + +class Q: + @staticmethod + def platforms() -> PlatformRequest: + return PlatformRequest(minimal=False) + + @staticmethod + def platforms_minimal() -> PlatformRequest: + return PlatformRequest(minimal=True) + + @staticmethod + def school_types() -> SchoolTypeRequest: + return SchoolTypeRequest(minimal=False) + + @staticmethod + def school_types_minimal() -> SchoolTypeRequest: + return SchoolTypeRequest(minimal=True) + + @staticmethod + def schools() -> SchoolRequest: + return SchoolRequest(minimal=False) + + @staticmethod + def schools_minimal() -> SchoolRequest: + return SchoolRequest(minimal=True) + + diff --git a/examples/facet-trace/lib/models/__init__.py b/examples/facet-trace/lib/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/facet-trace/lib/models/platform.py b/examples/facet-trace/lib/models/platform.py new file mode 100644 index 0000000..bc1d72d --- /dev/null +++ b/examples/facet-trace/lib/models/platform.py @@ -0,0 +1,435 @@ +from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core.entity import _LoadedScalarSnapshot, _QueryProjectionSnapshot +from teaql.core.list import SmartList +from teaql.core import MutationIntent +from teaql.core.value import Value +from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation +import itertools + + +class Platform: + _teaql_temporary_ids = itertools.count(1) + @classmethod + def refer(cls, entity_id): + return cls(id=entity_id) + + @classmethod + def _teaql_new_with_fixed_id(cls, entity_id): + """Generated bootstrap capability; application code must not call it.""" + return cls(id=entity_id)._teaql_force_create() + + def _teaql_force_create(self): + self._action = "Create" + self._entity_root.mark_as_new(self._teaql_entity_key()) + return self + + def __init__(self, **kwargs): + self._entity_root = kwargs.pop("_entity_root", None) or EntityRoot() + if "id" in kwargs and "id" not in kwargs: + kwargs["id"] = kwargs.pop("id") + if "name" in kwargs and "name" not in kwargs: + kwargs["name"] = kwargs.pop("name") + if "base_url" in kwargs and "baseUrl" not in kwargs: + kwargs["baseUrl"] = kwargs.pop("base_url") + if "create_time" in kwargs and "createTime" not in kwargs: + kwargs["createTime"] = kwargs.pop("create_time") + if "update_time" in kwargs and "updateTime" not in kwargs: + kwargs["updateTime"] = kwargs.pop("update_time") + if "version" in kwargs and "version" not in kwargs: + kwargs["version"] = kwargs.pop("version") + self._teaql_query_projections = _QueryProjectionSnapshot(kwargs, ( + "id", "id", + "name", "name", + "baseUrl", "base_url", + "createTime", "create_time", + "updateTime", "update_time", + "version", "version", + "school_type_list", + "school_list", + )) + self._action = "Update" if kwargs.get("id") else "Create" + self._comment = None + self._loaded_fields = set(kwargs.keys()) + self.id = kwargs.get("id") + + self.name = kwargs.get("name") + + self.baseUrl = kwargs.get("baseUrl") + + self.createTime = kwargs.get("createTime") + + self.updateTime = kwargs.get("updateTime") + + self.version = kwargs.get("version") + + self._school_type_list = kwargs.get("school_type_list", []) + if "school_type_list" in kwargs or kwargs.get("id") is None: + self._loaded_fields.add("school_type_list") + self._school_list = kwargs.get("school_list", []) + if "school_list" in kwargs or kwargs.get("id") is None: + self._loaded_fields.add("school_list") + if self._school_type_list: + from models.school_type import SchoolType + source = self._school_type_list + hydrated = [ + item if isinstance(item, SchoolType) else SchoolType(_entity_root=self._entity_root, **item) + for item in source + ] + self._school_type_list = (SmartList(hydrated, facets=source.facets, total_count=source.total_count) + if isinstance(source, SmartList) else hydrated) + if self._school_list: + from models.school import School + source = self._school_list + hydrated = [ + item if isinstance(item, School) else School(_entity_root=self._entity_root, **item) + for item in source + ] + self._school_list = (SmartList(hydrated, facets=source.facets, total_count=source.total_count) + if isinstance(source, SmartList) else hydrated) + self._ledger_id = getattr(self, "id", None) + if self._ledger_id is None: + self._ledger_id = -next(self._teaql_temporary_ids) + key = self._teaql_entity_key() + if self._action == "Create": + self._entity_root.mark_as_new(key) + elif getattr(self, "version", None) is not None: + self._entity_root.set_original_version(key, int(self.version)) + self._teaql_loaded_snapshot = _LoadedScalarSnapshot( + self._teaql_scalar_payload() if self._action == "Update" else {}) + + def query_projection(self, alias): + """Read a query-only alias; raises KeyError when it was not returned.""" + return self._teaql_query_projections.get(alias) + + def has_query_projection(self, alias): + return self._teaql_query_projections.contains(alias) + + def _teaql_entity_key(self): + return EntityKey("Platform", self._ledger_id) + + def _teaql_attach_root(self, root): + key = self._teaql_entity_key() + if self._entity_root is not root and self._entity_root.has_pending(key): + root.merge_entity_from(self._entity_root, key) + self._entity_root = root + for child in self._school_type_list: + child._teaql_attach_root(root) + for child in self._school_list: + child._teaql_attach_root(root) + return self + def mark_for_deletion(self): + self._action = "Delete" + self._entity_root.mark_as_deleted(self._teaql_entity_key()) + return self + + def audit_as(self, comment: str): + MutationIntent(comment) + self._comment = comment + return self + + async def save(self, context): + intent = MutationIntent(self._comment) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) + + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return + + async def _teaql_reserve_graph_ids(self, graph, visited): + context = graph.context + object_identity = id(self) + if object_identity in visited: + return + visited.add(object_identity) + if self._action == "Create" and getattr(self, "id", None) is None: + service = context.require_resource("dataService") + allocator = getattr(service, "next_id", None) + if not callable(allocator): + raise RuntimeError( + "Configured dataService does not support stable ID reservation" + ) + old_key = self._teaql_entity_key() + old_loaded = set(self._loaded_fields) + self.id = int(await allocator("Platform")) + self._loaded_fields.add("id") + self._ledger_id = self.id + new_key = self._teaql_entity_key() + self._entity_root.rekey(old_key, new_key) + self._entity_root.set(new_key, "id", Value.I64(self.id)) + def rollback_reservation(): + self.id = None + self._ledger_id = old_key.id + self._loaded_fields = old_loaded + self._entity_root.rekey(new_key, old_key) + self._entity_root.set(old_key, "id", Value.from_any(None)) + graph.after_rollback(rollback_reservation) + await self._teaql_ensure_business_ids(context) + for child in self._school_type_list: + child._teaql_attach_root(self._entity_root) + await child._teaql_reserve_graph_ids(graph, visited) + for child in self._school_list: + child._teaql_attach_root(self._entity_root) + await child._teaql_reserve_graph_ids(graph, visited) + + async def _teaql_preflight_and_save(self, graph): + await self._teaql_reserve_graph_ids(graph, set()) + self._teaql_preflight_graph(graph) + return await self._teaql_save_within_graph(graph) + + def _teaql_scalar_payload(self): + payload = {} + if "id" in self._loaded_fields: + payload["id"] = Value.I64(self.id) + + if "name" in self._loaded_fields: + payload["name"] = Value.Text(self.name) + + if "baseUrl" in self._loaded_fields: + payload["base_url"] = Value.Text(self.baseUrl) + + if "createTime" in self._loaded_fields: + payload["create_time"] = Value.DateTime(self.createTime) + + if "updateTime" in self._loaded_fields: + payload["update_time"] = Value.DateTime(self.updateTime) + + if "version" in self._loaded_fields: + payload["version"] = Value.I64(self.version) + + return payload + + def _teaql_build_command(self): + payload = self._teaql_scalar_payload() + action = self._action + if action == "Update": + ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) + payload = {field: value for field, value in ledger.items() if field not in ("id", "version")} + if action == "Create": + cmd = InsertCommand("Platform", payload) + elif action == "Update": + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = UpdateCommand("Platform", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) + for key, value in payload.items(): + if key not in ("id", "version"): cmd.value(key, value) + else: + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = DeleteCommand("Platform", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() + return action, cmd + + def _teaql_preflight_graph(self, graph): + context = graph.context + if self._action != "Update" or self._entity_root.has_pending(self._teaql_entity_key()): + if self._action == "Update": + if "id" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) + if "name" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("name"), message="Mutation requires a fully loaded entity")]) + if "baseUrl" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("base_url"), message="Mutation requires a fully loaded entity")]) + if "createTime" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("create_time"), message="Mutation requires a fully loaded entity")]) + if "updateTime" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("update_time"), message="Mutation requires a fully loaded entity")]) + if "version" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) + _action, cmd = self._teaql_build_command() + try: + context.preflight_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) + for index, child in enumerate(self._school_type_list): + child._teaql_attach_root(self._entity_root) + current = getattr(child, "platform", None) + if getattr(current, "id", current) != self.id: + child.update_platform(self) + try: + child._teaql_preflight_graph(graph) + except CheckException as error: + prefix = ObjectLocation().property("school_type_list").index(index) + raise CheckException([ + CheckResult(v.rule_id, v.location.prefixed_by(prefix), v.input_value, v.system_value, v.message) + for v in error.violations + ]) from error + for index, child in enumerate(self._school_list): + child._teaql_attach_root(self._entity_root) + current = getattr(child, "platform", None) + if getattr(current, "id", current) != self.id: + child.update_platform(self) + try: + child._teaql_preflight_graph(graph) + except CheckException as error: + prefix = ObjectLocation().property("school_list").index(index) + raise CheckException([ + CheckResult(v.rule_id, v.location.prefixed_by(prefix), v.input_value, v.system_value, v.message) + for v in error.violations + ]) from error + + async def _teaql_save_within_graph(self, graph, parent_scope=None): + context = graph.context + scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) + + if self._action == "Update" and not self._entity_root.has_pending(self._teaql_entity_key()): + await self._teaql_save_children(graph, scope) + return self + + self._teaql_attach_root(self._entity_root) + action, cmd = self._teaql_build_command() + + + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) + + try: + context.check_and_fix_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) + context.mark_mutation_checked(cmd) + service = context.require_resource("dataService") + result = await service.mutate(context, req) + persisted = result.persisted_record + if persisted is None: + raise RuntimeError( + "Mutation provider did not return authoritative persisted state for Platform" + ) + rollback_payload = {field: getattr(self, field, None) for field in self._loaded_fields | {"id", "version"}} + rollback_ledger_id = self._ledger_id + rollback_action = self._action + rollback_loaded_fields = set(self._loaded_fields) + old_key = self._teaql_entity_key() + if "id" in persisted: + self.id = persisted["id"] + self._loaded_fields.add("id") + elif "id" in persisted: + self.id = persisted["id"] + self._loaded_fields.add("id") + if "name" in persisted: + self.name = persisted["name"] + self._loaded_fields.add("name") + elif "name" in persisted: + self.name = persisted["name"] + self._loaded_fields.add("name") + if "base_url" in persisted: + self.baseUrl = persisted["base_url"] + self._loaded_fields.add("baseUrl") + elif "baseUrl" in persisted: + self.baseUrl = persisted["baseUrl"] + self._loaded_fields.add("baseUrl") + if "create_time" in persisted: + self.createTime = persisted["create_time"] + self._loaded_fields.add("createTime") + elif "createTime" in persisted: + self.createTime = persisted["createTime"] + self._loaded_fields.add("createTime") + if "update_time" in persisted: + self.updateTime = persisted["update_time"] + self._loaded_fields.add("updateTime") + elif "updateTime" in persisted: + self.updateTime = persisted["updateTime"] + self._loaded_fields.add("updateTime") + if "version" in persisted: + self.version = persisted["version"] + self._loaded_fields.add("version") + elif "version" in persisted: + self.version = persisted["version"] + self._loaded_fields.add("version") + committed_snapshot = _LoadedScalarSnapshot(self._teaql_scalar_payload()) + self._ledger_id = getattr(self, "id", self._ledger_id) + new_key = self._teaql_entity_key() + if old_key != new_key: + self._entity_root.rekey(old_key, new_key) + def rollback_entity(): + for field, value in rollback_payload.items(): + setattr(self, field, value) + self._ledger_id = rollback_ledger_id + self._action = rollback_action + self._loaded_fields = rollback_loaded_fields + if old_key != new_key: + self._entity_root.rekey(new_key, old_key) + graph.after_rollback(rollback_entity) + if action != "Delete": + self._action = "Update" + + if action != "Delete": + await self._teaql_save_children(graph, scope) + def commit_entity(): + self._teaql_loaded_snapshot = committed_snapshot + self._entity_root.clear_entity(new_key) + if getattr(self, "version", None) is not None: + self._entity_root.accept_committed_version(new_key, int(self.version)) + graph.after_commit(commit_entity) + return self + + async def _teaql_save_children(self, graph, scope): + cascade_relations = [] + cascade_relations.append(("school_type_list", self._school_type_list, "update_platform", "platform")) + cascade_relations.append(("school_list", self._school_list, "update_platform", "platform")) + for relation_name, children, updater, member in cascade_relations: + for index, child in enumerate(children): + child._teaql_attach_root(self._entity_root) + current = getattr(child, member, None) + if getattr(current, "id", current) != self.id: + getattr(child, updater)(self) + try: + await child._teaql_save_within_graph(graph, scope) + except CheckException as error: + prefix = ObjectLocation().property(relation_name).index(index) + raise CheckException([ + CheckResult(v.rule_id, v.location.prefixed_by(prefix), v.input_value, v.system_value, v.message) + for v in error.violations + ]) from error + + def update_id(self, value): + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + raise ValueError("id must be a positive integer") + old_key = self._teaql_entity_key() + new_key = EntityKey(old_key.entity, value) + self._entity_root.rekey(old_key, new_key) + self._ledger_id = value + self.id = value + self._loaded_fields.add("id") + self._entity_root.set(self._teaql_entity_key(), "id", Value.from_any(value)) + return self + + def update_name(self, value): + self.name = value + self._loaded_fields.add("name") + self._entity_root.set(self._teaql_entity_key(), "name", Value.from_any(value)) + return self + + def update_base_url(self, value): + self.baseUrl = value + self._loaded_fields.add("baseUrl") + self._entity_root.set(self._teaql_entity_key(), "base_url", Value.from_any(value)) + return self + + def update_create_time(self, value): + self.createTime = value + self._loaded_fields.add("createTime") + self._entity_root.set(self._teaql_entity_key(), "create_time", Value.from_any(value)) + return self + + def update_update_time(self, value): + self.updateTime = value + self._loaded_fields.add("updateTime") + self._entity_root.set(self._teaql_entity_key(), "update_time", Value.from_any(value)) + return self + + def update_version(self, value): + self.version = value + self._loaded_fields.add("version") + self._entity_root.set(self._teaql_entity_key(), "version", Value.from_any(value)) + return self + def school_type_list(self) -> list: + self._loaded_fields.add("school_type_list") + return self._school_type_list + + def school_list(self) -> list: + self._loaded_fields.add("school_list") + return self._school_list diff --git a/examples/facet-trace/lib/models/school.py b/examples/facet-trace/lib/models/school.py new file mode 100644 index 0000000..a2785bb --- /dev/null +++ b/examples/facet-trace/lib/models/school.py @@ -0,0 +1,493 @@ +from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core.entity import _LoadedScalarSnapshot, _QueryProjectionSnapshot +from teaql.core.list import SmartList +from teaql.core import MutationIntent +from teaql.core.value import Value +from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation +import itertools +from models.platform import Platform +from models.school_type import SchoolType + + +class School: + _teaql_temporary_ids = itertools.count(1) + @classmethod + def refer(cls, entity_id): + return cls(id=entity_id) + + @classmethod + def _teaql_new_with_fixed_id(cls, entity_id): + """Generated bootstrap capability; application code must not call it.""" + return cls(id=entity_id)._teaql_force_create() + + def _teaql_force_create(self): + self._action = "Create" + self._entity_root.mark_as_new(self._teaql_entity_key()) + return self + + def __init__(self, **kwargs): + self._entity_root = kwargs.pop("_entity_root", None) or EntityRoot() + if "id" in kwargs and "id" not in kwargs: + kwargs["id"] = kwargs.pop("id") + if "platform" in kwargs and "platform" not in kwargs: + kwargs["platform"] = kwargs.pop("platform") + if "school_type" in kwargs and "schoolType" not in kwargs: + kwargs["schoolType"] = kwargs.pop("school_type") + if "name" in kwargs and "name" not in kwargs: + kwargs["name"] = kwargs.pop("name") + if "address" in kwargs and "address" not in kwargs: + kwargs["address"] = kwargs.pop("address") + if "established_date" in kwargs and "establishedDate" not in kwargs: + kwargs["establishedDate"] = kwargs.pop("established_date") + if "student_capacity" in kwargs and "studentCapacity" not in kwargs: + kwargs["studentCapacity"] = kwargs.pop("student_capacity") + if "active" in kwargs and "active" not in kwargs: + kwargs["active"] = kwargs.pop("active") + if "create_time" in kwargs and "createTime" not in kwargs: + kwargs["createTime"] = kwargs.pop("create_time") + if "update_time" in kwargs and "updateTime" not in kwargs: + kwargs["updateTime"] = kwargs.pop("update_time") + if "version" in kwargs and "version" not in kwargs: + kwargs["version"] = kwargs.pop("version") + self._teaql_query_projections = _QueryProjectionSnapshot(kwargs, ( + "id", "id", + "platform", "platform", + "schoolType", "school_type", + "name", "name", + "address", "address", + "establishedDate", "established_date", + "studentCapacity", "student_capacity", + "active", "active", + "createTime", "create_time", + "updateTime", "update_time", + "version", "version", + )) + self._action = "Update" if kwargs.get("id") else "Create" + self._comment = None + self._loaded_fields = set(kwargs.keys()) + self.id = kwargs.get("id") + + self.platform = kwargs.get("platform") + + self.schoolType = kwargs.get("schoolType") + + self.name = kwargs.get("name") + + self.address = kwargs.get("address") + + self.establishedDate = kwargs.get("establishedDate") + + self.studentCapacity = kwargs.get("studentCapacity") + + self.active = kwargs.get("active") + + self.createTime = kwargs.get("createTime") + + self.updateTime = kwargs.get("updateTime") + + self.version = kwargs.get("version") + + if isinstance(self.platform, dict): + self.platform = Platform(**self.platform) + if isinstance(self.schoolType, dict): + self.schoolType = SchoolType(**self.schoolType) + self._ledger_id = getattr(self, "id", None) + if self._ledger_id is None: + self._ledger_id = -next(self._teaql_temporary_ids) + key = self._teaql_entity_key() + if self._action == "Create": + self._entity_root.mark_as_new(key) + elif getattr(self, "version", None) is not None: + self._entity_root.set_original_version(key, int(self.version)) + self._teaql_loaded_snapshot = _LoadedScalarSnapshot( + self._teaql_scalar_payload() if self._action == "Update" else {}) + + def query_projection(self, alias): + """Read a query-only alias; raises KeyError when it was not returned.""" + return self._teaql_query_projections.get(alias) + + def has_query_projection(self, alias): + return self._teaql_query_projections.contains(alias) + + def _teaql_entity_key(self): + return EntityKey("School", self._ledger_id) + + def _teaql_attach_root(self, root): + key = self._teaql_entity_key() + if self._entity_root is not root and self._entity_root.has_pending(key): + root.merge_entity_from(self._entity_root, key) + self._entity_root = root + return self + def mark_for_deletion(self): + self._action = "Delete" + self._entity_root.mark_as_deleted(self._teaql_entity_key()) + return self + + def audit_as(self, comment: str): + MutationIntent(comment) + self._comment = comment + return self + + async def save(self, context): + intent = MutationIntent(self._comment) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) + + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return + + async def _teaql_reserve_graph_ids(self, graph, visited): + context = graph.context + object_identity = id(self) + if object_identity in visited: + return + visited.add(object_identity) + if self._action == "Create" and getattr(self, "id", None) is None: + service = context.require_resource("dataService") + allocator = getattr(service, "next_id", None) + if not callable(allocator): + raise RuntimeError( + "Configured dataService does not support stable ID reservation" + ) + old_key = self._teaql_entity_key() + old_loaded = set(self._loaded_fields) + self.id = int(await allocator("School")) + self._loaded_fields.add("id") + self._ledger_id = self.id + new_key = self._teaql_entity_key() + self._entity_root.rekey(old_key, new_key) + self._entity_root.set(new_key, "id", Value.I64(self.id)) + def rollback_reservation(): + self.id = None + self._ledger_id = old_key.id + self._loaded_fields = old_loaded + self._entity_root.rekey(new_key, old_key) + self._entity_root.set(old_key, "id", Value.from_any(None)) + graph.after_rollback(rollback_reservation) + await self._teaql_ensure_business_ids(context) + + async def _teaql_preflight_and_save(self, graph): + await self._teaql_reserve_graph_ids(graph, set()) + self._teaql_preflight_graph(graph) + return await self._teaql_save_within_graph(graph) + + def _teaql_scalar_payload(self): + payload = {} + if "id" in self._loaded_fields: + payload["id"] = Value.I64(self.id) + + if "platform" in self._loaded_fields: + reference = self.platform + reference_id = getattr(reference, "id", reference) + payload["platform"] = (Value.Object(reference) + if reference is not None and hasattr(reference, "id") and reference_id is None + else Value.I64(reference_id)) + + if "schoolType" in self._loaded_fields: + reference = self.schoolType + reference_id = getattr(reference, "id", reference) + payload["school_type"] = (Value.Object(reference) + if reference is not None and hasattr(reference, "id") and reference_id is None + else Value.I64(reference_id)) + + if "name" in self._loaded_fields: + payload["name"] = Value.Text(self.name) + + if "address" in self._loaded_fields: + payload["address"] = Value.Text(self.address) + + if "establishedDate" in self._loaded_fields: + payload["established_date"] = Value.Date(self.establishedDate) + + if "studentCapacity" in self._loaded_fields: + payload["student_capacity"] = Value.I64(self.studentCapacity) + + if "active" in self._loaded_fields: + payload["active"] = Value.Bool(self.active) + + if "createTime" in self._loaded_fields: + payload["create_time"] = Value.DateTime(self.createTime) + + if "updateTime" in self._loaded_fields: + payload["update_time"] = Value.DateTime(self.updateTime) + + if "version" in self._loaded_fields: + payload["version"] = Value.I64(self.version) + + return payload + + def _teaql_build_command(self): + payload = self._teaql_scalar_payload() + action = self._action + if action == "Update": + ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) + payload = {field: value for field, value in ledger.items() if field not in ("id", "version")} + if action == "Create": + cmd = InsertCommand("School", payload) + elif action == "Update": + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = UpdateCommand("School", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) + for key, value in payload.items(): + if key not in ("id", "version"): cmd.value(key, value) + else: + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = DeleteCommand("School", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() + return action, cmd + + def _teaql_preflight_graph(self, graph): + context = graph.context + if self._action != "Update" or self._entity_root.has_pending(self._teaql_entity_key()): + if self._action == "Update": + if "id" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) + if "platform" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("platform"), message="Mutation requires a fully loaded entity")]) + if "schoolType" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("school_type"), message="Mutation requires a fully loaded entity")]) + if "name" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("name"), message="Mutation requires a fully loaded entity")]) + if "address" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("address"), message="Mutation requires a fully loaded entity")]) + if "establishedDate" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("established_date"), message="Mutation requires a fully loaded entity")]) + if "studentCapacity" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("student_capacity"), message="Mutation requires a fully loaded entity")]) + if "active" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("active"), message="Mutation requires a fully loaded entity")]) + if "createTime" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("create_time"), message="Mutation requires a fully loaded entity")]) + if "updateTime" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("update_time"), message="Mutation requires a fully loaded entity")]) + if "version" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) + _action, cmd = self._teaql_build_command() + try: + context.preflight_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) + + async def _teaql_save_within_graph(self, graph, parent_scope=None): + context = graph.context + scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) + + if self._action == "Update" and not self._entity_root.has_pending(self._teaql_entity_key()): + await self._teaql_save_children(graph, scope) + return self + + self._teaql_attach_root(self._entity_root) + action, cmd = self._teaql_build_command() + + + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) + + try: + context.check_and_fix_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) + context.mark_mutation_checked(cmd) + service = context.require_resource("dataService") + result = await service.mutate(context, req) + persisted = result.persisted_record + if persisted is None: + raise RuntimeError( + "Mutation provider did not return authoritative persisted state for School" + ) + rollback_payload = {field: getattr(self, field, None) for field in self._loaded_fields | {"id", "version"}} + rollback_ledger_id = self._ledger_id + rollback_action = self._action + rollback_loaded_fields = set(self._loaded_fields) + old_key = self._teaql_entity_key() + if "id" in persisted: + self.id = persisted["id"] + self._loaded_fields.add("id") + elif "id" in persisted: + self.id = persisted["id"] + self._loaded_fields.add("id") + if "platform" in persisted: + self.platform = persisted["platform"] + self._loaded_fields.add("platform") + elif "platform" in persisted: + self.platform = persisted["platform"] + self._loaded_fields.add("platform") + if "school_type" in persisted: + self.schoolType = persisted["school_type"] + self._loaded_fields.add("schoolType") + elif "schoolType" in persisted: + self.schoolType = persisted["schoolType"] + self._loaded_fields.add("schoolType") + if "name" in persisted: + self.name = persisted["name"] + self._loaded_fields.add("name") + elif "name" in persisted: + self.name = persisted["name"] + self._loaded_fields.add("name") + if "address" in persisted: + self.address = persisted["address"] + self._loaded_fields.add("address") + elif "address" in persisted: + self.address = persisted["address"] + self._loaded_fields.add("address") + if "established_date" in persisted: + self.establishedDate = persisted["established_date"] + self._loaded_fields.add("establishedDate") + elif "establishedDate" in persisted: + self.establishedDate = persisted["establishedDate"] + self._loaded_fields.add("establishedDate") + if "student_capacity" in persisted: + self.studentCapacity = persisted["student_capacity"] + self._loaded_fields.add("studentCapacity") + elif "studentCapacity" in persisted: + self.studentCapacity = persisted["studentCapacity"] + self._loaded_fields.add("studentCapacity") + if "active" in persisted: + self.active = persisted["active"] + self._loaded_fields.add("active") + elif "active" in persisted: + self.active = persisted["active"] + self._loaded_fields.add("active") + if "create_time" in persisted: + self.createTime = persisted["create_time"] + self._loaded_fields.add("createTime") + elif "createTime" in persisted: + self.createTime = persisted["createTime"] + self._loaded_fields.add("createTime") + if "update_time" in persisted: + self.updateTime = persisted["update_time"] + self._loaded_fields.add("updateTime") + elif "updateTime" in persisted: + self.updateTime = persisted["updateTime"] + self._loaded_fields.add("updateTime") + if "version" in persisted: + self.version = persisted["version"] + self._loaded_fields.add("version") + elif "version" in persisted: + self.version = persisted["version"] + self._loaded_fields.add("version") + committed_snapshot = _LoadedScalarSnapshot(self._teaql_scalar_payload()) + self._ledger_id = getattr(self, "id", self._ledger_id) + new_key = self._teaql_entity_key() + if old_key != new_key: + self._entity_root.rekey(old_key, new_key) + def rollback_entity(): + for field, value in rollback_payload.items(): + setattr(self, field, value) + self._ledger_id = rollback_ledger_id + self._action = rollback_action + self._loaded_fields = rollback_loaded_fields + if old_key != new_key: + self._entity_root.rekey(new_key, old_key) + graph.after_rollback(rollback_entity) + if action != "Delete": + self._action = "Update" + + if action != "Delete": + await self._teaql_save_children(graph, scope) + def commit_entity(): + self._teaql_loaded_snapshot = committed_snapshot + self._entity_root.clear_entity(new_key) + if getattr(self, "version", None) is not None: + self._entity_root.accept_committed_version(new_key, int(self.version)) + graph.after_commit(commit_entity) + return self + + async def _teaql_save_children(self, graph, scope): + cascade_relations = [] + for relation_name, children, updater, member in cascade_relations: + for index, child in enumerate(children): + child._teaql_attach_root(self._entity_root) + current = getattr(child, member, None) + if getattr(current, "id", current) != self.id: + getattr(child, updater)(self) + try: + await child._teaql_save_within_graph(graph, scope) + except CheckException as error: + prefix = ObjectLocation().property(relation_name).index(index) + raise CheckException([ + CheckResult(v.rule_id, v.location.prefixed_by(prefix), v.input_value, v.system_value, v.message) + for v in error.violations + ]) from error + + def update_id(self, value): + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + raise ValueError("id must be a positive integer") + old_key = self._teaql_entity_key() + new_key = EntityKey(old_key.entity, value) + self._entity_root.rekey(old_key, new_key) + self._ledger_id = value + self.id = value + self._loaded_fields.add("id") + self._entity_root.set(self._teaql_entity_key(), "id", Value.from_any(value)) + return self + + def update_name(self, value): + self.name = value + self._loaded_fields.add("name") + self._entity_root.set(self._teaql_entity_key(), "name", Value.from_any(value)) + return self + + def update_address(self, value): + self.address = value + self._loaded_fields.add("address") + self._entity_root.set(self._teaql_entity_key(), "address", Value.from_any(value)) + return self + + def update_established_date(self, value): + self.establishedDate = value + self._loaded_fields.add("establishedDate") + self._entity_root.set(self._teaql_entity_key(), "established_date", Value.from_any(value)) + return self + + def update_student_capacity(self, value): + self.studentCapacity = value + self._loaded_fields.add("studentCapacity") + self._entity_root.set(self._teaql_entity_key(), "student_capacity", Value.from_any(value)) + return self + + def update_active(self, value): + self.active = value + self._loaded_fields.add("active") + self._entity_root.set(self._teaql_entity_key(), "active", Value.from_any(value)) + return self + + def update_create_time(self, value): + self.createTime = value + self._loaded_fields.add("createTime") + self._entity_root.set(self._teaql_entity_key(), "create_time", Value.from_any(value)) + return self + + def update_update_time(self, value): + self.updateTime = value + self._loaded_fields.add("updateTime") + self._entity_root.set(self._teaql_entity_key(), "update_time", Value.from_any(value)) + return self + + def update_version(self, value): + self.version = value + self._loaded_fields.add("version") + self._entity_root.set(self._teaql_entity_key(), "version", Value.from_any(value)) + return self + def update_platform(self, value): + self.platform = getattr(value, "id", value) if value else None + self._loaded_fields.add("platform") + self._entity_root.set(self._teaql_entity_key(), "platform", Value.from_any(self.platform)) + return self + + + def update_school_type(self, value): + self.schoolType = getattr(value, "id", value) if value else None + self._loaded_fields.add("schoolType") + self._entity_root.set(self._teaql_entity_key(), "school_type", Value.from_any(self.schoolType)) + return self + def update_school_type_to_primary(self): + self.schoolType = 1001 + self._loaded_fields.add("schoolType") + self._entity_root.set(self._teaql_entity_key(), "school_type", Value.from_any(self.schoolType)) + return self diff --git a/examples/facet-trace/lib/models/school_type.py b/examples/facet-trace/lib/models/school_type.py new file mode 100644 index 0000000..01cfe88 --- /dev/null +++ b/examples/facet-trace/lib/models/school_type.py @@ -0,0 +1,406 @@ +from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +from teaql.core.entity import _LoadedScalarSnapshot, _QueryProjectionSnapshot +from teaql.core.list import SmartList +from teaql.core import MutationIntent +from teaql.core.value import Value +from teaql.runtime import CheckException, CheckResult, EntityKey, EntityRoot, ObjectLocation +import itertools +from models.platform import Platform + + +class SchoolType: + _teaql_temporary_ids = itertools.count(1) + @classmethod + def refer(cls, entity_id): + return cls(id=entity_id) + + @classmethod + def _teaql_new_with_fixed_id(cls, entity_id): + """Generated bootstrap capability; application code must not call it.""" + return cls(id=entity_id)._teaql_force_create() + + def _teaql_force_create(self): + self._action = "Create" + self._entity_root.mark_as_new(self._teaql_entity_key()) + return self + + def __init__(self, **kwargs): + self._entity_root = kwargs.pop("_entity_root", None) or EntityRoot() + if "platform" in kwargs and "platform" not in kwargs: + kwargs["platform"] = kwargs.pop("platform") + if "id" in kwargs and "id" not in kwargs: + kwargs["id"] = kwargs.pop("id") + if "name" in kwargs and "name" not in kwargs: + kwargs["name"] = kwargs.pop("name") + if "code" in kwargs and "code" not in kwargs: + kwargs["code"] = kwargs.pop("code") + if "display_order" in kwargs and "displayOrder" not in kwargs: + kwargs["displayOrder"] = kwargs.pop("display_order") + if "version" in kwargs and "version" not in kwargs: + kwargs["version"] = kwargs.pop("version") + self._teaql_query_projections = _QueryProjectionSnapshot(kwargs, ( + "platform", "platform", + "id", "id", + "name", "name", + "code", "code", + "displayOrder", "display_order", + "version", "version", + "school_list", + )) + self._action = "Update" if kwargs.get("id") else "Create" + self._comment = None + self._loaded_fields = set(kwargs.keys()) + self.platform = kwargs.get("platform") + + self.id = kwargs.get("id") + + self.name = kwargs.get("name") + + self.code = kwargs.get("code") + + self.displayOrder = kwargs.get("displayOrder") + + self.version = kwargs.get("version") + + if isinstance(self.platform, dict): + self.platform = Platform(**self.platform) + self._school_list = kwargs.get("school_list", []) + if "school_list" in kwargs or kwargs.get("id") is None: + self._loaded_fields.add("school_list") + if self._school_list: + from models.school import School + source = self._school_list + hydrated = [ + item if isinstance(item, School) else School(_entity_root=self._entity_root, **item) + for item in source + ] + self._school_list = (SmartList(hydrated, facets=source.facets, total_count=source.total_count) + if isinstance(source, SmartList) else hydrated) + self._ledger_id = getattr(self, "id", None) + if self._ledger_id is None: + self._ledger_id = -next(self._teaql_temporary_ids) + key = self._teaql_entity_key() + if self._action == "Create": + self._entity_root.mark_as_new(key) + elif getattr(self, "version", None) is not None: + self._entity_root.set_original_version(key, int(self.version)) + self._teaql_loaded_snapshot = _LoadedScalarSnapshot( + self._teaql_scalar_payload() if self._action == "Update" else {}) + + def query_projection(self, alias): + """Read a query-only alias; raises KeyError when it was not returned.""" + return self._teaql_query_projections.get(alias) + + def has_query_projection(self, alias): + return self._teaql_query_projections.contains(alias) + + def _teaql_entity_key(self): + return EntityKey("SchoolType", self._ledger_id) + + def _teaql_attach_root(self, root): + key = self._teaql_entity_key() + if self._entity_root is not root and self._entity_root.has_pending(key): + root.merge_entity_from(self._entity_root, key) + self._entity_root = root + for child in self._school_list: + child._teaql_attach_root(root) + return self + def mark_for_deletion(self): + self._action = "Delete" + self._entity_root.mark_as_deleted(self._teaql_entity_key()) + return self + + def audit_as(self, comment: str): + MutationIntent(comment) + self._comment = comment + return self + + async def save(self, context): + intent = MutationIntent(self._comment) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) + + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return + + async def _teaql_reserve_graph_ids(self, graph, visited): + context = graph.context + object_identity = id(self) + if object_identity in visited: + return + visited.add(object_identity) + if self._action == "Create" and getattr(self, "id", None) is None: + service = context.require_resource("dataService") + allocator = getattr(service, "next_id", None) + if not callable(allocator): + raise RuntimeError( + "Configured dataService does not support stable ID reservation" + ) + old_key = self._teaql_entity_key() + old_loaded = set(self._loaded_fields) + self.id = int(await allocator("SchoolType")) + self._loaded_fields.add("id") + self._ledger_id = self.id + new_key = self._teaql_entity_key() + self._entity_root.rekey(old_key, new_key) + self._entity_root.set(new_key, "id", Value.I64(self.id)) + def rollback_reservation(): + self.id = None + self._ledger_id = old_key.id + self._loaded_fields = old_loaded + self._entity_root.rekey(new_key, old_key) + self._entity_root.set(old_key, "id", Value.from_any(None)) + graph.after_rollback(rollback_reservation) + await self._teaql_ensure_business_ids(context) + for child in self._school_list: + child._teaql_attach_root(self._entity_root) + await child._teaql_reserve_graph_ids(graph, visited) + + async def _teaql_preflight_and_save(self, graph): + await self._teaql_reserve_graph_ids(graph, set()) + self._teaql_preflight_graph(graph) + return await self._teaql_save_within_graph(graph) + + def _teaql_scalar_payload(self): + payload = {} + if "platform" in self._loaded_fields: + reference = self.platform + reference_id = getattr(reference, "id", reference) + payload["platform"] = (Value.Object(reference) + if reference is not None and hasattr(reference, "id") and reference_id is None + else Value.I64(reference_id)) + + if "id" in self._loaded_fields: + payload["id"] = Value.I64(self.id) + + if "name" in self._loaded_fields: + payload["name"] = Value.Text(self.name) + + if "code" in self._loaded_fields: + payload["code"] = Value.Text(self.code) + + if "displayOrder" in self._loaded_fields: + payload["display_order"] = Value.Decimal(self.displayOrder) + + if "version" in self._loaded_fields: + payload["version"] = Value.I64(self.version) + + return payload + + def _teaql_build_command(self): + payload = self._teaql_scalar_payload() + action = self._action + if action == "Update": + ledger = dict(self._entity_root.current_change_set().changes()).get(self._teaql_entity_key(), {}) + payload = {field: value for field, value in ledger.items() if field not in ("id", "version")} + if action == "Create": + cmd = InsertCommand("SchoolType", payload) + elif action == "Update": + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = UpdateCommand("SchoolType", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) + for key, value in payload.items(): + if key not in ("id", "version"): cmd.value(key, value) + else: + original_version = self._entity_root.original_version(self._teaql_entity_key()) + cmd = DeleteCommand("SchoolType", Value.from_any(getattr(self, "id", None)), + original_version if original_version is not None else getattr(self, "version", None)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() + return action, cmd + + def _teaql_preflight_graph(self, graph): + context = graph.context + if self._action != "Update" or self._entity_root.has_pending(self._teaql_entity_key()): + if self._action == "Update": + if "platform" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("platform"), message="Mutation requires a fully loaded entity")]) + if "id" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("id"), message="Mutation requires a fully loaded entity")]) + if "name" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("name"), message="Mutation requires a fully loaded entity")]) + if "code" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("code"), message="Mutation requires a fully loaded entity")]) + if "displayOrder" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("display_order"), message="Mutation requires a fully loaded entity")]) + if "version" not in self._loaded_fields: + raise CheckException([CheckResult("invalid_type", ObjectLocation().property("version"), message="Mutation requires a fully loaded entity")]) + _action, cmd = self._teaql_build_command() + try: + context.preflight_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) + for index, child in enumerate(self._school_list): + child._teaql_attach_root(self._entity_root) + current = getattr(child, "schoolType", None) + if getattr(current, "id", current) != self.id: + child.update_school_type(self) + try: + child._teaql_preflight_graph(graph) + except CheckException as error: + prefix = ObjectLocation().property("school_list").index(index) + raise CheckException([ + CheckResult(v.rule_id, v.location.prefixed_by(prefix), v.input_value, v.system_value, v.message) + for v in error.violations + ]) from error + + async def _teaql_save_within_graph(self, graph, parent_scope=None): + context = graph.context + scope = graph.scope(self._teaql_entity_key(), parent_scope, self._comment) + + if self._action == "Update" and not self._entity_root.has_pending(self._teaql_entity_key()): + await self._teaql_save_children(graph, scope) + return self + + self._teaql_attach_root(self._entity_root) + action, cmd = self._teaql_build_command() + + + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) + + try: + context.check_and_fix_mutation(cmd) + finally: + for field, value in getattr(cmd, "values", {}).items(): + if field not in ("id", "version"): + self._entity_root.set(self._teaql_entity_key(), field, value) + context.mark_mutation_checked(cmd) + service = context.require_resource("dataService") + result = await service.mutate(context, req) + persisted = result.persisted_record + if persisted is None: + raise RuntimeError( + "Mutation provider did not return authoritative persisted state for SchoolType" + ) + rollback_payload = {field: getattr(self, field, None) for field in self._loaded_fields | {"id", "version"}} + rollback_ledger_id = self._ledger_id + rollback_action = self._action + rollback_loaded_fields = set(self._loaded_fields) + old_key = self._teaql_entity_key() + if "platform" in persisted: + self.platform = persisted["platform"] + self._loaded_fields.add("platform") + elif "platform" in persisted: + self.platform = persisted["platform"] + self._loaded_fields.add("platform") + if "id" in persisted: + self.id = persisted["id"] + self._loaded_fields.add("id") + elif "id" in persisted: + self.id = persisted["id"] + self._loaded_fields.add("id") + if "name" in persisted: + self.name = persisted["name"] + self._loaded_fields.add("name") + elif "name" in persisted: + self.name = persisted["name"] + self._loaded_fields.add("name") + if "code" in persisted: + self.code = persisted["code"] + self._loaded_fields.add("code") + elif "code" in persisted: + self.code = persisted["code"] + self._loaded_fields.add("code") + if "display_order" in persisted: + self.displayOrder = persisted["display_order"] + self._loaded_fields.add("displayOrder") + elif "displayOrder" in persisted: + self.displayOrder = persisted["displayOrder"] + self._loaded_fields.add("displayOrder") + if "version" in persisted: + self.version = persisted["version"] + self._loaded_fields.add("version") + elif "version" in persisted: + self.version = persisted["version"] + self._loaded_fields.add("version") + committed_snapshot = _LoadedScalarSnapshot(self._teaql_scalar_payload()) + self._ledger_id = getattr(self, "id", self._ledger_id) + new_key = self._teaql_entity_key() + if old_key != new_key: + self._entity_root.rekey(old_key, new_key) + def rollback_entity(): + for field, value in rollback_payload.items(): + setattr(self, field, value) + self._ledger_id = rollback_ledger_id + self._action = rollback_action + self._loaded_fields = rollback_loaded_fields + if old_key != new_key: + self._entity_root.rekey(new_key, old_key) + graph.after_rollback(rollback_entity) + if action != "Delete": + self._action = "Update" + + if action != "Delete": + await self._teaql_save_children(graph, scope) + def commit_entity(): + self._teaql_loaded_snapshot = committed_snapshot + self._entity_root.clear_entity(new_key) + if getattr(self, "version", None) is not None: + self._entity_root.accept_committed_version(new_key, int(self.version)) + graph.after_commit(commit_entity) + return self + + async def _teaql_save_children(self, graph, scope): + cascade_relations = [] + cascade_relations.append(("school_list", self._school_list, "update_school_type", "schoolType")) + for relation_name, children, updater, member in cascade_relations: + for index, child in enumerate(children): + child._teaql_attach_root(self._entity_root) + current = getattr(child, member, None) + if getattr(current, "id", current) != self.id: + getattr(child, updater)(self) + try: + await child._teaql_save_within_graph(graph, scope) + except CheckException as error: + prefix = ObjectLocation().property(relation_name).index(index) + raise CheckException([ + CheckResult(v.rule_id, v.location.prefixed_by(prefix), v.input_value, v.system_value, v.message) + for v in error.violations + ]) from error + + def update_id(self, value): + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + raise ValueError("id must be a positive integer") + old_key = self._teaql_entity_key() + new_key = EntityKey(old_key.entity, value) + self._entity_root.rekey(old_key, new_key) + self._ledger_id = value + self.id = value + self._loaded_fields.add("id") + self._entity_root.set(self._teaql_entity_key(), "id", Value.from_any(value)) + return self + + def update_name(self, value): + self.name = value + self._loaded_fields.add("name") + self._entity_root.set(self._teaql_entity_key(), "name", Value.from_any(value)) + return self + + def update_code(self, value): + self.code = value + self._loaded_fields.add("code") + self._entity_root.set(self._teaql_entity_key(), "code", Value.from_any(value)) + return self + + def update_display_order(self, value): + self.displayOrder = value + self._loaded_fields.add("displayOrder") + self._entity_root.set(self._teaql_entity_key(), "display_order", Value.from_any(value)) + return self + + def update_version(self, value): + self.version = value + self._loaded_fields.add("version") + self._entity_root.set(self._teaql_entity_key(), "version", Value.from_any(value)) + return self + def update_platform(self, value): + self.platform = getattr(value, "id", value) if value else None + self._loaded_fields.add("platform") + self._entity_root.set(self._teaql_entity_key(), "platform", Value.from_any(self.platform)) + return self + + def school_list(self) -> list: + self._loaded_fields.add("school_list") + return self._school_list diff --git a/examples/facet-trace/lib/pyproject.toml b/examples/facet-trace/lib/pyproject.toml new file mode 100644 index 0000000..062f286 --- /dev/null +++ b/examples/facet-trace/lib/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "school-management-service-lib" +version = "1.0.0" +description = "Generated python library" +dependencies = ["teaql==0.2.9", "aiosqlite>=0.22.1"] + +[tool.setuptools] +py-modules = ["Q", "E"] + +[tool.setuptools.packages.find] +where = ["."] +include = ["models*", "requests*"] + +[build-system] +requires = ["setuptools>=42"] +build-backend = "setuptools.build_meta" \ No newline at end of file diff --git a/examples/facet-trace/lib/requests/__init__.py b/examples/facet-trace/lib/requests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/facet-trace/lib/requests/platform_request.py b/examples/facet-trace/lib/requests/platform_request.py new file mode 100644 index 0000000..d07370d --- /dev/null +++ b/examples/facet-trace/lib/requests/platform_request.py @@ -0,0 +1,858 @@ +from teaql.core.query import RelationAggregate, SelectQuery +from teaql.core.list import SmartList, TeaQLPage +from teaql.data_service import QueryRequest +from teaql.core import QueryIntent +from copy import deepcopy +from teaql.core.expr import ( + begin_with, between, column, contain, end_with, eq, gt, gte, + in_list, in_subquery, is_not_null, is_null, lt, lte, ne, not_begin_with, + not_contain, not_end_with, not_in_list, not_in_subquery, value, + sound_like, +) +from models.platform import Platform +from typing import Protocol + +class QuerySelection(Protocol): + query: SelectQuery + +class PlatformRequest: + def __init__(self, minimal=False): + self.query = SelectQuery("Platform") + self._purpose = None + self._comment = None + self.query.and_filter(gte("version", 1)) + if minimal: + self.select_id() + self.select_version() + else: + self.select_self_fields() + + def comment(self, c: str): + self.query.comment(c) + self._comment = c + return self + + def purpose(self, p: str): + self.query.purpose(p) + self._purpose = p + return ExecutablePlatformRequest(self) + + def optimize_for_continuous_page_fetch(self): + self.query.optimize_for_continuous_page_fetch() + return self + + def optimize_for_continuous_page_fetch_with(self, namespace: str, ttl_seconds: int): + self.query.optimize_for_continuous_page_fetch_with(namespace, ttl_seconds) + return self + + def optimize_pagination_with_id_set(self): + self.query.optimize_pagination_with_id_set() + return self + + def optimize_pagination_with_id_set_config(self, namespace: str, ttl_seconds: int, max_ids: int): + self.query.optimize_pagination_with_id_set_config(namespace, ttl_seconds, max_ids) + return self + + def top_n_probe_parent_threshold(self, threshold: int): + self.query.top_n_probe_parent_threshold(threshold) + return self + + def limit(self, n: int): + self.query.limit(n) + return self + + def offset(self, n: int): + self.query.offset(n) + return self + + def with_deleted_rows(self): + self.query.with_deleted_rows() + return self + + def deleted_rows_only(self): + self.query.deleted_rows_only() + return self + + def select_self_fields(self): + self.query.project("id", "name", "base_url", "create_time", "update_time", "version") + return self + + def select_id(self): + self.query.project("id") + return self + + def select_name(self): + self.query.project("name") + return self + + def select_base_url(self): + self.query.project("base_url") + return self + + def select_create_time(self): + self.query.project("create_time") + return self + + def select_update_time(self): + self.query.project("update_time") + return self + + def select_version(self): + self.query.project("version") + return self + + + def with_id_is(self, val): + self.query.and_filter(eq("id", val)) + return self + + def with_id_is_not(self, val): + self.query.and_filter(ne("id", val)) + return self + + def with_id_in(self, *vals): + self.query.and_filter(in_list("id", list(vals))) + return self + + def with_id_not_in(self, *vals): + self.query.and_filter(not_in_list("id", list(vals))) + return self + + def with_id_greater_than(self, val): + self.query.and_filter(gt("id", val)) + return self + + def with_id_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("id", val)) + return self + + def with_id_less_than(self, val): + self.query.and_filter(lt("id", val)) + return self + + def with_id_less_than_or_equal_to(self, val): + self.query.and_filter(lte("id", val)) + return self + + def with_id_between(self, lower, upper): + self.query.and_filter(between(column("id"), value(lower), value(upper))) + return self + + def with_id_is_known(self): + self.query.and_filter(is_not_null(column("id"))) + return self + + def with_id_is_unknown(self): + self.query.and_filter(is_null(column("id"))) + return self + + def with_name_containing(self, val: str): + self.query.and_filter(contain("name", val)) + return self + + def with_name_not_containing(self, val: str): + self.query.and_filter(not_contain("name", val)) + return self + + def with_name_starting_with(self, val: str): + self.query.and_filter(begin_with("name", val)) + return self + + def with_name_not_starting_with(self, val: str): + self.query.and_filter(not_begin_with("name", val)) + return self + + def with_name_ending_with(self, val: str): + self.query.and_filter(end_with("name", val)) + return self + + def with_name_not_ending_with(self, val: str): + self.query.and_filter(not_end_with("name", val)) + return self + + def with_name_sounding_like(self, val: str): + self.query.and_filter(sound_like("name", val)) + return self + + def with_name_is(self, val: str): + self.query.and_filter(eq("name", val)) + return self + def with_name_is_not(self, val): + self.query.and_filter(ne("name", val)) + return self + + def with_name_in(self, *vals): + self.query.and_filter(in_list("name", list(vals))) + return self + + def with_name_not_in(self, *vals): + self.query.and_filter(not_in_list("name", list(vals))) + return self + + def with_name_greater_than(self, val): + self.query.and_filter(gt("name", val)) + return self + + def with_name_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("name", val)) + return self + + def with_name_less_than(self, val): + self.query.and_filter(lt("name", val)) + return self + + def with_name_less_than_or_equal_to(self, val): + self.query.and_filter(lte("name", val)) + return self + + def with_name_between(self, lower, upper): + self.query.and_filter(between(column("name"), value(lower), value(upper))) + return self + + def with_name_is_known(self): + self.query.and_filter(is_not_null(column("name"))) + return self + + def with_name_is_unknown(self): + self.query.and_filter(is_null(column("name"))) + return self + + def with_base_url_containing(self, val: str): + self.query.and_filter(contain("base_url", val)) + return self + + def with_base_url_not_containing(self, val: str): + self.query.and_filter(not_contain("base_url", val)) + return self + + def with_base_url_starting_with(self, val: str): + self.query.and_filter(begin_with("base_url", val)) + return self + + def with_base_url_not_starting_with(self, val: str): + self.query.and_filter(not_begin_with("base_url", val)) + return self + + def with_base_url_ending_with(self, val: str): + self.query.and_filter(end_with("base_url", val)) + return self + + def with_base_url_not_ending_with(self, val: str): + self.query.and_filter(not_end_with("base_url", val)) + return self + + def with_base_url_sounding_like(self, val: str): + self.query.and_filter(sound_like("base_url", val)) + return self + + def with_base_url_is(self, val: str): + self.query.and_filter(eq("base_url", val)) + return self + def with_base_url_is_not(self, val): + self.query.and_filter(ne("base_url", val)) + return self + + def with_base_url_in(self, *vals): + self.query.and_filter(in_list("base_url", list(vals))) + return self + + def with_base_url_not_in(self, *vals): + self.query.and_filter(not_in_list("base_url", list(vals))) + return self + + def with_base_url_greater_than(self, val): + self.query.and_filter(gt("base_url", val)) + return self + + def with_base_url_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("base_url", val)) + return self + + def with_base_url_less_than(self, val): + self.query.and_filter(lt("base_url", val)) + return self + + def with_base_url_less_than_or_equal_to(self, val): + self.query.and_filter(lte("base_url", val)) + return self + + def with_base_url_between(self, lower, upper): + self.query.and_filter(between(column("base_url"), value(lower), value(upper))) + return self + + def with_base_url_is_known(self): + self.query.and_filter(is_not_null(column("base_url"))) + return self + + def with_base_url_is_unknown(self): + self.query.and_filter(is_null(column("base_url"))) + return self + + def with_create_time_is(self, val): + self.query.and_filter(eq("create_time", val)) + return self + + def with_create_time_is_not(self, val): + self.query.and_filter(ne("create_time", val)) + return self + + def with_create_time_in(self, *vals): + self.query.and_filter(in_list("create_time", list(vals))) + return self + + def with_create_time_not_in(self, *vals): + self.query.and_filter(not_in_list("create_time", list(vals))) + return self + + def with_create_time_greater_than(self, val): + self.query.and_filter(gt("create_time", val)) + return self + + def with_create_time_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("create_time", val)) + return self + + def with_create_time_less_than(self, val): + self.query.and_filter(lt("create_time", val)) + return self + + def with_create_time_less_than_or_equal_to(self, val): + self.query.and_filter(lte("create_time", val)) + return self + + def with_create_time_between(self, lower, upper): + self.query.and_filter(between(column("create_time"), value(lower), value(upper))) + return self + + def with_create_time_is_known(self): + self.query.and_filter(is_not_null(column("create_time"))) + return self + + def with_create_time_is_unknown(self): + self.query.and_filter(is_null(column("create_time"))) + return self + + def with_update_time_is(self, val): + self.query.and_filter(eq("update_time", val)) + return self + + def with_update_time_is_not(self, val): + self.query.and_filter(ne("update_time", val)) + return self + + def with_update_time_in(self, *vals): + self.query.and_filter(in_list("update_time", list(vals))) + return self + + def with_update_time_not_in(self, *vals): + self.query.and_filter(not_in_list("update_time", list(vals))) + return self + + def with_update_time_greater_than(self, val): + self.query.and_filter(gt("update_time", val)) + return self + + def with_update_time_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("update_time", val)) + return self + + def with_update_time_less_than(self, val): + self.query.and_filter(lt("update_time", val)) + return self + + def with_update_time_less_than_or_equal_to(self, val): + self.query.and_filter(lte("update_time", val)) + return self + + def with_update_time_between(self, lower, upper): + self.query.and_filter(between(column("update_time"), value(lower), value(upper))) + return self + + def with_update_time_is_known(self): + self.query.and_filter(is_not_null(column("update_time"))) + return self + + def with_update_time_is_unknown(self): + self.query.and_filter(is_null(column("update_time"))) + return self + + def with_version_is(self, val): + self.query.and_filter(eq("version", val)) + return self + + def with_version_is_not(self, val): + self.query.and_filter(ne("version", val)) + return self + + def with_version_in(self, *vals): + self.query.and_filter(in_list("version", list(vals))) + return self + + def with_version_not_in(self, *vals): + self.query.and_filter(not_in_list("version", list(vals))) + return self + + def with_version_greater_than(self, val): + self.query.and_filter(gt("version", val)) + return self + + def with_version_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("version", val)) + return self + + def with_version_less_than(self, val): + self.query.and_filter(lt("version", val)) + return self + + def with_version_less_than_or_equal_to(self, val): + self.query.and_filter(lte("version", val)) + return self + + def with_version_between(self, lower, upper): + self.query.and_filter(between(column("version"), value(lower), value(upper))) + return self + + def with_version_is_known(self): + self.query.and_filter(is_not_null(column("version"))) + return self + + def with_version_is_unknown(self): + self.query.and_filter(is_null(column("version"))) + return self + + def order_by_id_ascending(self): + self.query.order_by("id", "asc") + return self + + def order_by_id_descending(self): + self.query.order_by("id", "desc") + return self + + def order_by_name_ascending(self): + self.query.order_by("name", "asc") + return self + + def order_by_name_descending(self): + self.query.order_by("name", "desc") + return self + + def order_by_base_url_ascending(self): + self.query.order_by("base_url", "asc") + return self + + def order_by_base_url_descending(self): + self.query.order_by("base_url", "desc") + return self + + def order_by_create_time_ascending(self): + self.query.order_by("create_time", "asc") + return self + + def order_by_create_time_descending(self): + self.query.order_by("create_time", "desc") + return self + + def order_by_update_time_ascending(self): + self.query.order_by("update_time", "asc") + return self + + def order_by_update_time_descending(self): + self.query.order_by("update_time", "desc") + return self + + def order_by_version_ascending(self): + self.query.order_by("version", "asc") + return self + + def order_by_version_descending(self): + self.query.order_by("version", "desc") + return self + + + def count(self): + self.query.count_field("id", "count") + return self + + def count_as(self, ret_name: str): + self.query.count_field("id", ret_name) + return self + + def group_by_id(self): + self.query.group_by("id") + return self + + def group_by_id_as(self, ret_name: str): + self.query.group_by("id") + return self + def group_by_name(self): + self.query.group_by("name") + return self + + def group_by_name_as(self, ret_name: str): + self.query.group_by("name") + return self + def group_by_base_url(self): + self.query.group_by("base_url") + return self + + def group_by_base_url_as(self, ret_name: str): + self.query.group_by("base_url") + return self + def group_by_create_time(self): + self.query.group_by("create_time") + return self + + def group_by_create_time_as(self, ret_name: str): + self.query.group_by("create_time") + return self + def group_by_update_time(self): + self.query.group_by("update_time") + return self + + def group_by_update_time_as(self, ret_name: str): + self.query.group_by("update_time") + return self + def group_by_version(self): + self.query.group_by("version") + return self + + def group_by_version_as(self, ret_name: str): + self.query.group_by("version") + return self + def select_school_type_list(self): + from requests.school_type_request import SchoolTypeRequest + return self.select_school_type_list_with(SchoolTypeRequest()) + + def select_school_type_list_with(self, child_request): + self.query.relation_query("school_type_list", child_request.query) + return self + def select_school_list(self): + from requests.school_request import SchoolRequest + return self.select_school_list_with(SchoolRequest()) + + def select_school_list_with(self, child_request): + self.query.relation_query("school_list", child_request.query) + return self + def have_school_types(self): + from requests.school_type_request import SchoolTypeRequest + return self.with_school_type_list_matching(SchoolTypeRequest()) + + def have_no_school_types(self): + from requests.school_type_request import SchoolTypeRequest + return self.without_school_type_list_matching(SchoolTypeRequest()) + + def with_school_type_list_matching(self, child_request): + child_request.query.projection = ["platform"] + self.query.and_filter(in_subquery(column("id"), "SchoolType", child_request.query)) + return self + + def without_school_type_list_matching(self, child_request): + child_request.query.projection = ["platform"] + self.query.and_filter(not_in_subquery(column("id"), "SchoolType", child_request.query)) + return self + def have_schools(self): + from requests.school_request import SchoolRequest + return self.with_school_list_matching(SchoolRequest()) + + def have_no_schools(self): + from requests.school_request import SchoolRequest + return self.without_school_list_matching(SchoolRequest()) + + def with_school_list_matching(self, child_request): + child_request.query.projection = ["platform"] + self.query.and_filter(in_subquery(column("id"), "School", child_request.query)) + return self + + def without_school_list_matching(self, child_request): + child_request.query.projection = ["platform"] + self.query.and_filter(not_in_subquery(column("id"), "School", child_request.query)) + return self + def count_school_types(self): + return self.count_school_types_as("count_school_types") + + def count_school_types_as(self, alias: str): + from requests.school_type_request import SchoolTypeRequest + return self.count_school_types_with(alias, SchoolTypeRequest()) + + def count_school_types_with(self, alias: str, child_request): + child_request.query.count_field("id", alias) + self.query.relation_aggregates.append( + RelationAggregate("school_type_list", alias, child_request.query, True) + ) + return self + + def min_display_order_of_school_types(self): + from requests.school_type_request import SchoolTypeRequest + return self.min_display_order_of_school_types_as( + "min_display_order_of_school_types", SchoolTypeRequest()) + + def min_display_order_of_school_types_as(self, alias: str, child_request): + child_request.query.min("display_order", "min_display_order") + self.query.relation_aggregates.append( + RelationAggregate("school_type_list", alias, child_request.query, True) + ) + return self + def max_display_order_of_school_types(self): + from requests.school_type_request import SchoolTypeRequest + return self.max_display_order_of_school_types_as( + "max_display_order_of_school_types", SchoolTypeRequest()) + + def max_display_order_of_school_types_as(self, alias: str, child_request): + child_request.query.max("display_order", "max_display_order") + self.query.relation_aggregates.append( + RelationAggregate("school_type_list", alias, child_request.query, True) + ) + return self + def sum_display_order_of_school_types(self): + from requests.school_type_request import SchoolTypeRequest + return self.sum_display_order_of_school_types_as( + "sum_display_order_of_school_types", SchoolTypeRequest()) + + def sum_display_order_of_school_types_as(self, alias: str, child_request): + child_request.query.sum("display_order", "sum_display_order") + self.query.relation_aggregates.append( + RelationAggregate("school_type_list", alias, child_request.query, True) + ) + return self + def avg_display_order_of_school_types(self): + from requests.school_type_request import SchoolTypeRequest + return self.avg_display_order_of_school_types_as( + "avg_display_order_of_school_types", SchoolTypeRequest()) + + def avg_display_order_of_school_types_as(self, alias: str, child_request): + child_request.query.avg("display_order", "avg_display_order") + self.query.relation_aggregates.append( + RelationAggregate("school_type_list", alias, child_request.query, True) + ) + return self + def standardDeviation_display_order_of_school_types(self): + from requests.school_type_request import SchoolTypeRequest + return self.standardDeviation_display_order_of_school_types_as( + "standardDeviation_display_order_of_school_types", SchoolTypeRequest()) + + def standardDeviation_display_order_of_school_types_as(self, alias: str, child_request): + child_request.query.standardDeviation("display_order", "standardDeviation_display_order") + self.query.relation_aggregates.append( + RelationAggregate("school_type_list", alias, child_request.query, True) + ) + return self + def squareRootOfPopulationStandardDeviation_display_order_of_school_types(self): + from requests.school_type_request import SchoolTypeRequest + return self.squareRootOfPopulationStandardDeviation_display_order_of_school_types_as( + "squareRootOfPopulationStandardDeviation_display_order_of_school_types", SchoolTypeRequest()) + + def squareRootOfPopulationStandardDeviation_display_order_of_school_types_as(self, alias: str, child_request): + child_request.query.squareRootOfPopulationStandardDeviation("display_order", "squareRootOfPopulationStandardDeviation_display_order") + self.query.relation_aggregates.append( + RelationAggregate("school_type_list", alias, child_request.query, True) + ) + return self + def sampleVariance_display_order_of_school_types(self): + from requests.school_type_request import SchoolTypeRequest + return self.sampleVariance_display_order_of_school_types_as( + "sampleVariance_display_order_of_school_types", SchoolTypeRequest()) + + def sampleVariance_display_order_of_school_types_as(self, alias: str, child_request): + child_request.query.sampleVariance("display_order", "sampleVariance_display_order") + self.query.relation_aggregates.append( + RelationAggregate("school_type_list", alias, child_request.query, True) + ) + return self + def samplePopulationVariance_display_order_of_school_types(self): + from requests.school_type_request import SchoolTypeRequest + return self.samplePopulationVariance_display_order_of_school_types_as( + "samplePopulationVariance_display_order_of_school_types", SchoolTypeRequest()) + + def samplePopulationVariance_display_order_of_school_types_as(self, alias: str, child_request): + child_request.query.samplePopulationVariance("display_order", "samplePopulationVariance_display_order") + self.query.relation_aggregates.append( + RelationAggregate("school_type_list", alias, child_request.query, True) + ) + return self + def count_schools(self): + return self.count_schools_as("count_schools") + + def count_schools_as(self, alias: str): + from requests.school_request import SchoolRequest + return self.count_schools_with(alias, SchoolRequest()) + + def count_schools_with(self, alias: str, child_request): + child_request.query.count_field("id", alias) + self.query.relation_aggregates.append( + RelationAggregate("school_list", alias, child_request.query, True) + ) + return self + + def min_student_capacity_of_schools(self): + from requests.school_request import SchoolRequest + return self.min_student_capacity_of_schools_as( + "min_student_capacity_of_schools", SchoolRequest()) + + def min_student_capacity_of_schools_as(self, alias: str, child_request): + child_request.query.min("student_capacity", "min_student_capacity") + self.query.relation_aggregates.append( + RelationAggregate("school_list", alias, child_request.query, True) + ) + return self + def max_student_capacity_of_schools(self): + from requests.school_request import SchoolRequest + return self.max_student_capacity_of_schools_as( + "max_student_capacity_of_schools", SchoolRequest()) + + def max_student_capacity_of_schools_as(self, alias: str, child_request): + child_request.query.max("student_capacity", "max_student_capacity") + self.query.relation_aggregates.append( + RelationAggregate("school_list", alias, child_request.query, True) + ) + return self + def sum_student_capacity_of_schools(self): + from requests.school_request import SchoolRequest + return self.sum_student_capacity_of_schools_as( + "sum_student_capacity_of_schools", SchoolRequest()) + + def sum_student_capacity_of_schools_as(self, alias: str, child_request): + child_request.query.sum("student_capacity", "sum_student_capacity") + self.query.relation_aggregates.append( + RelationAggregate("school_list", alias, child_request.query, True) + ) + return self + def avg_student_capacity_of_schools(self): + from requests.school_request import SchoolRequest + return self.avg_student_capacity_of_schools_as( + "avg_student_capacity_of_schools", SchoolRequest()) + + def avg_student_capacity_of_schools_as(self, alias: str, child_request): + child_request.query.avg("student_capacity", "avg_student_capacity") + self.query.relation_aggregates.append( + RelationAggregate("school_list", alias, child_request.query, True) + ) + return self + def standardDeviation_student_capacity_of_schools(self): + from requests.school_request import SchoolRequest + return self.standardDeviation_student_capacity_of_schools_as( + "standardDeviation_student_capacity_of_schools", SchoolRequest()) + + def standardDeviation_student_capacity_of_schools_as(self, alias: str, child_request): + child_request.query.standardDeviation("student_capacity", "standardDeviation_student_capacity") + self.query.relation_aggregates.append( + RelationAggregate("school_list", alias, child_request.query, True) + ) + return self + def squareRootOfPopulationStandardDeviation_student_capacity_of_schools(self): + from requests.school_request import SchoolRequest + return self.squareRootOfPopulationStandardDeviation_student_capacity_of_schools_as( + "squareRootOfPopulationStandardDeviation_student_capacity_of_schools", SchoolRequest()) + + def squareRootOfPopulationStandardDeviation_student_capacity_of_schools_as(self, alias: str, child_request): + child_request.query.squareRootOfPopulationStandardDeviation("student_capacity", "squareRootOfPopulationStandardDeviation_student_capacity") + self.query.relation_aggregates.append( + RelationAggregate("school_list", alias, child_request.query, True) + ) + return self + def sampleVariance_student_capacity_of_schools(self): + from requests.school_request import SchoolRequest + return self.sampleVariance_student_capacity_of_schools_as( + "sampleVariance_student_capacity_of_schools", SchoolRequest()) + + def sampleVariance_student_capacity_of_schools_as(self, alias: str, child_request): + child_request.query.sampleVariance("student_capacity", "sampleVariance_student_capacity") + self.query.relation_aggregates.append( + RelationAggregate("school_list", alias, child_request.query, True) + ) + return self + def samplePopulationVariance_student_capacity_of_schools(self): + from requests.school_request import SchoolRequest + return self.samplePopulationVariance_student_capacity_of_schools_as( + "samplePopulationVariance_student_capacity_of_schools", SchoolRequest()) + + def samplePopulationVariance_student_capacity_of_schools_as(self, alias: str, child_request): + child_request.query.samplePopulationVariance("student_capacity", "samplePopulationVariance_student_capacity") + self.query.relation_aggregates.append( + RelationAggregate("school_list", alias, child_request.query, True) + ) + return self + +class ExecutablePlatformRequest: + def __init__(self, request): + self._request = request + + def comment(self, c: str): + self._request.comment(c) + return self + + def new_entity(self, context) -> Platform: + request = self._request + QueryIntent(request._comment, request._purpose) + entity = context.initialize_entity("Platform", Platform()) + if not isinstance(entity, Platform): + raise TypeError("entity initializer returned an incompatible Platform") + return entity + + async def execute_for_result(self, context): + self = self._request + req = QueryRequest(self.query, _comment=self._comment, _purpose=self._purpose) + req = context.prepare_query_request(req) + service = context.require_resource("dataService") + return await service.query(context, req) + + async def execute_for_rows(self, context): + return (await self.execute_for_result(context)).rows + + async def execute_for_list(self, context) -> SmartList[Platform]: + result = await self.execute_for_result(context) + return SmartList( + (Platform(**row) for row in result.rows), + facets=result.facets) + + async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[Platform]: + request = self._request + intent = QueryIntent(request._comment, request._purpose) + query = deepcopy(request.query) + query.offset(offset).limit(limit) + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)) + service = context.require_resource("dataService") + alias = "__teaql_total" + if authorized.query.id_set_pagination is not None: + row_result = await service.query(context, authorized) + retained_count, accuracy = context.id_set_count() + if accuracy == "EXACT": + total_count = retained_count + else: + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) + if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): + raise RuntimeError("dataService did not return an exact page count") + total_count = int(count_result.rows[0][alias]) + else: + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) + if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): + raise RuntimeError("dataService did not return an exact page count") + total_count = int(count_result.rows[0][alias]) + row_result = await service.query(context, authorized) + data = SmartList(Platform(**row) for row in row_result.rows) + return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) + + async def execute_for_one(self, context): + request = deepcopy(self._request) + request.limit(1) + entities = await ExecutablePlatformRequest(request).execute_for_list(context) + return entities[0] if entities else None + + def execute_for_stream(self, context, chunk_size: int = 1000): + """Yield entity chunks lazily from the provider cursor.""" + request = self._request + req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) + req = context.prepare_query_request(req) + service = context.require_resource("dataService") + if not hasattr(service, "query_stream"): + raise RuntimeError("dataService does not implement query_stream") + stream = service.query_stream(context, req, chunk_size) + async def entities(): + try: + async for chunk in stream: + for row in chunk.rows: + yield Platform(**row) + finally: + close = getattr(stream, "aclose", None) + if close is not None: + await close() + return entities() diff --git a/examples/facet-trace/lib/requests/school_request.py b/examples/facet-trace/lib/requests/school_request.py new file mode 100644 index 0000000..3a20d9c --- /dev/null +++ b/examples/facet-trace/lib/requests/school_request.py @@ -0,0 +1,927 @@ +from teaql.core.query import RelationAggregate, SelectQuery +from teaql.core.list import SmartList, TeaQLPage +from teaql.data_service import QueryRequest +from teaql.core import QueryIntent +from copy import deepcopy +from teaql.core.expr import ( + begin_with, between, column, contain, end_with, eq, gt, gte, + in_list, in_subquery, is_not_null, is_null, lt, lte, ne, not_begin_with, + not_contain, not_end_with, not_in_list, not_in_subquery, value, + sound_like, +) +from models.school import School +from typing import Protocol + +class QuerySelection(Protocol): + query: SelectQuery + +class SchoolRequest: + def __init__(self, minimal=False): + self.query = SelectQuery("School") + self._purpose = None + self._comment = None + self.query.and_filter(gte("version", 1)) + if minimal: + self.select_id() + self.select_version() + else: + self.select_self_fields() + + def comment(self, c: str): + self.query.comment(c) + self._comment = c + return self + + def purpose(self, p: str): + self.query.purpose(p) + self._purpose = p + return ExecutableSchoolRequest(self) + + def optimize_for_continuous_page_fetch(self): + self.query.optimize_for_continuous_page_fetch() + return self + + def optimize_for_continuous_page_fetch_with(self, namespace: str, ttl_seconds: int): + self.query.optimize_for_continuous_page_fetch_with(namespace, ttl_seconds) + return self + + def optimize_pagination_with_id_set(self): + self.query.optimize_pagination_with_id_set() + return self + + def optimize_pagination_with_id_set_config(self, namespace: str, ttl_seconds: int, max_ids: int): + self.query.optimize_pagination_with_id_set_config(namespace, ttl_seconds, max_ids) + return self + + def top_n_probe_parent_threshold(self, threshold: int): + self.query.top_n_probe_parent_threshold(threshold) + return self + + def limit(self, n: int): + self.query.limit(n) + return self + + def offset(self, n: int): + self.query.offset(n) + return self + + def with_deleted_rows(self): + self.query.with_deleted_rows() + return self + + def deleted_rows_only(self): + self.query.deleted_rows_only() + return self + + def select_self_fields(self): + self.query.project("id", "platform", "school_type", "name", "address", "established_date", "student_capacity", "active", "create_time", "update_time", "version") + return self + + def select_id(self): + self.query.project("id") + return self + + + + def select_name(self): + self.query.project("name") + return self + + def select_address(self): + self.query.project("address") + return self + + def select_established_date(self): + self.query.project("established_date") + return self + + def select_student_capacity(self): + self.query.project("student_capacity") + return self + + def select_active(self): + self.query.project("active") + return self + + def select_create_time(self): + self.query.project("create_time") + return self + + def select_update_time(self): + self.query.project("update_time") + return self + + def select_version(self): + self.query.project("version") + return self + + def select_platform_with(self, child_request): + self.query.project("platform") + self.query.relation_query("platform", child_request.query) + return self + def select_school_type_with(self, child_request): + self.query.project("school_type") + self.query.relation_query("school_type", child_request.query) + return self + def with_platform_matching(self, child_request): + child_request.query.projection = ["id"] + self.query.and_filter(in_subquery(column("platform"), "Platform", child_request.query)) + return self + + def without_platform_matching(self, child_request): + child_request.query.projection = ["id"] + self.query.and_filter(not_in_subquery(column("platform"), "Platform", child_request.query)) + return self + + def have_platform(self): + self.query.and_filter(is_not_null(column("platform"))) + return self + + def have_no_platform(self): + self.query.and_filter(is_null(column("platform"))) + return self + def with_school_type_matching(self, child_request): + child_request.query.projection = ["id"] + self.query.and_filter(in_subquery(column("school_type"), "SchoolType", child_request.query)) + return self + + def without_school_type_matching(self, child_request): + child_request.query.projection = ["id"] + self.query.and_filter(not_in_subquery(column("school_type"), "SchoolType", child_request.query)) + return self + + def have_school_type(self): + self.query.and_filter(is_not_null(column("school_type"))) + return self + + def have_no_school_type(self): + self.query.and_filter(is_null(column("school_type"))) + return self + + def with_id_is(self, val): + self.query.and_filter(eq("id", val)) + return self + + def with_id_is_not(self, val): + self.query.and_filter(ne("id", val)) + return self + + def with_id_in(self, *vals): + self.query.and_filter(in_list("id", list(vals))) + return self + + def with_id_not_in(self, *vals): + self.query.and_filter(not_in_list("id", list(vals))) + return self + + def with_id_greater_than(self, val): + self.query.and_filter(gt("id", val)) + return self + + def with_id_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("id", val)) + return self + + def with_id_less_than(self, val): + self.query.and_filter(lt("id", val)) + return self + + def with_id_less_than_or_equal_to(self, val): + self.query.and_filter(lte("id", val)) + return self + + def with_id_between(self, lower, upper): + self.query.and_filter(between(column("id"), value(lower), value(upper))) + return self + + def with_id_is_known(self): + self.query.and_filter(is_not_null(column("id"))) + return self + + def with_id_is_unknown(self): + self.query.and_filter(is_null(column("id"))) + return self + + def filter_by_platform(self, val): + self.query.and_filter(eq("platform", val)) + return self + + def filter_by_school_type(self, val): + self.query.and_filter(eq("school_type", val)) + return self + def with_school_type_is_primary(self): + self.query.and_filter(eq("school_type", 1001)) + return self + + def with_name_containing(self, val: str): + self.query.and_filter(contain("name", val)) + return self + + def with_name_not_containing(self, val: str): + self.query.and_filter(not_contain("name", val)) + return self + + def with_name_starting_with(self, val: str): + self.query.and_filter(begin_with("name", val)) + return self + + def with_name_not_starting_with(self, val: str): + self.query.and_filter(not_begin_with("name", val)) + return self + + def with_name_ending_with(self, val: str): + self.query.and_filter(end_with("name", val)) + return self + + def with_name_not_ending_with(self, val: str): + self.query.and_filter(not_end_with("name", val)) + return self + + def with_name_sounding_like(self, val: str): + self.query.and_filter(sound_like("name", val)) + return self + + def with_name_is(self, val: str): + self.query.and_filter(eq("name", val)) + return self + def with_name_is_not(self, val): + self.query.and_filter(ne("name", val)) + return self + + def with_name_in(self, *vals): + self.query.and_filter(in_list("name", list(vals))) + return self + + def with_name_not_in(self, *vals): + self.query.and_filter(not_in_list("name", list(vals))) + return self + + def with_name_greater_than(self, val): + self.query.and_filter(gt("name", val)) + return self + + def with_name_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("name", val)) + return self + + def with_name_less_than(self, val): + self.query.and_filter(lt("name", val)) + return self + + def with_name_less_than_or_equal_to(self, val): + self.query.and_filter(lte("name", val)) + return self + + def with_name_between(self, lower, upper): + self.query.and_filter(between(column("name"), value(lower), value(upper))) + return self + + def with_name_is_known(self): + self.query.and_filter(is_not_null(column("name"))) + return self + + def with_name_is_unknown(self): + self.query.and_filter(is_null(column("name"))) + return self + + def with_address_containing(self, val: str): + self.query.and_filter(contain("address", val)) + return self + + def with_address_not_containing(self, val: str): + self.query.and_filter(not_contain("address", val)) + return self + + def with_address_starting_with(self, val: str): + self.query.and_filter(begin_with("address", val)) + return self + + def with_address_not_starting_with(self, val: str): + self.query.and_filter(not_begin_with("address", val)) + return self + + def with_address_ending_with(self, val: str): + self.query.and_filter(end_with("address", val)) + return self + + def with_address_not_ending_with(self, val: str): + self.query.and_filter(not_end_with("address", val)) + return self + + def with_address_sounding_like(self, val: str): + self.query.and_filter(sound_like("address", val)) + return self + + def with_address_is(self, val: str): + self.query.and_filter(eq("address", val)) + return self + def with_address_is_not(self, val): + self.query.and_filter(ne("address", val)) + return self + + def with_address_in(self, *vals): + self.query.and_filter(in_list("address", list(vals))) + return self + + def with_address_not_in(self, *vals): + self.query.and_filter(not_in_list("address", list(vals))) + return self + + def with_address_greater_than(self, val): + self.query.and_filter(gt("address", val)) + return self + + def with_address_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("address", val)) + return self + + def with_address_less_than(self, val): + self.query.and_filter(lt("address", val)) + return self + + def with_address_less_than_or_equal_to(self, val): + self.query.and_filter(lte("address", val)) + return self + + def with_address_between(self, lower, upper): + self.query.and_filter(between(column("address"), value(lower), value(upper))) + return self + + def with_address_is_known(self): + self.query.and_filter(is_not_null(column("address"))) + return self + + def with_address_is_unknown(self): + self.query.and_filter(is_null(column("address"))) + return self + + def with_established_date_is(self, val): + self.query.and_filter(eq("established_date", val)) + return self + + def with_established_date_is_not(self, val): + self.query.and_filter(ne("established_date", val)) + return self + + def with_established_date_in(self, *vals): + self.query.and_filter(in_list("established_date", list(vals))) + return self + + def with_established_date_not_in(self, *vals): + self.query.and_filter(not_in_list("established_date", list(vals))) + return self + + def with_established_date_greater_than(self, val): + self.query.and_filter(gt("established_date", val)) + return self + + def with_established_date_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("established_date", val)) + return self + + def with_established_date_less_than(self, val): + self.query.and_filter(lt("established_date", val)) + return self + + def with_established_date_less_than_or_equal_to(self, val): + self.query.and_filter(lte("established_date", val)) + return self + + def with_established_date_between(self, lower, upper): + self.query.and_filter(between(column("established_date"), value(lower), value(upper))) + return self + + def with_established_date_is_known(self): + self.query.and_filter(is_not_null(column("established_date"))) + return self + + def with_established_date_is_unknown(self): + self.query.and_filter(is_null(column("established_date"))) + return self + + def with_student_capacity_is(self, val): + self.query.and_filter(eq("student_capacity", val)) + return self + + def with_student_capacity_is_not(self, val): + self.query.and_filter(ne("student_capacity", val)) + return self + + def with_student_capacity_in(self, *vals): + self.query.and_filter(in_list("student_capacity", list(vals))) + return self + + def with_student_capacity_not_in(self, *vals): + self.query.and_filter(not_in_list("student_capacity", list(vals))) + return self + + def with_student_capacity_greater_than(self, val): + self.query.and_filter(gt("student_capacity", val)) + return self + + def with_student_capacity_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("student_capacity", val)) + return self + + def with_student_capacity_less_than(self, val): + self.query.and_filter(lt("student_capacity", val)) + return self + + def with_student_capacity_less_than_or_equal_to(self, val): + self.query.and_filter(lte("student_capacity", val)) + return self + + def with_student_capacity_between(self, lower, upper): + self.query.and_filter(between(column("student_capacity"), value(lower), value(upper))) + return self + + def with_student_capacity_is_known(self): + self.query.and_filter(is_not_null(column("student_capacity"))) + return self + + def with_student_capacity_is_unknown(self): + self.query.and_filter(is_null(column("student_capacity"))) + return self + + def which_are_active(self): + self.query.and_filter(eq("active", True)) + return self + + def which_are_not_active(self): + self.query.and_filter(eq("active", False)) + return self + def with_active_is_not(self, val): + self.query.and_filter(ne("active", val)) + return self + + def with_active_in(self, *vals): + self.query.and_filter(in_list("active", list(vals))) + return self + + def with_active_not_in(self, *vals): + self.query.and_filter(not_in_list("active", list(vals))) + return self + + def with_active_greater_than(self, val): + self.query.and_filter(gt("active", val)) + return self + + def with_active_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("active", val)) + return self + + def with_active_less_than(self, val): + self.query.and_filter(lt("active", val)) + return self + + def with_active_less_than_or_equal_to(self, val): + self.query.and_filter(lte("active", val)) + return self + + def with_active_between(self, lower, upper): + self.query.and_filter(between(column("active"), value(lower), value(upper))) + return self + + def with_active_is_known(self): + self.query.and_filter(is_not_null(column("active"))) + return self + + def with_active_is_unknown(self): + self.query.and_filter(is_null(column("active"))) + return self + + def with_create_time_is(self, val): + self.query.and_filter(eq("create_time", val)) + return self + + def with_create_time_is_not(self, val): + self.query.and_filter(ne("create_time", val)) + return self + + def with_create_time_in(self, *vals): + self.query.and_filter(in_list("create_time", list(vals))) + return self + + def with_create_time_not_in(self, *vals): + self.query.and_filter(not_in_list("create_time", list(vals))) + return self + + def with_create_time_greater_than(self, val): + self.query.and_filter(gt("create_time", val)) + return self + + def with_create_time_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("create_time", val)) + return self + + def with_create_time_less_than(self, val): + self.query.and_filter(lt("create_time", val)) + return self + + def with_create_time_less_than_or_equal_to(self, val): + self.query.and_filter(lte("create_time", val)) + return self + + def with_create_time_between(self, lower, upper): + self.query.and_filter(between(column("create_time"), value(lower), value(upper))) + return self + + def with_create_time_is_known(self): + self.query.and_filter(is_not_null(column("create_time"))) + return self + + def with_create_time_is_unknown(self): + self.query.and_filter(is_null(column("create_time"))) + return self + + def with_update_time_is(self, val): + self.query.and_filter(eq("update_time", val)) + return self + + def with_update_time_is_not(self, val): + self.query.and_filter(ne("update_time", val)) + return self + + def with_update_time_in(self, *vals): + self.query.and_filter(in_list("update_time", list(vals))) + return self + + def with_update_time_not_in(self, *vals): + self.query.and_filter(not_in_list("update_time", list(vals))) + return self + + def with_update_time_greater_than(self, val): + self.query.and_filter(gt("update_time", val)) + return self + + def with_update_time_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("update_time", val)) + return self + + def with_update_time_less_than(self, val): + self.query.and_filter(lt("update_time", val)) + return self + + def with_update_time_less_than_or_equal_to(self, val): + self.query.and_filter(lte("update_time", val)) + return self + + def with_update_time_between(self, lower, upper): + self.query.and_filter(between(column("update_time"), value(lower), value(upper))) + return self + + def with_update_time_is_known(self): + self.query.and_filter(is_not_null(column("update_time"))) + return self + + def with_update_time_is_unknown(self): + self.query.and_filter(is_null(column("update_time"))) + return self + + def with_version_is(self, val): + self.query.and_filter(eq("version", val)) + return self + + def with_version_is_not(self, val): + self.query.and_filter(ne("version", val)) + return self + + def with_version_in(self, *vals): + self.query.and_filter(in_list("version", list(vals))) + return self + + def with_version_not_in(self, *vals): + self.query.and_filter(not_in_list("version", list(vals))) + return self + + def with_version_greater_than(self, val): + self.query.and_filter(gt("version", val)) + return self + + def with_version_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("version", val)) + return self + + def with_version_less_than(self, val): + self.query.and_filter(lt("version", val)) + return self + + def with_version_less_than_or_equal_to(self, val): + self.query.and_filter(lte("version", val)) + return self + + def with_version_between(self, lower, upper): + self.query.and_filter(between(column("version"), value(lower), value(upper))) + return self + + def with_version_is_known(self): + self.query.and_filter(is_not_null(column("version"))) + return self + + def with_version_is_unknown(self): + self.query.and_filter(is_null(column("version"))) + return self + + def order_by_id_ascending(self): + self.query.order_by("id", "asc") + return self + + def order_by_id_descending(self): + self.query.order_by("id", "desc") + return self + + + + def order_by_name_ascending(self): + self.query.order_by("name", "asc") + return self + + def order_by_name_descending(self): + self.query.order_by("name", "desc") + return self + + def order_by_address_ascending(self): + self.query.order_by("address", "asc") + return self + + def order_by_address_descending(self): + self.query.order_by("address", "desc") + return self + + def order_by_established_date_ascending(self): + self.query.order_by("established_date", "asc") + return self + + def order_by_established_date_descending(self): + self.query.order_by("established_date", "desc") + return self + + def order_by_student_capacity_ascending(self): + self.query.order_by("student_capacity", "asc") + return self + + def order_by_student_capacity_descending(self): + self.query.order_by("student_capacity", "desc") + return self + + def order_by_active_ascending(self): + self.query.order_by("active", "asc") + return self + + def order_by_active_descending(self): + self.query.order_by("active", "desc") + return self + + def order_by_create_time_ascending(self): + self.query.order_by("create_time", "asc") + return self + + def order_by_create_time_descending(self): + self.query.order_by("create_time", "desc") + return self + + def order_by_update_time_ascending(self): + self.query.order_by("update_time", "asc") + return self + + def order_by_update_time_descending(self): + self.query.order_by("update_time", "desc") + return self + + def order_by_version_ascending(self): + self.query.order_by("version", "asc") + return self + + def order_by_version_descending(self): + self.query.order_by("version", "desc") + return self + + + def count(self): + self.query.count_field("id", "count") + return self + + def count_as(self, ret_name: str): + self.query.count_field("id", ret_name) + return self + + def min_student_capacity(self): + return self.min_student_capacity_as("minOfStudentCapacity") + + def min_student_capacity_as(self, ret_name: str): + self.query.min("student_capacity", ret_name) + return self + def max_student_capacity(self): + return self.max_student_capacity_as("maxOfStudentCapacity") + + def max_student_capacity_as(self, ret_name: str): + self.query.max("student_capacity", ret_name) + return self + def sum_student_capacity(self): + return self.sum_student_capacity_as("sumOfStudentCapacity") + + def sum_student_capacity_as(self, ret_name: str): + self.query.sum("student_capacity", ret_name) + return self + def avg_student_capacity(self): + return self.avg_student_capacity_as("avgOfStudentCapacity") + + def avg_student_capacity_as(self, ret_name: str): + self.query.avg("student_capacity", ret_name) + return self + def standardDeviation_student_capacity(self): + return self.standardDeviation_student_capacity_as("standardDeviationOfStudentCapacity") + + def standardDeviation_student_capacity_as(self, ret_name: str): + self.query.standardDeviation("student_capacity", ret_name) + return self + def squareRootOfPopulationStandardDeviation_student_capacity(self): + return self.squareRootOfPopulationStandardDeviation_student_capacity_as("squareRootOfPopulationStandardDeviationOfStudentCapacity") + + def squareRootOfPopulationStandardDeviation_student_capacity_as(self, ret_name: str): + self.query.squareRootOfPopulationStandardDeviation("student_capacity", ret_name) + return self + def sampleVariance_student_capacity(self): + return self.sampleVariance_student_capacity_as("sampleVarianceOfStudentCapacity") + + def sampleVariance_student_capacity_as(self, ret_name: str): + self.query.sampleVariance("student_capacity", ret_name) + return self + def samplePopulationVariance_student_capacity(self): + return self.samplePopulationVariance_student_capacity_as("samplePopulationVarianceOfStudentCapacity") + + def samplePopulationVariance_student_capacity_as(self, ret_name: str): + self.query.samplePopulationVariance("student_capacity", ret_name) + return self + def group_by_id(self): + self.query.group_by("id") + return self + + def group_by_id_as(self, ret_name: str): + self.query.group_by("id") + return self + def group_by_platform(self): + self.query.group_by("platform") + return self + + def group_by_platform_as(self, ret_name: str): + self.query.group_by("platform") + return self + def group_by_school_type(self): + self.query.group_by("school_type") + return self + + def group_by_school_type_as(self, ret_name: str): + self.query.group_by("school_type") + return self + def group_by_name(self): + self.query.group_by("name") + return self + + def group_by_name_as(self, ret_name: str): + self.query.group_by("name") + return self + def group_by_address(self): + self.query.group_by("address") + return self + + def group_by_address_as(self, ret_name: str): + self.query.group_by("address") + return self + def group_by_established_date(self): + self.query.group_by("established_date") + return self + + def group_by_established_date_as(self, ret_name: str): + self.query.group_by("established_date") + return self + def group_by_student_capacity(self): + self.query.group_by("student_capacity") + return self + + def group_by_student_capacity_as(self, ret_name: str): + self.query.group_by("student_capacity") + return self + def group_by_active(self): + self.query.group_by("active") + return self + + def group_by_active_as(self, ret_name: str): + self.query.group_by("active") + return self + def group_by_create_time(self): + self.query.group_by("create_time") + return self + + def group_by_create_time_as(self, ret_name: str): + self.query.group_by("create_time") + return self + def group_by_update_time(self): + self.query.group_by("update_time") + return self + + def group_by_update_time_as(self, ret_name: str): + self.query.group_by("update_time") + return self + def group_by_version(self): + self.query.group_by("version") + return self + + def group_by_version_as(self, ret_name: str): + self.query.group_by("version") + return self + def facet_by_platform_as(self, name: str, request: QuerySelection, + include_all_facets: bool = True): + self.query.facet_by(name, "platform", request.query, include_all_facets) + return self + + def facet_by_school_type_as(self, name: str, request: QuerySelection, + include_all_facets: bool = True): + self.query.facet_by(name, "school_type", request.query, include_all_facets) + return self + + +class ExecutableSchoolRequest: + def __init__(self, request): + self._request = request + + def comment(self, c: str): + self._request.comment(c) + return self + + def new_entity(self, context) -> School: + request = self._request + QueryIntent(request._comment, request._purpose) + entity = context.initialize_entity("School", School()) + if not isinstance(entity, School): + raise TypeError("entity initializer returned an incompatible School") + return entity + + async def execute_for_result(self, context): + self = self._request + req = QueryRequest(self.query, _comment=self._comment, _purpose=self._purpose) + req = context.prepare_query_request(req) + service = context.require_resource("dataService") + return await service.query(context, req) + + async def execute_for_rows(self, context): + return (await self.execute_for_result(context)).rows + + async def execute_for_list(self, context) -> SmartList[School]: + result = await self.execute_for_result(context) + return SmartList( + (School(**row) for row in result.rows), + facets=result.facets) + + async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[School]: + request = self._request + intent = QueryIntent(request._comment, request._purpose) + query = deepcopy(request.query) + query.offset(offset).limit(limit) + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)) + service = context.require_resource("dataService") + alias = "__teaql_total" + if authorized.query.id_set_pagination is not None: + row_result = await service.query(context, authorized) + retained_count, accuracy = context.id_set_count() + if accuracy == "EXACT": + total_count = retained_count + else: + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) + if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): + raise RuntimeError("dataService did not return an exact page count") + total_count = int(count_result.rows[0][alias]) + else: + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) + if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): + raise RuntimeError("dataService did not return an exact page count") + total_count = int(count_result.rows[0][alias]) + row_result = await service.query(context, authorized) + data = SmartList(School(**row) for row in row_result.rows) + return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) + + async def execute_for_one(self, context): + request = deepcopy(self._request) + request.limit(1) + entities = await ExecutableSchoolRequest(request).execute_for_list(context) + return entities[0] if entities else None + + def execute_for_stream(self, context, chunk_size: int = 1000): + """Yield entity chunks lazily from the provider cursor.""" + request = self._request + req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) + req = context.prepare_query_request(req) + service = context.require_resource("dataService") + if not hasattr(service, "query_stream"): + raise RuntimeError("dataService does not implement query_stream") + stream = service.query_stream(context, req, chunk_size) + async def entities(): + try: + async for chunk in stream: + for row in chunk.rows: + yield School(**row) + finally: + close = getattr(stream, "aclose", None) + if close is not None: + await close() + return entities() diff --git a/examples/facet-trace/lib/requests/school_type_request.py b/examples/facet-trace/lib/requests/school_type_request.py new file mode 100644 index 0000000..605dca8 --- /dev/null +++ b/examples/facet-trace/lib/requests/school_type_request.py @@ -0,0 +1,754 @@ +from teaql.core.query import RelationAggregate, SelectQuery +from teaql.core.list import SmartList, TeaQLPage +from teaql.data_service import QueryRequest +from teaql.core import QueryIntent +from copy import deepcopy +from teaql.core.expr import ( + begin_with, between, column, contain, end_with, eq, gt, gte, + in_list, in_subquery, is_not_null, is_null, lt, lte, ne, not_begin_with, + not_contain, not_end_with, not_in_list, not_in_subquery, value, + sound_like, +) +from models.school_type import SchoolType +from typing import Protocol + +class QuerySelection(Protocol): + query: SelectQuery + +class SchoolTypeRequest: + def __init__(self, minimal=False): + self.query = SelectQuery("SchoolType") + self._purpose = None + self._comment = None + self.query.and_filter(gte("version", 1)) + if minimal: + self.select_id() + self.select_version() + else: + self.select_self_fields() + + def comment(self, c: str): + self.query.comment(c) + self._comment = c + return self + + def purpose(self, p: str): + self.query.purpose(p) + self._purpose = p + return ExecutableSchoolTypeRequest(self) + + def optimize_for_continuous_page_fetch(self): + self.query.optimize_for_continuous_page_fetch() + return self + + def optimize_for_continuous_page_fetch_with(self, namespace: str, ttl_seconds: int): + self.query.optimize_for_continuous_page_fetch_with(namespace, ttl_seconds) + return self + + def optimize_pagination_with_id_set(self): + self.query.optimize_pagination_with_id_set() + return self + + def optimize_pagination_with_id_set_config(self, namespace: str, ttl_seconds: int, max_ids: int): + self.query.optimize_pagination_with_id_set_config(namespace, ttl_seconds, max_ids) + return self + + def top_n_probe_parent_threshold(self, threshold: int): + self.query.top_n_probe_parent_threshold(threshold) + return self + + def limit(self, n: int): + self.query.limit(n) + return self + + def offset(self, n: int): + self.query.offset(n) + return self + + def with_deleted_rows(self): + self.query.with_deleted_rows() + return self + + def deleted_rows_only(self): + self.query.deleted_rows_only() + return self + + def select_self_fields(self): + self.query.project("platform", "id", "name", "code", "display_order", "version") + return self + + def select_id(self): + self.query.project("id") + return self + + def select_name(self): + self.query.project("name") + return self + + def select_code(self): + self.query.project("code") + return self + + def select_display_order(self): + self.query.project("display_order") + return self + + def select_version(self): + self.query.project("version") + return self + + def select_platform_with(self, child_request): + self.query.project("platform") + self.query.relation_query("platform", child_request.query) + return self + def with_platform_matching(self, child_request): + child_request.query.projection = ["id"] + self.query.and_filter(in_subquery(column("platform"), "Platform", child_request.query)) + return self + + def without_platform_matching(self, child_request): + child_request.query.projection = ["id"] + self.query.and_filter(not_in_subquery(column("platform"), "Platform", child_request.query)) + return self + + def have_platform(self): + self.query.and_filter(is_not_null(column("platform"))) + return self + + def have_no_platform(self): + self.query.and_filter(is_null(column("platform"))) + return self + + def filter_by_platform(self, val): + self.query.and_filter(eq("platform", val)) + return self + + def with_id_is(self, val): + self.query.and_filter(eq("id", val)) + return self + + def with_id_is_not(self, val): + self.query.and_filter(ne("id", val)) + return self + + def with_id_in(self, *vals): + self.query.and_filter(in_list("id", list(vals))) + return self + + def with_id_not_in(self, *vals): + self.query.and_filter(not_in_list("id", list(vals))) + return self + + def with_id_greater_than(self, val): + self.query.and_filter(gt("id", val)) + return self + + def with_id_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("id", val)) + return self + + def with_id_less_than(self, val): + self.query.and_filter(lt("id", val)) + return self + + def with_id_less_than_or_equal_to(self, val): + self.query.and_filter(lte("id", val)) + return self + + def with_id_between(self, lower, upper): + self.query.and_filter(between(column("id"), value(lower), value(upper))) + return self + + def with_id_is_known(self): + self.query.and_filter(is_not_null(column("id"))) + return self + + def with_id_is_unknown(self): + self.query.and_filter(is_null(column("id"))) + return self + + def with_name_containing(self, val: str): + self.query.and_filter(contain("name", val)) + return self + + def with_name_not_containing(self, val: str): + self.query.and_filter(not_contain("name", val)) + return self + + def with_name_starting_with(self, val: str): + self.query.and_filter(begin_with("name", val)) + return self + + def with_name_not_starting_with(self, val: str): + self.query.and_filter(not_begin_with("name", val)) + return self + + def with_name_ending_with(self, val: str): + self.query.and_filter(end_with("name", val)) + return self + + def with_name_not_ending_with(self, val: str): + self.query.and_filter(not_end_with("name", val)) + return self + + def with_name_sounding_like(self, val: str): + self.query.and_filter(sound_like("name", val)) + return self + + def with_name_is(self, val: str): + self.query.and_filter(eq("name", val)) + return self + def with_name_is_not(self, val): + self.query.and_filter(ne("name", val)) + return self + + def with_name_in(self, *vals): + self.query.and_filter(in_list("name", list(vals))) + return self + + def with_name_not_in(self, *vals): + self.query.and_filter(not_in_list("name", list(vals))) + return self + + def with_name_greater_than(self, val): + self.query.and_filter(gt("name", val)) + return self + + def with_name_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("name", val)) + return self + + def with_name_less_than(self, val): + self.query.and_filter(lt("name", val)) + return self + + def with_name_less_than_or_equal_to(self, val): + self.query.and_filter(lte("name", val)) + return self + + def with_name_between(self, lower, upper): + self.query.and_filter(between(column("name"), value(lower), value(upper))) + return self + + def with_name_is_known(self): + self.query.and_filter(is_not_null(column("name"))) + return self + + def with_name_is_unknown(self): + self.query.and_filter(is_null(column("name"))) + return self + + def with_code_containing(self, val: str): + self.query.and_filter(contain("code", val)) + return self + + def with_code_not_containing(self, val: str): + self.query.and_filter(not_contain("code", val)) + return self + + def with_code_starting_with(self, val: str): + self.query.and_filter(begin_with("code", val)) + return self + + def with_code_not_starting_with(self, val: str): + self.query.and_filter(not_begin_with("code", val)) + return self + + def with_code_ending_with(self, val: str): + self.query.and_filter(end_with("code", val)) + return self + + def with_code_not_ending_with(self, val: str): + self.query.and_filter(not_end_with("code", val)) + return self + + def with_code_sounding_like(self, val: str): + self.query.and_filter(sound_like("code", val)) + return self + + def with_code_is(self, val: str): + self.query.and_filter(eq("code", val)) + return self + def with_code_is_not(self, val): + self.query.and_filter(ne("code", val)) + return self + + def with_code_in(self, *vals): + self.query.and_filter(in_list("code", list(vals))) + return self + + def with_code_not_in(self, *vals): + self.query.and_filter(not_in_list("code", list(vals))) + return self + + def with_code_greater_than(self, val): + self.query.and_filter(gt("code", val)) + return self + + def with_code_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("code", val)) + return self + + def with_code_less_than(self, val): + self.query.and_filter(lt("code", val)) + return self + + def with_code_less_than_or_equal_to(self, val): + self.query.and_filter(lte("code", val)) + return self + + def with_code_between(self, lower, upper): + self.query.and_filter(between(column("code"), value(lower), value(upper))) + return self + + def with_code_is_known(self): + self.query.and_filter(is_not_null(column("code"))) + return self + + def with_code_is_unknown(self): + self.query.and_filter(is_null(column("code"))) + return self + + def with_display_order_is(self, val): + self.query.and_filter(eq("display_order", val)) + return self + + def with_display_order_is_not(self, val): + self.query.and_filter(ne("display_order", val)) + return self + + def with_display_order_in(self, *vals): + self.query.and_filter(in_list("display_order", list(vals))) + return self + + def with_display_order_not_in(self, *vals): + self.query.and_filter(not_in_list("display_order", list(vals))) + return self + + def with_display_order_greater_than(self, val): + self.query.and_filter(gt("display_order", val)) + return self + + def with_display_order_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("display_order", val)) + return self + + def with_display_order_less_than(self, val): + self.query.and_filter(lt("display_order", val)) + return self + + def with_display_order_less_than_or_equal_to(self, val): + self.query.and_filter(lte("display_order", val)) + return self + + def with_display_order_between(self, lower, upper): + self.query.and_filter(between(column("display_order"), value(lower), value(upper))) + return self + + def with_display_order_is_known(self): + self.query.and_filter(is_not_null(column("display_order"))) + return self + + def with_display_order_is_unknown(self): + self.query.and_filter(is_null(column("display_order"))) + return self + + def with_version_is(self, val): + self.query.and_filter(eq("version", val)) + return self + + def with_version_is_not(self, val): + self.query.and_filter(ne("version", val)) + return self + + def with_version_in(self, *vals): + self.query.and_filter(in_list("version", list(vals))) + return self + + def with_version_not_in(self, *vals): + self.query.and_filter(not_in_list("version", list(vals))) + return self + + def with_version_greater_than(self, val): + self.query.and_filter(gt("version", val)) + return self + + def with_version_greater_than_or_equal_to(self, val): + self.query.and_filter(gte("version", val)) + return self + + def with_version_less_than(self, val): + self.query.and_filter(lt("version", val)) + return self + + def with_version_less_than_or_equal_to(self, val): + self.query.and_filter(lte("version", val)) + return self + + def with_version_between(self, lower, upper): + self.query.and_filter(between(column("version"), value(lower), value(upper))) + return self + + def with_version_is_known(self): + self.query.and_filter(is_not_null(column("version"))) + return self + + def with_version_is_unknown(self): + self.query.and_filter(is_null(column("version"))) + return self + + def order_by_id_ascending(self): + self.query.order_by("id", "asc") + return self + + def order_by_id_descending(self): + self.query.order_by("id", "desc") + return self + + def order_by_name_ascending(self): + self.query.order_by("name", "asc") + return self + + def order_by_name_descending(self): + self.query.order_by("name", "desc") + return self + + def order_by_code_ascending(self): + self.query.order_by("code", "asc") + return self + + def order_by_code_descending(self): + self.query.order_by("code", "desc") + return self + + def order_by_display_order_ascending(self): + self.query.order_by("display_order", "asc") + return self + + def order_by_display_order_descending(self): + self.query.order_by("display_order", "desc") + return self + + def order_by_version_ascending(self): + self.query.order_by("version", "asc") + return self + + def order_by_version_descending(self): + self.query.order_by("version", "desc") + return self + + + def count(self): + self.query.count_field("id", "count") + return self + + def count_as(self, ret_name: str): + self.query.count_field("id", ret_name) + return self + + def min_display_order(self): + return self.min_display_order_as("minOfDisplayOrder") + + def min_display_order_as(self, ret_name: str): + self.query.min("display_order", ret_name) + return self + def max_display_order(self): + return self.max_display_order_as("maxOfDisplayOrder") + + def max_display_order_as(self, ret_name: str): + self.query.max("display_order", ret_name) + return self + def sum_display_order(self): + return self.sum_display_order_as("sumOfDisplayOrder") + + def sum_display_order_as(self, ret_name: str): + self.query.sum("display_order", ret_name) + return self + def avg_display_order(self): + return self.avg_display_order_as("avgOfDisplayOrder") + + def avg_display_order_as(self, ret_name: str): + self.query.avg("display_order", ret_name) + return self + def standardDeviation_display_order(self): + return self.standardDeviation_display_order_as("standardDeviationOfDisplayOrder") + + def standardDeviation_display_order_as(self, ret_name: str): + self.query.standardDeviation("display_order", ret_name) + return self + def squareRootOfPopulationStandardDeviation_display_order(self): + return self.squareRootOfPopulationStandardDeviation_display_order_as("squareRootOfPopulationStandardDeviationOfDisplayOrder") + + def squareRootOfPopulationStandardDeviation_display_order_as(self, ret_name: str): + self.query.squareRootOfPopulationStandardDeviation("display_order", ret_name) + return self + def sampleVariance_display_order(self): + return self.sampleVariance_display_order_as("sampleVarianceOfDisplayOrder") + + def sampleVariance_display_order_as(self, ret_name: str): + self.query.sampleVariance("display_order", ret_name) + return self + def samplePopulationVariance_display_order(self): + return self.samplePopulationVariance_display_order_as("samplePopulationVarianceOfDisplayOrder") + + def samplePopulationVariance_display_order_as(self, ret_name: str): + self.query.samplePopulationVariance("display_order", ret_name) + return self + def group_by_platform(self): + self.query.group_by("platform") + return self + + def group_by_platform_as(self, ret_name: str): + self.query.group_by("platform") + return self + def group_by_id(self): + self.query.group_by("id") + return self + + def group_by_id_as(self, ret_name: str): + self.query.group_by("id") + return self + def group_by_name(self): + self.query.group_by("name") + return self + + def group_by_name_as(self, ret_name: str): + self.query.group_by("name") + return self + def group_by_code(self): + self.query.group_by("code") + return self + + def group_by_code_as(self, ret_name: str): + self.query.group_by("code") + return self + def group_by_display_order(self): + self.query.group_by("display_order") + return self + + def group_by_display_order_as(self, ret_name: str): + self.query.group_by("display_order") + return self + def group_by_version(self): + self.query.group_by("version") + return self + + def group_by_version_as(self, ret_name: str): + self.query.group_by("version") + return self + def select_school_list(self): + from requests.school_request import SchoolRequest + return self.select_school_list_with(SchoolRequest()) + + def select_school_list_with(self, child_request): + self.query.relation_query("school_list", child_request.query) + return self + def have_schools(self): + from requests.school_request import SchoolRequest + return self.with_school_list_matching(SchoolRequest()) + + def have_no_schools(self): + from requests.school_request import SchoolRequest + return self.without_school_list_matching(SchoolRequest()) + + def with_school_list_matching(self, child_request): + child_request.query.projection = ["school_type"] + self.query.and_filter(in_subquery(column("id"), "School", child_request.query)) + return self + + def without_school_list_matching(self, child_request): + child_request.query.projection = ["school_type"] + self.query.and_filter(not_in_subquery(column("id"), "School", child_request.query)) + return self + def count_schools(self): + return self.count_schools_as("count_schools") + + def count_schools_as(self, alias: str): + from requests.school_request import SchoolRequest + return self.count_schools_with(alias, SchoolRequest()) + + def count_schools_with(self, alias: str, child_request): + child_request.query.count_field("id", alias) + self.query.relation_aggregates.append( + RelationAggregate("school_list", alias, child_request.query, True) + ) + return self + + def min_student_capacity_of_schools(self): + from requests.school_request import SchoolRequest + return self.min_student_capacity_of_schools_as( + "min_student_capacity_of_schools", SchoolRequest()) + + def min_student_capacity_of_schools_as(self, alias: str, child_request): + child_request.query.min("student_capacity", "min_student_capacity") + self.query.relation_aggregates.append( + RelationAggregate("school_list", alias, child_request.query, True) + ) + return self + def max_student_capacity_of_schools(self): + from requests.school_request import SchoolRequest + return self.max_student_capacity_of_schools_as( + "max_student_capacity_of_schools", SchoolRequest()) + + def max_student_capacity_of_schools_as(self, alias: str, child_request): + child_request.query.max("student_capacity", "max_student_capacity") + self.query.relation_aggregates.append( + RelationAggregate("school_list", alias, child_request.query, True) + ) + return self + def sum_student_capacity_of_schools(self): + from requests.school_request import SchoolRequest + return self.sum_student_capacity_of_schools_as( + "sum_student_capacity_of_schools", SchoolRequest()) + + def sum_student_capacity_of_schools_as(self, alias: str, child_request): + child_request.query.sum("student_capacity", "sum_student_capacity") + self.query.relation_aggregates.append( + RelationAggregate("school_list", alias, child_request.query, True) + ) + return self + def avg_student_capacity_of_schools(self): + from requests.school_request import SchoolRequest + return self.avg_student_capacity_of_schools_as( + "avg_student_capacity_of_schools", SchoolRequest()) + + def avg_student_capacity_of_schools_as(self, alias: str, child_request): + child_request.query.avg("student_capacity", "avg_student_capacity") + self.query.relation_aggregates.append( + RelationAggregate("school_list", alias, child_request.query, True) + ) + return self + def standardDeviation_student_capacity_of_schools(self): + from requests.school_request import SchoolRequest + return self.standardDeviation_student_capacity_of_schools_as( + "standardDeviation_student_capacity_of_schools", SchoolRequest()) + + def standardDeviation_student_capacity_of_schools_as(self, alias: str, child_request): + child_request.query.standardDeviation("student_capacity", "standardDeviation_student_capacity") + self.query.relation_aggregates.append( + RelationAggregate("school_list", alias, child_request.query, True) + ) + return self + def squareRootOfPopulationStandardDeviation_student_capacity_of_schools(self): + from requests.school_request import SchoolRequest + return self.squareRootOfPopulationStandardDeviation_student_capacity_of_schools_as( + "squareRootOfPopulationStandardDeviation_student_capacity_of_schools", SchoolRequest()) + + def squareRootOfPopulationStandardDeviation_student_capacity_of_schools_as(self, alias: str, child_request): + child_request.query.squareRootOfPopulationStandardDeviation("student_capacity", "squareRootOfPopulationStandardDeviation_student_capacity") + self.query.relation_aggregates.append( + RelationAggregate("school_list", alias, child_request.query, True) + ) + return self + def sampleVariance_student_capacity_of_schools(self): + from requests.school_request import SchoolRequest + return self.sampleVariance_student_capacity_of_schools_as( + "sampleVariance_student_capacity_of_schools", SchoolRequest()) + + def sampleVariance_student_capacity_of_schools_as(self, alias: str, child_request): + child_request.query.sampleVariance("student_capacity", "sampleVariance_student_capacity") + self.query.relation_aggregates.append( + RelationAggregate("school_list", alias, child_request.query, True) + ) + return self + def samplePopulationVariance_student_capacity_of_schools(self): + from requests.school_request import SchoolRequest + return self.samplePopulationVariance_student_capacity_of_schools_as( + "samplePopulationVariance_student_capacity_of_schools", SchoolRequest()) + + def samplePopulationVariance_student_capacity_of_schools_as(self, alias: str, child_request): + child_request.query.samplePopulationVariance("student_capacity", "samplePopulationVariance_student_capacity") + self.query.relation_aggregates.append( + RelationAggregate("school_list", alias, child_request.query, True) + ) + return self + def facet_by_platform_as(self, name: str, request: QuerySelection, + include_all_facets: bool = True): + self.query.facet_by(name, "platform", request.query, include_all_facets) + return self + + +class ExecutableSchoolTypeRequest: + def __init__(self, request): + self._request = request + + def comment(self, c: str): + self._request.comment(c) + return self + + def new_entity(self, context) -> SchoolType: + request = self._request + QueryIntent(request._comment, request._purpose) + entity = context.initialize_entity("SchoolType", SchoolType()) + if not isinstance(entity, SchoolType): + raise TypeError("entity initializer returned an incompatible SchoolType") + return entity + + async def execute_for_result(self, context): + self = self._request + req = QueryRequest(self.query, _comment=self._comment, _purpose=self._purpose) + req = context.prepare_query_request(req) + service = context.require_resource("dataService") + return await service.query(context, req) + + async def execute_for_rows(self, context): + return (await self.execute_for_result(context)).rows + + async def execute_for_list(self, context) -> SmartList[SchoolType]: + result = await self.execute_for_result(context) + return SmartList( + (SchoolType(**row) for row in result.rows), + facets=result.facets) + + async def execute_for_page(self, context, offset: int, limit: int) -> TeaQLPage[SchoolType]: + request = self._request + intent = QueryIntent(request._comment, request._purpose) + query = deepcopy(request.query) + query.offset(offset).limit(limit) + authorized = context.prepare_query_request(QueryRequest(query, _comment=intent.comment, _purpose=intent.purpose)) + service = context.require_resource("dataService") + alias = "__teaql_total" + if authorized.query.id_set_pagination is not None: + row_result = await service.query(context, authorized) + retained_count, accuracy = context.id_set_count() + if accuracy == "EXACT": + total_count = retained_count + else: + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) + if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): + raise RuntimeError("dataService did not return an exact page count") + total_count = int(count_result.rows[0][alias]) + else: + count_result = await service.query(context, authorized.with_query(authorized.query.for_exact_count(alias))) + if not count_result.rows or not isinstance(count_result.rows[0].get(alias), (int, float)): + raise RuntimeError("dataService did not return an exact page count") + total_count = int(count_result.rows[0][alias]) + row_result = await service.query(context, authorized) + data = SmartList(SchoolType(**row) for row in row_result.rows) + return TeaQLPage(data=data, total_count=total_count, offset=offset, limit=limit) + + async def execute_for_one(self, context): + request = deepcopy(self._request) + request.limit(1) + entities = await ExecutableSchoolTypeRequest(request).execute_for_list(context) + return entities[0] if entities else None + + def execute_for_stream(self, context, chunk_size: int = 1000): + """Yield entity chunks lazily from the provider cursor.""" + request = self._request + req = QueryRequest(request.query, _comment=request._comment, _purpose=request._purpose) + req = context.prepare_query_request(req) + service = context.require_resource("dataService") + if not hasattr(service, "query_stream"): + raise RuntimeError("dataService does not implement query_stream") + stream = service.query_stream(context, req, chunk_size) + async def entities(): + try: + async for chunk in stream: + for row in chunk.rows: + yield SchoolType(**row) + finally: + close = getattr(stream, "aclose", None) + if close is not None: + await close() + return entities() diff --git a/examples/facet-trace/lib/runtime_module.py b/examples/facet-trace/lib/runtime_module.py new file mode 100644 index 0000000..4e8d108 --- /dev/null +++ b/examples/facet-trace/lib/runtime_module.py @@ -0,0 +1,274 @@ +import asyncio +from datetime import datetime, timezone +from teaql.runtime import CheckResult, ContextEntityRef, JsonFieldNamingProfile, ObjectLocation, RuntimeModule, create_wire_entity_metadata +from teaql.core.meta import EntityDescriptor, PropertyDescriptor, RelationDescriptor +from teaql.core.value import DataType +from Q import Q +from teaql.core.value import Value +try: + from teaql.core.graph import GraphNode +except ImportError: + class GraphNode: + def __init__(self, entity): + self.entity, self.fields = entity, {} + def set(self, field, value): + self.fields[field] = value + return self +from models.platform import Platform +from models.school_type import SchoolType +from models.school import School + +def _teaql_is_null(value): + return value.is_null() if hasattr(value, "is_null") else value is None + +def _teaql_raw(value): + return value.val if hasattr(value, "val") else value + +def _teaql_entity_id(value): + value = _teaql_raw(value) + if hasattr(value, "id"): + return value.id + if isinstance(value, dict): + return value.get("id") + return value + +class _PlatformChecker: + def check_and_fix(self, context, record, location, results): + operation = context.get_resource("fix_operation") + now = context.get_resource("fix_time") + if operation == "insert" and ("create_time" not in record or _teaql_is_null(record["create_time"])): + record["create_time"] = Value.from_any(now) + context.record_fix_evidence("Platform", "create_time", "clock", "graphClock") + + if operation == "insert" and ("update_time" not in record or _teaql_is_null(record["update_time"])): + record["update_time"] = Value.from_any(now) + context.record_fix_evidence("Platform", "update_time", "clock", "graphClock") + if operation == "insert" or operation == "update": + record["update_time"] = Value.from_any(now) + context.record_fix_evidence("Platform", "update_time", "clock", "graphClock") + + + if (operation == "insert" and "name" not in record) or ("name" in record and _teaql_is_null(record["name"])): + results.append(CheckResult("required", ObjectLocation().property("name"))) + if "name" in record and _teaql_raw(record["name"]) is not None and len(_teaql_raw(record["name"])) > 100: + results.append(CheckResult("max_length", ObjectLocation().property("name"), _teaql_raw(record["name"]), 100)) + + if (operation == "insert" and "base_url" not in record) or ("base_url" in record and _teaql_is_null(record["base_url"])): + results.append(CheckResult("required", ObjectLocation().property("base_url"))) + if "base_url" in record and _teaql_raw(record["base_url"]) is not None and len(_teaql_raw(record["base_url"])) > 100: + results.append(CheckResult("max_length", ObjectLocation().property("base_url"), _teaql_raw(record["base_url"]), 100)) + + if (operation == "insert" and "create_time" not in record) or ("create_time" in record and _teaql_is_null(record["create_time"])): + results.append(CheckResult("required", ObjectLocation().property("create_time"))) + + if (operation == "insert" and "update_time" not in record) or ("update_time" in record and _teaql_is_null(record["update_time"])): + results.append(CheckResult("required", ObjectLocation().property("update_time"))) + + + +class _SchoolTypeChecker: + def check_and_fix(self, context, record, location, results): + operation = context.get_resource("fix_operation") + now = context.get_resource("fix_time") + if (operation == "insert" and "platform" not in record) or ("platform" in record and _teaql_is_null(record["platform"])): + results.append(CheckResult("required", ObjectLocation().property("platform"))) + + + if (operation == "insert" and "name" not in record) or ("name" in record and _teaql_is_null(record["name"])): + results.append(CheckResult("required", ObjectLocation().property("name"))) + if "name" in record and _teaql_raw(record["name"]) is not None and len(_teaql_raw(record["name"])) > 100: + results.append(CheckResult("max_length", ObjectLocation().property("name"), _teaql_raw(record["name"]), 100)) + + if (operation == "insert" and "code" not in record) or ("code" in record and _teaql_is_null(record["code"])): + results.append(CheckResult("required", ObjectLocation().property("code"))) + if "code" in record and _teaql_raw(record["code"]) is not None and len(_teaql_raw(record["code"])) > 100: + results.append(CheckResult("max_length", ObjectLocation().property("code"), _teaql_raw(record["code"]), 100)) + + if (operation == "insert" and "display_order" not in record) or ("display_order" in record and _teaql_is_null(record["display_order"])): + results.append(CheckResult("required", ObjectLocation().property("display_order"))) + + + +class _SchoolChecker: + def check_and_fix(self, context, record, location, results): + operation = context.get_resource("fix_operation") + now = context.get_resource("fix_time") + if operation == "insert" and ("create_time" not in record or _teaql_is_null(record["create_time"])): + record["create_time"] = Value.from_any(now) + context.record_fix_evidence("School", "create_time", "clock", "graphClock") + + if operation == "insert" and ("update_time" not in record or _teaql_is_null(record["update_time"])): + record["update_time"] = Value.from_any(now) + context.record_fix_evidence("School", "update_time", "clock", "graphClock") + if operation == "insert" or operation == "update": + record["update_time"] = Value.from_any(now) + context.record_fix_evidence("School", "update_time", "clock", "graphClock") + + + if (operation == "insert" and "platform" not in record) or ("platform" in record and _teaql_is_null(record["platform"])): + results.append(CheckResult("required", ObjectLocation().property("platform"))) + + if (operation == "insert" and "school_type" not in record) or ("school_type" in record and _teaql_is_null(record["school_type"])): + results.append(CheckResult("required", ObjectLocation().property("school_type"))) + + if (operation == "insert" and "name" not in record) or ("name" in record and _teaql_is_null(record["name"])): + results.append(CheckResult("required", ObjectLocation().property("name"))) + if "name" in record and _teaql_raw(record["name"]) is not None and len(_teaql_raw(record["name"])) > 100: + results.append(CheckResult("max_length", ObjectLocation().property("name"), _teaql_raw(record["name"]), 100)) + + if (operation == "insert" and "address" not in record) or ("address" in record and _teaql_is_null(record["address"])): + results.append(CheckResult("required", ObjectLocation().property("address"))) + if "address" in record and _teaql_raw(record["address"]) is not None and len(_teaql_raw(record["address"])) > 100: + results.append(CheckResult("max_length", ObjectLocation().property("address"), _teaql_raw(record["address"]), 100)) + + if (operation == "insert" and "established_date" not in record) or ("established_date" in record and _teaql_is_null(record["established_date"])): + results.append(CheckResult("required", ObjectLocation().property("established_date"))) + + if (operation == "insert" and "student_capacity" not in record) or ("student_capacity" in record and _teaql_is_null(record["student_capacity"])): + results.append(CheckResult("required", ObjectLocation().property("student_capacity"))) + + if (operation == "insert" and "active" not in record) or ("active" in record and _teaql_is_null(record["active"])): + results.append(CheckResult("required", ObjectLocation().property("active"))) + + if (operation == "insert" and "create_time" not in record) or ("create_time" in record and _teaql_is_null(record["create_time"])): + results.append(CheckResult("required", ObjectLocation().property("create_time"))) + + if (operation == "insert" and "update_time" not in record) or ("update_time" in record and _teaql_is_null(record["update_time"])): + results.append(CheckResult("required", ObjectLocation().property("update_time"))) + + + +_Platform_DESCRIPTOR = (EntityDescriptor("Platform") + .audit_mask_fields(["name"]) + .table_name("platform_data").property(PropertyDescriptor("id", DataType.I64).column_name("id").log_policy("plain").is_id().required()).property(PropertyDescriptor("name", DataType.Text).column_name("name").log_policy("plain").required()).property(PropertyDescriptor("base_url", DataType.Text).column_name("base_url").log_policy("plain").required()).property(PropertyDescriptor("create_time", DataType.Timestamp).column_name("create_time").log_policy("plain").required()).property(PropertyDescriptor("update_time", DataType.Timestamp).column_name("update_time").log_policy("plain").required()).property(PropertyDescriptor("version", DataType.I64).column_name("version").log_policy("plain").is_version().required()).relation(RelationDescriptor("school_type_list", "SchoolType").local("id").foreign("platform").many()).relation(RelationDescriptor("school_list", "School").local("id").foreign("platform").many()) +) + +_SchoolType_DESCRIPTOR = (EntityDescriptor("SchoolType") + .audit_mask_fields([]) + .table_name("school_type_data").property(PropertyDescriptor("platform", DataType.I64).column_name("platform").log_policy("plain").required()).property(PropertyDescriptor("id", DataType.I64).column_name("id").log_policy("plain").is_id().required()).property(PropertyDescriptor("name", DataType.Text).column_name("name").log_policy("plain").required()).property(PropertyDescriptor("code", DataType.Text).column_name("code").log_policy("plain").required()).property(PropertyDescriptor("display_order", DataType.Decimal).column_name("display_order").log_policy("plain").required()).property(PropertyDescriptor("version", DataType.I64).column_name("version").log_policy("plain").is_version().required()).relation(RelationDescriptor("platform", "Platform").local("platform").foreign("id")).relation(RelationDescriptor("school_list", "School").local("id").foreign("school_type").many()) +) + +_School_DESCRIPTOR = (EntityDescriptor("School") + .audit_mask_fields(["name"]) + .table_name("school_data").property(PropertyDescriptor("id", DataType.I64).column_name("id").log_policy("plain").is_id().required()).property(PropertyDescriptor("platform", DataType.I64).column_name("platform").log_policy("plain").required()).property(PropertyDescriptor("school_type", DataType.I64).column_name("school_type").log_policy("plain").required()).property(PropertyDescriptor("name", DataType.Text).column_name("name").log_policy("plain").required()).property(PropertyDescriptor("address", DataType.Text).column_name("address").log_policy("plain").required()).property(PropertyDescriptor("established_date", DataType.Date).column_name("established_date").log_policy("plain").required()).property(PropertyDescriptor("student_capacity", DataType.I64).column_name("student_capacity").log_policy("plain").required()).property(PropertyDescriptor("active", DataType.Bool).column_name("active").log_policy("plain").required()).property(PropertyDescriptor("create_time", DataType.Timestamp).column_name("create_time").log_policy("plain").required()).property(PropertyDescriptor("update_time", DataType.Timestamp).column_name("update_time").log_policy("plain").required()).property(PropertyDescriptor("version", DataType.I64).column_name("version").log_policy("plain").is_version().required()).relation(RelationDescriptor("platform", "Platform").local("platform").foreign("id")).relation(RelationDescriptor("school_type", "SchoolType").local("school_type").foreign("id")) +) + +async def _ensure_generated_bootstrap_once(context): + previous_actor = context.user_identifier() if hasattr(context, 'user_identifier') else None + previous_category = context.get_resource('bootstrapCategory') + if hasattr(context, 'set_user_identifier'): + context.set_user_identifier('teaql-generated-bootstrap') + context.insert_resource('bootstrapCategory', 'runtime-bootstrap') + try: + platform_1 = await (Q.platforms().with_id_is(1).comment('what: locate generated bootstrap entity').purpose('why: idempotent runtime bootstrap').execute_for_one(context)) + if platform_1 is None: + platform_1 = Platform._teaql_new_with_fixed_id(1) + platform_1.update_name("Campus Learning Platform") + platform_1.update_base_url("https://campus.example.com") + try: + await platform_1.audit_as('create model root Platform(1)').save(context) + except Exception as _teaql_create_error: + for _teaql_attempt in range(5): + platform_1 = await (Q.platforms().with_id_is(1).comment('what: recover concurrent bootstrap').purpose('why: make generated bootstrap idempotent').execute_for_one(context)) + if platform_1 is not None: + break + if _teaql_attempt < 4: + await asyncio.sleep((_teaql_attempt + 1) * 0.01) + if platform_1 is None: + raise _teaql_create_error + context.with_active_root(ContextEntityRef("Platform", 1)) + school_type_1001 = await (Q.school_types().with_id_is(1001).comment('what: locate generated bootstrap entity').purpose('why: idempotent runtime bootstrap').execute_for_one(context)) + if school_type_1001 is None: + school_type_1001 = SchoolType._teaql_new_with_fixed_id(1001) + school_type_1001.update_platform(Platform.refer(1)) + school_type_1001.update_name("Primary") + school_type_1001.update_code("PRIMARY") + school_type_1001.update_display_order(1) + try: + await school_type_1001.audit_as('create model constant SchoolType(1001)').save(context) + except Exception as _teaql_create_error: + for _teaql_attempt in range(5): + school_type_1001 = await (Q.school_types().with_id_is(1001).comment('what: recover concurrent bootstrap').purpose('why: make generated bootstrap idempotent').execute_for_one(context)) + if school_type_1001 is not None: + break + if _teaql_attempt < 4: + await asyncio.sleep((_teaql_attempt + 1) * 0.01) + if school_type_1001 is None: + raise _teaql_create_error + _teaql_changed = False + if school_type_1001.platform != 1: + school_type_1001.update_platform(Platform.refer(1)) + _teaql_changed = True + if school_type_1001.name != "Primary": + school_type_1001.update_name("Primary") + _teaql_changed = True + if school_type_1001.code != "PRIMARY": + school_type_1001.update_code("PRIMARY") + _teaql_changed = True + if school_type_1001.displayOrder != 1: + school_type_1001.update_display_order(1) + _teaql_changed = True + if _teaql_changed: + await school_type_1001.audit_as('reconcile model constant SchoolType(1001)').save(context) + school_type_1002 = await (Q.school_types().with_id_is(1002).comment('what: locate generated bootstrap entity').purpose('why: idempotent runtime bootstrap').execute_for_one(context)) + if school_type_1002 is None: + school_type_1002 = SchoolType._teaql_new_with_fixed_id(1002) + school_type_1002.update_platform(Platform.refer(1)) + school_type_1002.update_name("Secondary") + school_type_1002.update_code("SECONDARY") + school_type_1002.update_display_order(2) + try: + await school_type_1002.audit_as('create model constant SchoolType(1002)').save(context) + except Exception as _teaql_create_error: + for _teaql_attempt in range(5): + school_type_1002 = await (Q.school_types().with_id_is(1002).comment('what: recover concurrent bootstrap').purpose('why: make generated bootstrap idempotent').execute_for_one(context)) + if school_type_1002 is not None: + break + if _teaql_attempt < 4: + await asyncio.sleep((_teaql_attempt + 1) * 0.01) + if school_type_1002 is None: + raise _teaql_create_error + _teaql_changed = False + if school_type_1002.platform != 1: + school_type_1002.update_platform(Platform.refer(1)) + _teaql_changed = True + if school_type_1002.name != "Secondary": + school_type_1002.update_name("Secondary") + _teaql_changed = True + if school_type_1002.code != "SECONDARY": + school_type_1002.update_code("SECONDARY") + _teaql_changed = True + if school_type_1002.displayOrder != 2: + school_type_1002.update_display_order(2) + _teaql_changed = True + if _teaql_changed: + await school_type_1002.audit_as('reconcile model constant SchoolType(1002)').save(context) + finally: + if hasattr(context, 'set_user_identifier'): + context.set_user_identifier(previous_actor) + context.insert_resource('bootstrapCategory', previous_category) + +async def _ensure_generated_bootstrap(context): + for _teaql_attempt in range(5): + try: + await _ensure_generated_bootstrap_once(context) + return + except Exception: + if _teaql_attempt == 4: + raise + await asyncio.sleep((_teaql_attempt + 1) * 0.01) + + +# Passive generated manifest. Call ensure_schema() separately and explicitly. +GENERATED_RUNTIME_MODULE = (RuntimeModule().entity(Platform) + .schema_entity(_Platform_DESCRIPTOR) + .checker("Platform", _PlatformChecker()) + .wire_metadata("Platform", create_wire_entity_metadata("Platform", ["id", "name", "base_url", "create_time", "update_time", "version"], JsonFieldNamingProfile.CAMEL_CASE, {"id": ["id"], "name": ["name"], "base_url": ["base_url"], "create_time": ["create_time"], "update_time": ["update_time"], "version": ["version"]})).entity(SchoolType) + .schema_entity(_SchoolType_DESCRIPTOR) + .checker("SchoolType", _SchoolTypeChecker()) + .wire_metadata("SchoolType", create_wire_entity_metadata("SchoolType", ["platform", "id", "name", "code", "display_order", "version"], JsonFieldNamingProfile.CAMEL_CASE, {"platform": ["platform"], "id": ["id"], "name": ["name"], "code": ["code"], "display_order": ["display_order"], "version": ["version"]})).entity(School) + .schema_entity(_School_DESCRIPTOR) + .checker("School", _SchoolChecker()) + .wire_metadata("School", create_wire_entity_metadata("School", ["id", "platform", "school_type", "name", "address", "established_date", "student_capacity", "active", "create_time", "update_time", "version"], JsonFieldNamingProfile.CAMEL_CASE, {"id": ["id"], "platform": ["platform"], "school_type": ["school_type"], "name": ["name"], "address": ["address"], "established_date": ["established_date"], "student_capacity": ["student_capacity"], "active": ["active"], "create_time": ["create_time"], "update_time": ["update_time"], "version": ["version"]})) + .generated_bootstrap(_ensure_generated_bootstrap) +) \ No newline at end of file diff --git a/examples/facet-trace/lib/teaql-i18n.json b/examples/facet-trace/lib/teaql-i18n.json new file mode 100644 index 0000000..21b3f9b --- /dev/null +++ b/examples/facet-trace/lib/teaql-i18n.json @@ -0,0 +1,122 @@ +{ + "schema": "teaql.i18n/v1", + "defaultLocale": "en", + "locales": { + "de": { + "vocabulary": { + }, + "messages": { + } + }, + "ko": { + "vocabulary": { + }, + "messages": { + } + }, + "pt": { + "vocabulary": { + }, + "messages": { + } + }, + "zh-TW": { + "vocabulary": { + }, + "messages": { + } + }, + "fil": { + "vocabulary": { + }, + "messages": { + } + }, + "en": { + "vocabulary": { + "property.platform.name": "Name", + "property.platform.createTime": "Create Time", + "property.platform.updateTime": "Update Time", + "property.school.platform": "Platform", + "property.school.id": "Id", + "property.schoolType.version": "Version", + "property.school.establishedDate": "Established Date", + "property.schoolType.code": "Code", + "property.school.studentCapacity": "Student Capacity", + "property.school.active": "Active", + "property.school.schoolType": "School Type", + "entity.platform": "Platform", + "property.platform.id": "Id", + "property.schoolType.displayOrder": "Display Order", + "property.schoolType.id": "Id", + "entity.school": "School", + "property.school.name": "Name", + "property.school.createTime": "Create Time", + "property.school.updateTime": "Update Time", + "entity.schoolType": "School Type", + "property.platform.baseUrl": "Base Url", + "property.platform.version": "Version", + "property.schoolType.platform": "Platform", + "property.school.version": "Version", + "property.school.address": "Address", + "property.schoolType.name": "Name" + }, + "messages": { + } + }, + "fr": { + "vocabulary": { + }, + "messages": { + } + }, + "zh-CN": { + "vocabulary": { + }, + "messages": { + } + }, + "es": { + "vocabulary": { + }, + "messages": { + } + }, + "ar": { + "vocabulary": { + }, + "messages": { + } + }, + "vi": { + "vocabulary": { + }, + "messages": { + } + }, + "th": { + "vocabulary": { + }, + "messages": { + } + }, + "uk": { + "vocabulary": { + }, + "messages": { + } + }, + "ja": { + "vocabulary": { + }, + "messages": { + } + }, + "id": { + "vocabulary": { + }, + "messages": { + } + } + } +} \ No newline at end of file From 21181e78eedfb367da85f10965d66ed6358a0a22 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Mon, 5 Oct 2026 02:17:58 +0800 Subject: [PATCH 28/40] test: assert canonical generated three-relation SQL trace paths --- examples/trace-chain/main.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/examples/trace-chain/main.py b/examples/trace-chain/main.py index 0f39446..2aa6cab 100644 --- a/examples/trace-chain/main.py +++ b/examples/trace-chain/main.py @@ -230,6 +230,12 @@ def resolve_policy(request_key): assert entry.purpose == 'why: retain root query intent' assert entry.trace_path[0].name == 'PaymentAttempt' assert [node.name for node in entry.trace_path[2:-2]] == ['payment', 'customer_order', 'platform'][:depth] + route = [('payment', 'PaymentAttempt.payment'), ('customer_order', 'Payment.customer_order'), + ('platform', 'CustomerOrder.platform')] + assert [(node.kind, node.name, node.comment) for node in entry.trace_path] == [ + ('operation', 'PaymentAttempt', 'query'), ('request', 'PaymentAttempt', ''), + *[('relation', name, detail) for name, detail in route[:depth]], + ('provider', 'sqlite', ''), ('sql', 'select', '')], 'canonical generated path at every physical boundary' print('PASS: generated query traverses three actual relations with inherited intent') graphs = [make_graph(context, platform, label + '-' + name) for name in ('first', 'second')] From 339d8f3743050dd9cb93f1292a93f2a162f0940f Mon Sep 17 00:00:00 2001 From: Philip Z Date: Mon, 5 Oct 2026 03:02:28 +0800 Subject: [PATCH 29/40] test: observe shared Context storage during live query graphs --- tests/provider/sqlite/test_trace_chain.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/provider/sqlite/test_trace_chain.py b/tests/provider/sqlite/test_trace_chain.py index 7f44162..0dd90ad 100644 --- a/tests/provider/sqlite/test_trace_chain.py +++ b/tests/provider/sqlite/test_trace_chain.py @@ -931,6 +931,19 @@ def observe(actual, **kwargs): monkeypatch.setattr(context, '_record_metadata_log', observe) requests = [QueryRequest(three_levels(), _comment=f'load {label} graph', _purpose=f'render {label} graph') for label in ('alpha', 'beta')] + # Native test-only inspection: query frames must not become ambient Context + # fields/resources. Keep legitimate SQL-log appends observable separately. + context_storage_before = {key: id(value) for key, value in vars(context).items()} + resource_storage_before = {key: id(value) for key, value in context._resources.items()} + + def assert_context_unchanged(): + assert {key: id(value) for key, value in vars(context).items()} == context_storage_before, \ + 'shared Context fields changed during independent queries' + assert {key: id(value) for key, value in context._resources.items()} == resource_storage_before, \ + 'shared Context resources changed during independent queries' + assert context._graph_save_guard.get() is None + assert context._graph_session is None + pending = [asyncio.create_task(service.query(context, request)) for request in requests] ready = asyncio.create_task(entered.wait()) try: @@ -940,12 +953,14 @@ def observe(actual, **kwargs): assert roots == 2 and not any(task.done() for task in pending) assert metadata == entries == [] assert [request.intent.comment for request in requests] == ['load alpha graph', 'load beta graph'] + assert_context_unchanged() release.set() results = await asyncio.wait_for(asyncio.gather(*pending), timeout=5) assert all(result.rows[0]['children'][0]['children'][0]['children'][0]['id'] == 1 for result in results) assert len(transport.reads) == len(metadata) == 8 assert len(entries) == (8 if logging else 0) + assert_context_unchanged() for observed, path_attr in [(metadata, 'trace_chain'), (entries, 'trace_path')]: for label in ('alpha', 'beta'): own = [entry for entry in observed if entry.comment == f'load {label} graph'] From 85b4372b2d2ec0eeccb69a398a2917b783bb1b52 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Mon, 5 Oct 2026 05:15:06 +0800 Subject: [PATCH 30/40] test: verify typed graph identities at Python mutation boundaries --- examples/trace-chain/README.md | 12 ++++ examples/trace-chain/main.py | 108 ++++++++++++++++++++++++++++++--- examples/trace-chain/verify.sh | 6 +- 3 files changed, 118 insertions(+), 8 deletions(-) diff --git a/examples/trace-chain/README.md b/examples/trace-chain/README.md index 4dd5881..1f48dfa 100644 --- a/examples/trace-chain/README.md +++ b/examples/trace-chain/README.md @@ -58,6 +58,18 @@ switches do not erase the physical result metadata. Native tests cover no-Contex results, grouped batches, zero affected rows, hard deletes and masked readback intent. +The six-item fixture also checks exact `(entity type, ID)` lists before creating +lineage dictionaries, so duplicate rows cannot disappear during comparison. +CustomerOrder and Payment deliberately share a numeric ID. Creation, the +update/deletion save, and each concurrent independent graph must retain all six +distinct typed targets in commands, command-bound physical SQL and committed +safe audit. SQL paths themselves do not carry IDs: each actual write/readback +pair is bound to the observed provider command, with its target entity frame, +operation, successful outcome and row counts checked. The normative save emits +one `GRAPH IDENTITY EVIDENCE` record. Three negative controls reject duplicate, +missing and equal-ID type-collapsed targets; the verifier requires their marker +and the complete example marker on both retained-database runs. + The mutation privacy suite configures Payment.reference_code as sensitive and creates/updates a three-entity graph using generated Q/E/save. Root and sibling write/readback intent plus committed safe audit must hide the future child's new diff --git a/examples/trace-chain/main.py b/examples/trace-chain/main.py index 2aa6cab..09feb5e 100644 --- a/examples/trace-chain/main.py +++ b/examples/trace-chain/main.py @@ -1,5 +1,6 @@ """Generated Q/E/Mutation traversal observed at three real SQLite boundaries.""" import asyncio +import json import os import uuid @@ -9,6 +10,7 @@ from teaql.provider.sqlite import create_sqlite_service from teaql.runtime import UserContext, DelegatingMutationPolicyRegistry from teaql.core.request_intent import RequestIntentError +from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand from teaql.sql.executor import TransportError @@ -20,12 +22,81 @@ def scalar(value): return getattr(value, 'val', value) +def graph_identities(graph): + order, item, payment, attempt, shipment, removed = graph + return [ + ('CustomerOrder', E.customer_order(order).id().eval()), + ('OrderItem', E.order_item(item).id().eval()), + ('Payment', E.payment(payment).id().eval()), + ('PaymentAttempt', E.payment_attempt(attempt).id().eval()), + ('Shipment', E.shipment(shipment).id().eval()), + ('OrderItem', E.order_item(removed).id().eval()), + ] + + +def check_identities(expected, actual, boundary): + # Check lists before making dictionaries: duplicate rows must not disappear. + assert len(expected) == len(actual) == 6, boundary + ': six identities required' + for entity, identity in [*expected, *actual]: + assert isinstance(entity, str) and entity, boundary + ': entity type required' + assert type(identity) is int and 0 < identity <= 2**64 - 1, boundary + ': positive u64 ID required' + assert len(set(expected)) == 6, boundary + ': duplicate expected identity' + assert len(set(actual)) == 6, boundary + ': duplicate identity' + assert set(actual) == set(expected), boundary + ': missing or unexpected identity' + + +def identity_controls(): + expected = [('CustomerOrder', 1), ('OrderItem', 1), ('Payment', 1), + ('PaymentAttempt', 1), ('Shipment', 1), ('OrderItem', 2)] + check_identities(expected, expected, 'positive control') + controls = [expected[:-1] + [expected[1]], expected[:-1], + [('CustomerOrder' if entity == 'Payment' else entity, identity) + for entity, identity in expected]] + for actual in controls: + try: + check_identities(expected, actual, 'negative control') + except AssertionError: + pass + else: + raise AssertionError('identity negative control was accepted') + print('PASS: identity controls reject duplicate, missing and equal-ID type collapse') + + +def physical_identities(service, context): + """Bind actual ordered SQL facts to independently observed provider commands. + + Canonical SQL paths deliberately do not contain IDs. Readback paths name + the operation root, not the target. Neither responsibility chain is a key. + """ + physical = [entry for entry in context.sql_logs() if entry.mutation_lineage] + assert len(service.requests) == len(service.results) == len(service.actions) + assert len(physical) == 2 * len(service.requests) + identities = [] + for index, ((entity, identity, nodes), record, action) in enumerate( + zip(service.requests, service.results, service.actions)): + write, read = physical[index * 2:index * 2 + 2] + assert len(record.statements) == 2 + assert record.affected_rows == write.affected_rows == 1 + assert write.execution_outcome == read.execution_outcome == 'success' + assert read.result_count == 1 and read.affected_rows is None + assert [node.kind for node in write.trace_path] == ['operation', 'entity', 'provider', 'sql'] + assert write.trace_path[1].name == entity + assert write.trace_path[-1].name == action and read.trace_path[-1].name == 'select' + assert write.trace_path == record.statements[0].trace_chain + assert read.trace_path == record.statements[1].trace_chain + assert chain(write.mutation_lineage) == chain(read.mutation_lineage) == nodes + assert write.comment == read.comment == record.comment + identities.append((entity, identity)) + return identities + + class ObservedService: """Transparent observer, not a replacement planner or provider.""" def __init__(self, service): self.service = service self.requests = [] self.results = [] + self.actions = [] self.finishes = [] self.begins = 0 self.fault = None @@ -57,6 +128,8 @@ async def mutate(self, context, request): command = request._data identity = scalar(getattr(command, 'id', None) or command.values.get('id')) self.owner.requests.append((command.entity, identity, chain(request.mutation_lineage))) + self.owner.actions.append({InsertCommand: 'insert', UpdateCommand: 'update', + DeleteCommand: 'delete'}[type(command)]) result = await self.transaction.mutate(context, request) self.owner.results.append(result.metadata) return result @@ -118,6 +191,7 @@ def expected_graph(graph, reason): def reset(service, sink, context): service.requests.clear() service.results.clear() + service.actions.clear() service.finishes.clear() service.begins = 0 sink.events.clear() @@ -125,6 +199,7 @@ def reset(service, sink, context): async def main(): + identity_controls() path = os.environ['TEAQL_TRACE_CHAIN_DB'] label = 'TRACE-' + uuid.uuid4().hex[:16] context = UserContext.new().install(GENERATED_RUNTIME_MODULE) @@ -162,7 +237,12 @@ def resolve_policy(request_key): graph = make_graph(context, platform, label) order, item, payment, attempt, shipment, removed = graph + reset(service, sink, context) await order.audit_as('prepare complete graph').save(context) + created = graph_identities(graph) + check_identities(created, [(entity, identity) for entity, identity, _ in service.requests], 'created commands') + check_identities(created, physical_identities(service, context), 'created physical SQL') + check_identities(created, [(event.entity, scalar(event.entity_id)) for event in sink.events], 'created audit') assert E.payment(payment).id().eval() == E.customer_order(order).id().eval(), 'fixture must exercise equal IDs of different types' order.update_description('Submitted trace example') item.update_name('Verified available item') @@ -172,6 +252,18 @@ def resolve_policy(request_key): removed.mark_for_deletion().audit_as('remove unavailable item') reset(service, sink, context) await order.audit_as('submit order').save(context) + want_identities = graph_identities(graph) + command_identities = [(entity, identity) for entity, identity, _ in service.requests] + statement_identities = physical_identities(service, context) + committed_identities = [(event.entity, scalar(event.entity_id)) for event in sink.events] + check_identities(want_identities, command_identities, 'actual commands') + check_identities(want_identities, statement_identities, 'command-bound physical SQL') + check_identities(want_identities, committed_identities, 'committed audit') + print('GRAPH IDENTITY EVIDENCE ' + json.dumps({ + name: [{'entity': entity, 'id': identity} for entity, identity in values] + for name, values in [('expected', want_identities), ('commands', command_identities), + ('physical', statement_identities), ('audit', committed_identities)] + }, sort_keys=True)) expected = expected_graph(graph, 'submit order') commands = {(entity, identity): nodes for entity, identity, nodes in service.requests} metadata = {(entity, identity): chain(item.mutation_lineage) @@ -193,13 +285,6 @@ def resolve_policy(request_key): assert [node.kind for node in read.trace_chain] == ['operation', 'request', 'provider', 'sql'] assert [node.name for node in read.trace_chain] == ['CustomerOrder', 'CustomerOrder', 'sqlite', 'select'] assert read.trace_chain[0].comment == 'query' - physical = [entry for entry in context.sql_logs() if entry.mutation_lineage] - assert len(physical) == 12 - for index, record in enumerate(service.results): - write, read = physical[index * 2:index * 2 + 2] - assert write.trace_path[-1].name == record.trace_chain[-1].name - assert read.trace_path[-1].name == 'select' - assert read.mutation_lineage == write.mutation_lineage print('PASS: six writes plus six successful readbacks; root request paths; no duplicate SQL facts') print('PASS: normative six items; assigned typed identity; branch/deletion reasons; request/SQL/audit boundaries') @@ -247,6 +332,15 @@ def resolve_policy(request_key): for g, name in zip(graphs, ('first', 'second'))} assert all(chain(event.trace_chain) == [('CustomerOrder', chain(event.trace_chain)[0][1], roots[chain(event.trace_chain)[0][1]])] for event in sink.events) + concurrent_physical = physical_identities(service, context) + for graph in graphs: + root_id = E.customer_order(graph[0]).id().eval() + expected = graph_identities(graph) + indices = [index for index, (_, _, nodes) in enumerate(service.requests) if nodes[0][1] == root_id] + check_identities(expected, [service.requests[index][:2] for index in indices], 'concurrent commands') + check_identities(expected, [concurrent_physical[index] for index in indices], 'concurrent physical SQL') + check_identities(expected, [(event.entity, scalar(event.entity_id)) for event in sink.events + if chain(event.trace_chain)[0][1] == root_id], 'concurrent audit') assert context.require_resource('dataService') is service print('PASS: two concurrent generated graph saves in one real Context remain isolated') diff --git a/examples/trace-chain/verify.sh b/examples/trace-chain/verify.sh index 7f49967..a59322a 100644 --- a/examples/trace-chain/verify.sh +++ b/examples/trace-chain/verify.sh @@ -69,7 +69,11 @@ for attempt in first second; do rg -q '^66 passed in ' "$like_log" echo "PASS: native LIKE privacy $attempt; evidence $like_log" echo "Run $attempt on $TEAQL_TRACE_CHAIN_DB without cleanup" - python "$example/main.py" + main_log="$(mktemp -t teaql-python-main.XXXXXX.log)" + timeout --kill-after=5s 60s python "$example/main.py" | tee "$main_log" + rg -Fxq 'PASS: identity controls reject duplicate, missing and equal-ID type collapse' "$main_log" + [[ "$(rg -c '^GRAPH IDENTITY EVIDENCE ' "$main_log")" == 1 ]] + rg -Fxq 'PASS: generated library unchanged; all trace example checks passed' "$main_log" echo "Mutation privacy $attempt on $TEAQL_TRACE_CHAIN_DB without cleanup" privacy_log="$(mktemp -t teaql-python-privacy.XXXXXX.log)" python "$example/mutation_privacy.py" | tee "$privacy_log" From a5de2fc25e05a21eae3f796170701e6973a2790e Mon Sep 17 00:00:00 2001 From: Philip Z Date: Mon, 5 Oct 2026 06:40:03 +0800 Subject: [PATCH 31/40] fix: align graph local reasons with Unicode intent whitespace --- src/teaql/core/trace_scope.py | 6 +- tests/provider/sqlite/test_trace_chain.py | 103 ++++++++++++++++++++++ 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/src/teaql/core/trace_scope.py b/src/teaql/core/trace_scope.py index 3f40bc7..71f66d4 100644 --- a/src/teaql/core/trace_scope.py +++ b/src/teaql/core/trace_scope.py @@ -30,10 +30,12 @@ def root(cls, owner: object, key: EntityKey, reason: str) -> 'TraceScope': return cls(owner, _AuditReason(key.entity, key.id, intent.comment)) def child(self, key: EntityKey, reason: Optional[str]) -> 'TraceScope': + from .request_intent import MutationIntent, _white_space self._require_assigned(key) - if reason is None or (isinstance(reason, str) and not reason.strip()): + # Match the public intent contract (Unicode White_Space), not Python + # strip(), which also discards the valid U+001C..U+001F separators. + if reason is None or (isinstance(reason, str) and all(_white_space(c) for c in reason)): return self - from .request_intent import MutationIntent intent = MutationIntent(reason) return TraceScope(self._owner, _AuditReason(key.entity, key.id, intent.comment), self) diff --git a/tests/provider/sqlite/test_trace_chain.py b/tests/provider/sqlite/test_trace_chain.py index 0dd90ad..8190b61 100644 --- a/tests/provider/sqlite/test_trace_chain.py +++ b/tests/provider/sqlite/test_trace_chain.py @@ -647,6 +647,109 @@ def native_batch_witness(observed, logging, nested, phase): })) +@pytest.mark.asyncio +@pytest.mark.parametrize('logging', [False, True]) +async def test_blank_local_reasons_inherit_at_command_sql_and_committed_audit( + tmp_path, monkeypatch, logging): + """TC-MUT-11: real graph scopes and SQLite execution, not supplied SQL frames.""" + from teaql.core import RequestIntentError + from teaql.core.entity import EntityKey + context, service, transport, entries, seen = await ordered_batch_fixture(tmp_path, monkeypatch, logging) + context.insert_resource('dataService', service) + blanks = [None, '', ' \t\r\n', '\u0085', '\u00a0', '\u2003'] + for blank in blanks: + with pytest.raises(RequestIntentError) as caught: + await service.mutate(context, MutationRequest( + InsertCommand.new('PaymentAttempt').value('name', 'rejected public write'), comment=blank)) + error = caught.value + assert (error.code, error.field, error.request_kind) == ('REQUEST_COMMENT_REQUIRED', 'comment', 'mutation') + assert seen.commands == seen.physical == seen.audits == seen.lifecycle == entries == [] + assert transport.begins == 0 and transport.reads == transport.writes == [] + expected_payment = [('auditReason', 'CustomerOrder', 100, 'submit order'), + ('auditReason', 'Payment', 201, 'authorize payment')] + expected_shipment = [('auditReason', 'CustomerOrder', 100, 'submit order'), + ('auditReason', 'Shipment', 301, 'prepare shipment')] + expected = [expected_payment] * len(blanks) + [expected_shipment] + identities = [('PaymentAttempt', 400 + index) for index in range(len(blanks))] + [('Shipment', 301)] + + async def work(graph): + root = graph.scope(EntityKey('CustomerOrder', 100)) + payment = graph.scope(EntityKey('Payment', 201), parent=root, local_reason='authorize payment') + shipment = graph.scope(EntityKey('Shipment', 301), parent=root, local_reason='prepare shipment') + for index, blank in enumerate(blanks): + key = EntityKey('PaymentAttempt', 400 + index) + inherited = graph.scope(key, parent=payment, local_reason=blank) + assert inherited is payment, 'blank local reason must not add an entity reason' + command = (InsertCommand.new(key.entity).value('id', key.id).value('version', 1) + .value('name', 'native attempt').value('parent_id', 201)) + result = await graph.transaction.mutate(graph.context, graph.request(command, inherited)) + assert result.affected_rows == 1 and result.persisted_record is not None + assert seen.audits == [], 'graph audit escaped before commit' + command = (InsertCommand.new('Shipment').value('id', 301).value('version', 1) + .value('name', 'native shipment').value('parent_id', 100)) + await graph.transaction.mutate(graph.context, graph.request(command, shipment)) + assert seen.audits == [], 'sibling audit escaped before commit' + + await context.execute_graph_save(work, comment='submit order') + assert seen.lifecycle == ['begin', 'commit'] + assert len(seen.commands) == len(seen.audits) == 7 and len(seen.physical) == 14 + assert [native_batch_identity(request) for request in seen.commands] == identities + assert [native_batch_lineage(request.mutation_lineage) for request in seen.commands] == expected + assert [(event.entity, event.entity_id.val) for event in seen.audits] == identities + assert [native_batch_lineage(event.trace_chain) for event in seen.audits] == expected + for index, ((entity, identity), chain) in enumerate(zip(identities, expected)): + write, read = seen.physical[index * 2:index * 2 + 2] + assert write.affected_rows == 1 and read.result_count == 1 + assert [value.val for value in read.parameters] == [identity] + for statement, operation in [(write, 'insert'), (read, 'select')]: + assert native_batch_lineage(statement.mutation_lineage) == chain + assert statement.comment == statement.audit_reason == 'submit order' + assert statement.trace_chain[-1].name == operation + assert statement.trace_chain[-2].name == 'sqlite' + assert len(entries) == (14 if logging else 0) + assert all(native_batch_lineage(entry.mutation_lineage) == expected[index // 2] + for index, entry in enumerate(entries)) + async with aiosqlite.connect(transport.db_path) as database: + for entity, identity in identities: + row = await (await database.execute( + f'SELECT id, version, parent_id FROM {entity.lower()}_data WHERE id=?', (identity,))).fetchone() + assert row == (identity, 1, 201 if entity == 'PaymentAttempt' else 100) + native_batch_witness(seen, logging, False, 'blank-local-reason') + + +@pytest.mark.asyncio +@pytest.mark.parametrize('logging', [False, True]) +@pytest.mark.parametrize('local_reason', ['\u001c', '\u001d', '\u001e', '\u001f']) +async def test_non_contract_whitespace_local_reason_is_preserved_at_real_sinks( + tmp_path, monkeypatch, logging, local_reason): + """Python strip() must not silently discard reasons accepted by the public contract.""" + from teaql.core.entity import EntityKey + context, service, _, entries, seen = await ordered_batch_fixture(tmp_path, monkeypatch, logging) + context.insert_resource('dataService', service) + + async def work(graph): + root = graph.scope(EntityKey('CustomerOrder', 100)) + key = EntityKey('PaymentAttempt', 400) + scope = graph.scope(key, parent=root, local_reason=local_reason) + assert scope is not root, 'not Unicode White_Space; this is a valid local reason' + command = (InsertCommand.new(key.entity).value('id', key.id).value('version', 1) + .value('name', 'native separator control').value('parent_id', 100)) + await graph.transaction.mutate(graph.context, graph.request(command, scope)) + assert seen.audits == [] + + await context.execute_graph_save(work, comment='submit order') + expected = [('auditReason', 'CustomerOrder', 100, 'submit order'), + ('auditReason', 'PaymentAttempt', 400, local_reason)] + assert seen.lifecycle == ['begin', 'commit'] + assert len(seen.commands) == len(seen.audits) == 1 and len(seen.physical) == 2 + assert native_batch_lineage(seen.commands[0].mutation_lineage) == expected + assert all(native_batch_lineage(statement.mutation_lineage) == expected for statement in seen.physical) + assert native_batch_lineage(seen.audits[0].trace_chain) == expected + assert (seen.audits[0].entity, seen.audits[0].entity_id.val) == ('PaymentAttempt', 400) + assert len(entries) == (2 if logging else 0) + assert all(native_batch_lineage(entry.mutation_lineage) == expected for entry in entries) + + @pytest.mark.asyncio @pytest.mark.parametrize('logging', [False, True]) @pytest.mark.parametrize('nested', [False, True]) From 2c18292e8f7d3a86abe8a4e86fef3643422974f9 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Mon, 5 Oct 2026 13:28:18 +0800 Subject: [PATCH 32/40] test: observe generated bootstrap intent with logging enabled and disabled --- examples/trace-chain/main.py | 93 +++++++++++++++++++++++++++++++--- examples/trace-chain/verify.sh | 1 + 2 files changed, 88 insertions(+), 6 deletions(-) diff --git a/examples/trace-chain/main.py b/examples/trace-chain/main.py index 09feb5e..e5fbb8f 100644 --- a/examples/trace-chain/main.py +++ b/examples/trace-chain/main.py @@ -2,6 +2,7 @@ import asyncio import json import os +import sqlite3 import uuid from E import E @@ -98,12 +99,19 @@ def __init__(self, service): self.results = [] self.actions = [] self.finishes = [] + self.queries = [] + self.raw_requests = [] self.begins = 0 self.fault = None def __getattr__(self, name): return getattr(self.service, name) + async def query(self, context, request): + result = await self.service.query(context, request) + self.queries.append((request, result.metadata)) + return result + async def begin(self, context): self.begins += 1 transaction = await self.service.begin(context) @@ -126,6 +134,7 @@ def __getattr__(self, name): async def mutate(self, context, request): command = request._data + self.owner.raw_requests.append(request) identity = scalar(getattr(command, 'id', None) or command.values.get('id')) self.owner.requests.append((command.entity, identity, chain(request.mutation_lineage))) self.owner.actions.append({InsertCommand: 'insert', UpdateCommand: 'update', @@ -193,26 +202,98 @@ def reset(service, sink, context): service.results.clear() service.actions.clear() service.finishes.clear() + service.queries.clear() + service.raw_requests.clear() service.begins = 0 sink.events.clear() context.clear_sql_logs() +async def verify_bootstrap(path, logging): + """Observe generated startup without supplying caller intent or trace frames.""" + context = UserContext.new().install(GENERATED_RUNTIME_MODULE) + context.with_user_identifier('trace-example-user') + service = ObservedService(create_sqlite_service(path)) + sink = AuditSink(service) + context.insert_resource('dataService', service).with_app_audit_event_sink(sink) + context.set_diagnostic_sql_log_sink(type('Silent', (), {'write': lambda self, entry: None})()) + if not logging: + context.disable_sql_log() + await context.ensure_schema() + first_queries = tuple(service.queries) + first_requests = tuple(service.raw_requests) + first_results = tuple(service.results) + first_audits = tuple(sink.events) + first_sql = tuple(context.sql_logs()) + assert first_queries, 'observe generated bootstrap lookup even with logging disabled' + first_intent = first_queries[0][1].comment + first_purpose = first_queries[0][1].purpose + assert first_intent and first_intent.strip(), 'bootstrap owns a nonblank lookup comment' + assert first_purpose and first_purpose.strip(), 'bootstrap owns a nonblank lookup purpose' + assert len(first_requests) == len(first_results) == len(first_audits) <= 1 + for request, result, event in zip(first_requests, first_results, first_audits): + assert request.comment() and request.comment().strip() + assert chain(request.mutation_lineage) == [('Platform', 1, request.comment())] + assert result.comment == request.comment() + assert len(result.statements) == 2 and result.affected_rows == 1 + assert event.actor == 'teaql-generated-bootstrap' and event.category == 'runtime-bootstrap' + assert event.entity == 'Platform' and scalar(event.entity_id) == 1 + assert chain(event.trace_chain) == [('Platform', 1, request.comment().replace('1', '[REDACTED]'))], 'safe bootstrap audit preserves responsibility while redacting identity text' + for statement in result.statements: + assert statement.comment == request.comment() + assert statement.execution_outcome == 'success' + assert chain(statement.mutation_lineage) == chain(request.mutation_lineage) + assert result.statements[1].result_count == 1 + assert result.statements[1].purpose and result.statements[1].purpose.strip() + # The sink already checks that audit followed commit. Independently verify + # the resulting row through a read-only connection, not the runtime cache. + with sqlite3.connect('file:' + os.path.abspath(path) + '?mode=ro', uri=True) as connection: + assert connection.execute('SELECT id, version FROM platform_data WHERE id = 1').fetchall() == [(1, 1)] + reset(service, sink, context) + await context.ensure_schema() + assert service.raw_requests == [] and service.results == [] and sink.events == [], 'repeat bootstrap cannot write or audit' + assert service.queries, 'repeat bootstrap must inspect persisted root' + for _, metadata in (*first_queries, *service.queries): + assert metadata.comment == first_intent, 'generated bootstrap lookup intent is stable' + assert metadata.purpose == first_purpose + assert metadata.execution_outcome == 'success' + assert [node.kind for node in metadata.trace_chain] == ['operation', 'request', 'provider', 'sql'] + assert metadata.trace_chain[0].name == 'Platform' + if logging: + assert len(first_sql) == len(first_queries) + 2 * len(first_requests) + assert len(context.sql_logs()) == len(service.queries) + for entry in (*first_sql, *context.sql_logs()): + select = entry.operation.name.lower() == 'select' + assert [node.kind for node in entry.trace_path] == ['operation', 'request' if select else 'entity', 'provider', 'sql'] + assert entry.comment and entry.comment.strip() + if select: + assert entry.purpose and entry.purpose.strip() + if entry.mutation_lineage: + assert len(first_audits) == 1 + assert chain(entry.mutation_lineage) == chain(first_audits[0].trace_chain) + assert entry.audit_reason == first_audits[0].trace_chain[0].comment + else: + assert first_sql == () and context.sql_logs() == [] + assert context.user_identifier() == 'trace-example-user', 'bootstrap must restore caller identity' + print('BOOTSTRAP INTENT EVIDENCE ' + json.dumps({'logging': logging, + 'first_writes': len(first_requests), 'repeat_writes': 0, + 'comment': first_intent, 'purpose': first_purpose, + 'queries': len(first_queries) + len(service.queries)})) + reset(service, sink, context) + return context, service, sink + + async def main(): identity_controls() path = os.environ['TEAQL_TRACE_CHAIN_DB'] label = 'TRACE-' + uuid.uuid4().hex[:16] - context = UserContext.new().install(GENERATED_RUNTIME_MODULE) + await verify_bootstrap(path + '.bootstrap-off', False) + context, service, sink = await verify_bootstrap(path, True) policy_calls = [] def resolve_policy(request_key): policy_calls.append(request_key) return None # Observe the configured resolver; retain generated-default governance. context.with_mutation_policy_registry(DelegatingMutationPolicyRegistry(resolve_policy)) - service = ObservedService(create_sqlite_service(path)) - sink = AuditSink(service) - context.insert_resource('dataService', service).with_app_audit_event_sink(sink) - context.set_diagnostic_sql_log_sink(type('Silent', (), {'write': lambda self, entry: None})()) - await context.ensure_schema() platforms = await Q.platforms().with_id_is(1).limit(1).comment( 'what: reuse bootstrap root').purpose('why: attach trace example').execute_for_list(context) assert len(platforms) == 1 diff --git a/examples/trace-chain/verify.sh b/examples/trace-chain/verify.sh index a59322a..887e93a 100644 --- a/examples/trace-chain/verify.sh +++ b/examples/trace-chain/verify.sh @@ -73,6 +73,7 @@ for attempt in first second; do timeout --kill-after=5s 60s python "$example/main.py" | tee "$main_log" rg -Fxq 'PASS: identity controls reject duplicate, missing and equal-ID type collapse' "$main_log" [[ "$(rg -c '^GRAPH IDENTITY EVIDENCE ' "$main_log")" == 1 ]] + [[ "$(rg -c '^BOOTSTRAP INTENT EVIDENCE ' "$main_log")" == 2 ]] rg -Fxq 'PASS: generated library unchanged; all trace example checks passed' "$main_log" echo "Mutation privacy $attempt on $TEAQL_TRACE_CHAIN_DB without cleanup" privacy_log="$(mktemp -t teaql-python-privacy.XXXXXX.log)" From a40690a3b26fb8a21fc3afc6839a927a55fc624b Mon Sep 17 00:00:00 2001 From: Philip Z Date: Mon, 5 Oct 2026 15:11:37 +0800 Subject: [PATCH 33/40] test: verify generated Python readback request intent --- examples/trace-chain/main.py | 28 +++++++++++++++++++++++++--- examples/trace-chain/verify.sh | 1 + 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/examples/trace-chain/main.py b/examples/trace-chain/main.py index e5fbb8f..f715354 100644 --- a/examples/trace-chain/main.py +++ b/examples/trace-chain/main.py @@ -91,6 +91,20 @@ def physical_identities(service, context): return identities +def readback_fact(entry): + """Project actual safe SQL observations, omitting all parameter values.""" + def nodes(values): + return [{'Kind': node.kind, 'Name': node.name, 'EntityId': node.entity_id, + 'Comment': node.comment} for node in values] + return {'Operation': entry.operation.name, 'Comment': entry.comment, + 'Purpose': entry.purpose, 'AuditReason': entry.audit_reason, + 'ExecutionOutcome': entry.execution_outcome, + 'AffectedRows': entry.affected_rows, 'ResultCount': entry.result_count, + 'StartedAt': entry.started_at.isoformat(), 'EndedAt': entry.ended_at.isoformat(), + 'ParameterizedSQL': entry.sql, 'TraceChain': nodes(entry.trace_path), + 'MutationLineage': nodes(entry.mutation_lineage)} + + class ObservedService: """Transparent observer, not a replacement planner or provider.""" def __init__(self, service): @@ -350,22 +364,30 @@ def resolve_policy(request_key): metadata = {(entity, identity): chain(item.mutation_lineage) for (entity, identity, _), item in zip(service.requests, service.results)} audits = {(event.entity, scalar(event.entity_id)): chain(event.trace_chain) for event in sink.events} - assert len(service.requests) == len(service.results) == len(sink.events) == 6 + assert len(service.raw_requests) == len(service.requests) == len(service.results) == len(sink.events) == 6 assert commands == metadata == audits == expected - for record in service.results: + for request, record in zip(service.raw_requests, service.results): assert record.trace_chain[0].name == 'CustomerOrder' assert [node.kind for node in record.trace_chain] == ['operation', 'entity', 'provider', 'sql'] assert all(node.kind == 'auditReason' for node in record.mutation_lineage) assert len(record.statements) == 2 write, read = record.statements assert not write.statements and not read.statements - assert read.comment == write.comment == 'submit order' + assert request.comment() == 'submit order' + assert read.comment == write.comment == request.comment(), 'derived readback inherits actual mutation comment' + assert read.audit_reason == write.audit_reason == request.comment(), 'derived readback inherits root audit reason' + assert read.purpose == 'verify the persisted mutation result', 'derived readback owns verification purpose' assert read.mutation_lineage == write.mutation_lineage == record.mutation_lineage assert read.result_count == 1 and read.affected_rows is None assert read.execution_outcome == 'success' assert [node.kind for node in read.trace_chain] == ['operation', 'request', 'provider', 'sql'] assert [node.name for node in read.trace_chain] == ['CustomerOrder', 'CustomerOrder', 'sqlite', 'select'] assert read.trace_chain[0].comment == 'query' + assert write.ended_at <= read.started_at, 'physical write precedes its authoritative readback' + print('TC-REQ-10 PYTHON READBACK EVIDENCE ' + json.dumps({ + 'case': 'TC-REQ-10', 'writes': 6, 'readbacks': 6, + 'statements': [readback_fact(entry) for entry in context.sql_logs()], + }, sort_keys=True)) print('PASS: six writes plus six successful readbacks; root request paths; no duplicate SQL facts') print('PASS: normative six items; assigned typed identity; branch/deletion reasons; request/SQL/audit boundaries') diff --git a/examples/trace-chain/verify.sh b/examples/trace-chain/verify.sh index 887e93a..6baffa1 100644 --- a/examples/trace-chain/verify.sh +++ b/examples/trace-chain/verify.sh @@ -73,6 +73,7 @@ for attempt in first second; do timeout --kill-after=5s 60s python "$example/main.py" | tee "$main_log" rg -Fxq 'PASS: identity controls reject duplicate, missing and equal-ID type collapse' "$main_log" [[ "$(rg -c '^GRAPH IDENTITY EVIDENCE ' "$main_log")" == 1 ]] + [[ "$(rg -c '^TC-REQ-10 PYTHON READBACK EVIDENCE ' "$main_log")" == 1 ]] [[ "$(rg -c '^BOOTSTRAP INTENT EVIDENCE ' "$main_log")" == 2 ]] rg -Fxq 'PASS: generated library unchanged; all trace example checks passed' "$main_log" echo "Mutation privacy $attempt on $TEAQL_TRACE_CHAIN_DB without cleanup" From 982eeb4cf7fdebcbc22d93be600181040c6b283d Mon Sep 17 00:00:00 2001 From: Philip Z Date: Mon, 5 Oct 2026 18:50:12 +0800 Subject: [PATCH 34/40] test: require complete private lineage at Python generated save boundaries --- examples/trace-chain/mutation_privacy.py | 93 +++++++++++++++++++++++- examples/trace-chain/verify.sh | 2 + 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/examples/trace-chain/mutation_privacy.py b/examples/trace-chain/mutation_privacy.py index 78951bb..2000277 100644 --- a/examples/trace-chain/mutation_privacy.py +++ b/examples/trace-chain/mutation_privacy.py @@ -1,5 +1,6 @@ """Generated save privacy; no generated source inspection or synthetic traces.""" import asyncio +import json import os import uuid @@ -9,7 +10,84 @@ from teaql.provider.sqlite import create_sqlite_service from teaql.runtime import UserContext -from main import AuditSink, ObservedService, new, reset +from main import AuditSink, ObservedService, new, reset, readback_fact, scalar + + +def complete_lineage(nodes, root_id, reason, boundary): + """Empty/redacted-away responsibility is not a successful privacy projection.""" + assert len(nodes) == 1, boundary + ': complete typed root lineage required' + node = nodes[0] + assert (node.kind, node.name, node.entity_id, node.comment) == ( + 'auditReason', 'CustomerOrder', root_id, reason), boundary + ': wrong root responsibility' + + +def assert_private_lineage(service, sink, context, root_id, reason, identities, secrets, + expected_bindings, phase): + safe_reason = reason + for secret in secrets: + safe_reason = safe_reason.replace(secret, '[REDACTED]') + expected = set(identities) + assert len(expected) == len(identities), 'privacy graph needs distinct typed identities' + count = len(identities) + assert len(service.raw_requests) == len(service.results) == len(sink.events) == count + assert service.finishes == ['commit'], 'privacy graph must commit before audit delivery' + assert len(context.sql_logs()) == count * 2 + commands = [] + seen = set() + for request, result in zip(service.raw_requests, service.results): + command = request._data + identity = (command.entity, scalar(getattr(command, 'id', None) or command.values.get('id'))) + assert identity in expected and identity not in seen, 'actual privacy command identity mismatch' + seen.add(identity) + assert request.comment() == result.comment == reason, 'privacy must not rewrite command reason' + complete_lineage(request.mutation_lineage, root_id, reason, 'raw privacy command') + complete_lineage(result.mutation_lineage, root_id, reason, 'raw provider mutation') + assert len(result.statements) == 2 and result.statements[0].affected_rows == 1 + for field, value in expected_bindings.get(command.entity, {}).items(): + assert scalar(command.values[field]) == value, 'privacy must not rewrite command bindings' + assert value in [scalar(parameter) for parameter in result.statements[0].parameters], 'privacy must not rewrite provider parameters' + commands.append({'entity': identity[0], 'id': identity[1], 'comment': request.comment(), + 'lineage': readback_nodes(request.mutation_lineage)}) + assert seen == expected + for index, (request, result) in enumerate(zip(service.raw_requests, service.results)): + write, read = context.sql_logs()[index * 2:index * 2 + 2] + for fact in (write, read): + complete_lineage(fact.mutation_lineage, root_id, safe_reason, 'safe privacy SQL') + assert fact.comment == fact.audit_reason == safe_reason, 'safe SQL must retain public reason' + assert fact.execution_outcome == 'success' + assert [node.kind for node in fact.trace_path] == [ + 'operation', 'entity' if fact is write else 'request', 'provider', 'sql'] + assert fact.trace_path[0].name == 'CustomerOrder' + assert fact.trace_path[2].name == 'sqlite' + assert write.trace_path[1].name == request._data.entity + assert write.trace_path[-1].name == service.actions[index] + assert read.trace_path[-1].name == 'select' + assert write.affected_rows == read.result_count == 1 and read.affected_rows is None + assert read.purpose == 'verify the persisted mutation result' + assert write.ended_at <= read.started_at + assert not result.statements[0].statements and not result.statements[1].statements + audits = [] + seen = set() + for event in sink.events: + identity = (event.entity, scalar(event.entity_id)) + assert identity in expected and identity not in seen, 'committed privacy audit identity mismatch' + seen.add(identity) + complete_lineage(event.trace_chain, root_id, safe_reason, 'safe privacy audit') + audits.append({'entity': identity[0], 'id': identity[1], + 'lineage': readback_nodes(event.trace_chain)}) + assert seen == expected + for secret in secrets: + assert secret not in repr(context.sql_logs()) and secret not in repr(sink.events) + print('PRIVATE_LINEAGE_OBSERVED ' + json.dumps({ + 'phase': phase, 'rootId': root_id, 'rawReason': reason, 'safeReason': safe_reason, + 'commands': commands, 'sql': [readback_fact(fact) for fact in context.sql_logs()], + 'audit': audits}, sort_keys=True)) + print('PASS Python complete private lineage: raw commands, safe SQL/readback and committed audit') + + +def readback_nodes(nodes): + return [{'Kind': node.kind, 'Name': node.name, 'EntityId': node.entity_id, + 'Comment': node.comment} for node in nodes] async def main(): @@ -52,6 +130,10 @@ async def main(): for metadata in service.results: assert len(metadata.statements) == 2 assert metadata.comment == reason # raw owned intent is not rewritten + assert_private_lineage(service, sink, context, E.customer_order(root).id().eval(), reason, + [('CustomerOrder', E.customer_order(root).id().eval()), + ('OrderItem', E.order_item(item).id().eval()), ('Payment', E.payment(payment).id().eval())], + [secret, old], {'Payment': {'reference_code': secret}}, phase) loaded = await Q.payments().with_id_is(E.payment(payment).id().eval()).limit(1).comment( 'reload authoritative payment').purpose('verify safe logs do not alter business data').execute_for_one(context) assert E.payment(loaded).reference_code().eval() == secret @@ -77,6 +159,9 @@ async def main(): assert secret not in repr(context.sql_logs()), 'loaded delete value leaked into SQL intent' assert secret not in repr(sink.events), 'loaded delete value leaked into committed audit' assert all(metadata.comment == reason for metadata in service.results) + assert_private_lineage(service, sink, context, E.customer_order(root).id().eval(), reason, + [('CustomerOrder', E.customer_order(root).id().eval()), ('Payment', payment_id)], + [secret], {}, 'delete') missing = await Q.payments().with_id_is(payment_id).limit(1).comment('verify deleted payment').purpose( 'prove soft delete hides the row').execute_for_one(context) assert missing is None @@ -94,10 +179,14 @@ async def main(): 'load all order fields').purpose('retain an unchanged private description').execute_for_one(context) root.update_order_number(label + '-revised') reset(service, sink, context) - await root.audit_as(f'renumber {private_description}').save(context) + reason = f'renumber {private_description}' + await root.audit_as(reason).save(context) assert len(service.requests) == len(sink.events) == 1 assert private_description not in repr(context.sql_logs()), 'unchanged loaded scalar leaked into SQL intent' assert private_description not in repr(sink.events), 'unchanged loaded scalar leaked into audit' + assert_private_lineage(service, sink, context, E.customer_order(root).id().eval(), reason, + [('CustomerOrder', E.customer_order(root).id().eval())], [private_description], + {'CustomerOrder': {'order_number': label + '-revised'}}, 'unchanged') reloaded = await Q.customer_orders().with_id_is(E.customer_order(root).id().eval()).limit(1).comment( 'verify unchanged description').purpose('prove provenance did not alter business values').execute_for_one(context) assert E.customer_order(reloaded).description().eval() == private_description diff --git a/examples/trace-chain/verify.sh b/examples/trace-chain/verify.sh index 6baffa1..cc1988d 100644 --- a/examples/trace-chain/verify.sh +++ b/examples/trace-chain/verify.sh @@ -81,6 +81,8 @@ for attempt in first second; do python "$example/mutation_privacy.py" | tee "$privacy_log" rg -Fq 'PASS: Python generated loaded delete privacy; 2 writes/2 reads/2 audits; independent next request' "$privacy_log" rg -Fq 'PASS: Python generated unchanged scalar privacy; 1 write/1 read/1 audit' "$privacy_log" + [[ "$(rg -c '^PRIVATE_LINEAGE_OBSERVED ' "$privacy_log")" == 4 ]] + [[ "$(rg -c '^PASS Python complete private lineage:' "$privacy_log")" == 4 ]] echo "Assigned identities $attempt on $TEAQL_TRACE_CHAIN_SHARED_DB without cleanup" assigned_log="$(mktemp -t teaql-python-assigned.XXXXXX.log)" TEAQL_TRACE_CHAIN_DB="$TEAQL_TRACE_CHAIN_SHARED_DB" \ From 7b80c15e24bfca53af6c04d4a0098db015f4a6d5 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Mon, 5 Oct 2026 22:09:35 +0800 Subject: [PATCH 35/40] test: inspect generated Facet raw paths with logging disabled --- examples/facet-trace/README.md | 7 ++++++- examples/facet-trace/main.py | 34 +++++++++++++++++++++++++++++----- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/examples/facet-trace/README.md b/examples/facet-trace/README.md index 47c1822..b2d6d0e 100644 --- a/examples/facet-trace/README.md +++ b/examples/facet-trace/README.md @@ -12,7 +12,12 @@ full matching counts, and SQL logging on/off. Generated Q/E and audited mutation APIs seed and traverse the graph. Real database reads, count SQL, safe diagnostics, request immutability, raw policy intent, future-binding masking, and independent next-request `NotLoaded` semantics are asserted. `FACET_OBSERVED` records actual -counts/results and diagnostic routes, not just a success marker. +counts/results and diagnostic routes, not just a success marker. The transport +and Context metadata boundary are observed separately: every SELECT retains +its full original root/ancestor path and raw request-owned intent even with +logging disabled, safe diagnostics emit nothing when disabled, and query +execution performs no writes. Expected frames are never injected into runtime +requests by the fixture. `lib/` is read-only generated output retained from producer commit `c7407c93c186fa9f0e2ff9599952f333a5963020` (Python issue #43 / producer #251). diff --git a/examples/facet-trace/main.py b/examples/facet-trace/main.py index b5a2723..c04f59a 100644 --- a/examples/facet-trace/main.py +++ b/examples/facet-trace/main.py @@ -48,7 +48,7 @@ def log_row(entry): 'debug_sql': entry.debug_sql, 'params': [getattr(value, 'val', value) for value in entry.params], 'path': [(n.kind, n.name, n.comment) for n in entry.trace_path]} -def assert_observed(context, diagnostics, reads, loaded, nested, logging): +def assert_observed(context, diagnostics, reads, raw, writes, loaded, nested, logging, comment): child = [('school_list', 'SchoolType.school_list')] if loaded else [] first = child + [('school_type', 'School.school_type')] second = first + [('platform', 'SchoolType.platform')] @@ -56,11 +56,21 @@ def assert_observed(context, diagnostics, reads, loaded, nested, logging): routes = [[], child, *branch, *branch] if loaded else [[], [], first] + ([first, second] if nested else []) assert len(reads) == len(routes), ('physical SQL count', len(reads), routes) assert sum('COUNT(' in sql.upper() for sql, _ in reads) == (4 if loaded else 2 if nested else 1) + assert not writes, 'Facet reads must not write query metadata or entities' + assert len(raw) == len(reads), 'logging-off must retain actual execution metadata' + root = 'SchoolType' if loaded else 'School' + for entry, route, (sql, _) in zip(raw, routes, reads): + assert entry['sql'] == sql + assert entry['outcome'] == 'success' + assert entry['purpose'] == PURPOSE + assert entry['comment'] == comment, 'raw metadata must retain original request-owned intent' + assert entry['path'] == [('operation', root, 'query'), ('request', root, ''), + *[('relation', name, detail) for name, detail in route], + ('provider', 'sqlite', ''), ('sql', 'select', '')] entries = context.sql_logs() assert len(entries) == len(diagnostics) == (len(reads) if logging else 0) if not logging: return - root = 'SchoolType' if loaded else 'School' for entry, route in zip(entries, routes): assert entry.execution_outcome == 'success' assert entry.purpose == PURPOSE @@ -77,7 +87,7 @@ def assert_observed(context, diagnostics, reads, loaded, nested, logging): async def main(): service = create_sqlite_service(os.environ['TEAQL_FACET_TRACE_DB']) context = UserContext.new().install(GENERATED_RUNTIME_MODULE).insert_resource('dataService', service) - diagnostics, reads, policies = [], [], [] + diagnostics, reads, policies, raw, writes = [], [], [], [], [] context.set_diagnostic_sql_log_sink(SimpleNamespace(write=diagnostics.append)) await context.ensure_schema() for suffix in ('', '-B'): @@ -95,6 +105,19 @@ async def capture(compiled): reads.append((compiled.sql, [value.val for value in compiled.params])) return await original_fetch(compiled) service.transport.fetch_all_sql = capture + original_execute = service.transport.execute_sql + async def capture_write(compiled): + writes.append(compiled.sql) + return await original_execute(compiled) + service.transport.execute_sql = capture_write + original_record = context._record_metadata_log + def capture_metadata(metadata, *args, **kwargs): + # Observe runtime-produced frames without injecting expected lineage. + raw.append({'comment': metadata.comment, 'purpose': metadata.purpose, + 'outcome': metadata.execution_outcome, 'sql': metadata.parameterized_sql, + 'path': [(n.kind, n.name, n.comment) for n in metadata.trace_chain]}) + return original_record(metadata, *args, **kwargs) + context._record_metadata_log = capture_metadata def policy(query): policies.append(query.comment_text) return query @@ -106,7 +129,7 @@ def policy(query): for empty in ((False,) if mode == 'loaded' else (False, True)): label = (mode, include_all, logging, empty) context.enable_all_sql_log() if logging else context.disable_sql_log() - context.clear_sql_logs(); diagnostics.clear(); reads.clear(); policies.clear() + context.clear_sql_logs(); diagnostics.clear(); reads.clear(); policies.clear(); raw.clear(); writes.clear() nested = mode != 'root' request = school_facets(include_all, nested) if empty: request.with_name_is('absent') @@ -121,7 +144,7 @@ def policy(query): rows = await executable.execute_for_list(context) assert request.query == before, 'execution mutated caller query' assert policies[0] == comment, 'safe projection changed policy intent' - assert_observed(context, diagnostics, reads, mode == 'loaded', nested, logging) + assert_observed(context, diagnostics, reads, raw, writes, mode == 'loaded', nested, logging, comment) if mode == 'loaded': assert [E.school_type(parent).id().eval() for parent in rows] == [1001, 1002] results = [] @@ -148,6 +171,7 @@ def policy(query): print('FACET_OBSERVED ' + json.dumps({'case': label, 'calls': len(reads), 'counts': sum('COUNT(' in sql.upper() for sql, _ in reads), 'results': results, + 'raw': raw, 'writes': writes, 'sql': [log_row(entry) for entry in context.sql_logs()]})) cases += 1 except Exception as error: From d9bf8732f4c3a3ab3ee9ebde3efff608cc05fe10 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Mon, 5 Oct 2026 23:23:49 +0800 Subject: [PATCH 36/40] test: observe generated aggregate paths with logs disabled and filtered details --- examples/trace-chain/README.md | 15 ++++- examples/trace-chain/relation_aggregate.py | 75 ++++++++++++++++++++-- 2 files changed, 83 insertions(+), 7 deletions(-) diff --git a/examples/trace-chain/README.md b/examples/trace-chain/README.md index 1f48dfa..aa1a30e 100644 --- a/examples/trace-chain/README.md +++ b/examples/trace-chain/README.md @@ -7,6 +7,17 @@ captures actual requests and SQL metadata; the safe audit sink asserts that database commit has already completed. No expected trace frames are supplied to the planner or provider. +The generated aggregate suite observes every physical SQL's unmodified runtime +metadata with logging enabled and disabled. Root/nested aggregates retain the +original query, verified reverse-relation edges and all loaded ancestors. In the +same query, a loaded Payment list preserves membership and its real parent ID +even when a forward detail filter excludes the parent; generated E rejects +unfetched description as NotLoaded. Counts remain 1 filtered / 2 total. Numeric +non-relation grouping/window paths are separately tested by +`tests/provider/sqlite/test_numeric_partition_trace.py`; they must not invent a +relation edge. These checks do not decide whether to allow forward-detail +filtering as an application API, which remains a design discussion. + ```bash cd /path/to/teaql-python bash examples/trace-chain/verify.sh @@ -117,7 +128,9 @@ This rejection includes related aggregate enhancements and occurs before opening a provider cursor, even with SQL logging disabled. Native SQLite trace tests also cover loading and aggregating the same forward relation, nested scalar-key recovery and aggregate failure/privacy. These native tests do not yet constitute -generated Q/E acceptance of related aggregates in this example. +generated acceptance of a forward-relation aggregate API; the generated +`relation_aggregate.py` suite covers reverse counts combined with filtered +forward detail and checked list membership. `PAGE_STREAM_OBSERVED` records actual SQL, policy-reviewed operations, commands, optimistic versions and safe audits. Native tests additionally cover COUNT diff --git a/examples/trace-chain/relation_aggregate.py b/examples/trace-chain/relation_aggregate.py index afefd7e..a02f168 100644 --- a/examples/trace-chain/relation_aggregate.py +++ b/examples/trace-chain/relation_aggregate.py @@ -1,5 +1,6 @@ """Assist-guided generated Q/E/save: root and nested aggregate provenance.""" import asyncio +import copy import json import os import uuid @@ -40,7 +41,7 @@ async def main(): order.payment_list().append(payment) await order.audit_as('seed aggregate graph').save(context) order_id, payment_id = E.customer_order(order).id().eval(), E.payment(payment).id().eval() - reads, fault = [], [False] + reads, raw, fault = [], [], [False] original_fetch = native.transport.fetch_all_sql async def capture(compiled): @@ -50,13 +51,45 @@ async def capture(compiled): return await original_fetch(compiled) native.transport.fetch_all_sql = capture + original_record = context._record_metadata_log + + def capture_metadata(metadata, *args, **kwargs): + # Read-only observation of runtime-produced frames, even with logs off. + raw.append(copy.deepcopy(metadata)) + return original_record(metadata, *args, **kwargs) + + context._record_metadata_log = capture_metadata + + def raw_rows(): + return [{'sql': entry.parameterized_sql, 'comment': entry.comment, + 'purpose': entry.purpose, 'outcome': entry.execution_outcome, + 'path': [(n.kind, n.name, n.comment) for n in entry.trace_chain]} for entry in raw] + + def assert_raw(root, routes, comment, purpose, failed=False): + assert len(raw) == len(reads) == len(routes), 'every physical SQL needs metadata' + actual_routes = [] + for entry, sql in zip(raw, reads): + assert entry.parameterized_sql == sql + assert entry.comment == comment and entry.purpose == purpose + route = [(node.name, node.comment) for node in entry.trace_chain if node.kind == 'relation'] + actual_routes.append(route) + assert [(node.kind, node.name, node.comment) for node in entry.trace_chain] == [ + ('operation', root, 'query'), ('request', root, ''), + *[('relation', name, detail) for name, detail in route], + ('provider', 'sqlite', ''), ('sql', 'select', '')] + assert all(name not in ('filtered_count', 'save') for name, _ in route) + assert sorted(actual_routes) == sorted(routes), (actual_routes, routes) + assert [entry.execution_outcome for entry in raw] == [ + *['success'] * (len(raw) - 1), 'failure' if failed else 'success'] def query(nested, filtered=secret): # Each aggregate has its own child builder; no hand-written application SQL. request = (Q.customer_orders().with_id_is(order_id).limit(1) .count_order_items_with('filtered_count', Q.order_items().with_name_is(filtered).limit(10)) .count_order_items_as('save') - .select_order_item_list_with(Q.order_items().limit(10))) + .select_order_item_list_with(Q.order_items().limit(10)) + .select_payment_list_with(Q.payments().limit(10).select_customer_order_with( + Q.customer_orders().with_id_is(0).limit(1)))) if nested: request = (Q.payments().with_id_is(payment_id).limit(1) .select_customer_order_with(request)) @@ -66,6 +99,7 @@ def clear(): reset(service, sink, context) diagnostic.clear() reads.clear() + raw.clear() policy.plans.clear() for logging in (True, False): @@ -103,6 +137,17 @@ def assert_unfetched(): loaded = E.payment(rows[0]).customer_order().eval() if nested else rows[0] assert E.customer_order(loaded).id().eval() == order_id assert E.customer_order(loaded).order_item_list().size().eval() == 2 + assert E.customer_order(loaded).payment_list().size().eval() == 1 + # Filtering detail must not erase either list membership or its real FK. + child = loaded.payment_list()[0] + unfetched = E.payment(child).customer_order().eval() + assert E.customer_order(unfetched).id().eval() == order_id + try: + E.customer_order(unfetched).description().eval() + except RuntimeError as error: + assert type(error).__name__ == 'TeaQLNotLoadedError', error + else: + raise AssertionError('aggregate child detail became loaded-null') assert loaded.has_query_projection('filtered_count') assert loaded.query_projection('filtered_count') == 1 assert loaded.query_projection('save') == 2 and callable(loaded.save) @@ -113,14 +158,23 @@ def assert_unfetched(): pass else: raise AssertionError('missing aggregate silently became a value') - assert len(reads) == (5 if nested else 4), reads + assert len(reads) == (7 if nested else 6), reads + prefix = [('customer_order', 'Payment.customer_order')] if nested else [] + item_route = prefix + [('order_item_list', 'CustomerOrder.order_item_list')] + payment_route = prefix + [('payment_list', 'CustomerOrder.payment_list')] + routes = [[], *([prefix] if nested else []), item_route, payment_route, + payment_route + [('customer_order', 'Payment.customer_order')], item_route, item_route] + assert_raw('Payment' if nested else 'CustomerOrder', routes, + 'inspect ' + secret, 'verify aggregate provenance') assert service.requests == [] and policy.plans == [] and sink.events == [] if logging: assert len(context.sql_logs()) == len(diagnostic) == len(reads) - for entry in context.sql_logs(): + for entry, metadata in zip(context.sql_logs(), raw): assert entry.trace_path[0].name == ('Payment' if nested else 'CustomerOrder') assert entry.purpose == 'verify aggregate provenance' assert secret not in json.dumps(log_row(entry)) + assert [(n.kind, n.name, n.comment) for n in entry.trace_path] == [ + (n.kind, n.name, n.comment) for n in metadata.trace_chain] aggregates = [entry for entry in context.sql_logs() if 'COUNT(' in entry.sql.upper()] assert len(aggregates) == 2 for entry in aggregates: @@ -131,7 +185,10 @@ def assert_unfetched(): assert context.sql_logs() == diagnostic == [] print('AGGREGATE_OBSERVED ' + json.dumps({'nested': nested, 'logging': logging, 'count': loaded.query_projection('filtered_count'), 'provider_calls': len(reads), - 'sql': [log_row(entry) for entry in context.sql_logs()]})) + 'sql': [log_row(entry) for entry in context.sql_logs()], + 'raw': raw_rows(), + 'membership': {'items': 2, 'payments': 1, 'foreign_id': order_id, + 'target_detail': 'NotLoaded'}})) clear() await loaded.update_description('aggregate saved ' + uuid.uuid4().hex).audit_as( 'save model field without projection aliases').save(context) @@ -157,20 +214,26 @@ def assert_unfetched(): raise AssertionError('aggregate provider failure was swallowed') finally: fault[0] = False + # Aggregates precede eager relation loads; failure must stop those later reads. assert len(reads) == 2 and service.requests == [] and sink.events == [] + assert_raw('CustomerOrder', [[], [('order_item_list', 'CustomerOrder.order_item_list')]], + 'inspect ' + secret, 'verify aggregate provenance', failed=True) if logging: assert context.sql_logs()[-1].execution_outcome == 'failure' assert all(secret not in json.dumps(log_row(entry)) for entry in context.sql_logs()) else: assert context.sql_logs() == diagnostic == [] print('AGGREGATE_FAILURE ' + json.dumps({'logging': logging, 'provider_calls': len(reads), - 'sql': [log_row(entry) for entry in context.sql_logs()]})) + 'sql': [log_row(entry) for entry in context.sql_logs()], 'raw': raw_rows()})) clear() await (Q.platforms().with_id_is(1).limit(1).comment('independent next query') .purpose('prove scope restored').execute_for_list(context)) assert len(reads) == 1 + assert_raw('Platform', [[]], 'independent next query', 'prove scope restored') assert all(entry.comment == 'independent next query' and entry.trace_path[0].name == 'Platform' for entry in context.sql_logs()) + print('AGGREGATE_INDEPENDENT ' + json.dumps({'logging': logging, 'raw': raw_rows(), + 'sql': [log_row(entry) for entry in context.sql_logs()]})) print('PASS: Python generated aggregates; 4 root/nested/logging cases; isolated save; 2 failures restored') From 6bb54c6059bbd5e541a53090db10974af0cf1a67 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Tue, 6 Oct 2026 11:25:16 +0800 Subject: [PATCH 37/40] fix: stabilize grouped relation limits by group keys (#43) --- src/teaql/sql/executor.py | 9 +++++++-- tests/provider/sqlite/test_numeric_partition_trace.py | 3 +++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/teaql/sql/executor.py b/src/teaql/sql/executor.py index 0613ceb..7b45aa9 100644 --- a/src/teaql/sql/executor.py +++ b/src/teaql/sql/executor.py @@ -693,8 +693,13 @@ async def _enhance_relations(self, context, parents: List[Dict[str, Any]], reque if relation.foreign_key not in child_query.projection: child_query.projection.append(relation.foreign_key) limited = child_query.slice is not None and child_query.slice.limit is not None - if limited and not any(order.field_name == "id" for order in child_query.order_by_items): - child_query.order_asc("id") + if limited: + # Aggregate rows have group identities, not source-row IDs. + stable_fields = child_query.group_by_items or ( + ['id'] if not child_query.aggregates else []) + for field in stable_fields: + if not any(order.field_name == field for order in child_query.order_by_items): + child_query.order_asc(field) threshold = child_query.top_n_probe_threshold_value provider_policy = self.dialect.relation_top_n_policy() use_probes = limited and ( diff --git a/tests/provider/sqlite/test_numeric_partition_trace.py b/tests/provider/sqlite/test_numeric_partition_trace.py index d4d796f..d4219cf 100644 --- a/tests/provider/sqlite/test_numeric_partition_trace.py +++ b/tests/provider/sqlite/test_numeric_partition_trace.py @@ -1,6 +1,7 @@ """Native numeric grouping/window evidence, not generated aggregation acceptance.""" from copy import deepcopy import json +import re from types import SimpleNamespace import aiosqlite @@ -187,6 +188,8 @@ async def test_loaded_grouped_relation_keeps_ancestors_membership_and_future_pri assert all('__teaql_partition_rank' not in row for parent in loaded for row in parent['items']) child_reads = transport.reads[2:] assert all('GROUP BY' in item.sql and 'HAVING' in item.sql for item in child_reads) + assert all(not re.search(r'(? Date: Tue, 6 Oct 2026 13:04:17 +0800 Subject: [PATCH 38/40] test: reject wrong-kind nodes at generated graph lineage boundaries (#43) --- examples/trace-chain/main.py | 18 +++++++++++++++++- examples/trace-chain/verify.sh | 1 + 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/examples/trace-chain/main.py b/examples/trace-chain/main.py index f715354..1a46c0e 100644 --- a/examples/trace-chain/main.py +++ b/examples/trace-chain/main.py @@ -4,6 +4,7 @@ import os import sqlite3 import uuid +from types import SimpleNamespace from E import E from Q import Q @@ -16,7 +17,9 @@ def chain(nodes): - return [(node.name or node.entity_type, node.entity_id, node.comment) for node in nodes] + values = list(nodes) + assert all(node.kind == 'auditReason' for node in values), 'lineage requires typed AuditReason nodes' + return [(node.name or node.entity_type, node.entity_id, node.comment) for node in values] def scalar(value): @@ -47,6 +50,19 @@ def check_identities(expected, actual, boundary): def identity_controls(): + # Pure oracle inputs only: these nodes are never supplied to the runtime. + def sample(kind): + return SimpleNamespace(kind=kind, name='CustomerOrder', entity_type='CustomerOrder', + entity_id=1, comment='submit order') + assert chain([sample('auditReason')]) == [('CustomerOrder', 1, 'submit order')] + for kind in ('comment', 'purpose', 'entity', 'sql'): + try: + chain([sample(kind)]) + except AssertionError as error: + assert str(error) == 'lineage requires typed AuditReason nodes' + else: + raise AssertionError('wrong-kind lineage oracle input was accepted: ' + kind) + print('PASS: typed lineage oracle rejects correct-text Comment/Purpose/Entity/Sql nodes') expected = [('CustomerOrder', 1), ('OrderItem', 1), ('Payment', 1), ('PaymentAttempt', 1), ('Shipment', 1), ('OrderItem', 2)] check_identities(expected, expected, 'positive control') diff --git a/examples/trace-chain/verify.sh b/examples/trace-chain/verify.sh index cc1988d..a4c7920 100644 --- a/examples/trace-chain/verify.sh +++ b/examples/trace-chain/verify.sh @@ -71,6 +71,7 @@ for attempt in first second; do echo "Run $attempt on $TEAQL_TRACE_CHAIN_DB without cleanup" main_log="$(mktemp -t teaql-python-main.XXXXXX.log)" timeout --kill-after=5s 60s python "$example/main.py" | tee "$main_log" + rg -Fxq 'PASS: typed lineage oracle rejects correct-text Comment/Purpose/Entity/Sql nodes' "$main_log" rg -Fxq 'PASS: identity controls reject duplicate, missing and equal-ID type collapse' "$main_log" [[ "$(rg -c '^GRAPH IDENTITY EVIDENCE ' "$main_log")" == 1 ]] [[ "$(rg -c '^TC-REQ-10 PYTHON READBACK EVIDENCE ' "$main_log")" == 1 ]] From 4167e1d998dd163c3c596dbd9c84dbde51eff751 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Tue, 6 Oct 2026 20:25:42 +0800 Subject: [PATCH 39/40] ci: install ripgrep for complete example verification --- .github/workflows/example-gate.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/example-gate.yml b/.github/workflows/example-gate.yml index 032f7bd..af52d20 100644 --- a/.github/workflows/example-gate.yml +++ b/.github/workflows/example-gate.yml @@ -9,6 +9,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Install example verification tools + run: sudo apt-get update && sudo apt-get install -y ripgrep - uses: actions/setup-python@v5 with: python-version: '3.10' From f9d7032903c47bdc5fc5bfb1d4f003a3a29be375 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Tue, 6 Oct 2026 20:27:50 +0800 Subject: [PATCH 40/40] ci: install native Python example test dependencies --- .github/workflows/example-gate.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/example-gate.yml b/.github/workflows/example-gate.yml index af52d20..b501732 100644 --- a/.github/workflows/example-gate.yml +++ b/.github/workflows/example-gate.yml @@ -15,5 +15,5 @@ jobs: with: python-version: '3.10' cache: pip - - run: pip install -e . aiosqlite + - run: pip install -e . aiosqlite pytest pytest-asyncio - run: ./scripts/verify-examples.sh