diff --git a/.github/workflows/example-gate.yml b/.github/workflows/example-gate.yml index 032f7bd..b501732 100644 --- a/.github/workflows/example-gate.yml +++ b/.github/workflows/example-gate.yml @@ -9,9 +9,11 @@ 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' cache: pip - - run: pip install -e . aiosqlite + - run: pip install -e . aiosqlite pytest pytest-asyncio - run: ./scripts/verify-examples.sh diff --git a/README.md b/README.md index a92bfc4..2f65adb 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,81 @@ 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. + +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. + +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. + +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. + +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, +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 +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/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..9abd71d 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)) + 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) @@ -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: @@ -100,17 +100,17 @@ 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) + await context.execute_graph_save(partial_graph, comment='what: runtime regression fixture') 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)', [])) @@ -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..84ba4e5 100644 --- a/examples/conformance/models/platform.py +++ b/examples/conformance/models/platform.py @@ -1,8 +1,11 @@ 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 import itertools + class Platform: _teaql_temporary_ids = itertools.count(1) @classmethod @@ -31,15 +34,18 @@ 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") 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) @@ -50,13 +56,16 @@ 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) 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) @@ -67,26 +76,71 @@ 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) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) - async def _teaql_preflight_and_save(self, context): - self._teaql_preflight_graph(context) - return await self._teaql_save_within_graph(context) + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return - def _teaql_build_command(self): + 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._work_item_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 "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(), {}) @@ -94,38 +148,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)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() 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()") - 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.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) + 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 "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) - setattr(child, "platform", self) - child._loaded_fields.add("platform") - child._entity_root.set(child._teaql_entity_key(), "platform", Value.Object(self)) - child.audit_as(self._comment) + current = getattr(child, "platform", None) + if getattr(current, "id", current) != self.id: + child.update_platform(self) 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([ @@ -133,17 +192,19 @@ def _teaql_preflight_graph(self, context): for v in error.violations ]) 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()") + 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 = MutationRequest(cmd) - if self._comment: - req.comment = self._comment + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) try: context.check_and_fix_mutation(cmd) @@ -182,6 +243,7 @@ async def _teaql_save_within_graph(self, context): 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: @@ -194,40 +256,45 @@ 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" - 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) - child.audit_as(self._comment) - try: - await child._teaql_save_within_graph(context) - 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._teaql_loaded_snapshot = committed_snapshot 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) + 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): + 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 2ffd6b3..cec96ce 100644 --- a/examples/conformance/models/work_item.py +++ b/examples/conformance/models/work_item.py @@ -1,9 +1,12 @@ 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 import itertools from models.platform import Platform + class WorkItem: _teaql_temporary_ids = itertools.count(1) @classmethod @@ -36,10 +39,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) @@ -50,13 +58,16 @@ 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) 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): @@ -65,30 +76,78 @@ 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) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) - async def _teaql_preflight_and_save(self, context): - self._teaql_preflight_graph(context) - return await self._teaql_save_within_graph(context) + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return - def _teaql_build_command(self): + 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("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, 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 "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) + + 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(), {}) @@ -96,46 +155,54 @@ 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)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() 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()") - 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.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) + 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 "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) - 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()") + 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 = MutationRequest(cmd) - if self._comment: - req.comment = self._comment + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) try: context.check_and_fix_mutation(cmd) @@ -186,6 +253,7 @@ async def _teaql_save_within_graph(self, context): 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: @@ -198,39 +266,44 @@ 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" - 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) - child.audit_as(self._comment) - try: - await child._teaql_save_within_graph(context) - 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._teaql_loaded_snapshot = committed_snapshot 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) + 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)) 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..ea0f3a3 100644 --- a/examples/conformance/requests/platform_request.py +++ b/examples/conformance/requests/platform_request.py @@ -1,7 +1,8 @@ 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, @@ -352,8 +353,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 +361,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): @@ -372,53 +371,59 @@ 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]: 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)) 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)) - query_root = EntityRoot() - data = SmartList(Platform(_entity_root=query_root, **row) for row in row_result.rows) + 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): - 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): + 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): - for row in chunk.rows: - yield Platform(_entity_root=query_root, **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 d3ed1ad..0728807 100644 --- a/examples/conformance/requests/work_item_request.py +++ b/examples/conformance/requests/work_item_request.py @@ -1,7 +1,8 @@ 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, @@ -442,8 +443,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 +451,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): @@ -462,53 +461,59 @@ 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]: 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)) 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)) - query_root = EntityRoot() - data = SmartList(WorkItem(_entity_root=query_root, **row) for row in row_result.rows) + 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) 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): + 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): - for row in chunk.rows: - yield WorkItem(_entity_root=query_root, **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/facet-trace/README.md b/examples/facet-trace/README.md new file mode 100644 index 0000000..b2d6d0e --- /dev/null +++ b/examples/facet-trace/README.md @@ -0,0 +1,33 @@ +# 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. 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). +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/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 diff --git a/examples/facet-trace/main.py b/examples/facet-trace/main.py new file mode 100644 index 0000000..c04f59a --- /dev/null +++ b/examples/facet-trace/main.py @@ -0,0 +1,192 @@ +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, 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')] + 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) + 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 + 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, raw, writes = [], [], [], [], [] + 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 + 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 + 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(); raw.clear(); writes.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, raw, writes, mode == 'loaded', nested, logging, comment) + 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, + 'raw': raw, 'writes': writes, + '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/examples/mutation-policy/main.py b/examples/mutation-policy/main.py index 008ed06..e31380a 100644 --- a/examples/mutation-policy/main.py +++ b/examples/mutation-policy/main.py @@ -127,14 +127,15 @@ 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: - 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..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,8 +1,11 @@ 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 import itertools + class CommercePlatform: _teaql_temporary_ids = itertools.count(1) @classmethod @@ -35,10 +38,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") @@ -60,37 +68,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) @@ -101,13 +109,16 @@ 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) 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) @@ -128,30 +139,92 @@ 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) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) - async def _teaql_preflight_and_save(self, context): - self._teaql_preflight_graph(context) - return await self._teaql_save_within_graph(context) + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return - def _teaql_build_command(self): + 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("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(graph, visited) + for child in self._order_status_list: + child._teaql_attach_root(self._entity_root) + 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(graph, visited) + for child in self._product_list: + child._teaql_attach_root(self._entity_root) + 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(graph, visited) + for child in self._order_search_preset_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 "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(), {}) @@ -159,42 +232,47 @@ 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)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() 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()") - 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.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) + 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 "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) - setattr(child, "commercePlatform", self) - child._loaded_fields.add("commercePlatform") - child._entity_root.set(child._teaql_entity_key(), "commerce_platform", Value.Object(self)) - child.audit_as(self._comment) + current = getattr(child, "commercePlatform", None) + if getattr(current, "id", current) != self.id: + child.update_commerce_platform(self) try: - child._teaql_preflight_graph(context) + child._teaql_preflight_graph(graph) except CheckException as error: prefix = ObjectLocation().property("customer_list").index(index) raise CheckException([ @@ -203,12 +281,11 @@ 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.audit_as(self._comment) + current = getattr(child, "commercePlatform", None) + if getattr(current, "id", current) != self.id: + child.update_commerce_platform(self) 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([ @@ -217,12 +294,11 @@ 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.audit_as(self._comment) + current = getattr(child, "commercePlatform", None) + if getattr(current, "id", current) != self.id: + child.update_commerce_platform(self) 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([ @@ -231,12 +307,11 @@ 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.audit_as(self._comment) + current = getattr(child, "commercePlatform", None) + if getattr(current, "id", current) != self.id: + child.update_commerce_platform(self) try: - child._teaql_preflight_graph(context) + child._teaql_preflight_graph(graph) except CheckException as error: prefix = ObjectLocation().property("product_list").index(index) raise CheckException([ @@ -245,12 +320,11 @@ 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.audit_as(self._comment) + current = getattr(child, "commercePlatform", None) + if getattr(current, "id", current) != self.id: + child.update_commerce_platform(self) 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([ @@ -259,12 +333,11 @@ 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.audit_as(self._comment) + current = getattr(child, "commercePlatform", None) + if getattr(current, "id", current) != self.id: + child.update_commerce_platform(self) 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([ @@ -272,17 +345,19 @@ def _teaql_preflight_graph(self, context): for v in error.violations ]) 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()") + 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 = MutationRequest(cmd) - if self._comment: - req.comment = self._comment + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) try: context.check_and_fix_mutation(cmd) @@ -333,6 +408,7 @@ async def _teaql_save_within_graph(self, context): 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: @@ -345,45 +421,50 @@ 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" - 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) - child.audit_as(self._comment) - try: - await child._teaql_save_within_graph(context) - 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._teaql_loaded_snapshot = committed_snapshot 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) + 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): + 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 c8cd7ab..ff0b9c5 100644 --- a/examples/order-management/python-lib-core/models/customer.py +++ b/examples/order-management/python-lib-core/models/customer.py @@ -1,9 +1,12 @@ 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 import itertools from models.commerce_platform import CommercePlatform + class Customer: _teaql_temporary_ids = itertools.count(1) @classmethod @@ -40,12 +43,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", []) @@ -54,7 +64,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) @@ -65,13 +75,16 @@ 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) 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) @@ -82,34 +95,87 @@ 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) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) - async def _teaql_preflight_and_save(self, context): - self._teaql_preflight_graph(context) - return await self._teaql_save_within_graph(context) + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return - def _teaql_build_command(self): + 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("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(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 "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) + + 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(), {}) @@ -117,46 +183,51 @@ 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)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() 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()") - 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.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) + 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 "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) - setattr(child, "customer", self) - child._loaded_fields.add("customer") - child._entity_root.set(child._teaql_entity_key(), "customer", Value.Object(self)) - child.audit_as(self._comment) + current = getattr(child, "customer", None) + if getattr(current, "id", current) != self.id: + child.update_customer(self) 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([ @@ -164,17 +235,19 @@ def _teaql_preflight_graph(self, context): for v in error.violations ]) 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()") + 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 = MutationRequest(cmd) - if self._comment: - req.comment = self._comment + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) try: context.check_and_fix_mutation(cmd) @@ -237,6 +310,7 @@ async def _teaql_save_within_graph(self, context): 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: @@ -249,40 +323,45 @@ 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" - 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) - child.audit_as(self._comment) - try: - await child._teaql_save_within_graph(context) - 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._teaql_loaded_snapshot = committed_snapshot 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) + 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): + 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 7eb8c5c..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,6 @@ 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 import itertools @@ -6,6 +8,7 @@ from models.customer import Customer from models.commerce_platform import CommercePlatform + class CustomerOrder: _teaql_temporary_ids = itertools.count(1) @classmethod @@ -48,15 +51,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): @@ -69,7 +82,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) @@ -80,13 +93,16 @@ 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) 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) @@ -97,40 +113,104 @@ 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) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) - async def _teaql_preflight_and_save(self, context): - self._teaql_preflight_graph(context) - return await self._teaql_save_within_graph(context) + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return - def _teaql_build_command(self): + 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_line_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 "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) + + 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(), {}) @@ -138,52 +218,57 @@ 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)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() 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()") - 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.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) + 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 "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) - setattr(child, "customerOrder", self) - child._loaded_fields.add("customerOrder") - child._entity_root.set(child._teaql_entity_key(), "customer_order", Value.Object(self)) - child.audit_as(self._comment) + current = getattr(child, "customerOrder", None) + if getattr(current, "id", current) != self.id: + child.update_customer_order(self) 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([ @@ -191,17 +276,19 @@ def _teaql_preflight_graph(self, context): for v in error.violations ]) 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()") + 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 = MutationRequest(cmd) - if self._comment: - req.comment = self._comment + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) try: context.check_and_fix_mutation(cmd) @@ -282,6 +369,7 @@ async def _teaql_save_within_graph(self, context): 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: @@ -294,40 +382,45 @@ 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" - 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) - child.audit_as(self._comment) - try: - await child._teaql_save_within_graph(context) - 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._teaql_loaded_snapshot = committed_snapshot 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) + 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): + 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 d754e9a..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,6 @@ 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 import itertools @@ -6,6 +8,7 @@ from models.product import Product from models.commerce_platform import CommercePlatform + class OrderLine: _teaql_temporary_ids = itertools.count(1) @classmethod @@ -46,14 +49,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): @@ -68,13 +80,16 @@ 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) 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): @@ -83,38 +98,98 @@ 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) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) - async def _teaql_preflight_and_save(self, context): - self._teaql_preflight_graph(context) - return await self._teaql_save_within_graph(context) + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return - def _teaql_build_command(self): + 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("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, 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 "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) + + 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(), {}) @@ -122,54 +197,62 @@ 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)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() 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()") - 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.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) + 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 "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, context): - if not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: audit_as() must be called before save()") + 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 = MutationRequest(cmd) - if self._comment: - req.comment = self._comment + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) try: context.check_and_fix_mutation(cmd) @@ -244,6 +327,7 @@ async def _teaql_save_within_graph(self, context): 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: @@ -256,39 +340,44 @@ 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" - 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) - child.audit_as(self._comment) - try: - await child._teaql_save_within_graph(context) - 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._teaql_loaded_snapshot = committed_snapshot 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) + 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)) 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..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,9 +1,12 @@ 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 import itertools from models.commerce_platform import CommercePlatform + class OrderSearchPreset: _teaql_temporary_ids = itertools.count(1) @classmethod @@ -44,14 +47,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) @@ -62,13 +74,16 @@ 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) 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): @@ -77,38 +92,90 @@ 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) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) - async def _teaql_preflight_and_save(self, context): - self._teaql_preflight_graph(context) - return await self._teaql_save_within_graph(context) + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return - def _teaql_build_command(self): + 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("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, 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 "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) + + 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(), {}) @@ -116,54 +183,62 @@ 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)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() 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()") - 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.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) + 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 "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, context): - if not self._comment or not self._comment.strip(): - raise Exception("Security audit failure: audit_as() must be called before save()") + 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 = MutationRequest(cmd) - if self._comment: - req.comment = self._comment + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) try: context.check_and_fix_mutation(cmd) @@ -238,6 +313,7 @@ async def _teaql_save_within_graph(self, context): 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: @@ -250,39 +326,44 @@ 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" - 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) - child.audit_as(self._comment) - try: - await child._teaql_save_within_graph(context) - 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._teaql_loaded_snapshot = committed_snapshot 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) + 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)) 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..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,9 +1,12 @@ 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 import itertools from models.commerce_platform import CommercePlatform + class OrderStatus: _teaql_temporary_ids = itertools.count(1) @classmethod @@ -40,12 +43,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", []) @@ -54,7 +64,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) @@ -65,13 +75,16 @@ 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) 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) @@ -82,34 +95,87 @@ 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) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) - async def _teaql_preflight_and_save(self, context): - self._teaql_preflight_graph(context) - return await self._teaql_save_within_graph(context) + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return - def _teaql_build_command(self): + 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("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(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 "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) + + 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(), {}) @@ -117,46 +183,51 @@ 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)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() 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()") - 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.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) + 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 "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) - setattr(child, "status", self) - child._loaded_fields.add("status") - child._entity_root.set(child._teaql_entity_key(), "status", Value.Object(self)) - child.audit_as(self._comment) + current = getattr(child, "status", None) + if getattr(current, "id", current) != self.id: + child.update_status(self) 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([ @@ -164,17 +235,19 @@ def _teaql_preflight_graph(self, context): for v in error.violations ]) 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()") + 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 = MutationRequest(cmd) - if self._comment: - req.comment = self._comment + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) try: context.check_and_fix_mutation(cmd) @@ -237,6 +310,7 @@ async def _teaql_save_within_graph(self, context): 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: @@ -249,40 +323,45 @@ 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" - 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) - child.audit_as(self._comment) - try: - await child._teaql_save_within_graph(context) - 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._teaql_loaded_snapshot = committed_snapshot 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) + 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): + 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 6718e79..ea563c3 100644 --- a/examples/order-management/python-lib-core/models/product.py +++ b/examples/order-management/python-lib-core/models/product.py @@ -1,9 +1,12 @@ 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 import itertools from models.commerce_platform import CommercePlatform + class Product: _teaql_temporary_ids = itertools.count(1) @classmethod @@ -42,13 +45,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", []) @@ -57,7 +68,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) @@ -68,13 +79,16 @@ 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) 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) @@ -85,36 +99,90 @@ 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) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) - async def _teaql_preflight_and_save(self, context): - self._teaql_preflight_graph(context) - return await self._teaql_save_within_graph(context) + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return - def _teaql_build_command(self): + 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("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(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 "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) + + 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(), {}) @@ -122,48 +190,53 @@ 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)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() 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()") - 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.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) + 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 "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) - setattr(child, "product", self) - child._loaded_fields.add("product") - child._entity_root.set(child._teaql_entity_key(), "product", Value.Object(self)) - child.audit_as(self._comment) + current = getattr(child, "product", None) + if getattr(current, "id", current) != self.id: + child.update_product(self) 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([ @@ -171,17 +244,19 @@ def _teaql_preflight_graph(self, context): for v in error.violations ]) 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()") + 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 = MutationRequest(cmd) - if self._comment: - req.comment = self._comment + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) try: context.check_and_fix_mutation(cmd) @@ -250,6 +325,7 @@ async def _teaql_save_within_graph(self, context): 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: @@ -262,40 +338,45 @@ 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" - 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) - child.audit_as(self._comment) - try: - await child._teaql_save_within_graph(context) - 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._teaql_loaded_snapshot = committed_snapshot 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) + 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): + 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/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..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 @@ -1,7 +1,8 @@ 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, @@ -934,8 +935,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 +943,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): @@ -954,53 +953,59 @@ 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]: 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)) 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)) - query_root = EntityRoot() - data = SmartList(CommercePlatform(_entity_root=query_root, **row) for row in row_result.rows) + 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) 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): + 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): - for row in chunk.rows: - yield CommercePlatform(_entity_root=query_root, **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 8dcc698..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 @@ -1,7 +1,8 @@ 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, @@ -862,8 +863,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 +871,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): @@ -882,53 +881,59 @@ 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]: 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)) 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)) - query_root = EntityRoot() - data = SmartList(CustomerOrder(_entity_root=query_root, **row) for row in row_result.rows) + 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) 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): + 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): - for row in chunk.rows: - yield CustomerOrder(_entity_root=query_root, **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 868c902..500630a 100644 --- a/examples/order-management/python-lib-core/requests/customer_request.py +++ b/examples/order-management/python-lib-core/requests/customer_request.py @@ -1,7 +1,8 @@ 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, @@ -694,8 +695,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 +703,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): @@ -714,53 +713,59 @@ 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]: 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)) 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)) - query_root = EntityRoot() - data = SmartList(Customer(_entity_root=query_root, **row) for row in row_result.rows) + 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) 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): + 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): - for row in chunk.rows: - yield Customer(_entity_root=query_root, **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 135cfdb..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 @@ -1,7 +1,8 @@ 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, @@ -694,8 +695,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 +703,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): @@ -714,53 +713,59 @@ 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]: 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)) 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)) - query_root = EntityRoot() - data = SmartList(OrderLine(_entity_root=query_root, **row) for row in row_result.rows) + 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) 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): + 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): - for row in chunk.rows: - yield OrderLine(_entity_root=query_root, **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 f41b3e6..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 @@ -1,7 +1,8 @@ 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, @@ -748,8 +749,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 +757,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): @@ -768,53 +767,59 @@ 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]: 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)) 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)) - query_root = EntityRoot() - data = SmartList(OrderSearchPreset(_entity_root=query_root, **row) for row in row_result.rows) + 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) 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): + 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): - for row in chunk.rows: - yield OrderSearchPreset(_entity_root=query_root, **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 27a10d6..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 @@ -1,7 +1,8 @@ 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, @@ -769,8 +770,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 +778,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): @@ -789,53 +788,59 @@ 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]: 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)) 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)) - query_root = EntityRoot() - data = SmartList(OrderStatus(_entity_root=query_root, **row) for row in row_result.rows) + 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) 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): + 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): - for row in chunk.rows: - yield OrderStatus(_entity_root=query_root, **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 5bbbad0..6223f4a 100644 --- a/examples/order-management/python-lib-core/requests/product_request.py +++ b/examples/order-management/python-lib-core/requests/product_request.py @@ -1,7 +1,8 @@ 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, @@ -784,8 +785,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 +793,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): @@ -804,53 +803,59 @@ 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]: 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)) 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)) - query_root = EntityRoot() - data = SmartList(Product(_entity_root=query_root, **row) for row in row_result.rows) + 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) 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): + 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): - for row in chunk.rows: - yield Product(_entity_root=query_root, **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/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/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/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..bc3dc18 100644 --- a/examples/school-management/models/platform.py +++ b/examples/school-management/models/platform.py @@ -1,8 +1,11 @@ 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 import itertools + class Platform: _teaql_temporary_ids = itertools.count(1) @classmethod @@ -37,11 +40,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") @@ -51,13 +60,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) @@ -68,13 +77,16 @@ 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) 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) @@ -87,32 +99,83 @@ 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) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) - async def _teaql_preflight_and_save(self, context): - self._teaql_preflight_graph(context) - return await self._teaql_save_within_graph(context) + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return - def _teaql_build_command(self): + 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(), {}) @@ -120,44 +183,49 @@ 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)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() 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()") - 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.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) + 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) - setattr(child, "platform", self) - child._loaded_fields.add("platform") - child._entity_root.set(child._teaql_entity_key(), "platform", Value.Object(self)) - child.audit_as(self._comment) + current = getattr(child, "platform", None) + if getattr(current, "id", current) != self.id: + child.update_platform(self) 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([ @@ -166,12 +234,11 @@ 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.audit_as(self._comment) + current = getattr(child, "platform", None) + if getattr(current, "id", current) != self.id: + child.update_platform(self) try: - child._teaql_preflight_graph(context) + child._teaql_preflight_graph(graph) except CheckException as error: prefix = ObjectLocation().property("school_list").index(index) raise CheckException([ @@ -179,17 +246,19 @@ def _teaql_preflight_graph(self, context): for v in error.violations ]) 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()") + 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 = MutationRequest(cmd) - if self._comment: - req.comment = self._comment + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) try: context.check_and_fix_mutation(cmd) @@ -246,6 +315,7 @@ async def _teaql_save_within_graph(self, context): 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: @@ -258,41 +328,46 @@ 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" - 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) - child.audit_as(self._comment) - try: - await child._teaql_save_within_graph(context) - 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._teaql_loaded_snapshot = committed_snapshot 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) + 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)) diff --git a/examples/school-management/models/school.py b/examples/school-management/models/school.py index 43d2345..514a34c 100644 --- a/examples/school-management/models/school.py +++ b/examples/school-management/models/school.py @@ -1,10 +1,13 @@ 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 import itertools from models.platform import Platform from models.school_type import SchoolType + class School: _teaql_temporary_ids = itertools.count(1) @classmethod @@ -49,16 +52,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): @@ -71,13 +85,16 @@ 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) 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): @@ -86,42 +103,100 @@ 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) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) - async def _teaql_preflight_and_save(self, context): - self._teaql_preflight_graph(context) - return await self._teaql_save_within_graph(context) + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return - def _teaql_build_command(self): + 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: - 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) + + 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(), {}) @@ -129,58 +204,66 @@ 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)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() 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()") - 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.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) + 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) - 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()") + 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 = MutationRequest(cmd) - if self._comment: - req.comment = self._comment + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) try: context.check_and_fix_mutation(cmd) @@ -267,6 +350,7 @@ async def _teaql_save_within_graph(self, context): 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: @@ -279,39 +363,44 @@ 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" - 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) - child.audit_as(self._comment) - try: - await child._teaql_save_within_graph(context) - 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._teaql_loaded_snapshot = committed_snapshot 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) + 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)) diff --git a/examples/school-management/models/school_type.py b/examples/school-management/models/school_type.py index 418f288..b2ecef5 100644 --- a/examples/school-management/models/school_type.py +++ b/examples/school-management/models/school_type.py @@ -1,9 +1,12 @@ 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 import itertools from models.platform import Platform + class SchoolType: _teaql_temporary_ids = itertools.count(1) @classmethod @@ -38,11 +41,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", []) @@ -51,7 +60,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) @@ -62,13 +71,16 @@ 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) 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) @@ -79,32 +91,84 @@ 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) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) - async def _teaql_preflight_and_save(self, context): - self._teaql_preflight_graph(context) - return await self._teaql_save_within_graph(context) + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return - def _teaql_build_command(self): + 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: - 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) + + 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(), {}) @@ -112,44 +176,49 @@ 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)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() 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()") - 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.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) + 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) - setattr(child, "schoolType", self) - child._loaded_fields.add("schoolType") - child._entity_root.set(child._teaql_entity_key(), "school_type", Value.Object(self)) - child.audit_as(self._comment) + current = getattr(child, "schoolType", None) + if getattr(current, "id", current) != self.id: + child.update_school_type(self) try: - child._teaql_preflight_graph(context) + child._teaql_preflight_graph(graph) except CheckException as error: prefix = ObjectLocation().property("school_list").index(index) raise CheckException([ @@ -157,17 +226,19 @@ def _teaql_preflight_graph(self, context): for v in error.violations ]) 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()") + 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 = MutationRequest(cmd) - if self._comment: - req.comment = self._comment + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) try: context.check_and_fix_mutation(cmd) @@ -224,6 +295,7 @@ async def _teaql_save_within_graph(self, context): 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: @@ -236,40 +308,45 @@ 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" - 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) - child.audit_as(self._comment) - try: - await child._teaql_save_within_graph(context) - 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._teaql_loaded_snapshot = committed_snapshot 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) + 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)) 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..d07370d 100644 --- a/examples/school-management/requests/platform_request.py +++ b/examples/school-management/requests/platform_request.py @@ -1,7 +1,8 @@ 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, @@ -781,8 +782,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 +790,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): @@ -801,53 +800,59 @@ 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]: 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)) 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)) - query_root = EntityRoot() - data = SmartList(Platform(_entity_root=query_root, **row) for row in row_result.rows) + 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): - 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): + 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): - for row in chunk.rows: - yield Platform(_entity_root=query_root, **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 9d33284..3a20d9c 100644 --- a/examples/school-management/requests/school_request.py +++ b/examples/school-management/requests/school_request.py @@ -1,7 +1,8 @@ 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, @@ -850,8 +851,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 +859,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): @@ -870,53 +869,59 @@ 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]: 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)) 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)) - query_root = EntityRoot() - data = SmartList(School(_entity_root=query_root, **row) for row in row_result.rows) + 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): - 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): + 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): - for row in chunk.rows: - yield School(_entity_root=query_root, **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 76a097a..605dca8 100644 --- a/examples/school-management/requests/school_type_request.py +++ b/examples/school-management/requests/school_type_request.py @@ -1,7 +1,8 @@ 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, @@ -677,8 +678,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 +686,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): @@ -697,53 +696,59 @@ 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]: 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)) 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)) - query_root = EntityRoot() - data = SmartList(SchoolType(_entity_root=query_root, **row) for row in row_result.rows) + 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): - 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): + 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): - for row in chunk.rows: - yield SchoolType(_entity_root=query_root, **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/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..176e1b4 100644 --- a/examples/task_board/generated/models/platform.py +++ b/examples/task_board/generated/models/platform.py @@ -1,8 +1,11 @@ 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 import itertools + class Platform: _teaql_temporary_ids = itertools.count(1) @classmethod @@ -35,10 +38,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") @@ -48,13 +56,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) @@ -65,13 +73,16 @@ 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) 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) @@ -84,30 +95,80 @@ 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) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) - async def _teaql_preflight_and_save(self, context): - self._teaql_preflight_graph(context) - return await self._teaql_save_within_graph(context) + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return - def _teaql_build_command(self): + 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._task_status_list: + child._teaql_attach_root(self._entity_root) + 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(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 "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) + + 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(), {}) @@ -115,42 +176,47 @@ 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)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() 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()") - 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.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) + 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 "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) - setattr(child, "platform", self) - child._loaded_fields.add("platform") - child._entity_root.set(child._teaql_entity_key(), "platform", Value.Object(self)) - child.audit_as(self._comment) + current = getattr(child, "platform", None) + if getattr(current, "id", current) != self.id: + child.update_platform(self) 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([ @@ -159,12 +225,11 @@ 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.audit_as(self._comment) + current = getattr(child, "platform", None) + if getattr(current, "id", current) != self.id: + child.update_platform(self) try: - child._teaql_preflight_graph(context) + child._teaql_preflight_graph(graph) except CheckException as error: prefix = ObjectLocation().property("task_list").index(index) raise CheckException([ @@ -172,17 +237,19 @@ def _teaql_preflight_graph(self, context): for v in error.violations ]) 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()") + 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 = MutationRequest(cmd) - if self._comment: - req.comment = self._comment + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) try: context.check_and_fix_mutation(cmd) @@ -233,6 +300,7 @@ async def _teaql_save_within_graph(self, context): 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: @@ -245,41 +313,46 @@ 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" - 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) - child.audit_as(self._comment) - try: - await child._teaql_save_within_graph(context) - 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._teaql_loaded_snapshot = committed_snapshot 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) + 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): + 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 c938894..c022a34 100644 --- a/examples/task_board/generated/models/task.py +++ b/examples/task_board/generated/models/task.py @@ -1,10 +1,13 @@ 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 import itertools from models.task_status import TaskStatus from models.platform import Platform + class Task: _teaql_temporary_ids = itertools.count(1) @classmethod @@ -37,10 +40,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): @@ -51,7 +59,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) @@ -62,13 +70,16 @@ 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) 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) @@ -79,30 +90,85 @@ 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) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) - async def _teaql_preflight_and_save(self, context): - self._teaql_preflight_graph(context) - return await self._teaql_save_within_graph(context) + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return - def _teaql_build_command(self): + 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("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(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 "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) + + 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(), {}) @@ -110,42 +176,47 @@ 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)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() 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()") - 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.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) + 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 "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) - setattr(child, "task", self) - child._loaded_fields.add("task") - child._entity_root.set(child._teaql_entity_key(), "task", Value.Object(self)) - child.audit_as(self._comment) + current = getattr(child, "task", None) + if getattr(current, "id", current) != self.id: + child.update_task(self) 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([ @@ -153,17 +224,19 @@ def _teaql_preflight_graph(self, context): for v in error.violations ]) 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()") + 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 = MutationRequest(cmd) - if self._comment: - req.comment = self._comment + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) try: context.check_and_fix_mutation(cmd) @@ -214,6 +287,7 @@ async def _teaql_save_within_graph(self, context): 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: @@ -226,40 +300,45 @@ 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" - 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) - child.audit_as(self._comment) - try: - await child._teaql_save_within_graph(context) - 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._teaql_loaded_snapshot = committed_snapshot 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) + 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): + 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 15294c5..c9063dc 100644 --- a/examples/task_board/generated/models/task_execution_log.py +++ b/examples/task_board/generated/models/task_execution_log.py @@ -1,9 +1,12 @@ 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 import itertools from models.task import Task + class TaskExecutionLog: _teaql_temporary_ids = itertools.count(1) @classmethod @@ -36,10 +39,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) @@ -50,13 +58,16 @@ 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) 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): @@ -65,30 +76,78 @@ 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) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) - async def _teaql_preflight_and_save(self, context): - self._teaql_preflight_graph(context) - return await self._teaql_save_within_graph(context) + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return - def _teaql_build_command(self): + 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("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, 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 "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) + + 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(), {}) @@ -96,46 +155,54 @@ 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)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() 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()") - 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.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) + 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 "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) - 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()") + 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 = MutationRequest(cmd) - if self._comment: - req.comment = self._comment + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) try: context.check_and_fix_mutation(cmd) @@ -186,6 +253,7 @@ async def _teaql_save_within_graph(self, context): 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: @@ -198,39 +266,44 @@ 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" - 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) - child.audit_as(self._comment) - try: - await child._teaql_save_within_graph(context) - 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._teaql_loaded_snapshot = committed_snapshot 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) + 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)) diff --git a/examples/task_board/generated/models/task_status.py b/examples/task_board/generated/models/task_status.py index 047cad1..91e2afd 100644 --- a/examples/task_board/generated/models/task_status.py +++ b/examples/task_board/generated/models/task_status.py @@ -1,9 +1,12 @@ 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 import itertools from models.platform import Platform + class TaskStatus: _teaql_temporary_ids = itertools.count(1) @classmethod @@ -42,13 +45,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", []) @@ -57,7 +68,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) @@ -68,13 +79,16 @@ 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) 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) @@ -85,36 +99,90 @@ 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) + return await context.execute_graph_save(self._teaql_preflight_and_save, comment=intent.comment) - async def _teaql_preflight_and_save(self, context): - self._teaql_preflight_graph(context) - return await self._teaql_save_within_graph(context) + async def _teaql_ensure_business_ids(self, context): + if self._action != "Create": + return - def _teaql_build_command(self): + 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("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(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 "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) + + 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(), {}) @@ -122,48 +190,53 @@ 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)) + if action != "Create": + cmd.old_values = self._teaql_loaded_snapshot.business_values() 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()") - 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.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) + 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 "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) - setattr(child, "status", self) - child._loaded_fields.add("status") - child._entity_root.set(child._teaql_entity_key(), "status", Value.Object(self)) - child.audit_as(self._comment) + current = getattr(child, "status", None) + if getattr(current, "id", current) != self.id: + child.update_status(self) try: - child._teaql_preflight_graph(context) + child._teaql_preflight_graph(graph) except CheckException as error: prefix = ObjectLocation().property("task_list").index(index) raise CheckException([ @@ -171,17 +244,19 @@ def _teaql_preflight_graph(self, context): for v in error.violations ]) 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()") + 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 = MutationRequest(cmd) - if self._comment: - req.comment = self._comment + req = graph.request(cmd, scope, self._entity_root, self._teaql_entity_key()) try: context.check_and_fix_mutation(cmd) @@ -250,6 +325,7 @@ async def _teaql_save_within_graph(self, context): 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: @@ -262,40 +338,45 @@ 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" - 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) - child.audit_as(self._comment) - try: - await child._teaql_save_within_graph(context) - 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._teaql_loaded_snapshot = committed_snapshot 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) + 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): + 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/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..4fd798b 100644 --- a/examples/task_board/generated/requests/platform_request.py +++ b/examples/task_board/generated/requests/platform_request.py @@ -1,7 +1,8 @@ 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, @@ -719,8 +720,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 +728,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): @@ -739,53 +738,59 @@ 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]: 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)) 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)) - query_root = EntityRoot() - data = SmartList(Platform(_entity_root=query_root, **row) for row in row_result.rows) + 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): - 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): + 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): - for row in chunk.rows: - yield Platform(_entity_root=query_root, **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 1cbb08a..895a961 100644 --- a/examples/task_board/generated/requests/task_execution_log_request.py +++ b/examples/task_board/generated/requests/task_execution_log_request.py @@ -1,7 +1,8 @@ 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, @@ -442,8 +443,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 +451,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): @@ -462,53 +461,59 @@ 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]: 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)) 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)) - query_root = EntityRoot() - data = SmartList(TaskExecutionLog(_entity_root=query_root, **row) for row in row_result.rows) + 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) 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): + 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): - for row in chunk.rows: - yield TaskExecutionLog(_entity_root=query_root, **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 eaa6d57..fb2965a 100644 --- a/examples/task_board/generated/requests/task_request.py +++ b/examples/task_board/generated/requests/task_request.py @@ -1,7 +1,8 @@ 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, @@ -442,8 +443,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 +451,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): @@ -462,53 +461,59 @@ 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]: 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)) 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)) - query_root = EntityRoot() - data = SmartList(Task(_entity_root=query_root, **row) for row in row_result.rows) + 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) 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): + 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): - for row in chunk.rows: - yield Task(_entity_root=query_root, **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 737eb50..0bc30e6 100644 --- a/examples/task_board/generated/requests/task_status_request.py +++ b/examples/task_board/generated/requests/task_status_request.py @@ -1,7 +1,8 @@ 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, @@ -793,8 +794,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 +802,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): @@ -813,53 +812,59 @@ 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]: 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)) 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)) - query_root = EntityRoot() - data = SmartList(TaskStatus(_entity_root=query_root, **row) for row in row_result.rows) + 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) 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): + 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): - for row in chunk.rows: - yield TaskStatus(_entity_root=query_root, **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/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..aa1a30e --- /dev/null +++ b/examples/trace-chain/README.md @@ -0,0 +1,161 @@ +# 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. + +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 +``` + +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. + +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 +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. + +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 +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. +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. + +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. + +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. +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 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 +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 +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/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/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..0954fe7 --- /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 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 new file mode 100644 index 0000000..a163ea4 --- /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 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 new file mode 100644 index 0000000..ef93b65 --- /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 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 new file mode 100644 index 0000000..8fc3fca --- /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 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 new file mode 100644 index 0000000..37243ea --- /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 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 new file mode 100644 index 0000000..c89e451 --- /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 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-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-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/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.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..62d8423 --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-query/customer_order.order_item_list.md @@ -0,0 +1,72 @@ + + +# 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)` | +| 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. + + +--- + +## 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/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..fe6833a --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-query/customer_order.payment_list.md @@ -0,0 +1,72 @@ + + +# 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)` | +| 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. + + +--- + +## 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..9a4f03c --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-query/customer_order.shipment_list.md @@ -0,0 +1,72 @@ + + +# 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)` | +| 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. + + +--- + +## 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/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/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..e5b0830 --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-query/payment.payment_attempt_list.md @@ -0,0 +1,72 @@ + + +# 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)` | +| 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. + + +--- + +## 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..2f9c634 --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-query/payment.reference_code.md @@ -0,0 +1,73 @@ + + +# 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)` | +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`. + +--- + +## 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..b23f56e --- /dev/null +++ b/examples/trace-chain/evidence/assist/python-assist-query/payment_attempt.reference_code.md @@ -0,0 +1,73 @@ + + +# 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)` | +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`. + +--- + +## 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..9448bdd --- /dev/null +++ b/examples/trace-chain/evidence/library-sha256.txt @@ -0,0 +1,19 @@ +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/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..34459e4 --- /dev/null +++ b/examples/trace-chain/lib/models/customer_order.py @@ -0,0 +1,446 @@ +from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +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 +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._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()) + 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(_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(_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(_entity_root=self._entity_root, **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)) + 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) + + 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._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_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 "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) + + 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("CustomerOrder", payload) + elif action == "Update": + 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: + 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): + 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 "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) + 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: + 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) + 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: + 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) + 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: + 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) + + 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 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") + 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(("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): + 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_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..b6fd2a5 --- /dev/null +++ b/examples/trace-chain/lib/models/order_item.py @@ -0,0 +1,325 @@ +from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +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 +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._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()) + 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)) + 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) + + 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("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_scalar_payload(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) + + 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("OrderItem", payload) + elif action == "Update": + 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: + 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): + 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 "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() + + + 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") + 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_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..36256e0 --- /dev/null +++ b/examples/trace-chain/lib/models/payment.py @@ -0,0 +1,358 @@ +from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +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 +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._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()) + 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(_entity_root=self._entity_root, **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)) + 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) + + 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._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_scalar_payload(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) + + 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("Payment", payload) + elif action == "Update": + 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: + 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): + 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 "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) + 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: + 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) + + 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 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") + 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(("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): + 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_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..8aeeac0 --- /dev/null +++ b/examples/trace-chain/lib/models/payment_attempt.py @@ -0,0 +1,325 @@ +from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +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 +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._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()) + 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)) + 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) + + 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("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_scalar_payload(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) + + 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("PaymentAttempt", payload) + elif action == "Update": + 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: + 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): + 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 "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() + + + 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") + 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_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..b1cf88e --- /dev/null +++ b/examples/trace-chain/lib/models/platform.py @@ -0,0 +1,329 @@ +from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +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 +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._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()) + 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(_entity_root=self._entity_root, **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)) + 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._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_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 "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 "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) + 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("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) + + 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 "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(("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): + 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_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..406d1c6 --- /dev/null +++ b/examples/trace-chain/lib/models/shipment.py @@ -0,0 +1,325 @@ +from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand, MutationRequest +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 +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._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()) + 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)) + 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) + + 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("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_scalar_payload(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) + + 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("Shipment", payload) + elif action == "Update": + 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: + 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): + 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 "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() + + + 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") + 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_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..98aa857 --- /dev/null +++ b/examples/trace-chain/lib/requests/customer_order_request.py @@ -0,0 +1,636 @@ +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.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) + return SmartList( + (CustomerOrder(**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)) + 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(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): + request = deepcopy(self._request) + request.limit(1) + entities = await ExecutableCustomerOrderRequest(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 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 new file mode 100644 index 0000000..a1130e4 --- /dev/null +++ b/examples/trace-chain/lib/requests/order_item_request.py @@ -0,0 +1,429 @@ +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.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) + return SmartList( + (OrderItem(**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)) + 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(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): + request = deepcopy(self._request) + request.limit(1) + entities = await ExecutableOrderItemRequest(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 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 new file mode 100644 index 0000000..d841ed0 --- /dev/null +++ b/examples/trace-chain/lib/requests/payment_attempt_request.py @@ -0,0 +1,429 @@ +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.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) + return SmartList( + (PaymentAttempt(**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)) + 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(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): + request = deepcopy(self._request) + request.limit(1) + entities = await ExecutablePaymentAttemptRequest(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 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 new file mode 100644 index 0000000..9c63b9b --- /dev/null +++ b/examples/trace-chain/lib/requests/payment_request.py @@ -0,0 +1,468 @@ +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.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) + return SmartList( + (Payment(**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)) + 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(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): + request = deepcopy(self._request) + request.limit(1) + entities = await ExecutablePaymentRequest(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 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 new file mode 100644 index 0000000..7839df1 --- /dev/null +++ b/examples/trace-chain/lib/requests/platform_request.py @@ -0,0 +1,429 @@ +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", "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) + 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/trace-chain/lib/requests/shipment_request.py b/examples/trace-chain/lib/requests/shipment_request.py new file mode 100644 index 0000000..ceffe65 --- /dev/null +++ b/examples/trace-chain/lib/requests/shipment_request.py @@ -0,0 +1,429 @@ +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.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) + return SmartList( + (Shipment(**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)) + 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(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): + request = deepcopy(self._request) + request.limit(1) + entities = await ExecutableShipmentRequest(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 Shipment(**row) + finally: + close = getattr(stream, "aclose", None) + if close is not None: + await close() + return entities() 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..1a46c0e --- /dev/null +++ b/examples/trace-chain/main.py @@ -0,0 +1,493 @@ +"""Generated Q/E/Mutation traversal observed at three real SQLite boundaries.""" +import asyncio +import json +import os +import sqlite3 +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 teaql.core.request_intent import RequestIntentError +from teaql.core.mutation import InsertCommand, UpdateCommand, DeleteCommand +from teaql.sql.executor import TransportError + + +def chain(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): + 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(): + # 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') + 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 + + +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): + self.service = service + self.requests = [] + 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) + 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 + 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', + DeleteCommand: 'delete'}[type(command)]) + 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.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] + 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)) + 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 + 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') + 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) + 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) + 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.raw_requests) == len(service.requests) == len(service.results) == len(sink.events) == 6 + assert commands == metadata == audits == expected + 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 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') + + 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-authorized' + 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] + 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')] + 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) + 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') + + 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/mutation_privacy.py b/examples/trace-chain/mutation_privacy.py new file mode 100644 index 0000000..2000277 --- /dev/null +++ b/examples/trace-chain/mutation_privacy.py @@ -0,0 +1,198 @@ +"""Generated save privacy; no generated source inspection or synthetic traces.""" +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.provider.sqlite import create_sqlite_service +from teaql.runtime import UserContext + +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(): + 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 + 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 + 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') + + # 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) + 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 + 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) + 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 + 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/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/relation_aggregate.py b/examples/trace-chain/relation_aggregate.py new file mode 100644 index 0000000..a02f168 --- /dev/null +++ b/examples/trace-chain/relation_aggregate.py @@ -0,0 +1,241 @@ +"""Assist-guided generated Q/E/save: root and nested aggregate provenance.""" +import asyncio +import copy +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, raw, 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 + 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_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)) + return request.comment('inspect ' + secret).purpose('verify aggregate provenance') + + def clear(): + reset(service, sink, context) + diagnostic.clear() + reads.clear() + raw.clear() + policy.plans.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) + 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 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) + 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) == (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, 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: + 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()], + '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) + 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 + # 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()], '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') + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/examples/trace-chain/shared_reference.py b/examples/trace-chain/shared_reference.py new file mode 100644 index 0000000..95cbbde --- /dev/null +++ b/examples/trace-chain/shared_reference.py @@ -0,0 +1,264 @@ +"""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 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 + 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 new file mode 100644 index 0000000..a4c7920 --- /dev/null +++ b/examples/trace-chain/verify.sh @@ -0,0 +1,112 @@ +#!/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 +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 +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 +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" +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" + 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" + 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 ]] + [[ "$(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)" + 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" \ + 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" \ + 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" + echo "Aggregate $attempt on $TEAQL_TRACE_CHAIN_AGGREGATE_DB without cleanup" + 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" \ + 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" diff --git a/scripts/verify-examples.sh b/scripts/verify-examples.sh index 9c5ae48..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) +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 @@ -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" @@ -38,4 +39,6 @@ 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" +bash "$repo/examples/facet-trace/verify.sh" echo "PASS: all Python examples" 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" 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/entity.py b/src/teaql/core/entity.py index 7514ecd..e1d79e2 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 @@ -14,6 +15,36 @@ 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} + + 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 _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.""" @@ -57,6 +88,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: @@ -96,8 +141,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) @@ -115,18 +173,61 @@ 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: 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) + # 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: @@ -137,6 +238,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 +251,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/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/core/mutation.py b/src/teaql/core/mutation.py index 7e588a1..1f3daa9 100644 --- a/src/teaql/core/mutation.py +++ b/src/teaql/core/mutation.py @@ -1,7 +1,9 @@ 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 @dataclass class TraceNode: @@ -77,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': @@ -96,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': @@ -113,38 +117,62 @@ class MutationKind(Enum): BATCH = auto() class MutationRequest: - def __init__(self, data: Any): + __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: + return self.__intent + + 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).with_mutation_lineage(self.mutation_lineage) 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/core/trace.py b/src/teaql/core/trace.py new file mode 100644 index 0000000..9c83bee --- /dev/null +++ b/src/teaql/core/trace.py @@ -0,0 +1,61 @@ +"""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]: + """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/core/trace_scope.py b/src/teaql/core/trace_scope.py new file mode 100644 index 0000000..71f66d4 --- /dev/null +++ b/src/teaql/core/trace_scope.py @@ -0,0 +1,57 @@ +"""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': + from .request_intent import MutationIntent, _white_space + self._require_assigned(key) + # 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 + 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 bb1c255..cc0d3ef 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,67 @@ 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', '__origin_entity', + '_log_intent_source', '_log_intent_queries') + _UNSET = object() + + 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 + self.trace_chain = deepcopy(trace_chain) if trace_chain is not None else [] + + @property + 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 + + @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)) + 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, + _origin_entity=self.origin_entity) + if hasattr(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': - if not text: - raise ValueError("comment cannot be empty") - self._comment = text - return self + 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': - if not text: - raise ValueError("purpose cannot be empty") - self._purpose = text - return self + result = self.with_query(self.query) + result.__intent = QueryIntent(self._comment, text) + result.query.purpose_text = result.intent.purpose + return result class DataServiceOperation(Enum): @@ -75,6 +111,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 @@ -85,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 @@ -93,17 +133,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/__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..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, @@ -88,3 +91,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 a758daf..8e5d421 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() @@ -207,76 +209,59 @@ def decode_entity_reference( ) return claims - async def execute_graph_save(self, work): - """Run one generated entity graph in one provider transaction.""" - if self._graph_save_owner.get() is not None: - return await work() + async def execute_graph_save(self, work, *, comment=None): + """Run ``work(session)``; never implicitly join another root save.""" + from teaql.core.request_intent import MutationIntent + from .graph_session import GraphMutationSession + intent = MutationIntent(comment) + 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() - 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") @@ -385,6 +370,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 +407,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 @@ -738,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( @@ -748,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) @@ -797,11 +813,27 @@ 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.""" + 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 @@ -870,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() @@ -889,6 +921,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 '', @@ -911,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 @@ -1224,6 +1258,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..15b36ce --- /dev/null +++ b/src/teaql/runtime/graph_session.py @@ -0,0 +1,121 @@ +"""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): + from .log_privacy import _MutationIntentPrivacy + self._intent_privacy = _MutationIntentPrivacy() + 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): + from .log_privacy import _MutationIntentPrivacy + self._intent_privacy = _MutationIntentPrivacy() + 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..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 @@ -191,5 +238,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 78c68f0..0409460 100644 --- a/src/teaql/runtime/mutation_policy.py +++ b/src/teaql/runtime/mutation_policy.py @@ -244,12 +244,24 @@ def __init__(self) -> None: def current(self) -> Optional[MutationGovernanceSnapshot]: return self._active.get() - def begin_graph(self) -> None: + 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) 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 +285,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 +299,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 +312,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/dialect.py b/src/teaql/sql/dialect.py index b29d901..82e5af0 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}" @@ -338,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) @@ -399,15 +412,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): @@ -443,6 +449,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 2f4207e..7b45aa9 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,21 +37,50 @@ 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, 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): # 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: @@ -132,6 +162,61 @@ 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. + """ + 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) + _append_intent_bindings(source, bindings) + pending.append((original, original.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) + _append_intent_bindings(source, 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: @@ -189,15 +274,28 @@ 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 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.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, entity_id=target_id)] metadata = ExecutionMetadata( backend=provider, operation=operation, started_at=started_at, ended_at=datetime.now(), execution_outcome=outcome, @@ -207,13 +305,8 @@ 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), + mutation_lineage=() if query else request.mutation_lineage, ) if context is not None: try: @@ -227,11 +320,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. @@ -239,10 +333,16 @@ 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") - if (request.query.relations or request.query.child_enhancements + 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.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; " @@ -253,6 +353,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 @@ -291,6 +392,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,23 +404,21 @@ 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() 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: @@ -326,8 +426,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") @@ -344,6 +444,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: @@ -365,58 +468,97 @@ 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) + 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, 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] + 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_result = await self._query(context, QueryRequest(membership_query)) + 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, QueryRequest(nested_query)) + 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( + 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) @@ -518,7 +660,7 @@ def _id_set_query_key(context, query: SelectQuery, namespace: str) -> str: return "teaql:id-set:v1:" + hashlib.sha256(repr(scope).encode("utf-8")).hexdigest() 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 @@ -537,33 +679,56 @@ 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] + 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 + # 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 - 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 ( provider_policy == "always_probe" and threshold is None or threshold is not None and threshold > 0 and len(parent_ids) <= threshold ) - if use_probes: + child_trace = [*request.trace_chain, TraceNode( + kind="relation", name=load.name, + comment=f"{query.entity}.{load.name}")] + 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: probe = deepcopy(child_query) probe.partition_by = None 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=f"{query.entity}.{load.name}", - comment=load.name)] - children.extend((await self.query(context, _QueryWithLogIntent( - probe, child_trace, request._comment, request._purpose, intent_source))).rows) + assembly = _RelationAssembly(relation.foreign_key) + loaded = (await self.query(context, _QueryWithLogIntent( + probe, child_trace, request._comment, request._purpose, intent_source, + 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: @@ -572,21 +737,38 @@ 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=f"{query.entity}.{load.name}", - comment=load.name)] + assembly = _RelationAssembly(relation.foreign_key) 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, 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]]] = {} - for child in children: - buckets.setdefault(child.get(relation.foreign_key), []).append(child) - for parent in parents: - related = buckets.get(parent.get(relation.local_key), []) - parent[load.name] = related if relation.is_many else (related[0] if related else None) + 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, []) + 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)) + # 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), @@ -635,10 +817,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: @@ -673,12 +856,22 @@ def _attach_empty_relation_aggregate(self, parents, aggregate, query): parent[aggregate.alias] = value 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() 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) @@ -686,24 +879,35 @@ async def mutate(self, context: 'UserContext', request: MutationRequest) -> Muta 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() 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, privacy) 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: @@ -711,8 +915,36 @@ 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): + 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)) + 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( + 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, + 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") @@ -745,6 +977,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): @@ -780,12 +1020,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: @@ -838,9 +1079,12 @@ 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, privacy) + metadata = replace(metadata, statements=(metadata, read_metadata)) if affected_rows > 0 and context is not None: from teaql.runtime.audit import AuditFieldChange, MutationAuditKind, RawAuditEvent @@ -866,10 +1110,11 @@ 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(), + _intent_privacy=privacy, )) return MutationResult( affected_rows=affected_rows, @@ -878,25 +1123,27 @@ 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): - if context is None: - return + 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' 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, 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)) + if context is None: + 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 + return metadata async def next_id(self, entity: str) -> int: await self.transport.execute_sql(CompiledQuery( @@ -1050,6 +1297,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( @@ -1067,6 +1317,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) @@ -1079,7 +1344,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/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/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) 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_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') diff --git a/tests/core/test_request_intent.py b/tests/core/test_request_intent.py new file mode 100644 index 0000000..9d31837 --- /dev/null +++ b/tests/core/test_request_intent.py @@ -0,0 +1,154 @@ +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 +@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() + 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')) + 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') + 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 + diagnostic(caught.value, kind) + with pytest.raises(RequestIntentError) as caught: + await context.prepare_query_request(query) + 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 == [] + + +@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/core/test_trace.py b/tests/core/test_trace.py new file mode 100644 index 0000000..f5f6e52 --- /dev/null +++ b/tests/core/test_trace.py @@ -0,0 +1,74 @@ +"""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' + + +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 + 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/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/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/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/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/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_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:]) 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 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..d4219cf --- /dev/null +++ b/tests/provider/sqlite/test_numeric_partition_trace.py @@ -0,0 +1,200 @@ +"""Native numeric grouping/window evidence, not generated aggregation acceptance.""" +from copy import deepcopy +import json +import re +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(not re.search(r'(?= {"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,16 +671,17 @@ 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() - 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] == [ @@ -655,10 +691,11 @@ 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) - }))) - assert context.sql_logs() == [] + }), comment='what: runtime regression fixture')) + 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"))) + 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() == [] @@ -736,12 +773,17 @@ 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"})()) - result = await service.mutate(context, MutationRequest(command)) + 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 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" @@ -809,7 +851,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 @@ -822,21 +864,24 @@ 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() - 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 +946,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/sqlite/test_trace_chain.py b/tests/provider/sqlite/test_trace_chain.py new file mode 100644 index 0000000..8190b61 --- /dev/null +++ b/tests/provider/sqlite/test_trace_chain.py @@ -0,0 +1,1380 @@ +"""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 +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.streams = [] + self.writes = [] + self.begins = 0 + 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 stream_sql(self, compiled, chunk_size): + self.streams.append(compiled) + 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) + if self.fail_table and self.fail_table in compiled.sql: + raise self.failure + 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 + + +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() + transport.writes.clear() + transport.begins = 0 + 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))))) + + +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): + 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('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]) +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('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 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 + + +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]) +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]) +@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 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('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('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')] + # 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: + 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'] + 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'] + 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): + 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', '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: + 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 + + +@pytest.mark.asyncio +@pytest.mark.parametrize('nested', [False, True]) +@pytest.mark.parametrize('logging', [False, True]) +@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) + .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() + 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', 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) + 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 + if reference == 'visible': + assert row['parent_id']['name'] == secret + else: + 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') + 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 == [] + + +@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'} + 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 +@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): + 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', '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}') + 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/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_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_intent_privacy.py b/tests/runtime/test_mutation_intent_privacy.py new file mode 100644 index 0000000..1464380 --- /dev/null +++ b/tests/runtime/test_mutation_intent_privacy.py @@ -0,0 +1,62 @@ +"""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']} + + +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') diff --git a/tests/runtime/test_mutation_policy.py b/tests/runtime/test_mutation_policy.py index ef516b6..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( @@ -179,18 +181,19 @@ 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"))) - return await transaction.mutate(context, MutationRequest(line)) + 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) + 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 @@ -218,14 +221,14 @@ 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)) + 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) + 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 @@ -237,8 +240,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")))) + 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,14 +254,14 @@ 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"))) + graph.context, MutationRequest(_insert("Order", 47, "FIRST"), comment='what: runtime regression fixture')) await transaction.mutate( - context, MutationRequest(_insert("OrderLine", 102, "SECOND"))) + graph.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 @@ -274,13 +277,13 @@ 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"))) + 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) + await unplanned.execute_graph_save(unplanned_graph, comment='what: runtime regression fixture') assert unplanned_provider.mutations == 0 assert unplanned_provider.rollbacks == 1 @@ -292,14 +295,14 @@ 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)) + 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) + 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 @@ -317,12 +320,12 @@ 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)) + 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) + 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..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,13 +98,16 @@ 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': 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: @@ -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,8 +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': @@ -145,6 +155,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..1a38968 100644 --- a/tests/runtime/test_runtime.py +++ b/tests/runtime/test_runtime.py @@ -143,18 +143,19 @@ 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) + await child_work(graph) 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 @@ -166,11 +167,11 @@ 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) == "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 @@ -191,12 +192,12 @@ 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) + 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 @@ -226,11 +227,11 @@ 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") - 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..fce1b6d 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 @@ -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 @@ -327,7 +358,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 +369,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 +392,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 +402,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 +448,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 @@ -445,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()) @@ -520,15 +553,17 @@ 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) + 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 8dc0462..b51800b 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'] @@ -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']