diff --git a/DIALECT_INTEGRATION_GUIDE.md b/DIALECT_INTEGRATION_GUIDE.md index f90a2c1e..449392b4 100644 --- a/DIALECT_INTEGRATION_GUIDE.md +++ b/DIALECT_INTEGRATION_GUIDE.md @@ -10,7 +10,7 @@ TeaQL 的架构分为两层: - **`teaql-data-service-sql` (JDBC 适配层)**:基于 `SqlExecutionAdapter` 实现的与数据库真实通信的通道。 ## 2. 如何实现 `ensureSchema` (表结构同步) -你**不需要**去手工遍历所有列、对比类型、拼接 CREATE/ALTER 语句,这些在 `PortableSQLRepository.ensureSchema()` 中已经完美实现。 +你**不需要**去手工遍历所有列、对比类型、拼接 CREATE/ALTER 语句,这些由 `PortableSQLRepository.ensurePhysicalSchema()` 统一实现。 在具体的数据库方言执行器(例如 `PostgresDataServiceExecutor`)中,只需执行以下 3 步: @@ -50,7 +50,7 @@ TeaQLDatabase dbAdapter = new TeaQLDatabase() { ``` ### Step 3: 交给 Portable 引擎执行 DDL -针对每个 `EntityDescriptor`,实例化一个带有该伪装 Adapter 的 PortableRepository,并调用其 `ensureSchema` 方法: +针对每个 `EntityDescriptor`,实例化一个带有该伪装 Adapter 的 PortableRepository,并调用其 `ensurePhysicalSchema` 方法: ```java for (EntityDescriptor descriptor : descriptors) { // 实例化方言的 PortableSQLRepository(例如 PostgresPortableSQLRepository,如果没有则用基类) @@ -63,6 +63,12 @@ for (EntityDescriptor descriptor : descriptors) { 不能顺带修改生产数据库。新方言的最小验证应包含两个独立 context/metadata 实例, 证明它们只处理自己的实体,再对目标数据库执行 live schema、查询和审计写入测试。 +`ensurePhysicalSchema` 只处理数据库结构,不解释根对象或常量的候选值。 +数据播种由 context 调用已安装的 `GeneratedSchemaBootstrap`,使用带有 +comment/purpose 的 Q API 和 audited save,经过 Checker、Mutation Policy、 +乐观锁及提交后的审计链。旧的 Portable `ensureSchema` / `ensureInitData` +直写数据入口已移除;不要在方言中重建这条绕过路径。 + ## 3. 核心纪律 1. **彻底解耦 Spring**:在方言模块中,严禁直接使用 `JdbcTemplate` 或任何 `org.springframework` 包。全部通过 `SqlExecutionAdapter` 委托。 2. **职责极简**:方言层(后端层)只负责提供“查询数据字典的原生 SQL”和“JDBC 链接”,表结构的 Diff 对比和通用 DDL 必须收口在 Portable 引擎。 diff --git a/README.md b/README.md index ad351a67..a55147cb 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,103 @@ Mutations declare an audit action: task.auditAs("Move task to Done").save(userContext); ``` +### Request owned intent on the development branch + +The `feature/request-trace-chain` branch makes non-blank `comment` part of both +`QueryRequest` and `MutationRequest`. Query also requires non-blank `purpose`. +Existing generated `.comment(...).purpose(...)` and `.auditAs(...)` spelling +does not change. Low-level provider requests now expose an immutable validated +`QueryIntent` or `MutationIntent`; custom SPI implementations must adopt this +contract. These changes are not a claim about published Maven artifacts. + +Missing or Unicode-whitespace-only comment fails with +`REQUEST_COMMENT_REQUIRED` at `comment`; missing Query purpose fails with +`QUERY_PURPOSE_REQUIRED` at `purpose`. Validation happens before policy and +provider execution, including direct runtime calls and disabled logging. +Neither a Context default nor a fabricated trace supplies missing intent. + +List, aggregate, relation and streaming execution no longer push or pop query +frames on Context. Streaming providers now accept the same validated +`QueryRequest` envelope as materialized providers instead of a bare +`SearchRequest`. A custom `StreamingQueryExecutor` must migrate that SPI +signature; generated `.executeForStream(context)` calls remain unchanged. +The captured intent survives Policy changes to a builder and delayed cursor +consumption. SQL providers snapshot source paths and redaction provenance for +the invocation, including inherited internal streams. Legacy unbound direct SQL +diagnostics can still use explicitly supplied Context frames; those compatibility +calls are not the runtime query ownership contract. + +Derived relation, Facet and materialized relation-predicate queries carry their +validated originating intent instead of asking callers to repeat it. Mutation +reason is captured before policy and retained in provider requests and committed +audit facts. Graph saves now use immutable parent-linked mutation scopes, rather +than a Context push/pop stack. Each persistence request carries its own typed +lineage through SQL writes, authoritative readback and committed safe audit. +`TraceNode` includes entity type and assigned ID; Entity and Ledger trace setters +now accept immutable `List`, not flattened strings. Custom callers +using the old string API must migrate; this is a local source change, not a +released API. SQL `tracePath` remains separate from `mutationLineage`. + +The local runtime tests cover branch reasons, deleted children, same numeric ID +across types, assigned IDs, complete ledger overrides and concurrent saves sharing +one Context. Actual SQLite tests cover SQL/audit propagation, provider rollback, +readback failure/retry and masking. Native batch diagnostics distinguish the +batch call's `batchOutcome` from an individual member's possibly unknown +`executionOutcome`. Same-type graph inserts now use a validated +`MutationBatchRequest` and the optional `BatchMutationExecutor` capability. +Physical JDBC rows retain separate immutable trace bindings, including failure +and readback diagnostics; incompatible insert column layouts are grouped separately. +Root intent remains required even if children are annotated or logs are disabled. +Providers without the capability retain individual command execution. + +Internal reverse-list attachment-key projection preserves an explicitly requested +nested forward load. It must not use the public scalar selection operation that +removes a same-named relation load. Native SQLite tests cover root/nested graphs, +window/probe plans and logging on/off; the example gate runs these tests too. +Java retains an ID-only reference when its forward query has no matching target: +non-loaded fields remain guarded by `TeaQLNotLoadedException`, while list +membership and independent counts survive. This is not a claim of null-valued +reference parity with other runtimes. + +Bootstrap follows the same request and audit boundary. Call +`context.ensureSchema()` with the generated Runtime Module installed: providers +perform physical DDL, then generated Q and audited Mutation reconcile roots and +constants. The legacy Portable `ensureSchema(context, type)` and repository +`ensureInitData(context)` data-write APIs have been removed. The +[School example](examples/school-management/README.md) verifies real bootstrap +SQL intent, committed lineage, fixed IDs, no-op reseeding and versioned constant +reconciliation on two starts of the same database. + +The generated [Trace Chain example](examples/trace-chain/README.md) proves the +normative graph, overlapping three-level Q/E queries, late-consumed streams, +nested Facets with the original root and complete relation paths, +prepared insert grouping and complete ledger replacement, plus prepared +update/delete/recover batches with independent +optimistic versions. It now also runs real overlapping generated Checkers and +independent graph saves with one Context, observing per-item SQL and committed +audit lineage. Temporary check results, visited objects, Fix evidence and the +captured graph clock belong to each synchronous Checker invocation. Nested saves +restore the outer invocation while preserving the original custom Context and +its service hooks. `lastFixEvidence()` is the last completed check's diagnostic +receipt on the calling execution thread; it is not an async propagation API. +Read-only loaded relations retain their private ledger when reused by independent +graphs. Graph composition imports only pending mutations of explicitly visited +related entity keys, not every pending key from a foreign reference's ledger. +The provider-route guard also belongs to each mutation plan, not a retained +Context attribute. Independent graphs may use different providers on one +Context. A single atomic graph with writes to different routes is rejected +before mutation execution; read-only references do not count as writes. +Native tests cover separate SQLite databases and actual overlapping threads. + +Native dynamic-aggregation tests also retain the original root through nested +relations, preserve inherited masking provenance and avoid fabricated relation +nodes for numeric partitions. They are separate from generated Facet acceptance. + +Complete entry-point/privacy coverage, legacy unbound SQL diagnostic migration, asynchronous +handoff/cancellation and immutable internal Registry replay remain separate open +gates. The tested SQLite writer transactions serialize while the generated +Checkers overlap. This is local source evidence, not a merge or release claim. + Applications can replace runtime services such as `QueryPolicy`, the `MutationPolicyRegistry`, `MutationPolicyApprovalProvider`, `RuntimeLogSink`, `DataServiceRegistry`, `InternalIdGenerationService`, and `EntityMetaFactory` @@ -117,22 +214,26 @@ in their integration layer. Query and Mutation execution logs are enabled by default. The built-in default sink is safe for ordinary operator output: it includes intent, trace, elapsed -time, outcome, and parameterized SQL, but excludes bind values and rendered -Debug SQL. Enable copy/paste SQL only for a controlled troubleshooting surface: +time, outcome, and SQL with safely rendered parameters. Sensitive values follow +the field's masking policy; an unsafe statement is omitted with a reason rather +than printed as plaintext. Select an additional diagnostic destination only for +controlled troubleshooting: ```java TeaQLRuntime runtime = TeaQLRuntime.builder() .metadata(metadata) .queryExecutionLogging(true) .mutationExecutionLogging(true) - .diagnosticSqlLogging(true) // values and Debug SQL; apply restricted retention + .diagnosticSqlLogging(true) // selecting a destination alone does not authorize plaintext .build(); ``` The Query and Mutation switches remain independent. Selecting diagnostic SQL changes the built-in destination; it does not enable or disable either family. -Custom `RuntimeLogSink` implementations receive only parameterized SQL unless -they explicitly override `requiresSensitiveSqlData()` to return `true`. +Custom `RuntimeLogSink` implementations receive safe SQL projections too. +Plaintext requires both an explicitly sensitive destination and the exact +`TEAQL_ALLOW_SENSITIVE_PLAINTEXT_LOGS=I_UNDERSTAND_SENSITIVE_DATA_MAY_BE_WRITTEN_TO_DISK` +acknowledgement. Debug records are individually labeled; credentials remain protected. Custom `UserContext` implementations must also explicitly delegate or override `requiresSensitiveSqlLogData()` when they enable a diagnostic sink. The optional file-backed `LogManager` requests value-bearing SQL only with @@ -143,9 +244,9 @@ The optional file-backed `LogManager` requests value-bearing SQL only with TeaQL Java is a server-side security reference runtime: - ordinary Query and Mutation logs are enabled by default and retain intent, - trace, parameterized SQL, timing, and outcome without bind values; -- copy/paste SQL and parameter values require an explicitly selected sensitive - diagnostic sink; + typed trace, safely expanded SQL, timing, and outcome; +- ordinary SQL remains copy/paste-readable with masked values; plaintext requires + a sensitive diagnostic sink and the exact environment acknowledgement; - the TFP endpoint applies trusted server policy, bounded queries, writable-field rules, tenant scope, and optimistic version in the provider operation; - boundary-facing entity references can be issued and verified through diff --git a/examples/school-management/README.md b/examples/school-management/README.md index e7773bfe..c78d7e19 100644 --- a/examples/school-management/README.md +++ b/examples/school-management/README.md @@ -2,7 +2,15 @@ This generated example retains `models/school-model.xml`. It explicitly calls SQLite `ensureSchema` twice and verifies Platform `id=1` plus SchoolType constants -`1001`/`1002` are present exactly once with version 1. +`1001`/`1002` are present exactly once. Fresh rows start at version 1. + +The application-owned `BootstrapTraceVerifier` observes the generated bootstrap's +real SQL intent and committed audit lineage, without injecting trace frames. +It changes PRIMARY's name through audited Mutation, then calls ensureSchema to +restore the model-defined name. Each edit/repair advances the version once; +SECONDARY remains unchanged. Repeated ensureSchema performs lookups only, emits +no mutation audit, and restores the caller's bootstrap audit attributes. Both +fresh and already-seeded databases are exercised by the two-start verifier. The application-owned `SchoolLifecycleVerifier` also checks a missing required name is rejected by Checker before mutation or schema SQL during `save`, @@ -11,16 +19,24 @@ loaded E traversal, full-field update, mark-for-deletion plus save, and normal-q absence. The example gate runs with a fresh SQLite database; running it a second time against the same database is supported. +`RequestIntentVerifier` proves generated Q requests and graph saves reject +missing comment/purpose before policy or SQL. It covers Unicode whitespace, +fabricated ambient intent and root intent inheritance through both forward +relations. Unit tests separately prove the gate is independent of logging. + For updates, load the complete scalar entity. A read projection that includes only selected fields of related entities is useful for E/display, but should not be reused as the mutation graph: Checker correctly rejects those partial related entities as `NotLoaded`. Before publication, install the repository's local runtime and then run the -generated workspace. The portable SQL runtime test separately changes a constant -and verifies optimistic, single-version reconciliation. +generated workspace. Portable provider tests forbid the removed raw seed APIs +and prove physical DDL never inserts/reconciles root or constant data. Typed +constant reconciliation is verified here, through the same generated API as an +application, rather than through a lower-level raw SQL shortcut. From the repository root, run `examples/verify-runtime-examples.sh`. The gate builds both retained examples against the current reactor sources, assigns each -run an isolated temporary SQLite database, waits for its acceptance marker, and -exits non-zero if either application fails or times out. +example an isolated temporary SQLite database, runs each twice without cleanup +between repetitions, and requires both the lifecycle and request-intent markers. +It exits non-zero if either application fails or times out. diff --git a/examples/school-management/src/main/java/com/example/schoolmanagementservice/App.java b/examples/school-management/src/main/java/com/example/schoolmanagementservice/App.java index 1afaa7c0..3c12132d 100644 --- a/examples/school-management/src/main/java/com/example/schoolmanagementservice/App.java +++ b/examples/school-management/src/main/java/com/example/schoolmanagementservice/App.java @@ -87,6 +87,7 @@ public CommandLineRunner teaQLConsoleStartup( if (!(dataServiceExecutor instanceof SchemaExecutor schema)) { throw new IllegalStateException("default data service has no schema capability"); } + BootstrapTraceVerifier.verify(runtime); context.ensureSchema(); context.ensureSchema(); SmartList platforms = Q.platforms() @@ -104,7 +105,8 @@ public CommandLineRunner teaQLConsoleStartup( && constants.get(0).getId() == 1001L && constants.get(1).getId() == 1002L, "SchoolType constants were not seeded"); - require(constants.get(0).getVersion() == 1L && constants.get(1).getVersion() == 1L, + // The bootstrap verifier made one audited edit and one audited repair to PRIMARY. + require(constants.get(0).getVersion() >= 3L && constants.get(1).getVersion() == 1L, "Repeated ensureSchema was not idempotent"); require(new IdSpaceIdGenerator(database).nextId("SchoolType") > 1002L, "SchoolType ID floor did not advance beyond model constants"); diff --git a/examples/school-management/src/main/java/com/example/schoolmanagementservice/BootstrapTraceVerifier.java b/examples/school-management/src/main/java/com/example/schoolmanagementservice/BootstrapTraceVerifier.java new file mode 100644 index 00000000..df489a64 --- /dev/null +++ b/examples/school-management/src/main/java/com/example/schoolmanagementservice/BootstrapTraceVerifier.java @@ -0,0 +1,115 @@ +package com.example.schoolmanagementservice; + +import io.teaql.core.DataServiceOperation; +import io.teaql.core.ExecutionMetadata; +import io.teaql.core.GeneratedSchemaBootstrap; +import io.teaql.core.TraceKind; +import io.teaql.core.TraceNode; +import io.teaql.core.meta.SimpleEntityMetaFactory; +import io.teaql.runtime.AppAuditEventSink; +import io.teaql.runtime.SafeAuditEvent; +import io.teaql.runtime.TeaQLRuntime; +import java.util.ArrayList; +import java.util.List; + +/** Actual generated bootstrap, without supplying expected trace frames to runtime. */ +final class BootstrapTraceVerifier { + private BootstrapTraceVerifier() {} + + static void verify(TeaQLRuntime installedRuntime) { + var sql = new ArrayList(); + var audits = new ArrayList(); + var runtime = TeaQLRuntime.builder() + .metadata(new SimpleEntityMetaFactory()) + .registry(installedRuntime.getRegistry()) + .idGenerationService(installedRuntime.getIdGenerationService()) + .logSink((caller, entry) -> sql.add(entry)) + .build().install(GeneratedRuntimeModule.module()); + var context = new CustomUserContext(runtime); + context.putAttribute(AppAuditEventSink.class.getName(), (AppAuditEventSink) (caller, event) -> { + if (event.traceChain().stream().anyMatch(node -> node.getComment().startsWith("create model") + || node.getComment().startsWith("create generated") + || node.getComment().startsWith("reconcile model"))) { + require(GeneratedSchemaBootstrap.AUDIT_ACTOR.equals( + caller.getAttribute(GeneratedSchemaBootstrap.AUDIT_ACTOR_ATTRIBUTE)), + "Bootstrap audit lost its runtime actor"); + require(GeneratedSchemaBootstrap.AUDIT_CATEGORY.equals( + caller.getAttribute(GeneratedSchemaBootstrap.AUDIT_CATEGORY_ATTRIBUTE)), + "Bootstrap audit lost its category"); + } + audits.add(event); + }); + + context.ensureSchema(); + boolean freshlySeeded = audits.size() == 3; + require(freshlySeeded || audits.isEmpty(), "Expected a complete fresh bootstrap or unchanged existing seeds"); + verifyMutationTraces(sql, audits, freshlySeeded ? 3 : 0); + require(sql.stream().filter(entry -> entry.getOperation() == DataServiceOperation.QUERY).count() + == (freshlySeeded ? 6 : 3), "Bootstrap must retain lookup and authoritative readback evidence"); + sql.clear(); audits.clear(); + context.ensureSchema(); + require(audits.isEmpty(), "Repeated bootstrap emitted new mutation audit"); + require(sql.stream().noneMatch(entry -> entry.getOperation() == DataServiceOperation.MUTATION), + "Repeated bootstrap executed a data write"); + require(sql.stream().filter(entry -> entry.getOperation() == DataServiceOperation.QUERY).count() == 3, + "Repeated bootstrap must inspect root and both constants"); + + var primary = Q.schoolTypes().withIdIs(1001L) + .comment("load full constant for bootstrap reconciliation probe") + .purpose("verify typed bootstrap repairs model drift with audited save").executeForOne(context); + require(primary != null && primary.getVersion() > 0, "Missing active primary constant"); + long originalVersion = primary.getVersion(); + require(!freshlySeeded || originalVersion == 1L, "Fresh constant must start at version one"); + String originalName = primary.getName(); + primary.updateName("Temporary bootstrap probe value"); + primary.auditAs("prepare constant reconciliation probe").save(context); + sql.clear(); audits.clear(); + context.ensureSchema(); + verifyMutationTraces(sql, audits, 1); + require(audits.size() == 1 && "SchoolType".equals(audits.get(0).entityType()) + && Long.valueOf(1001).equals(audits.get(0).entityId()), + "Reconciliation must audit only the changed constant"); + var restored = Q.schoolTypes().withIdIs(1001L) + .comment("verify constant model value restored") + .purpose("check bootstrap reconciliation and optimistic version").executeForOne(context); + require(originalName.equals(restored.getName()) && restored.getVersion() == originalVersion + 2, + "Typed bootstrap did not reconcile the model value with an optimistic update"); + require(context.getAttribute(GeneratedSchemaBootstrap.AUDIT_ACTOR_ATTRIBUTE) == null + && context.getAttribute(GeneratedSchemaBootstrap.AUDIT_CATEGORY_ATTRIBUTE) == null, + "Bootstrap contaminated caller audit identity"); + sql.clear(); audits.clear(); + context.ensureSchema(); + require(audits.isEmpty() + && sql.stream().noneMatch(entry -> entry.getOperation() == DataServiceOperation.MUTATION), + "Reconciled bootstrap must be idempotent"); + System.out.println("PASS Java generated bootstrap request intent, committed trace and reconciliation" + + " fresh=" + freshlySeeded + " originalVersion=" + originalVersion); + } + + private static void verifyMutationTraces( + List sql, List audits, int expectedWrites) { + var writes = sql.stream().filter(entry -> entry.getOperation() == DataServiceOperation.MUTATION).toList(); + require(writes.size() == expectedWrites, "Unexpected physical bootstrap mutation count"); + for (var entry : sql) { + if (entry.getOperation() == DataServiceOperation.QUERY) { + require(entry.getComment() != null && !entry.getComment().isBlank() + && entry.getPurpose() != null && !entry.getPurpose().isBlank(), + "Bootstrap lookup/readback lost request intent"); + } + } + for (var write : writes) { + require(write.getAuditReason() != null && !write.getAuditReason().isBlank(), + "Bootstrap mutation lost request comment"); + require(write.getTraceChain().stream().map(TraceNode::getKind).toList().equals( + List.of(TraceKind.OPERATION, TraceKind.ENTITY, TraceKind.PROVIDER, TraceKind.SQL)), + "Bootstrap mutation SQL route is not canonical"); + require(!write.getMutationLineage().isEmpty() + && audits.stream().anyMatch(event -> event.traceChain().equals(write.getMutationLineage())), + "Bootstrap SQL lineage has no matching committed audit"); + } + } + + private static void require(boolean condition, String message) { + if (!condition) throw new IllegalStateException(message); + } +} diff --git a/examples/school-management/src/main/java/com/example/schoolmanagementservice/RequestIntentVerifier.java b/examples/school-management/src/main/java/com/example/schoolmanagementservice/RequestIntentVerifier.java new file mode 100644 index 00000000..4fdfece6 --- /dev/null +++ b/examples/school-management/src/main/java/com/example/schoolmanagementservice/RequestIntentVerifier.java @@ -0,0 +1,90 @@ +package com.example.schoolmanagementservice; + +import io.teaql.core.*; +import io.teaql.core.meta.SimpleEntityMetaFactory; +import io.teaql.runtime.TeaQLRuntime; +import java.util.ArrayList; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; + +/** Application-owned acceptance probe over the retained generated School Q/Mutation APIs. */ +final class RequestIntentVerifier { + private RequestIntentVerifier() {} + + static void verify(TeaQLRuntime original) { + var statements = new ArrayList(); + var queryPolicyCalls = new AtomicInteger(); + var mutationPolicyCalls = new AtomicInteger(); + var runtime = TeaQLRuntime.builder() + .metadata(new SimpleEntityMetaFactory()) + .registry(original.getRegistry()) + .idGenerationService(original.getIdGenerationService()) + .queryPolicy(new QueryPolicy() { + @Override public void enforceSelect(UserContext context, SearchRequest request) { + queryPolicyCalls.incrementAndGet(); + } + }) + .mutationPolicyRegistry(key -> Optional.of(new MutationPolicy() { + @Override public MutationPolicyIdentity identity() { + return new MutationPolicyIdentity("school.intent.probe", "1", "local-example"); + } + @Override public MutationDecision review(UserContext context, MutationPlan plan) { + mutationPolicyCalls.incrementAndGet(); return MutationDecision.allow(); + } + })) + .logSink((context, metadata) -> statements.add(metadata)) + .build().install(GeneratedRuntimeModule.module()); + UserContext context = new CustomUserContext(runtime); + context.pushTrace(TraceKind.COMMENT, "School", "unrelated ambient comment"); + context.pushTrace(TraceKind.PURPOSE, "School", "unrelated ambient purpose"); + try { + required("REQUEST_COMMENT_REQUIRED", () -> runtime.executeForList(context, Q.schools())); + required("REQUEST_COMMENT_REQUIRED", () -> runtime.executeForStream(context, Q.schools())); + required("REQUEST_COMMENT_REQUIRED", () -> runtime.aggregation(context, Q.schools())); + required("REQUEST_COMMENT_REQUIRED", () -> runtime.executeForPage(context, Q.schools(), 0, 10)); + required("QUERY_PURPOSE_REQUIRED", () -> runtime.executeForList(context, Q.schools().comment("load schools"))); + require(queryPolicyCalls.get() == 0 && statements.isEmpty(), "Invalid query reached policy or SQL"); + for (String blank : new String[]{"", " \t\r\n", "\u2003", "\u00a0"}) { + required("REQUEST_COMMENT_REQUIRED", () -> Q.schools().comment(blank).purpose("verify root intent")); + } + var school = Q.schools().comment("prepare request-intent rejection probe") + .purpose("verify mutation intent before Checker and provider access").newEntity(context); + statements.clear(); + school.setComment("\u2003"); + required("REQUEST_COMMENT_REQUIRED", () -> runtime.saveGraph(context, school)); + require(mutationPolicyCalls.get() == 0 && statements.isEmpty(), "Invalid mutation reached policy or SQL"); + } finally { + context.popTrace(); context.popTrace(); + } + + var rows = Q.schools().top(10) + .selectPlatformWith(Q.platformsWithMinimalFields().selectName()) + .selectSchoolTypeWith(Q.schoolTypesWithMinimalFields().selectCode()) + .comment("load request-intent relation probe") + .purpose("verify inherited root intent through generated relation requests") + .executeForList(context); + require(!rows.isEmpty(), "School relation probe returned no fixture"); + require(!statements.isEmpty(), "The valid generated query emitted no SQL diagnostics"); + for (var statement : statements) { + require("load request-intent relation probe".equals(statement.getComment()), "Derived query lost the root comment"); + require("verify inherited root intent through generated relation requests".equals(statement.getPurpose()), + "Derived query lost the root purpose"); + } + require(context.getTraceChain().isEmpty(), "Request intent leaked into the reused context"); + System.out.println("PASS Java request-owned comment/purpose gates and generated relation inheritance"); + } + + private static void required(String code, Runnable action) { + try { + action.run(); throw new IllegalStateException("Expected " + code); + } catch (RequestIntentException error) { + require(code.equals(error.getCode()), "Unexpected intent rejection: " + error.getCode()); + require((code.equals("QUERY_PURPOSE_REQUIRED") ? "purpose" : "comment").equals(error.getField()), + "Intent diagnostic has the wrong field location"); + } + } + + private static void require(boolean condition, String message) { + if (!condition) throw new IllegalStateException(message); + } +} diff --git a/examples/school-management/src/main/java/com/example/schoolmanagementservice/SchoolLifecycleVerifier.java b/examples/school-management/src/main/java/com/example/schoolmanagementservice/SchoolLifecycleVerifier.java index 9b20490e..a01d412a 100644 --- a/examples/school-management/src/main/java/com/example/schoolmanagementservice/SchoolLifecycleVerifier.java +++ b/examples/school-management/src/main/java/com/example/schoolmanagementservice/SchoolLifecycleVerifier.java @@ -18,6 +18,7 @@ final class SchoolLifecycleVerifier { private SchoolLifecycleVerifier() {} static void verify(TeaQLRuntime runtime) { + RequestIntentVerifier.verify(runtime); UserContext context = new CustomUserContext(runtime); Platform platform = Q.platforms() .withIdIs(1L) diff --git a/examples/trace-chain/AGENTS.md b/examples/trace-chain/AGENTS.md new file mode 100644 index 00000000..ee52e58e --- /dev/null +++ b/examples/trace-chain/AGENTS.md @@ -0,0 +1,62 @@ + + +# TeaQL Java Agent Instructions + +> [!WARNING] +> **IGNORE GENERIC ORM EXPERIENCE** +> +> Do **not** use pre-trained habits from data-access frameworks, ORMs, or database integration libraries. +> +> Do **not** use MyBatis, JPA, Hibernate, Spring Data, MyBatis-Plus, or similar frameworks. +> +> Do **not** write raw SQL, DAOs, Repository implementations, or custom persistence layers. +> +> Do **not** guess TeaQL method names. + +## How to Write Domain Code + +To get the exact API usage and query examples for the entity you are working on, execute the following command: + +```bash +cargo teaql --input models/trace-chain-service.xml java-assist-[action]/[entity-name] +``` + +> `models/trace-chain-service.xml` is the default model path. If the model file is located elsewhere, adjust the `--input` path to match the actual file location in this project. + +Replace `[action]` with one of the following: + +| action | when-to-use | +|--------|-------------| +| query | Read/find records from the database using Q. | +| create | Insert a new record into the database | +| update | Modify and save an existing record | +| delete | Remove or soft-delete a record | +| expression | Safely extract nested relation values using E. | +| list-page | Implement a paginated query returning SmartList | +| debug | View instructions for enabling SQL logging and debugging | + +Replace `[entity-name]` with the exact entity-name from the table below: + +| entity-name | display-name | +|-------------|--------------| +| platform | Platform | +| customer_order | Customer Order | +| order_item | Order Item | +| payment | Payment | +| payment_attempt | Payment Attempt | +| shipment | Shipment | + + +Once the command succeeds, read its output. Use the printed code as a template to write your logic. + +If the command cannot be executed, stop and report the missing context. Do not invent APIs. + +## Additional References + +Read these only when the task requires them: + +* **`TOOL_API_GUIDE.md`** + Framework runtime references and context handling. + +* **`RUNTIME_CUSTOM_GUIDE.md`** + Runtime setup, debugging, and project-specific restrictions. \ 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 00000000..0a5a74bb --- /dev/null +++ b/examples/trace-chain/README.md @@ -0,0 +1,182 @@ +# Java generated API Trace Chain example + +This focused example uses the six-entity KSML model in [model.xml](model.xml), +an unchanged generated domain library, and the runtime from this checkout. +Business creation, graph attachment, deletion, recovery, query and expression access use +generated public APIs. SQLite and the runtime's SQL and committed-audit sinks +provide the acceptance evidence; tests do not inject expected trace frames. + +Every successful provider mutation additionally checks `MutationResult.statements()`: +one physical write followed by its actual authoritative SELECT, with the same +typed branch lineage and the originating root's query/request path. Prepared +batches keep a separate statement list per member; concurrent saves do not share +a collection. This checks returned evidence independently of the log sink. +Native SQLite tests exercise all four query/mutation logging combinations. +`GeneratedAggregateTraceExampleTest` adds root/nested × logging-off/on dynamic +counts and numeric ID partitions, plus eight visible/filtered forward-detail +membership cases. Every physical raw path is asserted, and safe projections +redact private equal/set operands before the first root statement. Numeric +partitions add no invented relation. Two-child membership and scoped count remain +intact; a filtered parent keeps its actual ID while generated E rejects unfetched +description. Loading a separate full view cannot fill in that first view. +The aggregate helpers use the public inherited `BaseRequest` operations +`setPartitionProperty`, `count`, and `addSingleAggregateDynamicProperty`, as +exercised by `DerivedQueryTraceSqliteTest`; generated selectors and E access use +retained field Assist. No application SQL, injected trace frames or library edits. +Disabling diagnostic text does not remove the returned physical facts or change +the number of committed audits. These raw facts are trusted internal diagnostics; +apply `LogPrivacy.sql` before exporting one to a diagnostic sink, never serialize +raw SQL parameters into an application response. + +Ordinary materialized queries likewise return `QueryResult.statements()`, +including nested relation and aggregate statements, even when both logging +switches are off. The provider result owns an unmodifiable list, not Context. +The generated three-level query scenario observes those real returned facts +through a test-owned provider decorator while its SQL sink remains empty. +For streams, the SQL provider also exposes `queryForCursor(context, request)` +returning `QueryCursor` with `stream()`, `statements()` and `close()`. The +existing generated `executeForStream(context)` still returns a Java Stream and +uses this same implementation. Evidence is empty at open and finalized once on +exhaustion, cancellation or failure. Short-circuit operations must be closed. +Each `statements()` call returns an immutable list snapshot, not a live view; +metadata still requires safe projection. Generated tests verify completion, +early close and consumer failure with logging off and an unrelated query in +between. Unsupported custom providers reject the optional cursor-evidence +contract explicitly. Open failures throw before returning a cursor; this is not +a durable failure journal or a failed-query result-envelope contract. + +## Run the example + +Use Java 21 or newer, Maven, Bash and the normal repository dependencies: + +```bash +bash examples/trace-chain/verify.sh +``` + +The script installs local source dependencies, runs all twenty-four test methods twice +against one database without intermediate cleanup, and compares every generated +library file's SHA256 before and after execution. It prints the retained +directory containing the database, Maven logs and checksum manifests. Set +`TEAQL_TRACE_CHAIN_VERIFY_DIR` to retain subsequent replays in a chosen directory. +The example is also included in both repository example verification scripts +and the `runtime-examples` Maven profile. + +## Acceptance scenarios + +| Scenario | Observed boundary | +| --- | --- | +| Six-item normative graph | Root update, item update, item deletion, payment insert, attempt insert and shipment insert each retain their own typed lineage in provider commands, actual write/readback SQL and committed audit. Commands and committed events must contain exactly the six `(type, ID)` identities, without duplicates. Every command binds one unique physical write; paths do not invent entity IDs | +| Identity guard controls | Six records alone are insufficient: duplicated identity, replacement by an unknown ID and collapsing equal numeric IDs across types must each fail | +| Three-level query | PaymentAttempt → Payment → CustomerOrder → Platform produces four real SQL queries with ordered field-level relation nodes and the originating comment/purpose | +| Checker rejection | Missing `order_number` fails with its KSML location before provider execution, SQL or committed audit | +| Provider failure | A real SQLite UNIQUE violation rolls back the earlier root insert, retains attempted branch lineage and emits no committed audit | +| Readback failure | A real SQLite failure after a successful update retains separate write/readback outcomes; retry succeeds with the restored optimistic version | +| Prepared insert and ledger replacement | Two generated OrderItems execute in one real two-row JDBC prepared insert with independent command/write/readback/audit lineages; a subsequent update uses a complete ledger chain instead of appending graph fallback | +| Prepared update, delete and recovery | Two identified children with different optimistic versions execute each stage as a real two-row prepared batch, preserving separate command/write/readback/committed-audit lineages; deletion hides them and pure recovery restores both through generated Q/E | +| Overlapping real generated Checkers | With one Context, a valid order commits while an incomplete order fails for `order_number` before allocation/provider access; SQL and committed audit contain only the accepted request's lineage | +| Concurrent independent graphs | Two real threads overlap generated Checker invocations for separate root/child ledgers on one Context and share one unmodified loaded Platform without rebinding its ledger; physical SQLite writer transactions serialize, while each command/write/readback/audit retains only its graph's root and child reason; Q/E reload both commits | +| Concurrent three-level queries | Two live generated queries share one Context without adding ambient frames; each returns its own hydrated objects and four SQL records with only its root intent and logical relation path | +| Late-consumed stream | The real JDBC cursor opens without Context frames; consuming after an unrelated query retains the stream's original comment, purpose, root type and generated E result | +| Nested Facets | PaymentAttempt facets load Payment and its CustomerOrder facet; all five physical queries keep PaymentAttempt as the root, preserve the logical relation route and return the selected payment with count 1 | +| Facet inside a loaded relation | A PaymentAttempt loads Payment and its CustomerOrder facet; all four physical queries keep the original root, including the already-loaded `payment` ancestor | +| Returned nested Facet metadata | `GeneratedFacetTraceExampleTest` checks that the returned payments Facet retains its orders Facet; executing nested SQL alone does not prove result carriage | +| Full nested membership counts | A one-row page over three attempts / two payments returns both payment and order counts of 2; matching/all Facets and logging off/on execute seven real SELECTs, including materialized predicate lookups, on one originating collector | +| Loaded forward Facet results | Two loaded payments each retain their own orders Facet through runtime-owned `getQueryFacet("orders")`; seven physical SELECTs keep the complete root/ancestor path in both logging modes | +| Loaded empty and nonpersistent metadata | Eight matching/all × existing/absent target × logging combinations retain requested empty Facets and stable FK identity; subsequent audited payment save emits exactly one business mutation | +| Generated reverse collection Facets | Three orders have two, one and zero payments; each bounded child collection retains independent Facets, full counts and requested-empty metadata. All ten actual SELECT paths inherit the root intent in matching/all × logging off/on modes; query metadata schedules no mutations | + +The first run begins with CustomerOrder and Payment both numbered 100, items +201/202, attempt 401 and shipment 501. IDs come from `IdSpaceIdGenerator`, not +direct entity setters. Replays advance a type-specific floor rather than deleting +rows. The generated bootstrap Platform is reused. Generated checkers are +installed normally and are never replaced with permissive stubs. + +Root reason is `submit order`. Payment adds `authorize payment`; its attempt +inherits both. Shipment adds only `dispatch shipment`. The deleted item adds +`remove unavailable item`; the other item inherits only the root reason. +The same numeric ID on two entity types never identifies the same ledger entry. + +## Model and API provenance + +[AGENTS.md](AGENTS.md) and [retained Assist](evidence/assist/) come from local +model-aware services after [evaluation](evidence/evaluation.md). The generation +fixture is `JavaTraceChainExampleGenerationTest` in the paired generator checkout; +run it with `-Dteaql.java.dir=/absolute/path/to/teaql-java`. Domain-library files +must be regenerated from the model rather than patched by hand. + +The test-only `IdDatabase` is a JDBC bridge for the runtime's persistent ID +allocator. It is not a business DAO. Its SQL and failure-injection DDL are +infrastructure; all order/payment data is operated on through generated APIs. + +This closes the generated normative graph and three-level SQL path checks for +local Java source, including same-type prepared insert/update/delete/recover batches and a generated +complete-ledger override on an identified existing child. Java assigns IDs at +first graph save; the override probe runs after that insert/readback rather than +inventing a pre-save identity. Current Delete Assist documents `markToRecover()` +followed by audited save; recovery does not need a fabricated scalar-field change. +Native SQLite tests additionally cover stale batch members, multiple update +layouts, detached ledger recovery and rollback when JDBC cannot report exact +per-item optimistic row counts. Native reentrant/concurrent Checker tests also +verify isolated violations, visited identities, Fix evidence and per-graph clock +capture while retaining the original Context for application hooks. +The shared read-only Platform remains in the fixture: its independent ledger +must not be rebound or import another order's pending keys. Related mutation +import uses the explicitly visited type-qualified key. Receiver-owned detached +ledger mutations remain supported; no source ledger is cleared during import. + +The verifier now requires fourteen scenario markers. The additional privacy +scenario reloads a root and its child through generated Q, then saves the same +graph repeatedly. Old and new private child values mentioned in the root reason +must stay out of both SQL diagnostics and committed audit, across entity types. +It also checks a failed readback, database rollback, retry, marked deletion, and +an independent query that must not inherit earlier redaction state. The runtime +captures loaded/changed scalar provenance before the first graph write; that +request-local snapshot is neither a write payload nor Context state. + +Query provenance is carried +by the validated request and statement, not by a Context push/pop stack or a +ThreadLocal trace. The generated fluent stream API is unchanged; custom provider +implementations must migrate `StreamingQueryExecutor` from a bare SearchRequest +to QueryRequest. Runtime regressions also verify that Policy cannot replace the +captured stream intent and that internal streams inherit intent without repeating +it on their child builder. Legacy direct SQL diagnostics without statement +bindings retain a separate compatibility path; these tests do not establish +its concurrency safety or complete advanced-query/cancellation coverage. + +Derived Facet requests inherit both the root intent and the parent's complete +immutable path, rather than rebuilding the origin from the facet entity. +Membership COUNT compilation also carries the originating request, including +its statement observer. A materialized relation predicate is an actual query, +not invisible compiler work: its physical statement retains that root and the +verified relation edge. Matching Facet targets are restricted by the counted +FK identities, never by applying source-table predicates to the target table. +Facet materialization preserves nested collection metadata without sharing its +mutable map. The all-examples gate includes all four generated test classes +(24 JUnit methods), twice against the same retained SQLite database. +Facets loaded with a forward entity live in a runtime-owned `getQueryFacets()` +sidecar, not a KSML field, dynamic field, JSON property or mutation-ledger key. +`getQueryFacet(name) == null` means that Facet was not requested; a non-null +empty `SmartList` is a loaded empty result. The map is a copied, unmodifiable +snapshot. Loading a reverse collection preserves that collection's existing +`SmartList.getFacet(name)` metadata, independently for every parent, including +parents without children. Facet scopes currently use per-referenced-entity or +per-parent reads for correctness; there is no batched-Facet performance claim. +Ordinary relation loads without Facets retain their existing bulk/window/probe +policy. For a bounded reverse collection with Facets, selected-plan telemetry +reports `facet-scope`, not a fabricated window/probe selection. +Native `DerivedQueryTraceSqliteTest` separately exercises dynamic aggregates: +filtered counts, an aggregate inside a loaded relation, safe parent-value +redaction, logging disabled, and a numeric partition without a model relation. +Verified relation metadata supplies the reverse-list edge; an arbitrary numeric +partition keeps its parent's path without inventing an edge from the output +metric's name. These are native runtime tests, not generated dynamic-aggregate +acceptance: the retained field Assist does not document that operation. + +The synchronous Checker compatibility binding is runtime-internal and carries +no trace or ledger. Nested invocation close restores the parent; the diagnostic +`lastFixEvidence()` receipt belongs to the calling execution thread, not the +shared Context. It is not propagated to another thread or async task. These +focused probes do not prove every auxiliary-table layout, async handoff or +cancellation, all provider/entry-point combinations, complete privacy coverage, +or immutable internal Registry replay. The development dependency version is +not a new public release. diff --git a/examples/trace-chain/evidence/assist/java-assist-create/customer_order.md b/examples/trace-chain/evidence/assist/java-assist-create/customer_order.md new file mode 100644 index 00000000..7f31fc95 --- /dev/null +++ b/examples/trace-chain/evidence/assist/java-assist-create/customer_order.md @@ -0,0 +1,107 @@ + + +# Java Assist — Create `Customer Order` + +Use the exact generated `Q.customerOrders()` entry point. The +trusted `UserContext` owns actor, tenant, policy, provider, initialization, and +audit infrastructure; none of those values belong in writable business input. + +```java +package com.teaql.tracechainservice; + +import io.teaql.core.UserContext; +import com.teaql.tracechainservice.customerorder.CustomerOrder; + +public final class CustomerOrderCreateService { + private CustomerOrderCreateService() {} + + public static CustomerOrder create( + com.teaql.tracechainservice.platform.Platform platform, + java.lang.String orderNumber, + java.lang.String description, + UserContext context) { + var entity = Q.customerOrders() + .comment("what: initialize Customer Order") + .purpose("why: create Customer Order") + .newEntity(context); + + entity.updatePlatform(platform); + entity.updateOrderNumber(orderNumber); + entity.updateDescription(description); + + entity.auditAs("Create Customer Order for the requested business operation") + .save(context); + return entity; + } +} +``` + +Only the generated updater methods above are writable. Constant candidates, +when present, are also generated and must be copied exactly: + +Compile the source unchanged. Test persistence and query-back, and prove that +blank/missing intent, blank/missing audit reason, unknown fields, and attempted +trusted-context overrides fail. Never instantiate a generated entity directly +for creation and do not edit generated sources. + +## Compose an audited object graph + +Create children with their own generated Q entry point. The following exact +methods attach them to this object and set the corresponding parent reference: + +| Reverse relation | Child creation entry point | Attach to parent | +| --- | --- | --- | +| `order_item_list` | `Q.orderItems()` | `entity.addOrderItem(child)` | +| `payment_list` | `Q.payments()` | `entity.addPayment(child)` | +| `shipment_list` | `Q.shipments()` | `entity.addShipment(child)` | + + +Use `child.comment("authorize payment")` for a child-specific mutation reason. +Leave the child's mutation comment unset when it should inherit its parent's +reason. Creation query intent and mutation intent are distinct. Use a child's +current Create Assist to populate its required business fields; do not save it +separately when the operation must persist as one graph. + +For an already-loaded child, call `child.markForDeletion()` and then +`child.comment("remove unavailable item")`, keeping the child in the graph. +Finally call `entity.auditAs("submit order").save(context)` once. The runtime +assigns missing IDs, checks the complete graph, and persists inserts, updates +and deletion marks with each item's own root-to-leaf responsibility chain. + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact JAVA 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/java-assist-create/order_item.md b/examples/trace-chain/evidence/assist/java-assist-create/order_item.md new file mode 100644 index 00000000..8e1356fd --- /dev/null +++ b/examples/trace-chain/evidence/assist/java-assist-create/order_item.md @@ -0,0 +1,82 @@ + + +# Java Assist — Create `Order Item` + +Use the exact generated `Q.orderItems()` entry point. The +trusted `UserContext` owns actor, tenant, policy, provider, initialization, and +audit infrastructure; none of those values belong in writable business input. + +```java +package com.teaql.tracechainservice; + +import io.teaql.core.UserContext; +import com.teaql.tracechainservice.orderitem.OrderItem; + +public final class OrderItemCreateService { + private OrderItemCreateService() {} + + public static OrderItem create( + com.teaql.tracechainservice.customerorder.CustomerOrder customerOrder, + java.lang.String name, + UserContext context) { + var entity = Q.orderItems() + .comment("what: initialize Order Item") + .purpose("why: create Order Item") + .newEntity(context); + + entity.updateCustomerOrder(customerOrder); + entity.updateName(name); + + entity.auditAs("Create Order Item for the requested business operation") + .save(context); + return entity; + } +} +``` + +Only the generated updater methods above are writable. Constant candidates, +when present, are also generated and must be copied exactly: + +Compile the source unchanged. Test persistence and query-back, and prove that +blank/missing intent, blank/missing audit reason, unknown fields, and attempted +trusted-context overrides fail. Never instantiate a generated entity directly +for creation and do not edit generated sources. + + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact JAVA 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/java-assist-create/payment.md b/examples/trace-chain/evidence/assist/java-assist-create/payment.md new file mode 100644 index 00000000..b35b2b60 --- /dev/null +++ b/examples/trace-chain/evidence/assist/java-assist-create/payment.md @@ -0,0 +1,103 @@ + + +# Java Assist — Create `Payment` + +Use the exact generated `Q.payments()` entry point. The +trusted `UserContext` owns actor, tenant, policy, provider, initialization, and +audit infrastructure; none of those values belong in writable business input. + +```java +package com.teaql.tracechainservice; + +import io.teaql.core.UserContext; +import com.teaql.tracechainservice.payment.Payment; + +public final class PaymentCreateService { + private PaymentCreateService() {} + + public static Payment create( + com.teaql.tracechainservice.customerorder.CustomerOrder customerOrder, + java.lang.String referenceCode, + UserContext context) { + var entity = Q.payments() + .comment("what: initialize Payment") + .purpose("why: create Payment") + .newEntity(context); + + entity.updateCustomerOrder(customerOrder); + entity.updateReferenceCode(referenceCode); + + entity.auditAs("Create Payment for the requested business operation") + .save(context); + return entity; + } +} +``` + +Only the generated updater methods above are writable. Constant candidates, +when present, are also generated and must be copied exactly: + +Compile the source unchanged. Test persistence and query-back, and prove that +blank/missing intent, blank/missing audit reason, unknown fields, and attempted +trusted-context overrides fail. Never instantiate a generated entity directly +for creation and do not edit generated sources. + +## Compose an audited object graph + +Create children with their own generated Q entry point. The following exact +methods attach them to this object and set the corresponding parent reference: + +| Reverse relation | Child creation entry point | Attach to parent | +| --- | --- | --- | +| `payment_attempt_list` | `Q.paymentAttempts()` | `entity.addPaymentAttempt(child)` | + + +Use `child.comment("authorize payment")` for a child-specific mutation reason. +Leave the child's mutation comment unset when it should inherit its parent's +reason. Creation query intent and mutation intent are distinct. Use a child's +current Create Assist to populate its required business fields; do not save it +separately when the operation must persist as one graph. + +For an already-loaded child, call `child.markForDeletion()` and then +`child.comment("remove unavailable item")`, keeping the child in the graph. +Finally call `entity.auditAs("submit order").save(context)` once. The runtime +assigns missing IDs, checks the complete graph, and persists inserts, updates +and deletion marks with each item's own root-to-leaf responsibility chain. + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact JAVA 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/java-assist-create/payment_attempt.md b/examples/trace-chain/evidence/assist/java-assist-create/payment_attempt.md new file mode 100644 index 00000000..c12439a0 --- /dev/null +++ b/examples/trace-chain/evidence/assist/java-assist-create/payment_attempt.md @@ -0,0 +1,82 @@ + + +# Java Assist — Create `Payment Attempt` + +Use the exact generated `Q.paymentAttempts()` entry point. The +trusted `UserContext` owns actor, tenant, policy, provider, initialization, and +audit infrastructure; none of those values belong in writable business input. + +```java +package com.teaql.tracechainservice; + +import io.teaql.core.UserContext; +import com.teaql.tracechainservice.paymentattempt.PaymentAttempt; + +public final class PaymentAttemptCreateService { + private PaymentAttemptCreateService() {} + + public static PaymentAttempt create( + com.teaql.tracechainservice.payment.Payment payment, + java.lang.String referenceCode, + UserContext context) { + var entity = Q.paymentAttempts() + .comment("what: initialize Payment Attempt") + .purpose("why: create Payment Attempt") + .newEntity(context); + + entity.updatePayment(payment); + entity.updateReferenceCode(referenceCode); + + entity.auditAs("Create Payment Attempt for the requested business operation") + .save(context); + return entity; + } +} +``` + +Only the generated updater methods above are writable. Constant candidates, +when present, are also generated and must be copied exactly: + +Compile the source unchanged. Test persistence and query-back, and prove that +blank/missing intent, blank/missing audit reason, unknown fields, and attempted +trusted-context overrides fail. Never instantiate a generated entity directly +for creation and do not edit generated sources. + + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact JAVA 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/java-assist-create/shipment.md b/examples/trace-chain/evidence/assist/java-assist-create/shipment.md new file mode 100644 index 00000000..6031a828 --- /dev/null +++ b/examples/trace-chain/evidence/assist/java-assist-create/shipment.md @@ -0,0 +1,82 @@ + + +# Java Assist — Create `Shipment` + +Use the exact generated `Q.shipments()` entry point. The +trusted `UserContext` owns actor, tenant, policy, provider, initialization, and +audit infrastructure; none of those values belong in writable business input. + +```java +package com.teaql.tracechainservice; + +import io.teaql.core.UserContext; +import com.teaql.tracechainservice.shipment.Shipment; + +public final class ShipmentCreateService { + private ShipmentCreateService() {} + + public static Shipment create( + com.teaql.tracechainservice.customerorder.CustomerOrder customerOrder, + java.lang.String referenceCode, + UserContext context) { + var entity = Q.shipments() + .comment("what: initialize Shipment") + .purpose("why: create Shipment") + .newEntity(context); + + entity.updateCustomerOrder(customerOrder); + entity.updateReferenceCode(referenceCode); + + entity.auditAs("Create Shipment for the requested business operation") + .save(context); + return entity; + } +} +``` + +Only the generated updater methods above are writable. Constant candidates, +when present, are also generated and must be copied exactly: + +Compile the source unchanged. Test persistence and query-back, and prove that +blank/missing intent, blank/missing audit reason, unknown fields, and attempted +trusted-context overrides fail. Never instantiate a generated entity directly +for creation and do not edit generated sources. + + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact JAVA 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/java-assist-delete/order_item.md b/examples/trace-chain/evidence/assist/java-assist-delete/order_item.md new file mode 100644 index 00000000..53dcc04c --- /dev/null +++ b/examples/trace-chain/evidence/assist/java-assist-delete/order_item.md @@ -0,0 +1,100 @@ + + +# Java Assist — Delete `Order Item` + +Load the current row and its original optimistic version. Delete means audited, +version-aware soft deletion; do not issue SQL or invent a physical-delete API. + +```java +package com.teaql.tracechainservice; + +import io.teaql.core.UserContext; + +public final class OrderItemDeleteService { + private OrderItemDeleteService() {} + + public static boolean delete(Long id, UserContext context) { + var entity = Q.orderItems() + .withIdIs(id) + + .comment("what: load current Order Item for deletion") + .purpose("why: preserve original version for optimistic locking") + .executeForOne(context); + if (entity == null) { + return false; + } + + entity.markForDeletion() + .auditAs("Delete Order Item for the requested business operation") + .save(context); + return true; + } + + public static boolean restore(Long id, UserContext context) { + var entity = Q.orderItems() + .withIdIs(id) + + .deletedRowsOnly() + .limit(1) + .comment("what: load deleted Order Item for recovery") + .purpose("why: preserve the deleted optimistic version") + .executeForOne(context); + if (entity == null) { + return false; + } + entity.markToRecover() + .auditAs("Restore Order Item for the requested business operation") + .save(context); + return true; + } +} +``` + +Compile the source unchanged. Prove that the row remains stored with a negative +version, normal requests hide it, `deletedRowsOnly()` can retrieve it, a stale +independently loaded copy conflicts, a missing ID returns false, blank/missing +audit fails, and invented physical-delete methods do not compile. + +Recovery needs no scalar-field update. `markToRecover()` records a pending recovery; +audited `save(context)` restores visibility and advances the negative version to +a positive one. A graph can recover several children with independent local comments +using one audited root save. Test committed SQL/readback/audit lineage as well as Q/E +visibility; a status flag alone is not evidence that the row was recovered. + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact JAVA 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/java-assist-expression/customer_order.md b/examples/trace-chain/evidence/assist/java-assist-expression/customer_order.md new file mode 100644 index 00000000..05ccaf53 --- /dev/null +++ b/examples/trace-chain/evidence/assist/java-assist-expression/customer_order.md @@ -0,0 +1,135 @@ + + +# Java Assist — Expression `Customer Order` + +Generated expressions preserve three states: a loaded value, a loaded database +Null, and NotLoaded. `eval()` returns the first two as the native Java value or +`null`; it throws `TeaQLNotLoadedException` for NotLoaded. `orIfNull` applies only +to loaded Null and deliberately propagates NotLoaded. + +The following is complete model-derived source. + +```java +package com.teaql.tracechainservice; + +import com.teaql.tracechainservice.customerorder.CustomerOrder; + +public final class CustomerOrderExpressionService { + private CustomerOrderExpressionService() {} + + public static java.lang.Long extractId(CustomerOrder entity) { + return E.customerOrder(entity).getId().eval(); + } + + public static java.lang.Long extractIdOrIfNull(CustomerOrder entity, java.lang.Long fallback) { + return E.customerOrder(entity).getId().orIfNull(fallback); + } + + public static com.teaql.tracechainservice.platform.Platform traversePlatform(CustomerOrder entity) { + return E.customerOrder(entity).getPlatform().eval(); + } + + public static java.lang.String extractOrderNumber(CustomerOrder entity) { + return E.customerOrder(entity).getOrderNumber().eval(); + } + + public static java.lang.String extractOrderNumberOrIfNull(CustomerOrder entity, java.lang.String fallback) { + return E.customerOrder(entity).getOrderNumber().orIfNull(fallback); + } + + public static java.lang.String extractDescription(CustomerOrder entity) { + return E.customerOrder(entity).getDescription().eval(); + } + + public static java.lang.String extractDescriptionOrIfNull(CustomerOrder entity, java.lang.String fallback) { + return E.customerOrder(entity).getDescription().orIfNull(fallback); + } + + public static java.lang.Long extractVersion(CustomerOrder entity) { + return E.customerOrder(entity).getVersion().eval(); + } + + public static java.lang.Long extractVersionOrIfNull(CustomerOrder entity, java.lang.Long fallback) { + return E.customerOrder(entity).getVersion().orIfNull(fallback); + } + + public static Integer aggregateOrderItemListSize(CustomerOrder entity) { + return E.customerOrder(entity).getOrderItemList().size().eval(); + } + + public static Long firstOrderItemListId(CustomerOrder entity) { + return E.customerOrder(entity).getOrderItemList().first().getId().eval(); + } + + public static Long getOrderItemListId(CustomerOrder entity, int index) { + return E.customerOrder(entity).getOrderItemList().get(index).getId().eval(); + } + + public static Integer aggregatePaymentListSize(CustomerOrder entity) { + return E.customerOrder(entity).getPaymentList().size().eval(); + } + + public static Long firstPaymentListId(CustomerOrder entity) { + return E.customerOrder(entity).getPaymentList().first().getId().eval(); + } + + public static Long getPaymentListId(CustomerOrder entity, int index) { + return E.customerOrder(entity).getPaymentList().get(index).getId().eval(); + } + + public static Integer aggregateShipmentListSize(CustomerOrder entity) { + return E.customerOrder(entity).getShipmentList().size().eval(); + } + + public static Long firstShipmentListId(CustomerOrder entity) { + return E.customerOrder(entity).getShipmentList().first().getId().eval(); + } + + public static Long getShipmentListId(CustomerOrder entity, int index) { + return E.customerOrder(entity).getShipmentList().get(index).getId().eval(); + } + +} +``` + +Select every field and relation before traversal. Never catch +`TeaQLNotLoadedException` merely to provide a default, and never replace the E +facade with optional chaining or direct getters. + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact JAVA 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/java-assist-expression/order_item.md b/examples/trace-chain/evidence/assist/java-assist-expression/order_item.md new file mode 100644 index 00000000..6b35b7e6 --- /dev/null +++ b/examples/trace-chain/evidence/assist/java-assist-expression/order_item.md @@ -0,0 +1,91 @@ + + +# Java Assist — Expression `Order Item` + +Generated expressions preserve three states: a loaded value, a loaded database +Null, and NotLoaded. `eval()` returns the first two as the native Java value or +`null`; it throws `TeaQLNotLoadedException` for NotLoaded. `orIfNull` applies only +to loaded Null and deliberately propagates NotLoaded. + +The following is complete model-derived source. + +```java +package com.teaql.tracechainservice; + +import com.teaql.tracechainservice.orderitem.OrderItem; + +public final class OrderItemExpressionService { + private OrderItemExpressionService() {} + + public static java.lang.Long extractId(OrderItem entity) { + return E.orderItem(entity).getId().eval(); + } + + public static java.lang.Long extractIdOrIfNull(OrderItem entity, java.lang.Long fallback) { + return E.orderItem(entity).getId().orIfNull(fallback); + } + + public static com.teaql.tracechainservice.customerorder.CustomerOrder traverseCustomerOrder(OrderItem entity) { + return E.orderItem(entity).getCustomerOrder().eval(); + } + + public static java.lang.String extractName(OrderItem entity) { + return E.orderItem(entity).getName().eval(); + } + + public static java.lang.String extractNameOrIfNull(OrderItem entity, java.lang.String fallback) { + return E.orderItem(entity).getName().orIfNull(fallback); + } + + public static java.lang.Long extractVersion(OrderItem entity) { + return E.orderItem(entity).getVersion().eval(); + } + + public static java.lang.Long extractVersionOrIfNull(OrderItem entity, java.lang.Long fallback) { + return E.orderItem(entity).getVersion().orIfNull(fallback); + } + +} +``` + +Select every field and relation before traversal. Never catch +`TeaQLNotLoadedException` merely to provide a default, and never replace the E +facade with optional chaining or direct getters. + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact JAVA 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/java-assist-expression/payment.md b/examples/trace-chain/evidence/assist/java-assist-expression/payment.md new file mode 100644 index 00000000..ad1b6fa2 --- /dev/null +++ b/examples/trace-chain/evidence/assist/java-assist-expression/payment.md @@ -0,0 +1,103 @@ + + +# Java Assist — Expression `Payment` + +Generated expressions preserve three states: a loaded value, a loaded database +Null, and NotLoaded. `eval()` returns the first two as the native Java value or +`null`; it throws `TeaQLNotLoadedException` for NotLoaded. `orIfNull` applies only +to loaded Null and deliberately propagates NotLoaded. + +The following is complete model-derived source. + +```java +package com.teaql.tracechainservice; + +import com.teaql.tracechainservice.payment.Payment; + +public final class PaymentExpressionService { + private PaymentExpressionService() {} + + public static java.lang.Long extractId(Payment entity) { + return E.payment(entity).getId().eval(); + } + + public static java.lang.Long extractIdOrIfNull(Payment entity, java.lang.Long fallback) { + return E.payment(entity).getId().orIfNull(fallback); + } + + public static com.teaql.tracechainservice.customerorder.CustomerOrder traverseCustomerOrder(Payment entity) { + return E.payment(entity).getCustomerOrder().eval(); + } + + public static java.lang.String extractReferenceCode(Payment entity) { + return E.payment(entity).getReferenceCode().eval(); + } + + public static java.lang.String extractReferenceCodeOrIfNull(Payment entity, java.lang.String fallback) { + return E.payment(entity).getReferenceCode().orIfNull(fallback); + } + + public static java.lang.Long extractVersion(Payment entity) { + return E.payment(entity).getVersion().eval(); + } + + public static java.lang.Long extractVersionOrIfNull(Payment entity, java.lang.Long fallback) { + return E.payment(entity).getVersion().orIfNull(fallback); + } + + public static Integer aggregatePaymentAttemptListSize(Payment entity) { + return E.payment(entity).getPaymentAttemptList().size().eval(); + } + + public static Long firstPaymentAttemptListId(Payment entity) { + return E.payment(entity).getPaymentAttemptList().first().getId().eval(); + } + + public static Long getPaymentAttemptListId(Payment entity, int index) { + return E.payment(entity).getPaymentAttemptList().get(index).getId().eval(); + } + +} +``` + +Select every field and relation before traversal. Never catch +`TeaQLNotLoadedException` merely to provide a default, and never replace the E +facade with optional chaining or direct getters. + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact JAVA 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/java-assist-expression/payment_attempt.md b/examples/trace-chain/evidence/assist/java-assist-expression/payment_attempt.md new file mode 100644 index 00000000..523f7a20 --- /dev/null +++ b/examples/trace-chain/evidence/assist/java-assist-expression/payment_attempt.md @@ -0,0 +1,91 @@ + + +# Java Assist — Expression `Payment Attempt` + +Generated expressions preserve three states: a loaded value, a loaded database +Null, and NotLoaded. `eval()` returns the first two as the native Java value or +`null`; it throws `TeaQLNotLoadedException` for NotLoaded. `orIfNull` applies only +to loaded Null and deliberately propagates NotLoaded. + +The following is complete model-derived source. + +```java +package com.teaql.tracechainservice; + +import com.teaql.tracechainservice.paymentattempt.PaymentAttempt; + +public final class PaymentAttemptExpressionService { + private PaymentAttemptExpressionService() {} + + public static java.lang.Long extractId(PaymentAttempt entity) { + return E.paymentAttempt(entity).getId().eval(); + } + + public static java.lang.Long extractIdOrIfNull(PaymentAttempt entity, java.lang.Long fallback) { + return E.paymentAttempt(entity).getId().orIfNull(fallback); + } + + public static com.teaql.tracechainservice.payment.Payment traversePayment(PaymentAttempt entity) { + return E.paymentAttempt(entity).getPayment().eval(); + } + + public static java.lang.String extractReferenceCode(PaymentAttempt entity) { + return E.paymentAttempt(entity).getReferenceCode().eval(); + } + + public static java.lang.String extractReferenceCodeOrIfNull(PaymentAttempt entity, java.lang.String fallback) { + return E.paymentAttempt(entity).getReferenceCode().orIfNull(fallback); + } + + public static java.lang.Long extractVersion(PaymentAttempt entity) { + return E.paymentAttempt(entity).getVersion().eval(); + } + + public static java.lang.Long extractVersionOrIfNull(PaymentAttempt entity, java.lang.Long fallback) { + return E.paymentAttempt(entity).getVersion().orIfNull(fallback); + } + +} +``` + +Select every field and relation before traversal. Never catch +`TeaQLNotLoadedException` merely to provide a default, and never replace the E +facade with optional chaining or direct getters. + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact JAVA 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/java-assist-expression/platform.md b/examples/trace-chain/evidence/assist/java-assist-expression/platform.md new file mode 100644 index 00000000..f5e6512d --- /dev/null +++ b/examples/trace-chain/evidence/assist/java-assist-expression/platform.md @@ -0,0 +1,99 @@ + + +# Java Assist — Expression `Platform` + +Generated expressions preserve three states: a loaded value, a loaded database +Null, and NotLoaded. `eval()` returns the first two as the native Java value or +`null`; it throws `TeaQLNotLoadedException` for NotLoaded. `orIfNull` applies only +to loaded Null and deliberately propagates NotLoaded. + +The following is complete model-derived source. + +```java +package com.teaql.tracechainservice; + +import com.teaql.tracechainservice.platform.Platform; + +public final class PlatformExpressionService { + private PlatformExpressionService() {} + + public static java.lang.Long extractId(Platform entity) { + return E.platform(entity).getId().eval(); + } + + public static java.lang.Long extractIdOrIfNull(Platform entity, java.lang.Long fallback) { + return E.platform(entity).getId().orIfNull(fallback); + } + + public static java.lang.String extractName(Platform entity) { + return E.platform(entity).getName().eval(); + } + + public static java.lang.String extractNameOrIfNull(Platform entity, java.lang.String fallback) { + return E.platform(entity).getName().orIfNull(fallback); + } + + public static java.lang.Long extractVersion(Platform entity) { + return E.platform(entity).getVersion().eval(); + } + + public static java.lang.Long extractVersionOrIfNull(Platform entity, java.lang.Long fallback) { + return E.platform(entity).getVersion().orIfNull(fallback); + } + + public static Integer aggregateCustomerOrderListSize(Platform entity) { + return E.platform(entity).getCustomerOrderList().size().eval(); + } + + public static Long firstCustomerOrderListId(Platform entity) { + return E.platform(entity).getCustomerOrderList().first().getId().eval(); + } + + public static Long getCustomerOrderListId(Platform entity, int index) { + return E.platform(entity).getCustomerOrderList().get(index).getId().eval(); + } + +} +``` + +Select every field and relation before traversal. Never catch +`TeaQLNotLoadedException` merely to provide a default, and never replace the E +facade with optional chaining or direct getters. + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact JAVA 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/java-assist-query/customer_order.md b/examples/trace-chain/evidence/assist/java-assist-query/customer_order.md new file mode 100644 index 00000000..5a49f09e --- /dev/null +++ b/examples/trace-chain/evidence/assist/java-assist-query/customer_order.md @@ -0,0 +1,111 @@ + + +# Java Assist — Query `Customer Order` + +Use the exact generated `Q.customerOrdersWithMinimalFields()` +entry point. The request is bounded, stably ordered, and receives trusted +runtime and policy state only from `UserContext`. + +```java +package com.teaql.tracechainservice; + +import io.teaql.core.SmartList; +import io.teaql.core.UserContext; +import com.teaql.tracechainservice.customerorder.CustomerOrder; + +public final class CustomerOrderQueryService { + private CustomerOrderQueryService() {} + + public static SmartList queryById( + UserContext context, Long entityId) { + return Q.customerOrdersWithMinimalFields() + .withIdIs(entityId) + + .orderByIdAscending() + .limit(20) + .comment("what: load the requested Customer Order row") + .purpose("why: serve the authorized Customer Order view") + .executeForList(context); + } +} +``` + +Compile and execute this source unchanged. Use only generated projection, +predicate, ordering, and relation-selection APIs. Reuse the same active filters +for rows, count, facets, and aggregates. 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` | `java-assist-query/customer_order.id` | +| `platform` | `relation` | `java-assist-query/customer_order.platform` | +| `order_number` | `string` | `java-assist-query/customer_order.order_number` | +| `description` | `string` | `java-assist-query/customer_order.description` | +| `version` | `version` | `java-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` | `java-assist-query/customer_order.order_item_list` | +| `payment_list` | `payment` | `java-assist-query/customer_order.payment_list` | +| `shipment_list` | `shipment` | `java-assist-query/customer_order.shipment_list` | + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact JAVA 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 `topNProbeParentThreshold(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/java-assist-query/customer_order.order_item_list.md b/examples/trace-chain/evidence/assist/java-assist-query/customer_order.order_item_list.md new file mode 100644 index 00000000..cc390bdc --- /dev/null +++ b/examples/trace-chain/evidence/assist/java-assist-query/customer_order.order_item_list.md @@ -0,0 +1,66 @@ + + +# Query Reverse-Relation Assist — `customer_order.order_item_list` + +KSML entity: `customer_order` +Derived reverse relation: `order_item_list` +Child entity: `order_item` + +| Capability | Generated API | +| --- | --- | +| Select | `.selectOrderItemList()`, `.selectOrderItemListWith(Q.orderItemsWithMinimalFields())` | + +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 JAVA 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 `topNProbeParentThreshold(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/java-assist-query/customer_order.payment_list.md b/examples/trace-chain/evidence/assist/java-assist-query/customer_order.payment_list.md new file mode 100644 index 00000000..a5ff75d8 --- /dev/null +++ b/examples/trace-chain/evidence/assist/java-assist-query/customer_order.payment_list.md @@ -0,0 +1,66 @@ + + +# Query Reverse-Relation Assist — `customer_order.payment_list` + +KSML entity: `customer_order` +Derived reverse relation: `payment_list` +Child entity: `payment` + +| Capability | Generated API | +| --- | --- | +| Select | `.selectPaymentList()`, `.selectPaymentListWith(Q.paymentsWithMinimalFields())` | + +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 JAVA 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 `topNProbeParentThreshold(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/java-assist-query/customer_order.platform.md b/examples/trace-chain/evidence/assist/java-assist-query/customer_order.platform.md new file mode 100644 index 00000000..e6fd7316 --- /dev/null +++ b/examples/trace-chain/evidence/assist/java-assist-query/customer_order.platform.md @@ -0,0 +1,75 @@ + + +# Java Query Field Assist — `customer_order.platform` + +KSML entity: `Customer Order` +KSML field: `Platform` +Type: `Platform` + +Use these exact generated methods. The KSML location above is the discovery +identity; Java member and method casing is shown only as the generated API. + +| Capability | Generated API | +| --- | --- | +| Select | `selectPlatform()`, `selectPlatformWith(Q.platformsWithMinimalFields())` | +| Filter identity | `filterByPlatform(id)` | +| Filter nested request | `withPlatformMatching(request)`, `withoutPlatformMatching(request)` | +| Null state | `withPlatformIsKnown()`, `withPlatformIsUnknown()` | +| Order | `orderByPlatformAscending()`, `orderByPlatformDescending()` | +| Group | `groupByPlatform()`, `groupByPlatformAs(alias)` | +| Facet | `facetByPlatformAs(name, request)`, `facetByPlatformAs(name, request, includeAllFacets)` | +Apply methods before `purpose(...)`; execution still requires non-empty +`comment(...)`, `purpose(...)`, and exactly one `UserContext`. + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact JAVA 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 `topNProbeParentThreshold(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/java-assist-query/customer_order.shipment_list.md b/examples/trace-chain/evidence/assist/java-assist-query/customer_order.shipment_list.md new file mode 100644 index 00000000..644dda17 --- /dev/null +++ b/examples/trace-chain/evidence/assist/java-assist-query/customer_order.shipment_list.md @@ -0,0 +1,66 @@ + + +# Query Reverse-Relation Assist — `customer_order.shipment_list` + +KSML entity: `customer_order` +Derived reverse relation: `shipment_list` +Child entity: `shipment` + +| Capability | Generated API | +| --- | --- | +| Select | `.selectShipmentList()`, `.selectShipmentListWith(Q.shipmentsWithMinimalFields())` | + +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 JAVA 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 `topNProbeParentThreshold(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/java-assist-query/order_item.md b/examples/trace-chain/evidence/assist/java-assist-query/order_item.md new file mode 100644 index 00000000..3b5a6ef4 --- /dev/null +++ b/examples/trace-chain/evidence/assist/java-assist-query/order_item.md @@ -0,0 +1,102 @@ + + +# Java Assist — Query `Order Item` + +Use the exact generated `Q.orderItemsWithMinimalFields()` +entry point. The request is bounded, stably ordered, and receives trusted +runtime and policy state only from `UserContext`. + +```java +package com.teaql.tracechainservice; + +import io.teaql.core.SmartList; +import io.teaql.core.UserContext; +import com.teaql.tracechainservice.orderitem.OrderItem; + +public final class OrderItemQueryService { + private OrderItemQueryService() {} + + public static SmartList queryById( + UserContext context, Long entityId) { + return Q.orderItemsWithMinimalFields() + .withIdIs(entityId) + + .orderByIdAscending() + .limit(20) + .comment("what: load the requested Order Item row") + .purpose("why: serve the authorized Order Item view") + .executeForList(context); + } +} +``` + +Compile and execute this source unchanged. Use only generated projection, +predicate, ordering, and relation-selection APIs. Reuse the same active filters +for rows, count, facets, and aggregates. 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` | `java-assist-query/order_item.id` | +| `customer_order` | `relation` | `java-assist-query/order_item.customer_order` | +| `name` | `string` | `java-assist-query/order_item.name` | +| `version` | `version` | `java-assist-query/order_item.version` | + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact JAVA 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 `topNProbeParentThreshold(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/java-assist-query/order_item.name.md b/examples/trace-chain/evidence/assist/java-assist-query/order_item.name.md new file mode 100644 index 00000000..594af539 --- /dev/null +++ b/examples/trace-chain/evidence/assist/java-assist-query/order_item.name.md @@ -0,0 +1,75 @@ + + +# Java Query Field Assist — `order_item.name` + +KSML entity: `Order Item` +KSML field: `name` +Type: `string` + +Use these exact generated methods. The KSML location above is the discovery +identity; Java member and method casing is shown only as the generated API. + +| Capability | Generated API | +| --- | --- | +| Select | ``selectName()` | +| Equality/set | `withNameIs(value)`, `withNameIsNot(value)`, `withNameIn(values)`, `withNameNotIn(values)` | +| Comparison | `withNameGreaterThan(value)`, `withNameGreaterThanOrEqualTo(value)`, `withNameLessThan(value)`, `withNameLessThanOrEqualTo(value)`, `withNameBetween(lower, upper)` | +| Null state | `withNameIsKnown()`, `withNameIsUnknown()` | +| String | `withNameContaining(value)`, `withNameNotContaining(value)`, `withNameStartingWith(value)`, `withNameEndingWith(value)`, `withNameSoundingLike(value)` | +| Order | `orderByNameAscending()`, `orderByNameDescending()` | +| Group | `groupByName()`, `groupByNameAs(alias)` | +Apply methods before `purpose(...)`; execution still requires non-empty +`comment(...)`, `purpose(...)`, and exactly one `UserContext`. + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact JAVA 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 `topNProbeParentThreshold(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/java-assist-query/payment.customer_order.md b/examples/trace-chain/evidence/assist/java-assist-query/payment.customer_order.md new file mode 100644 index 00000000..9a37ab80 --- /dev/null +++ b/examples/trace-chain/evidence/assist/java-assist-query/payment.customer_order.md @@ -0,0 +1,75 @@ + + +# Java Query Field Assist — `payment.customer_order` + +KSML entity: `Payment` +KSML field: `Customer Order` +Type: `Customer Order` + +Use these exact generated methods. The KSML location above is the discovery +identity; Java member and method casing is shown only as the generated API. + +| Capability | Generated API | +| --- | --- | +| Select | `selectCustomerOrder()`, `selectCustomerOrderWith(Q.customerOrdersWithMinimalFields())` | +| Filter identity | `filterByCustomerOrder(id)` | +| Filter nested request | `withCustomerOrderMatching(request)`, `withoutCustomerOrderMatching(request)` | +| Null state | `withCustomerOrderIsKnown()`, `withCustomerOrderIsUnknown()` | +| Order | `orderByCustomerOrderAscending()`, `orderByCustomerOrderDescending()` | +| Group | `groupByCustomerOrder()`, `groupByCustomerOrderAs(alias)` | +| Facet | `facetByCustomerOrderAs(name, request)`, `facetByCustomerOrderAs(name, request, includeAllFacets)` | +Apply methods before `purpose(...)`; execution still requires non-empty +`comment(...)`, `purpose(...)`, and exactly one `UserContext`. + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact JAVA 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 `topNProbeParentThreshold(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/java-assist-query/payment.md b/examples/trace-chain/evidence/assist/java-assist-query/payment.md new file mode 100644 index 00000000..8ae71e01 --- /dev/null +++ b/examples/trace-chain/evidence/assist/java-assist-query/payment.md @@ -0,0 +1,108 @@ + + +# Java Assist — Query `Payment` + +Use the exact generated `Q.paymentsWithMinimalFields()` +entry point. The request is bounded, stably ordered, and receives trusted +runtime and policy state only from `UserContext`. + +```java +package com.teaql.tracechainservice; + +import io.teaql.core.SmartList; +import io.teaql.core.UserContext; +import com.teaql.tracechainservice.payment.Payment; + +public final class PaymentQueryService { + private PaymentQueryService() {} + + public static SmartList queryById( + UserContext context, Long entityId) { + return Q.paymentsWithMinimalFields() + .withIdIs(entityId) + + .orderByIdAscending() + .limit(20) + .comment("what: load the requested Payment row") + .purpose("why: serve the authorized Payment view") + .executeForList(context); + } +} +``` + +Compile and execute this source unchanged. Use only generated projection, +predicate, ordering, and relation-selection APIs. Reuse the same active filters +for rows, count, facets, and aggregates. 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` | `java-assist-query/payment.id` | +| `customer_order` | `relation` | `java-assist-query/payment.customer_order` | +| `reference_code` | `string` | `java-assist-query/payment.reference_code` | +| `version` | `version` | `java-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` | `java-assist-query/payment.payment_attempt_list` | + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact JAVA 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 `topNProbeParentThreshold(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/java-assist-query/payment.payment_attempt_list.md b/examples/trace-chain/evidence/assist/java-assist-query/payment.payment_attempt_list.md new file mode 100644 index 00000000..6ef3176e --- /dev/null +++ b/examples/trace-chain/evidence/assist/java-assist-query/payment.payment_attempt_list.md @@ -0,0 +1,66 @@ + + +# Query Reverse-Relation Assist — `payment.payment_attempt_list` + +KSML entity: `payment` +Derived reverse relation: `payment_attempt_list` +Child entity: `payment_attempt` + +| Capability | Generated API | +| --- | --- | +| Select | `.selectPaymentAttemptList()`, `.selectPaymentAttemptListWith(Q.paymentAttemptsWithMinimalFields())` | + +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 JAVA 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 `topNProbeParentThreshold(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/java-assist-query/payment_attempt.payment.md b/examples/trace-chain/evidence/assist/java-assist-query/payment_attempt.payment.md new file mode 100644 index 00000000..e421e1ad --- /dev/null +++ b/examples/trace-chain/evidence/assist/java-assist-query/payment_attempt.payment.md @@ -0,0 +1,75 @@ + + +# Java Query Field Assist — `payment_attempt.payment` + +KSML entity: `Payment Attempt` +KSML field: `Payment` +Type: `Payment` + +Use these exact generated methods. The KSML location above is the discovery +identity; Java member and method casing is shown only as the generated API. + +| Capability | Generated API | +| --- | --- | +| Select | `selectPayment()`, `selectPaymentWith(Q.paymentsWithMinimalFields())` | +| Filter identity | `filterByPayment(id)` | +| Filter nested request | `withPaymentMatching(request)`, `withoutPaymentMatching(request)` | +| Null state | `withPaymentIsKnown()`, `withPaymentIsUnknown()` | +| Order | `orderByPaymentAscending()`, `orderByPaymentDescending()` | +| Group | `groupByPayment()`, `groupByPaymentAs(alias)` | +| Facet | `facetByPaymentAs(name, request)`, `facetByPaymentAs(name, request, includeAllFacets)` | +Apply methods before `purpose(...)`; execution still requires non-empty +`comment(...)`, `purpose(...)`, and exactly one `UserContext`. + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact JAVA 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 `topNProbeParentThreshold(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/java-assist-query/payment_attempt.reference_code.md b/examples/trace-chain/evidence/assist/java-assist-query/payment_attempt.reference_code.md new file mode 100644 index 00000000..28fa8f91 --- /dev/null +++ b/examples/trace-chain/evidence/assist/java-assist-query/payment_attempt.reference_code.md @@ -0,0 +1,75 @@ + + +# Java Query Field Assist — `payment_attempt.reference_code` + +KSML entity: `Payment Attempt` +KSML field: `reference_code` +Type: `string` + +Use these exact generated methods. The KSML location above is the discovery +identity; Java member and method casing is shown only as the generated API. + +| Capability | Generated API | +| --- | --- | +| Select | ``selectReferenceCode()` | +| Equality/set | `withReferenceCodeIs(value)`, `withReferenceCodeIsNot(value)`, `withReferenceCodeIn(values)`, `withReferenceCodeNotIn(values)` | +| Comparison | `withReferenceCodeGreaterThan(value)`, `withReferenceCodeGreaterThanOrEqualTo(value)`, `withReferenceCodeLessThan(value)`, `withReferenceCodeLessThanOrEqualTo(value)`, `withReferenceCodeBetween(lower, upper)` | +| Null state | `withReferenceCodeIsKnown()`, `withReferenceCodeIsUnknown()` | +| String | `withReferenceCodeContaining(value)`, `withReferenceCodeNotContaining(value)`, `withReferenceCodeStartingWith(value)`, `withReferenceCodeEndingWith(value)`, `withReferenceCodeSoundingLike(value)` | +| Order | `orderByReferenceCodeAscending()`, `orderByReferenceCodeDescending()` | +| Group | `groupByReferenceCode()`, `groupByReferenceCodeAs(alias)` | +Apply methods before `purpose(...)`; execution still requires non-empty +`comment(...)`, `purpose(...)`, and exactly one `UserContext`. + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact JAVA 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 `topNProbeParentThreshold(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/java-assist-query/platform.md b/examples/trace-chain/evidence/assist/java-assist-query/platform.md new file mode 100644 index 00000000..7ad372fd --- /dev/null +++ b/examples/trace-chain/evidence/assist/java-assist-query/platform.md @@ -0,0 +1,107 @@ + + +# Java Assist — Query `Platform` + +Use the exact generated `Q.platformsWithMinimalFields()` +entry point. The request is bounded, stably ordered, and receives trusted +runtime and policy state only from `UserContext`. + +```java +package com.teaql.tracechainservice; + +import io.teaql.core.SmartList; +import io.teaql.core.UserContext; +import com.teaql.tracechainservice.platform.Platform; + +public final class PlatformQueryService { + private PlatformQueryService() {} + + public static SmartList queryById( + UserContext context, Long entityId) { + return Q.platformsWithMinimalFields() + .withIdIs(entityId) + + .orderByIdAscending() + .limit(20) + .comment("what: load the requested Platform row") + .purpose("why: serve the authorized Platform view") + .executeForList(context); + } +} +``` + +Compile and execute this source unchanged. Use only generated projection, +predicate, ordering, and relation-selection APIs. Reuse the same active filters +for rows, count, facets, and aggregates. 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` | `java-assist-query/platform.id` | +| `name` | `string` | `java-assist-query/platform.name` | +| `version` | `version` | `java-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` | `java-assist-query/platform.customer_order_list` | + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact JAVA 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 `topNProbeParentThreshold(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/java-assist-query/platform.name.md b/examples/trace-chain/evidence/assist/java-assist-query/platform.name.md new file mode 100644 index 00000000..4199196a --- /dev/null +++ b/examples/trace-chain/evidence/assist/java-assist-query/platform.name.md @@ -0,0 +1,75 @@ + + +# Java Query Field Assist — `platform.name` + +KSML entity: `Platform` +KSML field: `name` +Type: `string` + +Use these exact generated methods. The KSML location above is the discovery +identity; Java member and method casing is shown only as the generated API. + +| Capability | Generated API | +| --- | --- | +| Select | ``selectName()` | +| Equality/set | `withNameIs(value)`, `withNameIsNot(value)`, `withNameIn(values)`, `withNameNotIn(values)` | +| Comparison | `withNameGreaterThan(value)`, `withNameGreaterThanOrEqualTo(value)`, `withNameLessThan(value)`, `withNameLessThanOrEqualTo(value)`, `withNameBetween(lower, upper)` | +| Null state | `withNameIsKnown()`, `withNameIsUnknown()` | +| String | `withNameContaining(value)`, `withNameNotContaining(value)`, `withNameStartingWith(value)`, `withNameEndingWith(value)`, `withNameSoundingLike(value)` | +| Order | `orderByNameAscending()`, `orderByNameDescending()` | +| Group | `groupByName()`, `groupByNameAs(alias)` | +Apply methods before `purpose(...)`; execution still requires non-empty +`comment(...)`, `purpose(...)`, and exactly one `UserContext`. + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact JAVA 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 `topNProbeParentThreshold(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/java-assist-runtime-custom.md b/examples/trace-chain/evidence/assist/java-assist-runtime-custom.md new file mode 100644 index 00000000..ba3c820b --- /dev/null +++ b/examples/trace-chain/evidence/assist/java-assist-runtime-custom.md @@ -0,0 +1,130 @@ + +# TeaQL Java Runtime Customization + +```java +import io.teaql.core.DataServiceExecutor; +import io.teaql.core.MutationPolicyApprovalProvider; +import io.teaql.core.MutationPolicyRegistry; +import io.teaql.core.QueryPolicy; +import io.teaql.core.SchemaExecutor; +import io.teaql.core.meta.EntityMetaFactory; +import io.teaql.runtime.AppAuditEventSink; +import io.teaql.runtime.DefaultUserContext; +import io.teaql.runtime.RuntimeLogSink; +import io.teaql.runtime.TeaQLRuntime; +import java.util.List; +import java.util.Map; + +public final class RuntimeCustomization { + private RuntimeCustomization() {} + + public static TeaQLRuntime buildRuntime( + EntityMetaFactory metadata, + DataServiceExecutor provider, + QueryPolicy queryPolicy, + MutationPolicyRegistry mutationPolicies, + MutationPolicyApprovalProvider mutationPolicyApprovals, + RuntimeLogSink runtimeLogSink) { + if (metadata == null || provider == null || queryPolicy == null + || mutationPolicies == null || mutationPolicyApprovals == null) { + throw new IllegalArgumentException("metadata, provider and policies are required"); + } + return TeaQLRuntime.builder().metadata(metadata).dataService("default", provider) + .queryPolicy(queryPolicy) + .mutationPolicyRegistry(mutationPolicies) + .mutationPolicyApprovalProvider(mutationPolicyApprovals) + .logSink(runtimeLogSink).build(); + } + + public static DefaultUserContext requestContext( + TeaQLRuntime runtime, String trustedTenant, AppAuditEventSink appAuditSink) { + if (runtime == null || trustedTenant == null || trustedTenant.isBlank() || appAuditSink == null) { + throw new IllegalArgumentException("trusted runtime, tenant and App Audit Sink are required"); + } + DefaultUserContext context = new DefaultUserContext(runtime); + context.putAttribute("trustedTenant", trustedTenant); + context.putAttribute(AppAuditEventSink.class.getName(), appAuditSink); + return context; + } + + public static void readiness(DefaultUserContext context) { + if (context.getAttribute("trustedTenant", String.class) == null) { + throw new IllegalStateException("missing trusted tenant"); + } + context.ensureSchema(); + } + + public static void rejectGovernanceOverride(Object input) { + if (input instanceof Map values) { + for (Map.Entry entry : values.entrySet()) { + String key = String.valueOf(entry.getKey()); + if (List.of("tenant", "provider", "queryPolicy", "mutationPolicy", + "mutationPolicyApproval", "auditSink", "hardLimit", + "continuousPage").stream().anyMatch(value -> value.equalsIgnoreCase(key))) { + throw new IllegalArgumentException("forbidden governance override: " + key); + } + rejectGovernanceOverride(entry.getValue()); + } + } else if (input instanceof Iterable values) { + values.forEach(RuntimeCustomization::rejectGovernanceOverride); + } + } +} +``` + +The generated workspace owns application-scoped provider construction. `UserContext` +initialization is the trusted boundary for tenant data and the customizable App Audit Sink; +query/save still receive only that context. Raw row audit remains separate. Readiness invokes +the real `SchemaExecutor` and propagates provider failure. Reject governance keys recursively +from JSON/TFP. The integration gate must also run generated query and audited mutation against +SQLite; a route-only `/health` smoke test is insufficient. + +## Runtime telemetry + +Observability is optional and application-owned. Build +`io.teaql.opentelemetry.OpenTelemetryRuntimeTelemetry` from the application's +OpenTelemetry tracer, meter and logger, then pass it to +`TeaQLRuntime.builder().telemetry(telemetry)`. Keep the no-op default when it is +not configured. The application owns bounded SDK processors, OTLP exporters, +`forceFlush` and shutdown; telemetry failure must never change a query, save, +audit or readiness result. Installing telemetry does not call `ensureSchema`. +TeaQL derives `teaql.error.category` from the native error type. Sampling never +controls or replaces App Audit Sink delivery. Do not generate a Collector, +additional exporters, auto-discovery, or a telemetry configuration DSL. + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact JAVA 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: `runtime-custom`. + +- Keep trusted dependencies and global runtime policy in UserContext initialization. + Custom providers, policy hooks, and audit sinks must not add execute/save arguments. +- Preserve immutable row audit events and a separate customizable App Audit Sink. + Include health, integration, and negative governance tests for every customization. diff --git a/examples/trace-chain/evidence/assist/java-assist-update/customer_order.md b/examples/trace-chain/evidence/assist/java-assist-update/customer_order.md new file mode 100644 index 00000000..66ceb250 --- /dev/null +++ b/examples/trace-chain/evidence/assist/java-assist-update/customer_order.md @@ -0,0 +1,93 @@ + + +# Java Assist — Update `Customer Order` + +Load the current row and its original optimistic version. Never reconstruct an +entity from an ID/version supplied by a client. Only the model-derived writable +scalar arguments below may be changed. + +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 `WithMinimalFields()` or a reduced projection for an entity that will be saved. + +```java +package com.teaql.tracechainservice; + +import io.teaql.core.UserContext; +import com.teaql.tracechainservice.customerorder.CustomerOrder; + +public final class CustomerOrderUpdateService { + private CustomerOrderUpdateService() {} + + public static CustomerOrder update( + Long id, + java.lang.String orderNumber, + java.lang.String description, + boolean throwIfMissing, + UserContext context) { + var entity = Q.customerOrders() + .withIdIs(id) + + .comment("what: load current Customer Order for update") + .purpose("why: preserve original version for optimistic locking") + .executeForOne(context); + if (entity == null) { + if (throwIfMissing) { + throw new IllegalArgumentException("Customer Order not found: " + id); + } + return null; + } + + entity.updateOrderNumber(orderNumber); + entity.updateDescription(description); + + entity.auditAs("Update Customer Order for the requested business operation") + .save(context); + return entity; + } +} +``` + +Compile the source unchanged. Prove persistence and query-back, a conflict from +an independently loaded stale copy, null and throwing not-found behavior, +missing/blank audit rejection, and compilation failure for unknown or trusted +fields. Constant and relation updates require an explicitly selected generated +method; do not invent a generic setter. + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact JAVA 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/java-assist-update/order_item.md b/examples/trace-chain/evidence/assist/java-assist-update/order_item.md new file mode 100644 index 00000000..4de606c7 --- /dev/null +++ b/examples/trace-chain/evidence/assist/java-assist-update/order_item.md @@ -0,0 +1,91 @@ + + +# Java Assist — Update `Order Item` + +Load the current row and its original optimistic version. Never reconstruct an +entity from an ID/version supplied by a client. Only the model-derived writable +scalar arguments below may be changed. + +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 `WithMinimalFields()` or a reduced projection for an entity that will be saved. + +```java +package com.teaql.tracechainservice; + +import io.teaql.core.UserContext; +import com.teaql.tracechainservice.orderitem.OrderItem; + +public final class OrderItemUpdateService { + private OrderItemUpdateService() {} + + public static OrderItem update( + Long id, + java.lang.String name, + boolean throwIfMissing, + UserContext context) { + var entity = Q.orderItems() + .withIdIs(id) + + .comment("what: load current Order Item for update") + .purpose("why: preserve original version for optimistic locking") + .executeForOne(context); + if (entity == null) { + if (throwIfMissing) { + throw new IllegalArgumentException("Order Item not found: " + id); + } + return null; + } + + entity.updateName(name); + + entity.auditAs("Update Order Item for the requested business operation") + .save(context); + return entity; + } +} +``` + +Compile the source unchanged. Prove persistence and query-back, a conflict from +an independently loaded stale copy, null and throwing not-found behavior, +missing/blank audit rejection, and compilation failure for unknown or trusted +fields. Constant and relation updates require an explicitly selected generated +method; do not invent a generic setter. + + +--- + +## TeaQL seven-language assist contract + +Apply the verified Rust semantic ceiling while using only the exact JAVA 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 00000000..1342e1e1 --- /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/lib/pom.xml b/examples/trace-chain/lib/pom.xml new file mode 100644 index 00000000..35c0a42f --- /dev/null +++ b/examples/trace-chain/lib/pom.xml @@ -0,0 +1,142 @@ + + + 4.0.0 + + com.teaql + trace-chain-service-core + 0.0.1-SNAPSHOT + jar + + + 21 + 4.1.1 + 1.553-RELEASE + UTF-8 + true + + + + + teaql + https://maven.teaql.io/repository/maven-releases/ + + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + org.apache.tomcat.embed + tomcat-embed-core + 11.0.26 + + + org.apache.tomcat.embed + tomcat-embed-el + 11.0.26 + + + org.apache.tomcat.embed + tomcat-embed-websocket + 11.0.26 + + + + + + + org.springframework.boot + spring-boot-autoconfigure + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework + spring-tx + + + io.teaql + teaql-sqlite + 1.553-RELEASE + + + io.teaql + teaql-core + ${teaql.version} + + + io.teaql + teaql-runtime + ${teaql.version} + + + cn.hutool + hutool-all + 5.8.20 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.12.1 + + ${java.version} + ${maven.compiler.parameters} + + + + org.apache.maven.plugins + maven-enforcer-plugin + 3.5.0 + + + enforce-java-version + + enforce + + + + + [${java.version},) + + + + + + + + org.apache.maven.plugins + maven-source-plugin + 3.3.0 + + + attach-sources + + jar-no-fork + + + + + + + + + + target-repo + https://maven.teaql.io/repository/maven-releases/ + + + \ No newline at end of file diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/BaseServiceConfiguration.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/BaseServiceConfiguration.java new file mode 100644 index 00000000..e69de29b diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/Checkers.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/Checkers.java new file mode 100644 index 00000000..e69de29b diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/Constants.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/Constants.java new file mode 100644 index 00000000..75826fd1 --- /dev/null +++ b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/Constants.java @@ -0,0 +1,9 @@ + +package com.teaql.tracechainservice; + +import com.teaql.tracechainservice.platform.Platform; + +public interface Constants { + public static final long PLATFORM_ID = 1l; + public static final Platform PLATFORM = Platform.refer(PLATFORM_ID); +} \ No newline at end of file diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/E.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/E.java new file mode 100644 index 00000000..fffc5537 --- /dev/null +++ b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/E.java @@ -0,0 +1,37 @@ + +package com.teaql.tracechainservice; + +import com.teaql.tracechainservice.customerorder.CustomerOrder; +import com.teaql.tracechainservice.customerorder.CustomerOrderExpression; +import com.teaql.tracechainservice.orderitem.OrderItem; +import com.teaql.tracechainservice.orderitem.OrderItemExpression; +import com.teaql.tracechainservice.payment.Payment; +import com.teaql.tracechainservice.payment.PaymentExpression; +import com.teaql.tracechainservice.paymentattempt.PaymentAttempt; +import com.teaql.tracechainservice.paymentattempt.PaymentAttemptExpression; +import com.teaql.tracechainservice.platform.Platform; +import com.teaql.tracechainservice.platform.PlatformExpression; +import com.teaql.tracechainservice.shipment.Shipment; +import com.teaql.tracechainservice.shipment.ShipmentExpression; +import io.teaql.core.value.ValueExpression; + +public class E { + public static PlatformExpression platform(Platform platform){ + return new PlatformExpression(new ValueExpression(platform)); + } + public static CustomerOrderExpression customerOrder(CustomerOrder customerOrder){ + return new CustomerOrderExpression(new ValueExpression(customerOrder)); + } + public static OrderItemExpression orderItem(OrderItem orderItem){ + return new OrderItemExpression(new ValueExpression(orderItem)); + } + public static PaymentExpression payment(Payment payment){ + return new PaymentExpression(new ValueExpression(payment)); + } + public static PaymentAttemptExpression paymentAttempt(PaymentAttempt paymentAttempt){ + return new PaymentAttemptExpression(new ValueExpression(paymentAttempt)); + } + public static ShipmentExpression shipment(Shipment shipment){ + return new ShipmentExpression(new ValueExpression(shipment)); + } +} \ No newline at end of file diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/EntityMetaRegistry.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/EntityMetaRegistry.java new file mode 100644 index 00000000..bdb577ae --- /dev/null +++ b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/EntityMetaRegistry.java @@ -0,0 +1,494 @@ + +package com.teaql.tracechainservice; + +import io.teaql.core.meta.EntityDescriptor; +import io.teaql.core.meta.EntityMetaAssembler; +import io.teaql.core.meta.EntityMetaFactory; +import io.teaql.core.meta.PropertyDescriptor; + +public class EntityMetaRegistry implements EntityMetaAssembler { + private EntityMetaFactory $factory; + + @Override + public void assemble(EntityMetaFactory factory) { + this.$factory = factory; + registerPlatform(); + registerCustomerOrder(); + registerOrderItem(); + registerPayment(); + registerPaymentAttempt(); + registerShipment(); + } + private void registerPlatform() { + EntityDescriptor entityDescriptor = new EntityDescriptor(); + entityDescriptor.setType(com.teaql.tracechainservice.platform.Platform.INTERNAL_TYPE); + entityDescriptor.setTargetType(com.teaql.tracechainservice.platform.Platform.class); + entityDescriptor.setEntitySupplier(com.teaql.tracechainservice.platform.Platform::new); + entityDescriptor.with("name", "Platform") + .with("module", "Trace Chain") + .with("module_key", "trace-chain"); + + entityDescriptor.setAuditMaskFields(java.util.List.of()); + PropertyDescriptor id = + entityDescriptor.addSimpleProperty(com.teaql.tracechainservice.platform.Platform.ID_PROPERTY, Long.class) + ; + PropertyDescriptor name = + entityDescriptor.addSimpleProperty(com.teaql.tracechainservice.platform.Platform.NAME_PROPERTY, String.class) + ; + PropertyDescriptor version = + entityDescriptor.addSimpleProperty(com.teaql.tracechainservice.platform.Platform.VERSION_PROPERTY, Long.class) + ; + entityDescriptor.findProperty(com.teaql.tracechainservice.platform.Platform.ID_PROPERTY).with("required", "true"); + entityDescriptor.findProperty(com.teaql.tracechainservice.platform.Platform.ID_PROPERTY).with("isPassword", "false") + .with("isVersion", "false") + .with("oracle_sqlType", "number(11)") + .with("javaType", "java.lang.Long") + .with("sqlType", "BIGINT") + .with("isId", "true") + .with("isBaseEntityField", "true") + .with("isBool", "false") + .with("isNumber", "false") + .with("isString", "false") + .with("isDate", "false") + .with("snowflake_sqlType", "number") + .with("graphqlType", "Long") + .with("isTime", "false") + .with("isText", "false"); + + entityDescriptor.findProperty(com.teaql.tracechainservice.platform.Platform.NAME_PROPERTY).with("required", "true"); + entityDescriptor.findProperty(com.teaql.tracechainservice.platform.Platform.NAME_PROPERTY).with("isPassword", "false") + .with("max", "100") + .with("isVersion", "false") + .with("javaType", "java.lang.String") + .with("candidates", "Trace Chain Verification") + .with("sqlType", "VARCHAR()") + .with("isId", "false") + .with("isBool", "false") + .with("isBaseEntityField", "false") + .with("isNumber", "false") + .with("isString", "true") + .with("isDate", "false") + .with("graphqlType", "String") + .with("isTime", "false") + .with("isText", "false"); + + entityDescriptor.findProperty(com.teaql.tracechainservice.platform.Platform.VERSION_PROPERTY).with("required", "true"); + entityDescriptor.findProperty(com.teaql.tracechainservice.platform.Platform.VERSION_PROPERTY).with("isPassword", "false") + .with("isVersion", "true") + .with("oracle_sqlType", "number(11)") + .with("javaType", "java.lang.Long") + .with("sqlType", "BIGINT") + .with("isId", "false") + .with("isBaseEntityField", "true") + .with("isBool", "false") + .with("isNumber", "false") + .with("isString", "false") + .with("isDate", "false") + .with("snowflake_sqlType", "number") + .with("graphqlType", "Long") + .with("isTime", "false") + .with("isText", "false"); + + $factory.register(entityDescriptor); + } + private void registerCustomerOrder() { + EntityDescriptor entityDescriptor = new EntityDescriptor(); + entityDescriptor.setType(com.teaql.tracechainservice.customerorder.CustomerOrder.INTERNAL_TYPE); + entityDescriptor.setTargetType(com.teaql.tracechainservice.customerorder.CustomerOrder.class); + entityDescriptor.setEntitySupplier(com.teaql.tracechainservice.customerorder.CustomerOrder::new); + entityDescriptor.with("name", "Customer Order") + .with("module", "Trace Chain") + .with("module_key", "trace-chain"); + + entityDescriptor.setAuditMaskFields(java.util.List.of()); + PropertyDescriptor id = + entityDescriptor.addSimpleProperty(com.teaql.tracechainservice.customerorder.CustomerOrder.ID_PROPERTY, Long.class) + ; + PropertyDescriptor platform = + entityDescriptor.addObjectProperty($factory, com.teaql.tracechainservice.customerorder.CustomerOrder.PLATFORM_PROPERTY, com.teaql.tracechainservice.platform.Platform.INTERNAL_TYPE, com.teaql.tracechainservice.platform.Platform.CUSTOMER_ORDER_LIST_PROPERTY, com.teaql.tracechainservice.platform.Platform.class) + ; + PropertyDescriptor orderNumber = + entityDescriptor.addSimpleProperty(com.teaql.tracechainservice.customerorder.CustomerOrder.ORDER_NUMBER_PROPERTY, String.class) + ; + PropertyDescriptor description = + entityDescriptor.addSimpleProperty(com.teaql.tracechainservice.customerorder.CustomerOrder.DESCRIPTION_PROPERTY, String.class) + ; + PropertyDescriptor version = + entityDescriptor.addSimpleProperty(com.teaql.tracechainservice.customerorder.CustomerOrder.VERSION_PROPERTY, Long.class) + ; + entityDescriptor.findProperty(com.teaql.tracechainservice.customerorder.CustomerOrder.ID_PROPERTY).with("required", "true"); + entityDescriptor.findProperty(com.teaql.tracechainservice.customerorder.CustomerOrder.ID_PROPERTY).with("isPassword", "false") + .with("isVersion", "false") + .with("oracle_sqlType", "number(11)") + .with("javaType", "java.lang.Long") + .with("sqlType", "BIGINT") + .with("isId", "true") + .with("isBaseEntityField", "true") + .with("isBool", "false") + .with("isNumber", "false") + .with("isString", "false") + .with("isDate", "false") + .with("snowflake_sqlType", "number") + .with("graphqlType", "Long") + .with("isTime", "false") + .with("isText", "false"); + + entityDescriptor.findProperty(com.teaql.tracechainservice.customerorder.CustomerOrder.PLATFORM_PROPERTY).with("required", "true"); + + entityDescriptor.findProperty(com.teaql.tracechainservice.customerorder.CustomerOrder.ORDER_NUMBER_PROPERTY).with("required", "true"); + entityDescriptor.findProperty(com.teaql.tracechainservice.customerorder.CustomerOrder.ORDER_NUMBER_PROPERTY).with("isPassword", "false") + .with("max", "100") + .with("isVersion", "false") + .with("javaType", "java.lang.String") + .with("sqlType", "VARCHAR()") + .with("isId", "false") + .with("isBool", "false") + .with("isBaseEntityField", "false") + .with("isNumber", "false") + .with("isString", "true") + .with("isDate", "false") + .with("graphqlType", "String") + .with("isTime", "false") + .with("isText", "false"); + + entityDescriptor.findProperty(com.teaql.tracechainservice.customerorder.CustomerOrder.DESCRIPTION_PROPERTY).with("required", "true"); + entityDescriptor.findProperty(com.teaql.tracechainservice.customerorder.CustomerOrder.DESCRIPTION_PROPERTY).with("isPassword", "false") + .with("max", "100") + .with("isVersion", "false") + .with("javaType", "java.lang.String") + .with("sqlType", "VARCHAR()") + .with("isId", "false") + .with("isBool", "false") + .with("isBaseEntityField", "false") + .with("isNumber", "false") + .with("isString", "true") + .with("isDate", "false") + .with("graphqlType", "String") + .with("isTime", "false") + .with("isText", "false"); + + entityDescriptor.findProperty(com.teaql.tracechainservice.customerorder.CustomerOrder.VERSION_PROPERTY).with("required", "true"); + entityDescriptor.findProperty(com.teaql.tracechainservice.customerorder.CustomerOrder.VERSION_PROPERTY).with("isPassword", "false") + .with("isVersion", "true") + .with("oracle_sqlType", "number(11)") + .with("javaType", "java.lang.Long") + .with("sqlType", "BIGINT") + .with("isId", "false") + .with("isBaseEntityField", "true") + .with("isBool", "false") + .with("isNumber", "false") + .with("isString", "false") + .with("isDate", "false") + .with("snowflake_sqlType", "number") + .with("graphqlType", "Long") + .with("isTime", "false") + .with("isText", "false"); + + $factory.register(entityDescriptor); + } + private void registerOrderItem() { + EntityDescriptor entityDescriptor = new EntityDescriptor(); + entityDescriptor.setType(com.teaql.tracechainservice.orderitem.OrderItem.INTERNAL_TYPE); + entityDescriptor.setTargetType(com.teaql.tracechainservice.orderitem.OrderItem.class); + entityDescriptor.setEntitySupplier(com.teaql.tracechainservice.orderitem.OrderItem::new); + entityDescriptor.with("name", "Order Item") + .with("module", "Trace Chain") + .with("module_key", "trace-chain") + .with("audit_mask_fields", "name"); + + entityDescriptor.setAuditMaskFields(java.util.List.of(com.teaql.tracechainservice.orderitem.OrderItem.NAME_PROPERTY)); + PropertyDescriptor id = + entityDescriptor.addSimpleProperty(com.teaql.tracechainservice.orderitem.OrderItem.ID_PROPERTY, Long.class) + ; + PropertyDescriptor customerOrder = + entityDescriptor.addObjectProperty($factory, com.teaql.tracechainservice.orderitem.OrderItem.CUSTOMER_ORDER_PROPERTY, com.teaql.tracechainservice.customerorder.CustomerOrder.INTERNAL_TYPE, com.teaql.tracechainservice.customerorder.CustomerOrder.ORDER_ITEM_LIST_PROPERTY, com.teaql.tracechainservice.customerorder.CustomerOrder.class) + ; + PropertyDescriptor name = + entityDescriptor.addSimpleProperty(com.teaql.tracechainservice.orderitem.OrderItem.NAME_PROPERTY, String.class) + ; + PropertyDescriptor version = + entityDescriptor.addSimpleProperty(com.teaql.tracechainservice.orderitem.OrderItem.VERSION_PROPERTY, Long.class) + ; + entityDescriptor.findProperty(com.teaql.tracechainservice.orderitem.OrderItem.ID_PROPERTY).with("required", "true"); + entityDescriptor.findProperty(com.teaql.tracechainservice.orderitem.OrderItem.ID_PROPERTY).with("isPassword", "false") + .with("isVersion", "false") + .with("oracle_sqlType", "number(11)") + .with("javaType", "java.lang.Long") + .with("sqlType", "BIGINT") + .with("isId", "true") + .with("isBaseEntityField", "true") + .with("isBool", "false") + .with("isNumber", "false") + .with("isString", "false") + .with("isDate", "false") + .with("snowflake_sqlType", "number") + .with("graphqlType", "Long") + .with("isTime", "false") + .with("isText", "false"); + + entityDescriptor.findProperty(com.teaql.tracechainservice.orderitem.OrderItem.CUSTOMER_ORDER_PROPERTY).with("required", "true"); + + entityDescriptor.findProperty(com.teaql.tracechainservice.orderitem.OrderItem.NAME_PROPERTY).with("required", "true"); + entityDescriptor.findProperty(com.teaql.tracechainservice.orderitem.OrderItem.NAME_PROPERTY).with("isPassword", "false") + .with("max", "100") + .with("isVersion", "false") + .with("javaType", "java.lang.String") + .with("sqlType", "VARCHAR()") + .with("isId", "false") + .with("isBool", "false") + .with("isBaseEntityField", "false") + .with("isNumber", "false") + .with("isString", "true") + .with("isDate", "false") + .with("graphqlType", "String") + .with("isTime", "false") + .with("isText", "false"); + + entityDescriptor.findProperty(com.teaql.tracechainservice.orderitem.OrderItem.VERSION_PROPERTY).with("required", "true"); + entityDescriptor.findProperty(com.teaql.tracechainservice.orderitem.OrderItem.VERSION_PROPERTY).with("isPassword", "false") + .with("isVersion", "true") + .with("oracle_sqlType", "number(11)") + .with("javaType", "java.lang.Long") + .with("sqlType", "BIGINT") + .with("isId", "false") + .with("isBaseEntityField", "true") + .with("isBool", "false") + .with("isNumber", "false") + .with("isString", "false") + .with("isDate", "false") + .with("snowflake_sqlType", "number") + .with("graphqlType", "Long") + .with("isTime", "false") + .with("isText", "false"); + + $factory.register(entityDescriptor); + } + private void registerPayment() { + EntityDescriptor entityDescriptor = new EntityDescriptor(); + entityDescriptor.setType(com.teaql.tracechainservice.payment.Payment.INTERNAL_TYPE); + entityDescriptor.setTargetType(com.teaql.tracechainservice.payment.Payment.class); + entityDescriptor.setEntitySupplier(com.teaql.tracechainservice.payment.Payment::new); + entityDescriptor.with("name", "Payment") + .with("module", "Trace Chain") + .with("module_key", "trace-chain"); + + entityDescriptor.setAuditMaskFields(java.util.List.of()); + PropertyDescriptor id = + entityDescriptor.addSimpleProperty(com.teaql.tracechainservice.payment.Payment.ID_PROPERTY, Long.class) + ; + PropertyDescriptor customerOrder = + entityDescriptor.addObjectProperty($factory, com.teaql.tracechainservice.payment.Payment.CUSTOMER_ORDER_PROPERTY, com.teaql.tracechainservice.customerorder.CustomerOrder.INTERNAL_TYPE, com.teaql.tracechainservice.customerorder.CustomerOrder.PAYMENT_LIST_PROPERTY, com.teaql.tracechainservice.customerorder.CustomerOrder.class) + ; + PropertyDescriptor referenceCode = + entityDescriptor.addSimpleProperty(com.teaql.tracechainservice.payment.Payment.REFERENCE_CODE_PROPERTY, String.class) + ; + PropertyDescriptor version = + entityDescriptor.addSimpleProperty(com.teaql.tracechainservice.payment.Payment.VERSION_PROPERTY, Long.class) + ; + entityDescriptor.findProperty(com.teaql.tracechainservice.payment.Payment.ID_PROPERTY).with("required", "true"); + entityDescriptor.findProperty(com.teaql.tracechainservice.payment.Payment.ID_PROPERTY).with("isPassword", "false") + .with("isVersion", "false") + .with("oracle_sqlType", "number(11)") + .with("javaType", "java.lang.Long") + .with("sqlType", "BIGINT") + .with("isId", "true") + .with("isBaseEntityField", "true") + .with("isBool", "false") + .with("isNumber", "false") + .with("isString", "false") + .with("isDate", "false") + .with("snowflake_sqlType", "number") + .with("graphqlType", "Long") + .with("isTime", "false") + .with("isText", "false"); + + entityDescriptor.findProperty(com.teaql.tracechainservice.payment.Payment.CUSTOMER_ORDER_PROPERTY).with("required", "true"); + + entityDescriptor.findProperty(com.teaql.tracechainservice.payment.Payment.REFERENCE_CODE_PROPERTY).with("required", "true"); + entityDescriptor.findProperty(com.teaql.tracechainservice.payment.Payment.REFERENCE_CODE_PROPERTY).with("isPassword", "false") + .with("max", "100") + .with("isVersion", "false") + .with("javaType", "java.lang.String") + .with("sqlType", "VARCHAR()") + .with("isId", "false") + .with("isBool", "false") + .with("isBaseEntityField", "false") + .with("isNumber", "false") + .with("isString", "true") + .with("isDate", "false") + .with("graphqlType", "String") + .with("isTime", "false") + .with("isText", "false"); + + entityDescriptor.findProperty(com.teaql.tracechainservice.payment.Payment.VERSION_PROPERTY).with("required", "true"); + entityDescriptor.findProperty(com.teaql.tracechainservice.payment.Payment.VERSION_PROPERTY).with("isPassword", "false") + .with("isVersion", "true") + .with("oracle_sqlType", "number(11)") + .with("javaType", "java.lang.Long") + .with("sqlType", "BIGINT") + .with("isId", "false") + .with("isBaseEntityField", "true") + .with("isBool", "false") + .with("isNumber", "false") + .with("isString", "false") + .with("isDate", "false") + .with("snowflake_sqlType", "number") + .with("graphqlType", "Long") + .with("isTime", "false") + .with("isText", "false"); + + $factory.register(entityDescriptor); + } + private void registerPaymentAttempt() { + EntityDescriptor entityDescriptor = new EntityDescriptor(); + entityDescriptor.setType(com.teaql.tracechainservice.paymentattempt.PaymentAttempt.INTERNAL_TYPE); + entityDescriptor.setTargetType(com.teaql.tracechainservice.paymentattempt.PaymentAttempt.class); + entityDescriptor.setEntitySupplier(com.teaql.tracechainservice.paymentattempt.PaymentAttempt::new); + entityDescriptor.with("name", "Payment Attempt") + .with("module", "Trace Chain") + .with("module_key", "trace-chain"); + + entityDescriptor.setAuditMaskFields(java.util.List.of()); + PropertyDescriptor id = + entityDescriptor.addSimpleProperty(com.teaql.tracechainservice.paymentattempt.PaymentAttempt.ID_PROPERTY, Long.class) + ; + PropertyDescriptor payment = + entityDescriptor.addObjectProperty($factory, com.teaql.tracechainservice.paymentattempt.PaymentAttempt.PAYMENT_PROPERTY, com.teaql.tracechainservice.payment.Payment.INTERNAL_TYPE, com.teaql.tracechainservice.payment.Payment.PAYMENT_ATTEMPT_LIST_PROPERTY, com.teaql.tracechainservice.payment.Payment.class) + ; + PropertyDescriptor referenceCode = + entityDescriptor.addSimpleProperty(com.teaql.tracechainservice.paymentattempt.PaymentAttempt.REFERENCE_CODE_PROPERTY, String.class) + ; + PropertyDescriptor version = + entityDescriptor.addSimpleProperty(com.teaql.tracechainservice.paymentattempt.PaymentAttempt.VERSION_PROPERTY, Long.class) + ; + entityDescriptor.findProperty(com.teaql.tracechainservice.paymentattempt.PaymentAttempt.ID_PROPERTY).with("required", "true"); + entityDescriptor.findProperty(com.teaql.tracechainservice.paymentattempt.PaymentAttempt.ID_PROPERTY).with("isPassword", "false") + .with("isVersion", "false") + .with("oracle_sqlType", "number(11)") + .with("javaType", "java.lang.Long") + .with("sqlType", "BIGINT") + .with("isId", "true") + .with("isBaseEntityField", "true") + .with("isBool", "false") + .with("isNumber", "false") + .with("isString", "false") + .with("isDate", "false") + .with("snowflake_sqlType", "number") + .with("graphqlType", "Long") + .with("isTime", "false") + .with("isText", "false"); + + entityDescriptor.findProperty(com.teaql.tracechainservice.paymentattempt.PaymentAttempt.PAYMENT_PROPERTY).with("required", "true"); + + entityDescriptor.findProperty(com.teaql.tracechainservice.paymentattempt.PaymentAttempt.REFERENCE_CODE_PROPERTY).with("required", "true"); + entityDescriptor.findProperty(com.teaql.tracechainservice.paymentattempt.PaymentAttempt.REFERENCE_CODE_PROPERTY).with("isPassword", "false") + .with("max", "100") + .with("isVersion", "false") + .with("javaType", "java.lang.String") + .with("sqlType", "VARCHAR()") + .with("isId", "false") + .with("isBool", "false") + .with("isBaseEntityField", "false") + .with("isNumber", "false") + .with("isString", "true") + .with("isDate", "false") + .with("graphqlType", "String") + .with("isTime", "false") + .with("isText", "false"); + + entityDescriptor.findProperty(com.teaql.tracechainservice.paymentattempt.PaymentAttempt.VERSION_PROPERTY).with("required", "true"); + entityDescriptor.findProperty(com.teaql.tracechainservice.paymentattempt.PaymentAttempt.VERSION_PROPERTY).with("isPassword", "false") + .with("isVersion", "true") + .with("oracle_sqlType", "number(11)") + .with("javaType", "java.lang.Long") + .with("sqlType", "BIGINT") + .with("isId", "false") + .with("isBaseEntityField", "true") + .with("isBool", "false") + .with("isNumber", "false") + .with("isString", "false") + .with("isDate", "false") + .with("snowflake_sqlType", "number") + .with("graphqlType", "Long") + .with("isTime", "false") + .with("isText", "false"); + + $factory.register(entityDescriptor); + } + private void registerShipment() { + EntityDescriptor entityDescriptor = new EntityDescriptor(); + entityDescriptor.setType(com.teaql.tracechainservice.shipment.Shipment.INTERNAL_TYPE); + entityDescriptor.setTargetType(com.teaql.tracechainservice.shipment.Shipment.class); + entityDescriptor.setEntitySupplier(com.teaql.tracechainservice.shipment.Shipment::new); + entityDescriptor.with("name", "Shipment") + .with("module", "Trace Chain") + .with("module_key", "trace-chain"); + + entityDescriptor.setAuditMaskFields(java.util.List.of()); + PropertyDescriptor id = + entityDescriptor.addSimpleProperty(com.teaql.tracechainservice.shipment.Shipment.ID_PROPERTY, Long.class) + ; + PropertyDescriptor customerOrder = + entityDescriptor.addObjectProperty($factory, com.teaql.tracechainservice.shipment.Shipment.CUSTOMER_ORDER_PROPERTY, com.teaql.tracechainservice.customerorder.CustomerOrder.INTERNAL_TYPE, com.teaql.tracechainservice.customerorder.CustomerOrder.SHIPMENT_LIST_PROPERTY, com.teaql.tracechainservice.customerorder.CustomerOrder.class) + ; + PropertyDescriptor referenceCode = + entityDescriptor.addSimpleProperty(com.teaql.tracechainservice.shipment.Shipment.REFERENCE_CODE_PROPERTY, String.class) + ; + PropertyDescriptor version = + entityDescriptor.addSimpleProperty(com.teaql.tracechainservice.shipment.Shipment.VERSION_PROPERTY, Long.class) + ; + entityDescriptor.findProperty(com.teaql.tracechainservice.shipment.Shipment.ID_PROPERTY).with("required", "true"); + entityDescriptor.findProperty(com.teaql.tracechainservice.shipment.Shipment.ID_PROPERTY).with("isPassword", "false") + .with("isVersion", "false") + .with("oracle_sqlType", "number(11)") + .with("javaType", "java.lang.Long") + .with("sqlType", "BIGINT") + .with("isId", "true") + .with("isBaseEntityField", "true") + .with("isBool", "false") + .with("isNumber", "false") + .with("isString", "false") + .with("isDate", "false") + .with("snowflake_sqlType", "number") + .with("graphqlType", "Long") + .with("isTime", "false") + .with("isText", "false"); + + entityDescriptor.findProperty(com.teaql.tracechainservice.shipment.Shipment.CUSTOMER_ORDER_PROPERTY).with("required", "true"); + + entityDescriptor.findProperty(com.teaql.tracechainservice.shipment.Shipment.REFERENCE_CODE_PROPERTY).with("required", "true"); + entityDescriptor.findProperty(com.teaql.tracechainservice.shipment.Shipment.REFERENCE_CODE_PROPERTY).with("isPassword", "false") + .with("max", "100") + .with("isVersion", "false") + .with("javaType", "java.lang.String") + .with("sqlType", "VARCHAR()") + .with("isId", "false") + .with("isBool", "false") + .with("isBaseEntityField", "false") + .with("isNumber", "false") + .with("isString", "true") + .with("isDate", "false") + .with("graphqlType", "String") + .with("isTime", "false") + .with("isText", "false"); + + entityDescriptor.findProperty(com.teaql.tracechainservice.shipment.Shipment.VERSION_PROPERTY).with("required", "true"); + entityDescriptor.findProperty(com.teaql.tracechainservice.shipment.Shipment.VERSION_PROPERTY).with("isPassword", "false") + .with("isVersion", "true") + .with("oracle_sqlType", "number(11)") + .with("javaType", "java.lang.Long") + .with("sqlType", "BIGINT") + .with("isId", "false") + .with("isBaseEntityField", "true") + .with("isBool", "false") + .with("isNumber", "false") + .with("isString", "false") + .with("isDate", "false") + .with("snowflake_sqlType", "number") + .with("graphqlType", "Long") + .with("isTime", "false") + .with("isText", "false"); + + $factory.register(entityDescriptor); + } +} \ No newline at end of file diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/GeneratedRuntimeModule.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/GeneratedRuntimeModule.java new file mode 100644 index 00000000..1e2dc5fe --- /dev/null +++ b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/GeneratedRuntimeModule.java @@ -0,0 +1,40 @@ +package com.teaql.tracechainservice; + +/** Passive generated manifest. Database changes require context.ensureSchema(). */ +public final class GeneratedRuntimeModule { + private static final io.teaql.core.RuntimeModule MODULE = io.teaql.core.RuntimeModule.of(new EntityMetaRegistry()) + .withCheckers(new com.teaql.tracechainservice.platform.PlatformChecker(), new com.teaql.tracechainservice.customerorder.CustomerOrderChecker(), new com.teaql.tracechainservice.orderitem.OrderItemChecker(), new com.teaql.tracechainservice.payment.PaymentChecker(), new com.teaql.tracechainservice.paymentattempt.PaymentAttemptChecker(), new com.teaql.tracechainservice.shipment.ShipmentChecker()) + .withBootstrap(GeneratedRuntimeModule::ensureGeneratedBootstrap); + + private GeneratedRuntimeModule() {} + public static io.teaql.core.RuntimeModule module() { return MODULE; } + + /** Canonical KSML field to selected JSON wire name, consumed by HTTP/TFP adapters. */ + public static java.util.Map> wireFieldMappings() { + return java.util.Map.ofEntries( + java.util.Map.entry("Platform", java.util.Map.ofEntries(java.util.Map.entry("id", "id"), java.util.Map.entry("name", "name"), java.util.Map.entry("version", "version"))), + java.util.Map.entry("CustomerOrder", java.util.Map.ofEntries(java.util.Map.entry("id", "id"), java.util.Map.entry("platform", "platform"), java.util.Map.entry("order_number", "orderNumber"), java.util.Map.entry("description", "description"), java.util.Map.entry("version", "version"))), + java.util.Map.entry("OrderItem", java.util.Map.ofEntries(java.util.Map.entry("id", "id"), java.util.Map.entry("customer_order", "customerOrder"), java.util.Map.entry("name", "name"), java.util.Map.entry("version", "version"))), + java.util.Map.entry("Payment", java.util.Map.ofEntries(java.util.Map.entry("id", "id"), java.util.Map.entry("customer_order", "customerOrder"), java.util.Map.entry("reference_code", "referenceCode"), java.util.Map.entry("version", "version"))), + java.util.Map.entry("PaymentAttempt", java.util.Map.ofEntries(java.util.Map.entry("id", "id"), java.util.Map.entry("payment", "payment"), java.util.Map.entry("reference_code", "referenceCode"), java.util.Map.entry("version", "version"))), + java.util.Map.entry("Shipment", java.util.Map.ofEntries(java.util.Map.entry("id", "id"), java.util.Map.entry("customer_order", "customerOrder"), java.util.Map.entry("reference_code", "referenceCode"), java.util.Map.entry("version", "version"))) + ); + } + + /** Accepted legacy aliases; empty until explicitly declared by the model. */ + public static java.util.Map> wireFieldAliases() { + return java.util.Map.of(); + } + + private static void ensureGeneratedBootstrap(io.teaql.core.UserContext context) { + var domainRoots = Q.platforms().withIdIs(1L).comment("what: locate generated Domain Root").purpose("why: idempotent runtime bootstrap").executeForList(context); + com.teaql.tracechainservice.platform.Platform domainRoot; + if (domainRoots.isEmpty()) { + domainRoot = new com.teaql.tracechainservice.platform.Platform(); + io.teaql.core.GeneratedSchemaBootstrap.initializeFixedId(context, domainRoot, 1L); + domainRoot.updateName("Trace Chain Verification"); + domainRoot.auditAs("create generated Domain Root Platform").save(context); + } else { domainRoot = domainRoots.get(0); } + context.withActiveRoot(new io.teaql.core.ContextEntityRef("Platform", domainRoot.getId())); + } +} diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/Q.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/Q.java new file mode 100644 index 00000000..ba794b22 --- /dev/null +++ b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/Q.java @@ -0,0 +1,61 @@ + +package com.teaql.tracechainservice; + +import io.teaql.core.criteria.Operator; + +public class Q { + public static com.teaql.tracechainservice.platform.PlatformRequest platforms(){ + return new com.teaql.tracechainservice.platform.PlatformRequest(com.teaql.tracechainservice.platform.Platform.class).selectSelf().withVersion(Operator.GREATER_THAN, 0l); + } + public static com.teaql.tracechainservice.platform.PlatformRequest platformsWithMinimalFields(){ + return new com.teaql.tracechainservice.platform.PlatformRequest(com.teaql.tracechainservice.platform.Platform.class).withVersion(Operator.GREATER_THAN, 0l); + } + + + + public static com.teaql.tracechainservice.customerorder.CustomerOrderRequest customerOrders(){ + return new com.teaql.tracechainservice.customerorder.CustomerOrderRequest(com.teaql.tracechainservice.customerorder.CustomerOrder.class).selectSelf().withVersion(Operator.GREATER_THAN, 0l); + } + public static com.teaql.tracechainservice.customerorder.CustomerOrderRequest customerOrdersWithMinimalFields(){ + return new com.teaql.tracechainservice.customerorder.CustomerOrderRequest(com.teaql.tracechainservice.customerorder.CustomerOrder.class).withVersion(Operator.GREATER_THAN, 0l); + } + + + + public static com.teaql.tracechainservice.orderitem.OrderItemRequest orderItems(){ + return new com.teaql.tracechainservice.orderitem.OrderItemRequest(com.teaql.tracechainservice.orderitem.OrderItem.class).selectSelf().withVersion(Operator.GREATER_THAN, 0l); + } + public static com.teaql.tracechainservice.orderitem.OrderItemRequest orderItemsWithMinimalFields(){ + return new com.teaql.tracechainservice.orderitem.OrderItemRequest(com.teaql.tracechainservice.orderitem.OrderItem.class).withVersion(Operator.GREATER_THAN, 0l); + } + + + + public static com.teaql.tracechainservice.payment.PaymentRequest payments(){ + return new com.teaql.tracechainservice.payment.PaymentRequest(com.teaql.tracechainservice.payment.Payment.class).selectSelf().withVersion(Operator.GREATER_THAN, 0l); + } + public static com.teaql.tracechainservice.payment.PaymentRequest paymentsWithMinimalFields(){ + return new com.teaql.tracechainservice.payment.PaymentRequest(com.teaql.tracechainservice.payment.Payment.class).withVersion(Operator.GREATER_THAN, 0l); + } + + + + public static com.teaql.tracechainservice.paymentattempt.PaymentAttemptRequest paymentAttempts(){ + return new com.teaql.tracechainservice.paymentattempt.PaymentAttemptRequest(com.teaql.tracechainservice.paymentattempt.PaymentAttempt.class).selectSelf().withVersion(Operator.GREATER_THAN, 0l); + } + public static com.teaql.tracechainservice.paymentattempt.PaymentAttemptRequest paymentAttemptsWithMinimalFields(){ + return new com.teaql.tracechainservice.paymentattempt.PaymentAttemptRequest(com.teaql.tracechainservice.paymentattempt.PaymentAttempt.class).withVersion(Operator.GREATER_THAN, 0l); + } + + + + public static com.teaql.tracechainservice.shipment.ShipmentRequest shipments(){ + return new com.teaql.tracechainservice.shipment.ShipmentRequest(com.teaql.tracechainservice.shipment.Shipment.class).selectSelf().withVersion(Operator.GREATER_THAN, 0l); + } + public static com.teaql.tracechainservice.shipment.ShipmentRequest shipmentsWithMinimalFields(){ + return new com.teaql.tracechainservice.shipment.ShipmentRequest(com.teaql.tracechainservice.shipment.Shipment.class).withVersion(Operator.GREATER_THAN, 0l); + } + + + +} \ No newline at end of file diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/Repositories.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/Repositories.java new file mode 100644 index 00000000..e69de29b diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/TraceChainServiceUserContext.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/TraceChainServiceUserContext.java new file mode 100644 index 00000000..0fb4cb2a --- /dev/null +++ b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/TraceChainServiceUserContext.java @@ -0,0 +1,10 @@ + +package com.teaql.tracechainservice; + +import io.teaql.core.UserContext; +public interface TraceChainServiceUserContext extends UserContext{ + + + + +} \ No newline at end of file diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/customerorder/CustomerOrder.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/customerorder/CustomerOrder.java new file mode 100644 index 00000000..6cdb4bc8 --- /dev/null +++ b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/customerorder/CustomerOrder.java @@ -0,0 +1,189 @@ + +package com.teaql.tracechainservice.customerorder; + +import com.teaql.tracechainservice.orderitem.OrderItem; +import com.teaql.tracechainservice.payment.Payment; +import com.teaql.tracechainservice.platform.Platform; +import com.teaql.tracechainservice.shipment.Shipment; +import io.teaql.core.Audited; +import io.teaql.core.BaseEntity; +import io.teaql.core.EntityStatus; +import io.teaql.core.FrameworkInternal; +import io.teaql.core.RemoteInput; +import io.teaql.core.SmartList; +import java.util.Objects; + +/** + * [TEAQL AI WARNING] + * TeaQL was explicitly designed to PREVENT AI hallucinations and random guessing. + * DO NOT GUESS METHOD NAMES! + * The methods listed below are the ONLY valid ways to interact with this entity. + * If you encounter compilation errors (e.g., method not found), DO NOT guess another method name. + * Read the method signatures in this file before proceeding. + */ +public class CustomerOrder extends BaseEntity implements RemoteInput { + public static String INTERNAL_TYPE = "CustomerOrder"; + + + public static final String PLATFORM_PROPERTY = "platform"; + public static final String ORDER_NUMBER_PROPERTY = "orderNumber"; + public static final String DESCRIPTION_PROPERTY = "description"; + public static final String ORDER_ITEM_LIST_PROPERTY = "orderItemList"; + public static final String PAYMENT_LIST_PROPERTY = "paymentList"; + public static final String SHIPMENT_LIST_PROPERTY = "shipmentList"; + private Platform platform; + + private String orderNumber; + + private String description; + + private SmartList orderItemList; + private SmartList paymentList; + private SmartList shipmentList; + + public Platform getPlatform(){ + return this.platform; + } + + public String getOrderNumber(){ + return this.orderNumber; + } + + public String getDescription(){ + return this.description; + } + + public SmartList getOrderItemList(){ + return this.orderItemList; + } + public SmartList getPaymentList(){ + return this.paymentList; + } + public SmartList getShipmentList(){ + return this.shipmentList; + } + public CustomerOrder updatePlatform(Platform platform){ + if(Objects.equals(this.platform, platform)){ + return this; + } + handleUpdate(PLATFORM_PROPERTY, getPlatform(), platform); + this.platform = platform; + return this; + } + + public CustomerOrder updateOrderNumber(String orderNumber){ + orderNumber = (orderNumber == null ? null : orderNumber.trim()); + if(Objects.equals(this.orderNumber, orderNumber)){ + return this; + } + handleUpdate(ORDER_NUMBER_PROPERTY, getOrderNumber(), orderNumber); + this.orderNumber = orderNumber; + return this; + } + + public CustomerOrder updateDescription(String description){ + description = (description == null ? null : description.trim()); + if(Objects.equals(this.description, description)){ + return this; + } + handleUpdate(DESCRIPTION_PROPERTY, getDescription(), description); + this.description = description; + return this; + } + + public CustomerOrder addOrderItem(OrderItem orderItem){ + if (orderItem == null){ + return this; + } + + if(null == this.orderItemList){ + this.orderItemList = new SmartList<>(); + } + + this.orderItemList.add(orderItem); + orderItem.updateCustomerOrder(this); + return this; + } + public CustomerOrder addPayment(Payment payment){ + if (payment == null){ + return this; + } + + if(null == this.paymentList){ + this.paymentList = new SmartList<>(); + } + + this.paymentList.add(payment); + payment.updateCustomerOrder(this); + return this; + } + public CustomerOrder addShipment(Shipment shipment){ + if (shipment == null){ + return this; + } + + if(null == this.shipmentList){ + this.shipmentList = new SmartList<>(); + } + + this.shipmentList.add(shipment); + shipment.updateCustomerOrder(this); + return this; + } + + public static CustomerOrder refer(Long id){ + CustomerOrder refer = new CustomerOrder(); + refer.__internalSet("id", id); + refer.set$status(EntityStatus.REFER); + return refer; + } + @Override + public String typeName(){ + return INTERNAL_TYPE; + } + + public CustomerOrder comment(String comment){ + this.setComment(comment); + return this; + } + + @Override + @SuppressWarnings("unchecked") + public Audited auditAs(String action) { + return super.auditAs(action); + } + + // ===== Framework Internal: generated switch dispatch ===== + @Override + @FrameworkInternal + public void __internalSet(String property, Object value) { + markPropertyLoaded(property); + switch (property) { + case "platform": this.platform = (Platform) value; break; + + case "orderNumber": this.orderNumber = (value == null ? null : ((String)value).trim()); break; + + case "description": this.description = (value == null ? null : ((String)value).trim()); break; + + case "orderItemList": this.orderItemList = (SmartList) value; break; + case "paymentList": this.paymentList = (SmartList) value; break; + case "shipmentList": this.shipmentList = (SmartList) value; break; + default: super.__internalSet(property, value); + } + } + + @Override + @FrameworkInternal + public Object __internalGet(String property) { + switch (property) { + case "platform": return this.platform; + case "orderNumber": return this.orderNumber; + case "description": return this.description; + case "orderItemList": return this.orderItemList; + case "paymentList": return this.paymentList; + case "shipmentList": return this.shipmentList; + default: return super.__internalGet(property); + } + } + +} \ No newline at end of file diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/customerorder/CustomerOrderChecker.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/customerorder/CustomerOrderChecker.java new file mode 100644 index 00000000..60bacb4c --- /dev/null +++ b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/customerorder/CustomerOrderChecker.java @@ -0,0 +1,86 @@ + +package com.teaql.tracechainservice.customerorder; + +import com.teaql.tracechainservice.orderitem.OrderItem; +import com.teaql.tracechainservice.orderitem.OrderItemChecker; +import com.teaql.tracechainservice.payment.Payment; +import com.teaql.tracechainservice.payment.PaymentChecker; +import com.teaql.tracechainservice.platform.Platform; +import com.teaql.tracechainservice.platform.PlatformChecker; +import com.teaql.tracechainservice.shipment.Shipment; +import com.teaql.tracechainservice.shipment.ShipmentChecker; +import io.teaql.core.UserContext; +import io.teaql.core.checker.Checker; +import io.teaql.core.checker.ObjectLocation; + +public class CustomerOrderChecker implements Checker{ + + public String type(){ + return CustomerOrder.INTERNAL_TYPE; + } + + public void checkAndFix(UserContext _context, CustomerOrder customerOrder, ObjectLocation _parentLocation){ + if(needCheck(_context, customerOrder)){ + markAsChecked(_context, customerOrder); + doCheck(_context, customerOrder, _parentLocation); + } + } + + public void doCheck(UserContext _context, CustomerOrder customerOrder, ObjectLocation _parentLocation){ + if((customerOrder == null)){ + return; + } + if(customerOrder.newItem()){ + }else if(customerOrder.updateItem()){ + if(!customerOrder.isPropertyLoaded("platform")){ + invalidTypeCheck(_context, newLocation(_parentLocation, "platform"), "Mutation requires a fully loaded entity"); + } + if(!customerOrder.isPropertyLoaded("orderNumber")){ + invalidTypeCheck(_context, newLocation(_parentLocation, "order_number"), "Mutation requires a fully loaded entity"); + } + if(!customerOrder.isPropertyLoaded("description")){ + invalidTypeCheck(_context, newLocation(_parentLocation, "description"), "Mutation requires a fully loaded entity"); + } + + } + checkPlatform(_context, customerOrder.getProperty(CustomerOrder.PLATFORM_PROPERTY), newLocation(_parentLocation, "platform")); + checkOrderNumber(_context, customerOrder.getProperty(CustomerOrder.ORDER_NUMBER_PROPERTY), newLocation(_parentLocation, "order_number")); + checkDescription(_context, customerOrder.getProperty(CustomerOrder.DESCRIPTION_PROPERTY), newLocation(_parentLocation, "description")); + for(int i = 0; customerOrder.getOrderItemList() != null && i < customerOrder.getOrderItemList().size(); i++){ + OrderItem orderItem = customerOrder.getOrderItemList().get(i); + new OrderItemChecker().checkAndFix(_context, orderItem, newLocation(_parentLocation, "order_item_list", i)); + } + for(int i = 0; customerOrder.getPaymentList() != null && i < customerOrder.getPaymentList().size(); i++){ + Payment payment = customerOrder.getPaymentList().get(i); + new PaymentChecker().checkAndFix(_context, payment, newLocation(_parentLocation, "payment_list", i)); + } + for(int i = 0; customerOrder.getShipmentList() != null && i < customerOrder.getShipmentList().size(); i++){ + Shipment shipment = customerOrder.getShipmentList().get(i); + new ShipmentChecker().checkAndFix(_context, shipment, newLocation(_parentLocation, "shipment_list", i)); + } + } + + public void checkPlatform(UserContext _context, Platform platform, ObjectLocation _parentLocation){ + requiredCheck(_context, _parentLocation, platform); + if((platform == null)){ + return; + } + new PlatformChecker().checkAndFix(_context, platform, _parentLocation); + } + public void checkOrderNumber(UserContext _context, String orderNumber, ObjectLocation _parentLocation){ + requiredCheck(_context, _parentLocation, orderNumber); + if((orderNumber == null)){ + return; + } + maxStringCheck(_context, _parentLocation, 100, orderNumber); + + } + public void checkDescription(UserContext _context, String description, ObjectLocation _parentLocation){ + requiredCheck(_context, _parentLocation, description); + if((description == null)){ + return; + } + maxStringCheck(_context, _parentLocation, 100, description); + + } +} \ No newline at end of file diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/customerorder/CustomerOrderExpression.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/customerorder/CustomerOrderExpression.java new file mode 100644 index 00000000..f2cd05eb --- /dev/null +++ b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/customerorder/CustomerOrderExpression.java @@ -0,0 +1,84 @@ + +package com.teaql.tracechainservice.customerorder; + +import com.teaql.tracechainservice.orderitem.OrderItem; +import com.teaql.tracechainservice.orderitem.OrderItemListExpression; +import com.teaql.tracechainservice.payment.Payment; +import com.teaql.tracechainservice.payment.PaymentListExpression; +import com.teaql.tracechainservice.platform.Platform; +import com.teaql.tracechainservice.platform.PlatformExpression; +import com.teaql.tracechainservice.shipment.Shipment; +import com.teaql.tracechainservice.shipment.ShipmentListExpression; +import io.teaql.core.UserContext; +import io.teaql.core.value.BaseEntityExpression; +import io.teaql.core.value.Expression; +import io.teaql.core.value.ExpressionAdaptor; +import java.util.function.Function; + +public class CustomerOrderExpression extends ExpressionAdaptor implements BaseEntityExpression { + public CustomerOrderExpression(Expression expression){ + super(expression); + } + + public CustomerOrderExpression(Expression expression, Function function){ + super(expression, function); + } + + public CustomerOrderExpression updateId(Long id){ + return new CustomerOrderExpression(this, $it -> {((CustomerOrder)$it).__internalSet("id", id); return this;}); + } + + public CustomerOrderExpression save(UserContext userContext){ + return new CustomerOrderExpression(this, $it -> ((CustomerOrder)$it).auditAs("Saved by Expression").save(userContext)); + } + + public CustomerOrderExpression save(String intent, UserContext userContext){ + return new CustomerOrderExpression(this, $it -> ((CustomerOrder)$it).auditAs(intent).save(userContext)); + } + + public boolean isNull() { + return resolve() == null; + } + + + public PlatformExpression getPlatform(){ + return new PlatformExpression(loaded("platform", CustomerOrder::getPlatform)); + } + + public CustomerOrderExpression updatePlatform(Platform platform){ + return new CustomerOrderExpression(this, $it -> ((CustomerOrder)$it).updatePlatform(platform)); + } + + public Expression getOrderNumber(){ + return loaded("orderNumber", CustomerOrder::getOrderNumber); + } + public CustomerOrderExpression updateOrderNumber(String orderNumber){ + return new CustomerOrderExpression(this, $it -> ((CustomerOrder)$it).updateOrderNumber(orderNumber)); + } + + public Expression getDescription(){ + return loaded("description", CustomerOrder::getDescription); + } + public CustomerOrderExpression updateDescription(String description){ + return new CustomerOrderExpression(this, $it -> ((CustomerOrder)$it).updateDescription(description)); + } + + public OrderItemListExpression getOrderItemList(){ + return new OrderItemListExpression(loaded("orderItemList", CustomerOrder::getOrderItemList)); + } + public PaymentListExpression getPaymentList(){ + return new PaymentListExpression(loaded("paymentList", CustomerOrder::getPaymentList)); + } + public ShipmentListExpression getShipmentList(){ + return new ShipmentListExpression(loaded("shipmentList", CustomerOrder::getShipmentList)); + } + public CustomerOrderExpression addOrderItem(OrderItem orderItem){ + return new CustomerOrderExpression(this, $it -> ((CustomerOrder)$it).addOrderItem(orderItem)); + } + public CustomerOrderExpression addPayment(Payment payment){ + return new CustomerOrderExpression(this, $it -> ((CustomerOrder)$it).addPayment(payment)); + } + public CustomerOrderExpression addShipment(Shipment shipment){ + return new CustomerOrderExpression(this, $it -> ((CustomerOrder)$it).addShipment(shipment)); + } +} \ No newline at end of file diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/customerorder/CustomerOrderListExpression.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/customerorder/CustomerOrderListExpression.java new file mode 100644 index 00000000..2d57458e --- /dev/null +++ b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/customerorder/CustomerOrderListExpression.java @@ -0,0 +1,25 @@ + +package com.teaql.tracechainservice.customerorder; + +import io.teaql.core.SmartList; +import io.teaql.core.value.Expression; +import io.teaql.core.value.SmartListExpression; +import java.util.function.Function; + +public class CustomerOrderListExpression extends SmartListExpression { + public CustomerOrderListExpression(Expression> expression){ + super(expression); + } + + public CustomerOrderListExpression(Expression expression, Function> function){ + super(expression, function); + } + + public CustomerOrderExpression first() { + return new CustomerOrderExpression(super.first()); + } + + public CustomerOrderExpression get(int index) { + return new CustomerOrderExpression(super.get(index)); + } +} \ No newline at end of file diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/customerorder/CustomerOrderRequest.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/customerorder/CustomerOrderRequest.java new file mode 100644 index 00000000..29aa5466 --- /dev/null +++ b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/customerorder/CustomerOrderRequest.java @@ -0,0 +1,916 @@ + +package com.teaql.tracechainservice.customerorder; + +import com.teaql.tracechainservice.Q; +import com.teaql.tracechainservice.orderitem.OrderItem; +import com.teaql.tracechainservice.orderitem.OrderItemRequest; +import com.teaql.tracechainservice.payment.Payment; +import com.teaql.tracechainservice.payment.PaymentRequest; +import com.teaql.tracechainservice.platform.Platform; +import com.teaql.tracechainservice.platform.PlatformRequest; +import com.teaql.tracechainservice.shipment.Shipment; +import com.teaql.tracechainservice.shipment.ShipmentRequest; +import io.teaql.core.AggrFunction; +import io.teaql.core.BaseRequest; +import io.teaql.core.PropertyReference; +import io.teaql.core.SearchCriteria; +import io.teaql.core.SubQuerySearchCriteria; +import io.teaql.core.criteria.Operator; +import io.teaql.core.criteria.TwoOperatorCriteria; + +public class CustomerOrderRequest extends BaseRequest { + + /** + * @deprecated AI agents and business code must use the generated Q facade + * instead of constructing request builders directly. + */ + @Deprecated + @SuppressWarnings("unchecked") + public CustomerOrderRequest(Class returnType){ + super(returnType, () -> (T) new CustomerOrder()); + selectId(); + selectVersion(); + } + + public CustomerOrderRequest comment(String comment){ + super.internalComment(comment); + return this; + } + + // purpose() 继承自 BaseRequest,返回 ExecutableRequest(终结方法) + + public CustomerOrderRequest returnType(Class returnType){ + super.setReturnType(returnType); + return this; + } + + public CustomerOrderRequest enableAggregationCache(long cacheExpiredMillis){ + super.enableAggregationCache(); + super.aggregateCacheTime(cacheExpiredMillis); + return this; + } + + public CustomerOrderRequest enableAggregationCache(){ + return enableAggregationCache(0l); + } + + + public CustomerOrderRequest propagateAggregationCache(long cacheExpiredMillis){ + super.propagateAggregationCache(cacheExpiredMillis); + return this; + } + + /** + * Accept best-effort stateful seek optimization for browsing consecutive pages. + * Do not use this for business processing that must visit every row exactly once. + */ + public CustomerOrderRequest optimizeForContinuousPageFetch(){ + super.optimizeForContinuousPageFetch(); + return this; + } + + public CustomerOrderRequest optimizeForContinuousPageFetch(String namespace, int ttlSeconds){ + super.optimizeForContinuousPageFetch(namespace, ttlSeconds); + return this; + } + + public CustomerOrderRequest optimizePaginationWithIdSet(){ + super.optimizePaginationWithIdSet(); + return this; + } + + public CustomerOrderRequest optimizePaginationWithIdSet( + String namespace, int ttlSeconds, int maxIds){ + super.optimizePaginationWithIdSet(namespace, ttlSeconds, maxIds); + return this; + } + + public CustomerOrderRequest topNProbeParentThreshold(int threshold){ + super.topNProbeParentThreshold(threshold); + return this; + } + + public CustomerOrderRequest appendSearchCriteria(SearchCriteria searchCriteria){ + return (CustomerOrderRequest)super.appendSearchCriteria(searchCriteria); + } + + public CustomerOrderRequest filter(String property1, Operator operator, String property2){ + return appendSearchCriteria(new TwoOperatorCriteria(operator, new PropertyReference(property1), new PropertyReference(property2))); + } + + + public CustomerOrderRequest matchingAnyOf(CustomerOrderRequest customerOrder){ + super.internalMatchAny(customerOrder); + return this; + } + + public CustomerOrderRequest enhanceChildrenIfNeeded(){ + return this; + } + + public CustomerOrderRequest withDeletedRows(){ + super.withDeletedRows(); + return this; + } + + public CustomerOrderRequest deletedRowsOnly(){ + super.deletedRowsOnly(); + return this; + } + + public CustomerOrderRequest selectSelf(){ + super.selectSelf(); + return selectId().selectPlatformIdOnly().selectOrderNumber().selectDescription().selectVersion(); + } + + public CustomerOrderRequest selectSelfFields(){ + return selectSelf(); + } + + public CustomerOrderRequest selectAll(){ + super.selectAll(); + return selectId().selectPlatform().selectOrderNumber().selectDescription().selectVersion(); + } + + public CustomerOrderRequest selectChildren(){ + super.selectAny(); + selectOrderItemList().selectPaymentList().selectShipmentList(); + return selectId().selectPlatform().selectOrderNumber().selectDescription().selectVersion(); + } + + + public CustomerOrderRequest selectId(){ + selectProperty(CustomerOrder.ID_PROPERTY); + return this; + } + + /** + * fill the id with customized rawSqlSegment, TEAQL uses ({rawSqlSegment} AS id) to fetch id property. + * @param rawSqlSegment customized rawSqlSegment + */ + + + + + public CustomerOrderRequest unselectId(){ + unselectProperty(CustomerOrder.ID_PROPERTY); + return this; + } + public CustomerOrderRequest selectPlatformIdOnly(){ + selectProperty(CustomerOrder.PLATFORM_PROPERTY); + return this; + } + + public CustomerOrderRequest selectPlatform(){ + return selectPlatformWith(Q.platforms().unlimited().selectSelf()); + } + + public CustomerOrderRequest selectPlatformWith(PlatformRequest platform){ + selectProperty(CustomerOrder.PLATFORM_PROPERTY); + enhanceRelation(CustomerOrder.PLATFORM_PROPERTY, platform); + return this; + } + + public CustomerOrderRequest unselectPlatform(){ + unselectProperty(CustomerOrder.PLATFORM_PROPERTY); + return this; + } + public CustomerOrderRequest selectOrderNumber(){ + selectProperty(CustomerOrder.ORDER_NUMBER_PROPERTY); + return this; + } + + /** + * fill the orderNumber with customized rawSqlSegment, TEAQL uses ({rawSqlSegment} AS orderNumber) to fetch orderNumber property. + * @param rawSqlSegment customized rawSqlSegment + */ + + + + + public CustomerOrderRequest unselectOrderNumber(){ + unselectProperty(CustomerOrder.ORDER_NUMBER_PROPERTY); + return this; + } + public CustomerOrderRequest selectDescription(){ + selectProperty(CustomerOrder.DESCRIPTION_PROPERTY); + return this; + } + + /** + * fill the description with customized rawSqlSegment, TEAQL uses ({rawSqlSegment} AS description) to fetch description property. + * @param rawSqlSegment customized rawSqlSegment + */ + + + + + public CustomerOrderRequest unselectDescription(){ + unselectProperty(CustomerOrder.DESCRIPTION_PROPERTY); + return this; + } + public CustomerOrderRequest selectVersion(){ + selectProperty(CustomerOrder.VERSION_PROPERTY); + return this; + } + + /** + * fill the version with customized rawSqlSegment, TEAQL uses ({rawSqlSegment} AS version) to fetch version property. + * @param rawSqlSegment customized rawSqlSegment + */ + + + + + public CustomerOrderRequest unselectVersion(){ + unselectProperty(CustomerOrder.VERSION_PROPERTY); + return this; + } + public CustomerOrderRequest selectOrderItemList(){ + return selectOrderItemListWith(Q.orderItems().selectSelf()); + } + + public CustomerOrderRequest selectOrderItemListWith(OrderItemRequest orderItemList){ + enhanceRelation(CustomerOrder.ORDER_ITEM_LIST_PROPERTY, orderItemList); + return this; + } + public CustomerOrderRequest selectPaymentList(){ + return selectPaymentListWith(Q.payments().selectSelf()); + } + + public CustomerOrderRequest selectPaymentListWith(PaymentRequest paymentList){ + enhanceRelation(CustomerOrder.PAYMENT_LIST_PROPERTY, paymentList); + return this; + } + public CustomerOrderRequest selectShipmentList(){ + return selectShipmentListWith(Q.shipments().selectSelf()); + } + + public CustomerOrderRequest selectShipmentListWith(ShipmentRequest shipmentList){ + enhanceRelation(CustomerOrder.SHIPMENT_LIST_PROPERTY, shipmentList); + return this; + } + + public CustomerOrderRequest withId(Operator operator, Object... values){ + return appendSearchCriteria(createIdCriteria(operator, values)); + } + + public SearchCriteria createIdCriteria(Operator operator, Object... values) { + return createBasicSearchCriteria(CustomerOrder.ID_PROPERTY, operator, values); + } + + public CustomerOrderRequest withIdIsNot(Long id){ + return withId(Operator.NOT_EQUAL, id); + } + + public CustomerOrderRequest withIdIn(Long... id){ + return withId(Operator.IN, (Object[])id); + } + + public CustomerOrderRequest withIdNotIn(Long... id){ + return withId(Operator.NOT_IN, (Object[])id); + } + public CustomerOrderRequest withIdIs(Long id){ + return withId(Operator.EQUAL, id); + } + + + + public CustomerOrderRequest filterByPlatform(Platform... platform){ + if (platform == null || platform.length == 0) { + throw new IllegalArgumentException("filterByPlatform parameter platform cannot be empty"); + } + return appendSearchCriteria(createPlatformCriteria(Operator.EQUAL, (Object[])platform)); + } + + public CustomerOrderRequest withPlatform(Operator operator, Object... values){ + return appendSearchCriteria(createPlatformCriteria(operator, values)); + } + + public CustomerOrderRequest withPlatformIsUnknown(){ + return withPlatform(Operator.IS_NULL); + } + + public CustomerOrderRequest withPlatformIsKnown(){ + return withPlatform(Operator.IS_NOT_NULL); + } + + public SearchCriteria createPlatformCriteria(Operator operator, Object... values) { + return createBasicSearchCriteria(CustomerOrder.PLATFORM_PROPERTY, operator, values); + } + + public CustomerOrderRequest filterByPlatform(Long platform){ + if(platform == null){ + return this; + } + return withPlatform(Operator.EQUAL, platform); + } + public CustomerOrderRequest withPlatformMatching(PlatformRequest platform){ + return appendSearchCriteria(new SubQuerySearchCriteria(CustomerOrder.PLATFORM_PROPERTY, platform, Platform.ID_PROPERTY)); + } + + public CustomerOrderRequest withoutPlatformMatching(PlatformRequest platform){ + return appendSearchCriteria(SearchCriteria.not( + new SubQuerySearchCriteria(CustomerOrder.PLATFORM_PROPERTY, platform, Platform.ID_PROPERTY))); + } + + public CustomerOrderRequest filterByOrderNumber(String... orderNumber){ + if (orderNumber == null || orderNumber.length == 0) { + throw new IllegalArgumentException("filterByOrderNumber parameter orderNumber cannot be empty"); + } + return appendSearchCriteria(createOrderNumberCriteria(Operator.EQUAL, (Object[])orderNumber)); + } + + public CustomerOrderRequest withOrderNumber(Operator operator, Object... values){ + return appendSearchCriteria(createOrderNumberCriteria(operator, values)); + } + + public CustomerOrderRequest withOrderNumberIsUnknown(){ + return withOrderNumber(Operator.IS_NULL); + } + + public CustomerOrderRequest withOrderNumberIsKnown(){ + return withOrderNumber(Operator.IS_NOT_NULL); + } + + public SearchCriteria createOrderNumberCriteria(Operator operator, Object... values) { + return createBasicSearchCriteria(CustomerOrder.ORDER_NUMBER_PROPERTY, operator, values); + } + + public CustomerOrderRequest withOrderNumberIsNot(String orderNumber){ + return withOrderNumber(Operator.NOT_EQUAL, orderNumber); + } + + public CustomerOrderRequest withOrderNumberIn(String... orderNumber){ + return withOrderNumber(Operator.IN, (Object[])orderNumber); + } + + public CustomerOrderRequest withOrderNumberNotIn(String... orderNumber){ + return withOrderNumber(Operator.NOT_IN, (Object[])orderNumber); + } + public CustomerOrderRequest withOrderNumberGreaterThan(String orderNumber){ + return withOrderNumber(Operator.GREATER_THAN, orderNumber); + } + + public CustomerOrderRequest withOrderNumberGreaterThanOrEqualTo(String orderNumber){ + return withOrderNumber(Operator.GREATER_THAN_OR_EQUAL, orderNumber); + } + + public CustomerOrderRequest withOrderNumberLessThan(String orderNumber){ + return withOrderNumber(Operator.LESS_THAN, orderNumber); + } + + public CustomerOrderRequest withOrderNumberLessThanOrEqualTo(String orderNumber){ + return withOrderNumber(Operator.LESS_THAN_OR_EQUAL, orderNumber); + } + + public CustomerOrderRequest withOrderNumberBetween(String startOfOrderNumber, String endOfOrderNumber){ + return withOrderNumber(Operator.BETWEEN, startOfOrderNumber, endOfOrderNumber); + } + public CustomerOrderRequest withOrderNumberStartingWith(String orderNumber){ + return withOrderNumber(Operator.BEGIN_WITH, orderNumber); + } + public CustomerOrderRequest withOrderNumberContaining(String orderNumber){ + return withOrderNumber(Operator.CONTAIN, orderNumber); + } + + public CustomerOrderRequest withOrderNumberNotContaining(String orderNumber){ + return withOrderNumber(Operator.NOT_CONTAIN, orderNumber); + } + + public CustomerOrderRequest withOrderNumberNotStartingWith(String orderNumber){ + return withOrderNumber(Operator.NOT_BEGIN_WITH, orderNumber); + } + + public CustomerOrderRequest withOrderNumberEndingWith(String orderNumber){ + return withOrderNumber(Operator.END_WITH, orderNumber); + } + + public CustomerOrderRequest withOrderNumberNotEndingWith(String orderNumber){ + return withOrderNumber(Operator.NOT_END_WITH, orderNumber); + } + + public CustomerOrderRequest withOrderNumberIs(String orderNumber){ + return withOrderNumber(Operator.EQUAL, orderNumber); + } + + public CustomerOrderRequest withOrderNumberSoundingLike(String orderNumber){ + return withOrderNumber(Operator.SOUNDS_LIKE, orderNumber); + } + + + + public CustomerOrderRequest filterByDescription(String... description){ + if (description == null || description.length == 0) { + throw new IllegalArgumentException("filterByDescription parameter description cannot be empty"); + } + return appendSearchCriteria(createDescriptionCriteria(Operator.EQUAL, (Object[])description)); + } + + public CustomerOrderRequest withDescription(Operator operator, Object... values){ + return appendSearchCriteria(createDescriptionCriteria(operator, values)); + } + + public CustomerOrderRequest withDescriptionIsUnknown(){ + return withDescription(Operator.IS_NULL); + } + + public CustomerOrderRequest withDescriptionIsKnown(){ + return withDescription(Operator.IS_NOT_NULL); + } + + public SearchCriteria createDescriptionCriteria(Operator operator, Object... values) { + return createBasicSearchCriteria(CustomerOrder.DESCRIPTION_PROPERTY, operator, values); + } + + public CustomerOrderRequest withDescriptionIsNot(String description){ + return withDescription(Operator.NOT_EQUAL, description); + } + + public CustomerOrderRequest withDescriptionIn(String... description){ + return withDescription(Operator.IN, (Object[])description); + } + + public CustomerOrderRequest withDescriptionNotIn(String... description){ + return withDescription(Operator.NOT_IN, (Object[])description); + } + public CustomerOrderRequest withDescriptionGreaterThan(String description){ + return withDescription(Operator.GREATER_THAN, description); + } + + public CustomerOrderRequest withDescriptionGreaterThanOrEqualTo(String description){ + return withDescription(Operator.GREATER_THAN_OR_EQUAL, description); + } + + public CustomerOrderRequest withDescriptionLessThan(String description){ + return withDescription(Operator.LESS_THAN, description); + } + + public CustomerOrderRequest withDescriptionLessThanOrEqualTo(String description){ + return withDescription(Operator.LESS_THAN_OR_EQUAL, description); + } + + public CustomerOrderRequest withDescriptionBetween(String startOfDescription, String endOfDescription){ + return withDescription(Operator.BETWEEN, startOfDescription, endOfDescription); + } + public CustomerOrderRequest withDescriptionStartingWith(String description){ + return withDescription(Operator.BEGIN_WITH, description); + } + public CustomerOrderRequest withDescriptionContaining(String description){ + return withDescription(Operator.CONTAIN, description); + } + + public CustomerOrderRequest withDescriptionNotContaining(String description){ + return withDescription(Operator.NOT_CONTAIN, description); + } + + public CustomerOrderRequest withDescriptionNotStartingWith(String description){ + return withDescription(Operator.NOT_BEGIN_WITH, description); + } + + public CustomerOrderRequest withDescriptionEndingWith(String description){ + return withDescription(Operator.END_WITH, description); + } + + public CustomerOrderRequest withDescriptionNotEndingWith(String description){ + return withDescription(Operator.NOT_END_WITH, description); + } + + public CustomerOrderRequest withDescriptionIs(String description){ + return withDescription(Operator.EQUAL, description); + } + + public CustomerOrderRequest withDescriptionSoundingLike(String description){ + return withDescription(Operator.SOUNDS_LIKE, description); + } + + + + public CustomerOrderRequest filterByVersion(Long... version){ + if (version == null || version.length == 0) { + throw new IllegalArgumentException("filterByVersion parameter version cannot be empty"); + } + return appendSearchCriteria(createVersionCriteria(Operator.EQUAL, (Object[])version)); + } + + public CustomerOrderRequest withVersion(Operator operator, Object... values){ + return appendSearchCriteria(createVersionCriteria(operator, values)); + } + + public CustomerOrderRequest withVersionIsUnknown(){ + return withVersion(Operator.IS_NULL); + } + + public CustomerOrderRequest withVersionIsKnown(){ + return withVersion(Operator.IS_NOT_NULL); + } + + public SearchCriteria createVersionCriteria(Operator operator, Object... values) { + return createBasicSearchCriteria(CustomerOrder.VERSION_PROPERTY, operator, values); + } + + public CustomerOrderRequest withVersionIs(Long version){ + return withVersion(Operator.EQUAL, version); + } + + public CustomerOrderRequest withVersionIsNot(Long version){ + return withVersion(Operator.NOT_EQUAL, version); + } + + public CustomerOrderRequest withVersionIn(Long... version){ + return withVersion(Operator.IN, (Object[])version); + } + + public CustomerOrderRequest withVersionNotIn(Long... version){ + return withVersion(Operator.NOT_IN, (Object[])version); + } + public CustomerOrderRequest withVersionGreaterThan(Long version){ + return withVersion(Operator.GREATER_THAN, version); + } + + public CustomerOrderRequest withVersionGreaterThanOrEqualTo(Long version){ + return withVersion(Operator.GREATER_THAN_OR_EQUAL, version); + } + + public CustomerOrderRequest withVersionLessThan(Long version){ + return withVersion(Operator.LESS_THAN, version); + } + + public CustomerOrderRequest withVersionLessThanOrEqualTo(Long version){ + return withVersion(Operator.LESS_THAN_OR_EQUAL, version); + } + + public CustomerOrderRequest withVersionBetween(Long startOfVersion, Long endOfVersion){ + return withVersion(Operator.BETWEEN, startOfVersion, endOfVersion); + } + + + public CustomerOrderRequest withOrderItemListMatching(OrderItemRequest orderItemRequest){ + return appendSearchCriteria(new SubQuerySearchCriteria(CustomerOrder.ID_PROPERTY, orderItemRequest, OrderItem.CUSTOMER_ORDER_PROPERTY)); + } + + public CustomerOrderRequest withoutOrderItemListMatching(OrderItemRequest orderItemRequest){ + return appendSearchCriteria(SearchCriteria.not(new SubQuerySearchCriteria(CustomerOrder.ID_PROPERTY, orderItemRequest, OrderItem.CUSTOMER_ORDER_PROPERTY))); + } + + public CustomerOrderRequest haveOrderItems(){ + return withOrderItemListMatching(Q.orderItems().unlimited()); + } + + public CustomerOrderRequest haveNoOrderItems(){ + return withoutOrderItemListMatching(Q.orderItems().unlimited()); + } + public CustomerOrderRequest withPaymentListMatching(PaymentRequest paymentRequest){ + return appendSearchCriteria(new SubQuerySearchCriteria(CustomerOrder.ID_PROPERTY, paymentRequest, Payment.CUSTOMER_ORDER_PROPERTY)); + } + + public CustomerOrderRequest withoutPaymentListMatching(PaymentRequest paymentRequest){ + return appendSearchCriteria(SearchCriteria.not(new SubQuerySearchCriteria(CustomerOrder.ID_PROPERTY, paymentRequest, Payment.CUSTOMER_ORDER_PROPERTY))); + } + + public CustomerOrderRequest havePayments(){ + return withPaymentListMatching(Q.payments().unlimited()); + } + + public CustomerOrderRequest haveNoPayments(){ + return withoutPaymentListMatching(Q.payments().unlimited()); + } + public CustomerOrderRequest withShipmentListMatching(ShipmentRequest shipmentRequest){ + return appendSearchCriteria(new SubQuerySearchCriteria(CustomerOrder.ID_PROPERTY, shipmentRequest, Shipment.CUSTOMER_ORDER_PROPERTY)); + } + + public CustomerOrderRequest withoutShipmentListMatching(ShipmentRequest shipmentRequest){ + return appendSearchCriteria(SearchCriteria.not(new SubQuerySearchCriteria(CustomerOrder.ID_PROPERTY, shipmentRequest, Shipment.CUSTOMER_ORDER_PROPERTY))); + } + + public CustomerOrderRequest haveShipments(){ + return withShipmentListMatching(Q.shipments().unlimited()); + } + + public CustomerOrderRequest haveNoShipments(){ + return withoutShipmentListMatching(Q.shipments().unlimited()); + } + + public CustomerOrderRequest count(){ + super.count(); + return this; + } + public CustomerOrderRequest countAs(String retName){ + super.count(retName); + return this; + } + public CustomerOrderRequest groupByPlatformWithDetails(){ + return groupByPlatformWithDetails(Q.platforms().unlimited()); + } + + public CustomerOrderRequest groupByPlatformWithDetails(PlatformRequest subRequest){ + aggregate(CustomerOrder.PLATFORM_PROPERTY, subRequest); + return this; + } + + + + + public CustomerOrderRequest groupByOrderItemsWithDetails(OrderItemRequest subRequest){ + aggregate(CustomerOrder.ORDER_ITEM_LIST_PROPERTY, subRequest); + return this; + } + public CustomerOrderRequest groupByPaymentsWithDetails(PaymentRequest subRequest){ + aggregate(CustomerOrder.PAYMENT_LIST_PROPERTY, subRequest); + return this; + } + public CustomerOrderRequest groupByShipmentsWithDetails(ShipmentRequest subRequest){ + aggregate(CustomerOrder.SHIPMENT_LIST_PROPERTY, subRequest); + return this; + } + + public CustomerOrderRequest groupById(){ + groupBy(CustomerOrder.ID_PROPERTY); + return this; + } + + public CustomerOrderRequest groupByIdAs(String retName){ + groupBy(retName, CustomerOrder.ID_PROPERTY); + return this; + } + + public CustomerOrderRequest groupByIdWithFunction(String retName, AggrFunction function){ + groupBy(retName, CustomerOrder.ID_PROPERTY, function); + return this; + } + public CustomerOrderRequest groupByPlatformWith(PlatformRequest subRequest){ + groupBy(CustomerOrder.PLATFORM_PROPERTY, subRequest); + return this; + } + public CustomerOrderRequest groupByPlatform(){ + groupBy(CustomerOrder.PLATFORM_PROPERTY); + return this; + } + + public CustomerOrderRequest groupByPlatformAs(String retName){ + groupBy(retName, CustomerOrder.PLATFORM_PROPERTY); + return this; + } + + public CustomerOrderRequest groupByPlatformWithFunction(String retName, AggrFunction function){ + groupBy(retName, CustomerOrder.PLATFORM_PROPERTY, function); + return this; + } + + public CustomerOrderRequest groupByOrderNumber(){ + groupBy(CustomerOrder.ORDER_NUMBER_PROPERTY); + return this; + } + + public CustomerOrderRequest groupByOrderNumberAs(String retName){ + groupBy(retName, CustomerOrder.ORDER_NUMBER_PROPERTY); + return this; + } + + public CustomerOrderRequest groupByOrderNumberWithFunction(String retName, AggrFunction function){ + groupBy(retName, CustomerOrder.ORDER_NUMBER_PROPERTY, function); + return this; + } + + public CustomerOrderRequest groupByDescription(){ + groupBy(CustomerOrder.DESCRIPTION_PROPERTY); + return this; + } + + public CustomerOrderRequest groupByDescriptionAs(String retName){ + groupBy(retName, CustomerOrder.DESCRIPTION_PROPERTY); + return this; + } + + public CustomerOrderRequest groupByDescriptionWithFunction(String retName, AggrFunction function){ + groupBy(retName, CustomerOrder.DESCRIPTION_PROPERTY, function); + return this; + } + + public CustomerOrderRequest groupByVersion(){ + groupBy(CustomerOrder.VERSION_PROPERTY); + return this; + } + + public CustomerOrderRequest groupByVersionAs(String retName){ + groupBy(retName, CustomerOrder.VERSION_PROPERTY); + return this; + } + + public CustomerOrderRequest groupByVersionWithFunction(String retName, AggrFunction function){ + groupBy(retName, CustomerOrder.VERSION_PROPERTY, function); + return this; + } + + + + public CustomerOrderRequest orderByIdAscending(){ + addOrderByAscending(CustomerOrder.ID_PROPERTY); + return this; + } + + public CustomerOrderRequest orderByIdDescending(){ + addOrderByDescending(CustomerOrder.ID_PROPERTY); + return this; + } + + public CustomerOrderRequest orderByPlatformAscending(){ + addOrderByAscending(CustomerOrder.PLATFORM_PROPERTY); + return this; + } + + public CustomerOrderRequest orderByPlatformDescending(){ + addOrderByDescending(CustomerOrder.PLATFORM_PROPERTY); + return this; + } + + public CustomerOrderRequest orderByOrderNumberAscending(){ + addOrderByAscending(CustomerOrder.ORDER_NUMBER_PROPERTY); + return this; + } + + public CustomerOrderRequest orderByOrderNumberDescending(){ + addOrderByDescending(CustomerOrder.ORDER_NUMBER_PROPERTY); + return this; + } + public CustomerOrderRequest orderByOrderNumberAscendingUsingGBK(){ + addOrderByAscendingUsingGBK(CustomerOrder.ORDER_NUMBER_PROPERTY); + return this; + } + + public CustomerOrderRequest orderByOrderNumberDescendingUsingGBK(){ + addOrderByDescendingUsingGBK(CustomerOrder.ORDER_NUMBER_PROPERTY); + return this; + } + public CustomerOrderRequest orderByDescriptionAscending(){ + addOrderByAscending(CustomerOrder.DESCRIPTION_PROPERTY); + return this; + } + + public CustomerOrderRequest orderByDescriptionDescending(){ + addOrderByDescending(CustomerOrder.DESCRIPTION_PROPERTY); + return this; + } + public CustomerOrderRequest orderByDescriptionAscendingUsingGBK(){ + addOrderByAscendingUsingGBK(CustomerOrder.DESCRIPTION_PROPERTY); + return this; + } + + public CustomerOrderRequest orderByDescriptionDescendingUsingGBK(){ + addOrderByDescendingUsingGBK(CustomerOrder.DESCRIPTION_PROPERTY); + return this; + } + public CustomerOrderRequest orderByVersionAscending(){ + addOrderByAscending(CustomerOrder.VERSION_PROPERTY); + return this; + } + + public CustomerOrderRequest orderByVersionDescending(){ + addOrderByDescending(CustomerOrder.VERSION_PROPERTY); + return this; + } + + + public CustomerOrderRequest statsFromOrderItemsAs(String name, OrderItemRequest subRequest){ + return statsFromOrderItemsAs(name, subRequest, false); + } + + public CustomerOrderRequest statsFromOrderItemsAs(String name, OrderItemRequest subRequest, boolean singleResult){ + subRequest.setPartitionProperty(OrderItem.CUSTOMER_ORDER_PROPERTY); + addAggregateDynamicProperty(name, subRequest, singleResult); + return this; + } + + public CustomerOrderRequest statsFromOrderItems(OrderItemRequest subRequest){ + return statsFromOrderItemsAs(REFINEMENTS, subRequest); + } + public CustomerOrderRequest statsFromPaymentsAs(String name, PaymentRequest subRequest){ + return statsFromPaymentsAs(name, subRequest, false); + } + + public CustomerOrderRequest statsFromPaymentsAs(String name, PaymentRequest subRequest, boolean singleResult){ + subRequest.setPartitionProperty(Payment.CUSTOMER_ORDER_PROPERTY); + addAggregateDynamicProperty(name, subRequest, singleResult); + return this; + } + + public CustomerOrderRequest statsFromPayments(PaymentRequest subRequest){ + return statsFromPaymentsAs(REFINEMENTS, subRequest); + } + public CustomerOrderRequest statsFromShipmentsAs(String name, ShipmentRequest subRequest){ + return statsFromShipmentsAs(name, subRequest, false); + } + + public CustomerOrderRequest statsFromShipmentsAs(String name, ShipmentRequest subRequest, boolean singleResult){ + subRequest.setPartitionProperty(Shipment.CUSTOMER_ORDER_PROPERTY); + addAggregateDynamicProperty(name, subRequest, singleResult); + return this; + } + + public CustomerOrderRequest statsFromShipments(ShipmentRequest subRequest){ + return statsFromShipmentsAs(REFINEMENTS, subRequest); + } + public PlatformRequest rollUpToPlatform(){ + PlatformRequest platform = Q.platforms().unlimited(); + this.withPlatformMatching(platform) + .groupByPlatformWith(platform); + return platform; + } + + + + + public CustomerOrderRequest countOrderItems(){ + return countOrderItemsAs("Count"); + } + + public CustomerOrderRequest countOrderItemsAs(String name){ + return countOrderItemsWith(name, Q.orderItems().unlimited()); + } + + public CustomerOrderRequest countOrderItemsWith(String name, OrderItemRequest subRequest){ + return statsFromOrderItemsAs(name, subRequest.count(), true); + } + public CustomerOrderRequest countPayments(){ + return countPaymentsAs("Count"); + } + + public CustomerOrderRequest countPaymentsAs(String name){ + return countPaymentsWith(name, Q.payments().unlimited()); + } + + public CustomerOrderRequest countPaymentsWith(String name, PaymentRequest subRequest){ + return statsFromPaymentsAs(name, subRequest.count(), true); + } + public CustomerOrderRequest countShipments(){ + return countShipmentsAs("Count"); + } + + public CustomerOrderRequest countShipmentsAs(String name){ + return countShipmentsWith(name, Q.shipments().unlimited()); + } + + public CustomerOrderRequest countShipmentsWith(String name, ShipmentRequest subRequest){ + return statsFromShipmentsAs(name, subRequest.count(), true); + } + + public CustomerOrderRequest facetByPlatformAs(String facetName, PlatformRequest platform){ + return facetByPlatformAs(facetName, platform, true); + } + + public CustomerOrderRequest facetByPlatformAs(String facetName, PlatformRequest platform, boolean includeAllFacets){ + addFacet(facetName, CustomerOrder.PLATFORM_PROPERTY, platform, includeAllFacets); + return this; + } + + + /** + * get topN records + * @param topN records number + */ + public CustomerOrderRequest top(int topN) { + super.top(topN); + return this; + } + + /** Cross-runtime bounded-query alias. */ + public CustomerOrderRequest limit(int limit) { + return top(limit); + } + + /** + * get records from offset(inclusive) to offset+size(exclusive) + * @param offset record offset + * @param size records number + */ + public CustomerOrderRequest offset(int offset, int size) { + super.offset(offset, size); + return this; + } + + /** + * retrieve all records + */ + public CustomerOrderRequest unlimited() { + super.unlimited(); + return this; + } + + /** + * get records of one page + * @param pageNumber page number(1-based) + * @param pageSize page size + */ + public CustomerOrderRequest page(int pageNumber, int pageSize) { + int offset = (pageNumber - 1) * pageSize; + return offset(offset, pageSize); + } + + /** + * get records of one page, default page size is 10 + * @param pageNumber page number(1-based) + */ + public CustomerOrderRequest page(int pageNumber) { + return page(pageNumber, 10); + } +} \ No newline at end of file diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/orderitem/OrderItem.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/orderitem/OrderItem.java new file mode 100644 index 00000000..28a2ede6 --- /dev/null +++ b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/orderitem/OrderItem.java @@ -0,0 +1,105 @@ + +package com.teaql.tracechainservice.orderitem; + +import com.teaql.tracechainservice.customerorder.CustomerOrder; +import io.teaql.core.Audited; +import io.teaql.core.BaseEntity; +import io.teaql.core.EntityStatus; +import io.teaql.core.FrameworkInternal; +import io.teaql.core.RemoteInput; +import java.util.Objects; + +/** + * [TEAQL AI WARNING] + * TeaQL was explicitly designed to PREVENT AI hallucinations and random guessing. + * DO NOT GUESS METHOD NAMES! + * The methods listed below are the ONLY valid ways to interact with this entity. + * If you encounter compilation errors (e.g., method not found), DO NOT guess another method name. + * Read the method signatures in this file before proceeding. + */ +public class OrderItem extends BaseEntity implements RemoteInput { + public static String INTERNAL_TYPE = "OrderItem"; + + + public static final String CUSTOMER_ORDER_PROPERTY = "customerOrder"; + public static final String NAME_PROPERTY = "name"; + private CustomerOrder customerOrder; + + private String name; + + + public CustomerOrder getCustomerOrder(){ + return this.customerOrder; + } + + public String getName(){ + return this.name; + } + + public OrderItem updateCustomerOrder(CustomerOrder customerOrder){ + if(Objects.equals(this.customerOrder, customerOrder)){ + return this; + } + handleUpdate(CUSTOMER_ORDER_PROPERTY, getCustomerOrder(), customerOrder); + this.customerOrder = customerOrder; + return this; + } + + public OrderItem updateName(String name){ + name = (name == null ? null : name.trim()); + if(Objects.equals(this.name, name)){ + return this; + } + handleUpdate(NAME_PROPERTY, getName(), name); + this.name = name; + return this; + } + + + public static OrderItem refer(Long id){ + OrderItem refer = new OrderItem(); + refer.__internalSet("id", id); + refer.set$status(EntityStatus.REFER); + return refer; + } + @Override + public String typeName(){ + return INTERNAL_TYPE; + } + + public OrderItem comment(String comment){ + this.setComment(comment); + return this; + } + + @Override + @SuppressWarnings("unchecked") + public Audited auditAs(String action) { + return super.auditAs(action); + } + + // ===== Framework Internal: generated switch dispatch ===== + @Override + @FrameworkInternal + public void __internalSet(String property, Object value) { + markPropertyLoaded(property); + switch (property) { + case "customerOrder": this.customerOrder = (CustomerOrder) value; break; + + case "name": this.name = (value == null ? null : ((String)value).trim()); break; + + default: super.__internalSet(property, value); + } + } + + @Override + @FrameworkInternal + public Object __internalGet(String property) { + switch (property) { + case "customerOrder": return this.customerOrder; + case "name": return this.name; + default: return super.__internalGet(property); + } + } + +} \ No newline at end of file diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/orderitem/OrderItemChecker.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/orderitem/OrderItemChecker.java new file mode 100644 index 00000000..fd43c3d5 --- /dev/null +++ b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/orderitem/OrderItemChecker.java @@ -0,0 +1,56 @@ + +package com.teaql.tracechainservice.orderitem; + +import com.teaql.tracechainservice.customerorder.CustomerOrder; +import com.teaql.tracechainservice.customerorder.CustomerOrderChecker; +import io.teaql.core.UserContext; +import io.teaql.core.checker.Checker; +import io.teaql.core.checker.ObjectLocation; + +public class OrderItemChecker implements Checker{ + + public String type(){ + return OrderItem.INTERNAL_TYPE; + } + + public void checkAndFix(UserContext _context, OrderItem orderItem, ObjectLocation _parentLocation){ + if(needCheck(_context, orderItem)){ + markAsChecked(_context, orderItem); + doCheck(_context, orderItem, _parentLocation); + } + } + + public void doCheck(UserContext _context, OrderItem orderItem, ObjectLocation _parentLocation){ + if((orderItem == null)){ + return; + } + if(orderItem.newItem()){ + }else if(orderItem.updateItem()){ + if(!orderItem.isPropertyLoaded("customerOrder")){ + invalidTypeCheck(_context, newLocation(_parentLocation, "customer_order"), "Mutation requires a fully loaded entity"); + } + if(!orderItem.isPropertyLoaded("name")){ + invalidTypeCheck(_context, newLocation(_parentLocation, "name"), "Mutation requires a fully loaded entity"); + } + + } + checkCustomerOrder(_context, orderItem.getProperty(OrderItem.CUSTOMER_ORDER_PROPERTY), newLocation(_parentLocation, "customer_order")); + checkName(_context, orderItem.getProperty(OrderItem.NAME_PROPERTY), newLocation(_parentLocation, "name")); + } + + public void checkCustomerOrder(UserContext _context, CustomerOrder customerOrder, ObjectLocation _parentLocation){ + requiredCheck(_context, _parentLocation, customerOrder); + if((customerOrder == null)){ + return; + } + new CustomerOrderChecker().checkAndFix(_context, customerOrder, _parentLocation); + } + public void checkName(UserContext _context, String name, ObjectLocation _parentLocation){ + requiredCheck(_context, _parentLocation, name); + if((name == null)){ + return; + } + maxStringCheck(_context, _parentLocation, 100, name); + + } +} \ No newline at end of file diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/orderitem/OrderItemExpression.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/orderitem/OrderItemExpression.java new file mode 100644 index 00000000..a1acacda --- /dev/null +++ b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/orderitem/OrderItemExpression.java @@ -0,0 +1,53 @@ + +package com.teaql.tracechainservice.orderitem; + +import com.teaql.tracechainservice.customerorder.CustomerOrder; +import com.teaql.tracechainservice.customerorder.CustomerOrderExpression; +import io.teaql.core.UserContext; +import io.teaql.core.value.BaseEntityExpression; +import io.teaql.core.value.Expression; +import io.teaql.core.value.ExpressionAdaptor; +import java.util.function.Function; + +public class OrderItemExpression extends ExpressionAdaptor implements BaseEntityExpression { + public OrderItemExpression(Expression expression){ + super(expression); + } + + public OrderItemExpression(Expression expression, Function function){ + super(expression, function); + } + + public OrderItemExpression updateId(Long id){ + return new OrderItemExpression(this, $it -> {((OrderItem)$it).__internalSet("id", id); return this;}); + } + + public OrderItemExpression save(UserContext userContext){ + return new OrderItemExpression(this, $it -> ((OrderItem)$it).auditAs("Saved by Expression").save(userContext)); + } + + public OrderItemExpression save(String intent, UserContext userContext){ + return new OrderItemExpression(this, $it -> ((OrderItem)$it).auditAs(intent).save(userContext)); + } + + public boolean isNull() { + return resolve() == null; + } + + + public CustomerOrderExpression getCustomerOrder(){ + return new CustomerOrderExpression(loaded("customerOrder", OrderItem::getCustomerOrder)); + } + + public OrderItemExpression updateCustomerOrder(CustomerOrder customerOrder){ + return new OrderItemExpression(this, $it -> ((OrderItem)$it).updateCustomerOrder(customerOrder)); + } + + public Expression getName(){ + return loaded("name", OrderItem::getName); + } + public OrderItemExpression updateName(String name){ + return new OrderItemExpression(this, $it -> ((OrderItem)$it).updateName(name)); + } + +} \ No newline at end of file diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/orderitem/OrderItemListExpression.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/orderitem/OrderItemListExpression.java new file mode 100644 index 00000000..ae16d0f1 --- /dev/null +++ b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/orderitem/OrderItemListExpression.java @@ -0,0 +1,25 @@ + +package com.teaql.tracechainservice.orderitem; + +import io.teaql.core.SmartList; +import io.teaql.core.value.Expression; +import io.teaql.core.value.SmartListExpression; +import java.util.function.Function; + +public class OrderItemListExpression extends SmartListExpression { + public OrderItemListExpression(Expression> expression){ + super(expression); + } + + public OrderItemListExpression(Expression expression, Function> function){ + super(expression, function); + } + + public OrderItemExpression first() { + return new OrderItemExpression(super.first()); + } + + public OrderItemExpression get(int index) { + return new OrderItemExpression(super.get(index)); + } +} \ No newline at end of file diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/orderitem/OrderItemRequest.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/orderitem/OrderItemRequest.java new file mode 100644 index 00000000..149c3b20 --- /dev/null +++ b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/orderitem/OrderItemRequest.java @@ -0,0 +1,618 @@ + +package com.teaql.tracechainservice.orderitem; + +import com.teaql.tracechainservice.Q; +import com.teaql.tracechainservice.customerorder.CustomerOrder; +import com.teaql.tracechainservice.customerorder.CustomerOrderRequest; +import io.teaql.core.AggrFunction; +import io.teaql.core.BaseRequest; +import io.teaql.core.PropertyReference; +import io.teaql.core.SearchCriteria; +import io.teaql.core.SubQuerySearchCriteria; +import io.teaql.core.criteria.Operator; +import io.teaql.core.criteria.TwoOperatorCriteria; + +public class OrderItemRequest extends BaseRequest { + + /** + * @deprecated AI agents and business code must use the generated Q facade + * instead of constructing request builders directly. + */ + @Deprecated + @SuppressWarnings("unchecked") + public OrderItemRequest(Class returnType){ + super(returnType, () -> (T) new OrderItem()); + selectId(); + selectVersion(); + } + + public OrderItemRequest comment(String comment){ + super.internalComment(comment); + return this; + } + + // purpose() 继承自 BaseRequest,返回 ExecutableRequest(终结方法) + + public OrderItemRequest returnType(Class returnType){ + super.setReturnType(returnType); + return this; + } + + public OrderItemRequest enableAggregationCache(long cacheExpiredMillis){ + super.enableAggregationCache(); + super.aggregateCacheTime(cacheExpiredMillis); + return this; + } + + public OrderItemRequest enableAggregationCache(){ + return enableAggregationCache(0l); + } + + + public OrderItemRequest propagateAggregationCache(long cacheExpiredMillis){ + super.propagateAggregationCache(cacheExpiredMillis); + return this; + } + + /** + * Accept best-effort stateful seek optimization for browsing consecutive pages. + * Do not use this for business processing that must visit every row exactly once. + */ + public OrderItemRequest optimizeForContinuousPageFetch(){ + super.optimizeForContinuousPageFetch(); + return this; + } + + public OrderItemRequest optimizeForContinuousPageFetch(String namespace, int ttlSeconds){ + super.optimizeForContinuousPageFetch(namespace, ttlSeconds); + return this; + } + + public OrderItemRequest optimizePaginationWithIdSet(){ + super.optimizePaginationWithIdSet(); + return this; + } + + public OrderItemRequest optimizePaginationWithIdSet( + String namespace, int ttlSeconds, int maxIds){ + super.optimizePaginationWithIdSet(namespace, ttlSeconds, maxIds); + return this; + } + + public OrderItemRequest topNProbeParentThreshold(int threshold){ + super.topNProbeParentThreshold(threshold); + return this; + } + + public OrderItemRequest appendSearchCriteria(SearchCriteria searchCriteria){ + return (OrderItemRequest)super.appendSearchCriteria(searchCriteria); + } + + public OrderItemRequest filter(String property1, Operator operator, String property2){ + return appendSearchCriteria(new TwoOperatorCriteria(operator, new PropertyReference(property1), new PropertyReference(property2))); + } + + + public OrderItemRequest matchingAnyOf(OrderItemRequest orderItem){ + super.internalMatchAny(orderItem); + return this; + } + + public OrderItemRequest enhanceChildrenIfNeeded(){ + return this; + } + + public OrderItemRequest withDeletedRows(){ + super.withDeletedRows(); + return this; + } + + public OrderItemRequest deletedRowsOnly(){ + super.deletedRowsOnly(); + return this; + } + + public OrderItemRequest selectSelf(){ + super.selectSelf(); + return selectId().selectCustomerOrderIdOnly().selectName().selectVersion(); + } + + public OrderItemRequest selectSelfFields(){ + return selectSelf(); + } + + public OrderItemRequest selectAll(){ + super.selectAll(); + return selectId().selectCustomerOrder().selectName().selectVersion(); + } + + public OrderItemRequest selectChildren(){ + super.selectAny(); + return selectId().selectCustomerOrder().selectName().selectVersion(); + } + + + public OrderItemRequest selectId(){ + selectProperty(OrderItem.ID_PROPERTY); + return this; + } + + /** + * fill the id with customized rawSqlSegment, TEAQL uses ({rawSqlSegment} AS id) to fetch id property. + * @param rawSqlSegment customized rawSqlSegment + */ + + + + + public OrderItemRequest unselectId(){ + unselectProperty(OrderItem.ID_PROPERTY); + return this; + } + public OrderItemRequest selectCustomerOrderIdOnly(){ + selectProperty(OrderItem.CUSTOMER_ORDER_PROPERTY); + return this; + } + + public OrderItemRequest selectCustomerOrder(){ + return selectCustomerOrderWith(Q.customerOrders().unlimited().selectSelf()); + } + + public OrderItemRequest selectCustomerOrderWith(CustomerOrderRequest customerOrder){ + selectProperty(OrderItem.CUSTOMER_ORDER_PROPERTY); + enhanceRelation(OrderItem.CUSTOMER_ORDER_PROPERTY, customerOrder); + return this; + } + + public OrderItemRequest unselectCustomerOrder(){ + unselectProperty(OrderItem.CUSTOMER_ORDER_PROPERTY); + return this; + } + public OrderItemRequest selectName(){ + selectProperty(OrderItem.NAME_PROPERTY); + return this; + } + + /** + * fill the name with customized rawSqlSegment, TEAQL uses ({rawSqlSegment} AS name) to fetch name property. + * @param rawSqlSegment customized rawSqlSegment + */ + + + + + public OrderItemRequest unselectName(){ + unselectProperty(OrderItem.NAME_PROPERTY); + return this; + } + public OrderItemRequest selectVersion(){ + selectProperty(OrderItem.VERSION_PROPERTY); + return this; + } + + /** + * fill the version with customized rawSqlSegment, TEAQL uses ({rawSqlSegment} AS version) to fetch version property. + * @param rawSqlSegment customized rawSqlSegment + */ + + + + + public OrderItemRequest unselectVersion(){ + unselectProperty(OrderItem.VERSION_PROPERTY); + return this; + } + + public OrderItemRequest withId(Operator operator, Object... values){ + return appendSearchCriteria(createIdCriteria(operator, values)); + } + + public SearchCriteria createIdCriteria(Operator operator, Object... values) { + return createBasicSearchCriteria(OrderItem.ID_PROPERTY, operator, values); + } + + public OrderItemRequest withIdIsNot(Long id){ + return withId(Operator.NOT_EQUAL, id); + } + + public OrderItemRequest withIdIn(Long... id){ + return withId(Operator.IN, (Object[])id); + } + + public OrderItemRequest withIdNotIn(Long... id){ + return withId(Operator.NOT_IN, (Object[])id); + } + public OrderItemRequest withIdIs(Long id){ + return withId(Operator.EQUAL, id); + } + + + + public OrderItemRequest filterByCustomerOrder(CustomerOrder... customerOrder){ + if (customerOrder == null || customerOrder.length == 0) { + throw new IllegalArgumentException("filterByCustomerOrder parameter customerOrder cannot be empty"); + } + return appendSearchCriteria(createCustomerOrderCriteria(Operator.EQUAL, (Object[])customerOrder)); + } + + public OrderItemRequest withCustomerOrder(Operator operator, Object... values){ + return appendSearchCriteria(createCustomerOrderCriteria(operator, values)); + } + + public OrderItemRequest withCustomerOrderIsUnknown(){ + return withCustomerOrder(Operator.IS_NULL); + } + + public OrderItemRequest withCustomerOrderIsKnown(){ + return withCustomerOrder(Operator.IS_NOT_NULL); + } + + public SearchCriteria createCustomerOrderCriteria(Operator operator, Object... values) { + return createBasicSearchCriteria(OrderItem.CUSTOMER_ORDER_PROPERTY, operator, values); + } + + public OrderItemRequest filterByCustomerOrder(Long customerOrder){ + if(customerOrder == null){ + return this; + } + return withCustomerOrder(Operator.EQUAL, customerOrder); + } + public OrderItemRequest withCustomerOrderMatching(CustomerOrderRequest customerOrder){ + return appendSearchCriteria(new SubQuerySearchCriteria(OrderItem.CUSTOMER_ORDER_PROPERTY, customerOrder, CustomerOrder.ID_PROPERTY)); + } + + public OrderItemRequest withoutCustomerOrderMatching(CustomerOrderRequest customerOrder){ + return appendSearchCriteria(SearchCriteria.not( + new SubQuerySearchCriteria(OrderItem.CUSTOMER_ORDER_PROPERTY, customerOrder, CustomerOrder.ID_PROPERTY))); + } + + public OrderItemRequest filterByName(String... name){ + if (name == null || name.length == 0) { + throw new IllegalArgumentException("filterByName parameter name cannot be empty"); + } + return appendSearchCriteria(createNameCriteria(Operator.EQUAL, (Object[])name)); + } + + public OrderItemRequest withName(Operator operator, Object... values){ + return appendSearchCriteria(createNameCriteria(operator, values)); + } + + public OrderItemRequest withNameIsUnknown(){ + return withName(Operator.IS_NULL); + } + + public OrderItemRequest withNameIsKnown(){ + return withName(Operator.IS_NOT_NULL); + } + + public SearchCriteria createNameCriteria(Operator operator, Object... values) { + return createBasicSearchCriteria(OrderItem.NAME_PROPERTY, operator, values); + } + + public OrderItemRequest withNameIsNot(String name){ + return withName(Operator.NOT_EQUAL, name); + } + + public OrderItemRequest withNameIn(String... name){ + return withName(Operator.IN, (Object[])name); + } + + public OrderItemRequest withNameNotIn(String... name){ + return withName(Operator.NOT_IN, (Object[])name); + } + public OrderItemRequest withNameGreaterThan(String name){ + return withName(Operator.GREATER_THAN, name); + } + + public OrderItemRequest withNameGreaterThanOrEqualTo(String name){ + return withName(Operator.GREATER_THAN_OR_EQUAL, name); + } + + public OrderItemRequest withNameLessThan(String name){ + return withName(Operator.LESS_THAN, name); + } + + public OrderItemRequest withNameLessThanOrEqualTo(String name){ + return withName(Operator.LESS_THAN_OR_EQUAL, name); + } + + public OrderItemRequest withNameBetween(String startOfName, String endOfName){ + return withName(Operator.BETWEEN, startOfName, endOfName); + } + public OrderItemRequest withNameStartingWith(String name){ + return withName(Operator.BEGIN_WITH, name); + } + public OrderItemRequest withNameContaining(String name){ + return withName(Operator.CONTAIN, name); + } + + public OrderItemRequest withNameNotContaining(String name){ + return withName(Operator.NOT_CONTAIN, name); + } + + public OrderItemRequest withNameNotStartingWith(String name){ + return withName(Operator.NOT_BEGIN_WITH, name); + } + + public OrderItemRequest withNameEndingWith(String name){ + return withName(Operator.END_WITH, name); + } + + public OrderItemRequest withNameNotEndingWith(String name){ + return withName(Operator.NOT_END_WITH, name); + } + + public OrderItemRequest withNameIs(String name){ + return withName(Operator.EQUAL, name); + } + + public OrderItemRequest withNameSoundingLike(String name){ + return withName(Operator.SOUNDS_LIKE, name); + } + + + + public OrderItemRequest filterByVersion(Long... version){ + if (version == null || version.length == 0) { + throw new IllegalArgumentException("filterByVersion parameter version cannot be empty"); + } + return appendSearchCriteria(createVersionCriteria(Operator.EQUAL, (Object[])version)); + } + + public OrderItemRequest withVersion(Operator operator, Object... values){ + return appendSearchCriteria(createVersionCriteria(operator, values)); + } + + public OrderItemRequest withVersionIsUnknown(){ + return withVersion(Operator.IS_NULL); + } + + public OrderItemRequest withVersionIsKnown(){ + return withVersion(Operator.IS_NOT_NULL); + } + + public SearchCriteria createVersionCriteria(Operator operator, Object... values) { + return createBasicSearchCriteria(OrderItem.VERSION_PROPERTY, operator, values); + } + + public OrderItemRequest withVersionIs(Long version){ + return withVersion(Operator.EQUAL, version); + } + + public OrderItemRequest withVersionIsNot(Long version){ + return withVersion(Operator.NOT_EQUAL, version); + } + + public OrderItemRequest withVersionIn(Long... version){ + return withVersion(Operator.IN, (Object[])version); + } + + public OrderItemRequest withVersionNotIn(Long... version){ + return withVersion(Operator.NOT_IN, (Object[])version); + } + public OrderItemRequest withVersionGreaterThan(Long version){ + return withVersion(Operator.GREATER_THAN, version); + } + + public OrderItemRequest withVersionGreaterThanOrEqualTo(Long version){ + return withVersion(Operator.GREATER_THAN_OR_EQUAL, version); + } + + public OrderItemRequest withVersionLessThan(Long version){ + return withVersion(Operator.LESS_THAN, version); + } + + public OrderItemRequest withVersionLessThanOrEqualTo(Long version){ + return withVersion(Operator.LESS_THAN_OR_EQUAL, version); + } + + public OrderItemRequest withVersionBetween(Long startOfVersion, Long endOfVersion){ + return withVersion(Operator.BETWEEN, startOfVersion, endOfVersion); + } + + + + public OrderItemRequest count(){ + super.count(); + return this; + } + public OrderItemRequest countAs(String retName){ + super.count(retName); + return this; + } + public OrderItemRequest groupByCustomerOrderWithDetails(){ + return groupByCustomerOrderWithDetails(Q.customerOrders().unlimited()); + } + + public OrderItemRequest groupByCustomerOrderWithDetails(CustomerOrderRequest subRequest){ + aggregate(OrderItem.CUSTOMER_ORDER_PROPERTY, subRequest); + return this; + } + + + + + public OrderItemRequest groupById(){ + groupBy(OrderItem.ID_PROPERTY); + return this; + } + + public OrderItemRequest groupByIdAs(String retName){ + groupBy(retName, OrderItem.ID_PROPERTY); + return this; + } + + public OrderItemRequest groupByIdWithFunction(String retName, AggrFunction function){ + groupBy(retName, OrderItem.ID_PROPERTY, function); + return this; + } + public OrderItemRequest groupByCustomerOrderWith(CustomerOrderRequest subRequest){ + groupBy(OrderItem.CUSTOMER_ORDER_PROPERTY, subRequest); + return this; + } + public OrderItemRequest groupByCustomerOrder(){ + groupBy(OrderItem.CUSTOMER_ORDER_PROPERTY); + return this; + } + + public OrderItemRequest groupByCustomerOrderAs(String retName){ + groupBy(retName, OrderItem.CUSTOMER_ORDER_PROPERTY); + return this; + } + + public OrderItemRequest groupByCustomerOrderWithFunction(String retName, AggrFunction function){ + groupBy(retName, OrderItem.CUSTOMER_ORDER_PROPERTY, function); + return this; + } + + public OrderItemRequest groupByName(){ + groupBy(OrderItem.NAME_PROPERTY); + return this; + } + + public OrderItemRequest groupByNameAs(String retName){ + groupBy(retName, OrderItem.NAME_PROPERTY); + return this; + } + + public OrderItemRequest groupByNameWithFunction(String retName, AggrFunction function){ + groupBy(retName, OrderItem.NAME_PROPERTY, function); + return this; + } + + public OrderItemRequest groupByVersion(){ + groupBy(OrderItem.VERSION_PROPERTY); + return this; + } + + public OrderItemRequest groupByVersionAs(String retName){ + groupBy(retName, OrderItem.VERSION_PROPERTY); + return this; + } + + public OrderItemRequest groupByVersionWithFunction(String retName, AggrFunction function){ + groupBy(retName, OrderItem.VERSION_PROPERTY, function); + return this; + } + + + + public OrderItemRequest orderByIdAscending(){ + addOrderByAscending(OrderItem.ID_PROPERTY); + return this; + } + + public OrderItemRequest orderByIdDescending(){ + addOrderByDescending(OrderItem.ID_PROPERTY); + return this; + } + + public OrderItemRequest orderByCustomerOrderAscending(){ + addOrderByAscending(OrderItem.CUSTOMER_ORDER_PROPERTY); + return this; + } + + public OrderItemRequest orderByCustomerOrderDescending(){ + addOrderByDescending(OrderItem.CUSTOMER_ORDER_PROPERTY); + return this; + } + + public OrderItemRequest orderByNameAscending(){ + addOrderByAscending(OrderItem.NAME_PROPERTY); + return this; + } + + public OrderItemRequest orderByNameDescending(){ + addOrderByDescending(OrderItem.NAME_PROPERTY); + return this; + } + public OrderItemRequest orderByNameAscendingUsingGBK(){ + addOrderByAscendingUsingGBK(OrderItem.NAME_PROPERTY); + return this; + } + + public OrderItemRequest orderByNameDescendingUsingGBK(){ + addOrderByDescendingUsingGBK(OrderItem.NAME_PROPERTY); + return this; + } + public OrderItemRequest orderByVersionAscending(){ + addOrderByAscending(OrderItem.VERSION_PROPERTY); + return this; + } + + public OrderItemRequest orderByVersionDescending(){ + addOrderByDescending(OrderItem.VERSION_PROPERTY); + return this; + } + + + public CustomerOrderRequest rollUpToCustomerOrder(){ + CustomerOrderRequest customerOrder = Q.customerOrders().unlimited(); + this.withCustomerOrderMatching(customerOrder) + .groupByCustomerOrderWith(customerOrder); + return customerOrder; + } + + + + + public OrderItemRequest facetByCustomerOrderAs(String facetName, CustomerOrderRequest customerOrder){ + return facetByCustomerOrderAs(facetName, customerOrder, true); + } + + public OrderItemRequest facetByCustomerOrderAs(String facetName, CustomerOrderRequest customerOrder, boolean includeAllFacets){ + addFacet(facetName, OrderItem.CUSTOMER_ORDER_PROPERTY, customerOrder, includeAllFacets); + return this; + } + + + /** + * get topN records + * @param topN records number + */ + public OrderItemRequest top(int topN) { + super.top(topN); + return this; + } + + /** Cross-runtime bounded-query alias. */ + public OrderItemRequest limit(int limit) { + return top(limit); + } + + /** + * get records from offset(inclusive) to offset+size(exclusive) + * @param offset record offset + * @param size records number + */ + public OrderItemRequest offset(int offset, int size) { + super.offset(offset, size); + return this; + } + + /** + * retrieve all records + */ + public OrderItemRequest unlimited() { + super.unlimited(); + return this; + } + + /** + * get records of one page + * @param pageNumber page number(1-based) + * @param pageSize page size + */ + public OrderItemRequest page(int pageNumber, int pageSize) { + int offset = (pageNumber - 1) * pageSize; + return offset(offset, pageSize); + } + + /** + * get records of one page, default page size is 10 + * @param pageNumber page number(1-based) + */ + public OrderItemRequest page(int pageNumber) { + return page(pageNumber, 10); + } +} \ No newline at end of file diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/payment/Payment.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/payment/Payment.java new file mode 100644 index 00000000..1fb2eaaa --- /dev/null +++ b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/payment/Payment.java @@ -0,0 +1,127 @@ + +package com.teaql.tracechainservice.payment; + +import com.teaql.tracechainservice.customerorder.CustomerOrder; +import com.teaql.tracechainservice.paymentattempt.PaymentAttempt; +import io.teaql.core.Audited; +import io.teaql.core.BaseEntity; +import io.teaql.core.EntityStatus; +import io.teaql.core.FrameworkInternal; +import io.teaql.core.RemoteInput; +import io.teaql.core.SmartList; +import java.util.Objects; + +/** + * [TEAQL AI WARNING] + * TeaQL was explicitly designed to PREVENT AI hallucinations and random guessing. + * DO NOT GUESS METHOD NAMES! + * The methods listed below are the ONLY valid ways to interact with this entity. + * If you encounter compilation errors (e.g., method not found), DO NOT guess another method name. + * Read the method signatures in this file before proceeding. + */ +public class Payment extends BaseEntity implements RemoteInput { + public static String INTERNAL_TYPE = "Payment"; + + + public static final String CUSTOMER_ORDER_PROPERTY = "customerOrder"; + public static final String REFERENCE_CODE_PROPERTY = "referenceCode"; + public static final String PAYMENT_ATTEMPT_LIST_PROPERTY = "paymentAttemptList"; + private CustomerOrder customerOrder; + + private String referenceCode; + + private SmartList paymentAttemptList; + + public CustomerOrder getCustomerOrder(){ + return this.customerOrder; + } + + public String getReferenceCode(){ + return this.referenceCode; + } + + public SmartList getPaymentAttemptList(){ + return this.paymentAttemptList; + } + public Payment updateCustomerOrder(CustomerOrder customerOrder){ + if(Objects.equals(this.customerOrder, customerOrder)){ + return this; + } + handleUpdate(CUSTOMER_ORDER_PROPERTY, getCustomerOrder(), customerOrder); + this.customerOrder = customerOrder; + return this; + } + + public Payment updateReferenceCode(String referenceCode){ + referenceCode = (referenceCode == null ? null : referenceCode.trim()); + if(Objects.equals(this.referenceCode, referenceCode)){ + return this; + } + handleUpdate(REFERENCE_CODE_PROPERTY, getReferenceCode(), referenceCode); + this.referenceCode = referenceCode; + return this; + } + + public Payment addPaymentAttempt(PaymentAttempt paymentAttempt){ + if (paymentAttempt == null){ + return this; + } + + if(null == this.paymentAttemptList){ + this.paymentAttemptList = new SmartList<>(); + } + + this.paymentAttemptList.add(paymentAttempt); + paymentAttempt.updatePayment(this); + return this; + } + + public static Payment refer(Long id){ + Payment refer = new Payment(); + refer.__internalSet("id", id); + refer.set$status(EntityStatus.REFER); + return refer; + } + @Override + public String typeName(){ + return INTERNAL_TYPE; + } + + public Payment comment(String comment){ + this.setComment(comment); + return this; + } + + @Override + @SuppressWarnings("unchecked") + public Audited auditAs(String action) { + return super.auditAs(action); + } + + // ===== Framework Internal: generated switch dispatch ===== + @Override + @FrameworkInternal + public void __internalSet(String property, Object value) { + markPropertyLoaded(property); + switch (property) { + case "customerOrder": this.customerOrder = (CustomerOrder) value; break; + + case "referenceCode": this.referenceCode = (value == null ? null : ((String)value).trim()); break; + + case "paymentAttemptList": this.paymentAttemptList = (SmartList) value; break; + default: super.__internalSet(property, value); + } + } + + @Override + @FrameworkInternal + public Object __internalGet(String property) { + switch (property) { + case "customerOrder": return this.customerOrder; + case "referenceCode": return this.referenceCode; + case "paymentAttemptList": return this.paymentAttemptList; + default: return super.__internalGet(property); + } + } + +} \ No newline at end of file diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/payment/PaymentChecker.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/payment/PaymentChecker.java new file mode 100644 index 00000000..b6440c65 --- /dev/null +++ b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/payment/PaymentChecker.java @@ -0,0 +1,62 @@ + +package com.teaql.tracechainservice.payment; + +import com.teaql.tracechainservice.customerorder.CustomerOrder; +import com.teaql.tracechainservice.customerorder.CustomerOrderChecker; +import com.teaql.tracechainservice.paymentattempt.PaymentAttempt; +import com.teaql.tracechainservice.paymentattempt.PaymentAttemptChecker; +import io.teaql.core.UserContext; +import io.teaql.core.checker.Checker; +import io.teaql.core.checker.ObjectLocation; + +public class PaymentChecker implements Checker{ + + public String type(){ + return Payment.INTERNAL_TYPE; + } + + public void checkAndFix(UserContext _context, Payment payment, ObjectLocation _parentLocation){ + if(needCheck(_context, payment)){ + markAsChecked(_context, payment); + doCheck(_context, payment, _parentLocation); + } + } + + public void doCheck(UserContext _context, Payment payment, ObjectLocation _parentLocation){ + if((payment == null)){ + return; + } + if(payment.newItem()){ + }else if(payment.updateItem()){ + if(!payment.isPropertyLoaded("customerOrder")){ + invalidTypeCheck(_context, newLocation(_parentLocation, "customer_order"), "Mutation requires a fully loaded entity"); + } + if(!payment.isPropertyLoaded("referenceCode")){ + invalidTypeCheck(_context, newLocation(_parentLocation, "reference_code"), "Mutation requires a fully loaded entity"); + } + + } + checkCustomerOrder(_context, payment.getProperty(Payment.CUSTOMER_ORDER_PROPERTY), newLocation(_parentLocation, "customer_order")); + checkReferenceCode(_context, payment.getProperty(Payment.REFERENCE_CODE_PROPERTY), newLocation(_parentLocation, "reference_code")); + for(int i = 0; payment.getPaymentAttemptList() != null && i < payment.getPaymentAttemptList().size(); i++){ + PaymentAttempt paymentAttempt = payment.getPaymentAttemptList().get(i); + new PaymentAttemptChecker().checkAndFix(_context, paymentAttempt, newLocation(_parentLocation, "payment_attempt_list", i)); + } + } + + public void checkCustomerOrder(UserContext _context, CustomerOrder customerOrder, ObjectLocation _parentLocation){ + requiredCheck(_context, _parentLocation, customerOrder); + if((customerOrder == null)){ + return; + } + new CustomerOrderChecker().checkAndFix(_context, customerOrder, _parentLocation); + } + public void checkReferenceCode(UserContext _context, String referenceCode, ObjectLocation _parentLocation){ + requiredCheck(_context, _parentLocation, referenceCode); + if((referenceCode == null)){ + return; + } + maxStringCheck(_context, _parentLocation, 100, referenceCode); + + } +} \ No newline at end of file diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/payment/PaymentExpression.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/payment/PaymentExpression.java new file mode 100644 index 00000000..1f006909 --- /dev/null +++ b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/payment/PaymentExpression.java @@ -0,0 +1,61 @@ + +package com.teaql.tracechainservice.payment; + +import com.teaql.tracechainservice.customerorder.CustomerOrder; +import com.teaql.tracechainservice.customerorder.CustomerOrderExpression; +import com.teaql.tracechainservice.paymentattempt.PaymentAttempt; +import com.teaql.tracechainservice.paymentattempt.PaymentAttemptListExpression; +import io.teaql.core.UserContext; +import io.teaql.core.value.BaseEntityExpression; +import io.teaql.core.value.Expression; +import io.teaql.core.value.ExpressionAdaptor; +import java.util.function.Function; + +public class PaymentExpression extends ExpressionAdaptor implements BaseEntityExpression { + public PaymentExpression(Expression expression){ + super(expression); + } + + public PaymentExpression(Expression expression, Function function){ + super(expression, function); + } + + public PaymentExpression updateId(Long id){ + return new PaymentExpression(this, $it -> {((Payment)$it).__internalSet("id", id); return this;}); + } + + public PaymentExpression save(UserContext userContext){ + return new PaymentExpression(this, $it -> ((Payment)$it).auditAs("Saved by Expression").save(userContext)); + } + + public PaymentExpression save(String intent, UserContext userContext){ + return new PaymentExpression(this, $it -> ((Payment)$it).auditAs(intent).save(userContext)); + } + + public boolean isNull() { + return resolve() == null; + } + + + public CustomerOrderExpression getCustomerOrder(){ + return new CustomerOrderExpression(loaded("customerOrder", Payment::getCustomerOrder)); + } + + public PaymentExpression updateCustomerOrder(CustomerOrder customerOrder){ + return new PaymentExpression(this, $it -> ((Payment)$it).updateCustomerOrder(customerOrder)); + } + + public Expression getReferenceCode(){ + return loaded("referenceCode", Payment::getReferenceCode); + } + public PaymentExpression updateReferenceCode(String referenceCode){ + return new PaymentExpression(this, $it -> ((Payment)$it).updateReferenceCode(referenceCode)); + } + + public PaymentAttemptListExpression getPaymentAttemptList(){ + return new PaymentAttemptListExpression(loaded("paymentAttemptList", Payment::getPaymentAttemptList)); + } + public PaymentExpression addPaymentAttempt(PaymentAttempt paymentAttempt){ + return new PaymentExpression(this, $it -> ((Payment)$it).addPaymentAttempt(paymentAttempt)); + } +} \ No newline at end of file diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/payment/PaymentListExpression.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/payment/PaymentListExpression.java new file mode 100644 index 00000000..53bd9532 --- /dev/null +++ b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/payment/PaymentListExpression.java @@ -0,0 +1,25 @@ + +package com.teaql.tracechainservice.payment; + +import io.teaql.core.SmartList; +import io.teaql.core.value.Expression; +import io.teaql.core.value.SmartListExpression; +import java.util.function.Function; + +public class PaymentListExpression extends SmartListExpression { + public PaymentListExpression(Expression> expression){ + super(expression); + } + + public PaymentListExpression(Expression expression, Function> function){ + super(expression, function); + } + + public PaymentExpression first() { + return new PaymentExpression(super.first()); + } + + public PaymentExpression get(int index) { + return new PaymentExpression(super.get(index)); + } +} \ No newline at end of file diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/payment/PaymentRequest.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/payment/PaymentRequest.java new file mode 100644 index 00000000..a001c823 --- /dev/null +++ b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/payment/PaymentRequest.java @@ -0,0 +1,672 @@ + +package com.teaql.tracechainservice.payment; + +import com.teaql.tracechainservice.Q; +import com.teaql.tracechainservice.customerorder.CustomerOrder; +import com.teaql.tracechainservice.customerorder.CustomerOrderRequest; +import com.teaql.tracechainservice.paymentattempt.PaymentAttempt; +import com.teaql.tracechainservice.paymentattempt.PaymentAttemptRequest; +import io.teaql.core.AggrFunction; +import io.teaql.core.BaseRequest; +import io.teaql.core.PropertyReference; +import io.teaql.core.SearchCriteria; +import io.teaql.core.SubQuerySearchCriteria; +import io.teaql.core.criteria.Operator; +import io.teaql.core.criteria.TwoOperatorCriteria; + +public class PaymentRequest extends BaseRequest { + + /** + * @deprecated AI agents and business code must use the generated Q facade + * instead of constructing request builders directly. + */ + @Deprecated + @SuppressWarnings("unchecked") + public PaymentRequest(Class returnType){ + super(returnType, () -> (T) new Payment()); + selectId(); + selectVersion(); + } + + public PaymentRequest comment(String comment){ + super.internalComment(comment); + return this; + } + + // purpose() 继承自 BaseRequest,返回 ExecutableRequest(终结方法) + + public PaymentRequest returnType(Class returnType){ + super.setReturnType(returnType); + return this; + } + + public PaymentRequest enableAggregationCache(long cacheExpiredMillis){ + super.enableAggregationCache(); + super.aggregateCacheTime(cacheExpiredMillis); + return this; + } + + public PaymentRequest enableAggregationCache(){ + return enableAggregationCache(0l); + } + + + public PaymentRequest propagateAggregationCache(long cacheExpiredMillis){ + super.propagateAggregationCache(cacheExpiredMillis); + return this; + } + + /** + * Accept best-effort stateful seek optimization for browsing consecutive pages. + * Do not use this for business processing that must visit every row exactly once. + */ + public PaymentRequest optimizeForContinuousPageFetch(){ + super.optimizeForContinuousPageFetch(); + return this; + } + + public PaymentRequest optimizeForContinuousPageFetch(String namespace, int ttlSeconds){ + super.optimizeForContinuousPageFetch(namespace, ttlSeconds); + return this; + } + + public PaymentRequest optimizePaginationWithIdSet(){ + super.optimizePaginationWithIdSet(); + return this; + } + + public PaymentRequest optimizePaginationWithIdSet( + String namespace, int ttlSeconds, int maxIds){ + super.optimizePaginationWithIdSet(namespace, ttlSeconds, maxIds); + return this; + } + + public PaymentRequest topNProbeParentThreshold(int threshold){ + super.topNProbeParentThreshold(threshold); + return this; + } + + public PaymentRequest appendSearchCriteria(SearchCriteria searchCriteria){ + return (PaymentRequest)super.appendSearchCriteria(searchCriteria); + } + + public PaymentRequest filter(String property1, Operator operator, String property2){ + return appendSearchCriteria(new TwoOperatorCriteria(operator, new PropertyReference(property1), new PropertyReference(property2))); + } + + + public PaymentRequest matchingAnyOf(PaymentRequest payment){ + super.internalMatchAny(payment); + return this; + } + + public PaymentRequest enhanceChildrenIfNeeded(){ + return this; + } + + public PaymentRequest withDeletedRows(){ + super.withDeletedRows(); + return this; + } + + public PaymentRequest deletedRowsOnly(){ + super.deletedRowsOnly(); + return this; + } + + public PaymentRequest selectSelf(){ + super.selectSelf(); + return selectId().selectCustomerOrderIdOnly().selectReferenceCode().selectVersion(); + } + + public PaymentRequest selectSelfFields(){ + return selectSelf(); + } + + public PaymentRequest selectAll(){ + super.selectAll(); + return selectId().selectCustomerOrder().selectReferenceCode().selectVersion(); + } + + public PaymentRequest selectChildren(){ + super.selectAny(); + selectPaymentAttemptList(); + return selectId().selectCustomerOrder().selectReferenceCode().selectVersion(); + } + + + public PaymentRequest selectId(){ + selectProperty(Payment.ID_PROPERTY); + return this; + } + + /** + * fill the id with customized rawSqlSegment, TEAQL uses ({rawSqlSegment} AS id) to fetch id property. + * @param rawSqlSegment customized rawSqlSegment + */ + + + + + public PaymentRequest unselectId(){ + unselectProperty(Payment.ID_PROPERTY); + return this; + } + public PaymentRequest selectCustomerOrderIdOnly(){ + selectProperty(Payment.CUSTOMER_ORDER_PROPERTY); + return this; + } + + public PaymentRequest selectCustomerOrder(){ + return selectCustomerOrderWith(Q.customerOrders().unlimited().selectSelf()); + } + + public PaymentRequest selectCustomerOrderWith(CustomerOrderRequest customerOrder){ + selectProperty(Payment.CUSTOMER_ORDER_PROPERTY); + enhanceRelation(Payment.CUSTOMER_ORDER_PROPERTY, customerOrder); + return this; + } + + public PaymentRequest unselectCustomerOrder(){ + unselectProperty(Payment.CUSTOMER_ORDER_PROPERTY); + return this; + } + public PaymentRequest selectReferenceCode(){ + selectProperty(Payment.REFERENCE_CODE_PROPERTY); + return this; + } + + /** + * fill the referenceCode with customized rawSqlSegment, TEAQL uses ({rawSqlSegment} AS referenceCode) to fetch referenceCode property. + * @param rawSqlSegment customized rawSqlSegment + */ + + + + + public PaymentRequest unselectReferenceCode(){ + unselectProperty(Payment.REFERENCE_CODE_PROPERTY); + return this; + } + public PaymentRequest selectVersion(){ + selectProperty(Payment.VERSION_PROPERTY); + return this; + } + + /** + * fill the version with customized rawSqlSegment, TEAQL uses ({rawSqlSegment} AS version) to fetch version property. + * @param rawSqlSegment customized rawSqlSegment + */ + + + + + public PaymentRequest unselectVersion(){ + unselectProperty(Payment.VERSION_PROPERTY); + return this; + } + public PaymentRequest selectPaymentAttemptList(){ + return selectPaymentAttemptListWith(Q.paymentAttempts().selectSelf()); + } + + public PaymentRequest selectPaymentAttemptListWith(PaymentAttemptRequest paymentAttemptList){ + enhanceRelation(Payment.PAYMENT_ATTEMPT_LIST_PROPERTY, paymentAttemptList); + return this; + } + + public PaymentRequest withId(Operator operator, Object... values){ + return appendSearchCriteria(createIdCriteria(operator, values)); + } + + public SearchCriteria createIdCriteria(Operator operator, Object... values) { + return createBasicSearchCriteria(Payment.ID_PROPERTY, operator, values); + } + + public PaymentRequest withIdIsNot(Long id){ + return withId(Operator.NOT_EQUAL, id); + } + + public PaymentRequest withIdIn(Long... id){ + return withId(Operator.IN, (Object[])id); + } + + public PaymentRequest withIdNotIn(Long... id){ + return withId(Operator.NOT_IN, (Object[])id); + } + public PaymentRequest withIdIs(Long id){ + return withId(Operator.EQUAL, id); + } + + + + public PaymentRequest filterByCustomerOrder(CustomerOrder... customerOrder){ + if (customerOrder == null || customerOrder.length == 0) { + throw new IllegalArgumentException("filterByCustomerOrder parameter customerOrder cannot be empty"); + } + return appendSearchCriteria(createCustomerOrderCriteria(Operator.EQUAL, (Object[])customerOrder)); + } + + public PaymentRequest withCustomerOrder(Operator operator, Object... values){ + return appendSearchCriteria(createCustomerOrderCriteria(operator, values)); + } + + public PaymentRequest withCustomerOrderIsUnknown(){ + return withCustomerOrder(Operator.IS_NULL); + } + + public PaymentRequest withCustomerOrderIsKnown(){ + return withCustomerOrder(Operator.IS_NOT_NULL); + } + + public SearchCriteria createCustomerOrderCriteria(Operator operator, Object... values) { + return createBasicSearchCriteria(Payment.CUSTOMER_ORDER_PROPERTY, operator, values); + } + + public PaymentRequest filterByCustomerOrder(Long customerOrder){ + if(customerOrder == null){ + return this; + } + return withCustomerOrder(Operator.EQUAL, customerOrder); + } + public PaymentRequest withCustomerOrderMatching(CustomerOrderRequest customerOrder){ + return appendSearchCriteria(new SubQuerySearchCriteria(Payment.CUSTOMER_ORDER_PROPERTY, customerOrder, CustomerOrder.ID_PROPERTY)); + } + + public PaymentRequest withoutCustomerOrderMatching(CustomerOrderRequest customerOrder){ + return appendSearchCriteria(SearchCriteria.not( + new SubQuerySearchCriteria(Payment.CUSTOMER_ORDER_PROPERTY, customerOrder, CustomerOrder.ID_PROPERTY))); + } + + public PaymentRequest filterByReferenceCode(String... referenceCode){ + if (referenceCode == null || referenceCode.length == 0) { + throw new IllegalArgumentException("filterByReferenceCode parameter referenceCode cannot be empty"); + } + return appendSearchCriteria(createReferenceCodeCriteria(Operator.EQUAL, (Object[])referenceCode)); + } + + public PaymentRequest withReferenceCode(Operator operator, Object... values){ + return appendSearchCriteria(createReferenceCodeCriteria(operator, values)); + } + + public PaymentRequest withReferenceCodeIsUnknown(){ + return withReferenceCode(Operator.IS_NULL); + } + + public PaymentRequest withReferenceCodeIsKnown(){ + return withReferenceCode(Operator.IS_NOT_NULL); + } + + public SearchCriteria createReferenceCodeCriteria(Operator operator, Object... values) { + return createBasicSearchCriteria(Payment.REFERENCE_CODE_PROPERTY, operator, values); + } + + public PaymentRequest withReferenceCodeIsNot(String referenceCode){ + return withReferenceCode(Operator.NOT_EQUAL, referenceCode); + } + + public PaymentRequest withReferenceCodeIn(String... referenceCode){ + return withReferenceCode(Operator.IN, (Object[])referenceCode); + } + + public PaymentRequest withReferenceCodeNotIn(String... referenceCode){ + return withReferenceCode(Operator.NOT_IN, (Object[])referenceCode); + } + public PaymentRequest withReferenceCodeGreaterThan(String referenceCode){ + return withReferenceCode(Operator.GREATER_THAN, referenceCode); + } + + public PaymentRequest withReferenceCodeGreaterThanOrEqualTo(String referenceCode){ + return withReferenceCode(Operator.GREATER_THAN_OR_EQUAL, referenceCode); + } + + public PaymentRequest withReferenceCodeLessThan(String referenceCode){ + return withReferenceCode(Operator.LESS_THAN, referenceCode); + } + + public PaymentRequest withReferenceCodeLessThanOrEqualTo(String referenceCode){ + return withReferenceCode(Operator.LESS_THAN_OR_EQUAL, referenceCode); + } + + public PaymentRequest withReferenceCodeBetween(String startOfReferenceCode, String endOfReferenceCode){ + return withReferenceCode(Operator.BETWEEN, startOfReferenceCode, endOfReferenceCode); + } + public PaymentRequest withReferenceCodeStartingWith(String referenceCode){ + return withReferenceCode(Operator.BEGIN_WITH, referenceCode); + } + public PaymentRequest withReferenceCodeContaining(String referenceCode){ + return withReferenceCode(Operator.CONTAIN, referenceCode); + } + + public PaymentRequest withReferenceCodeNotContaining(String referenceCode){ + return withReferenceCode(Operator.NOT_CONTAIN, referenceCode); + } + + public PaymentRequest withReferenceCodeNotStartingWith(String referenceCode){ + return withReferenceCode(Operator.NOT_BEGIN_WITH, referenceCode); + } + + public PaymentRequest withReferenceCodeEndingWith(String referenceCode){ + return withReferenceCode(Operator.END_WITH, referenceCode); + } + + public PaymentRequest withReferenceCodeNotEndingWith(String referenceCode){ + return withReferenceCode(Operator.NOT_END_WITH, referenceCode); + } + + public PaymentRequest withReferenceCodeIs(String referenceCode){ + return withReferenceCode(Operator.EQUAL, referenceCode); + } + + public PaymentRequest withReferenceCodeSoundingLike(String referenceCode){ + return withReferenceCode(Operator.SOUNDS_LIKE, referenceCode); + } + + + + public PaymentRequest filterByVersion(Long... version){ + if (version == null || version.length == 0) { + throw new IllegalArgumentException("filterByVersion parameter version cannot be empty"); + } + return appendSearchCriteria(createVersionCriteria(Operator.EQUAL, (Object[])version)); + } + + public PaymentRequest withVersion(Operator operator, Object... values){ + return appendSearchCriteria(createVersionCriteria(operator, values)); + } + + public PaymentRequest withVersionIsUnknown(){ + return withVersion(Operator.IS_NULL); + } + + public PaymentRequest withVersionIsKnown(){ + return withVersion(Operator.IS_NOT_NULL); + } + + public SearchCriteria createVersionCriteria(Operator operator, Object... values) { + return createBasicSearchCriteria(Payment.VERSION_PROPERTY, operator, values); + } + + public PaymentRequest withVersionIs(Long version){ + return withVersion(Operator.EQUAL, version); + } + + public PaymentRequest withVersionIsNot(Long version){ + return withVersion(Operator.NOT_EQUAL, version); + } + + public PaymentRequest withVersionIn(Long... version){ + return withVersion(Operator.IN, (Object[])version); + } + + public PaymentRequest withVersionNotIn(Long... version){ + return withVersion(Operator.NOT_IN, (Object[])version); + } + public PaymentRequest withVersionGreaterThan(Long version){ + return withVersion(Operator.GREATER_THAN, version); + } + + public PaymentRequest withVersionGreaterThanOrEqualTo(Long version){ + return withVersion(Operator.GREATER_THAN_OR_EQUAL, version); + } + + public PaymentRequest withVersionLessThan(Long version){ + return withVersion(Operator.LESS_THAN, version); + } + + public PaymentRequest withVersionLessThanOrEqualTo(Long version){ + return withVersion(Operator.LESS_THAN_OR_EQUAL, version); + } + + public PaymentRequest withVersionBetween(Long startOfVersion, Long endOfVersion){ + return withVersion(Operator.BETWEEN, startOfVersion, endOfVersion); + } + + + public PaymentRequest withPaymentAttemptListMatching(PaymentAttemptRequest paymentAttemptRequest){ + return appendSearchCriteria(new SubQuerySearchCriteria(Payment.ID_PROPERTY, paymentAttemptRequest, PaymentAttempt.PAYMENT_PROPERTY)); + } + + public PaymentRequest withoutPaymentAttemptListMatching(PaymentAttemptRequest paymentAttemptRequest){ + return appendSearchCriteria(SearchCriteria.not(new SubQuerySearchCriteria(Payment.ID_PROPERTY, paymentAttemptRequest, PaymentAttempt.PAYMENT_PROPERTY))); + } + + public PaymentRequest havePaymentAttempts(){ + return withPaymentAttemptListMatching(Q.paymentAttempts().unlimited()); + } + + public PaymentRequest haveNoPaymentAttempts(){ + return withoutPaymentAttemptListMatching(Q.paymentAttempts().unlimited()); + } + + public PaymentRequest count(){ + super.count(); + return this; + } + public PaymentRequest countAs(String retName){ + super.count(retName); + return this; + } + public PaymentRequest groupByCustomerOrderWithDetails(){ + return groupByCustomerOrderWithDetails(Q.customerOrders().unlimited()); + } + + public PaymentRequest groupByCustomerOrderWithDetails(CustomerOrderRequest subRequest){ + aggregate(Payment.CUSTOMER_ORDER_PROPERTY, subRequest); + return this; + } + + + + public PaymentRequest groupByPaymentAttemptsWithDetails(PaymentAttemptRequest subRequest){ + aggregate(Payment.PAYMENT_ATTEMPT_LIST_PROPERTY, subRequest); + return this; + } + + public PaymentRequest groupById(){ + groupBy(Payment.ID_PROPERTY); + return this; + } + + public PaymentRequest groupByIdAs(String retName){ + groupBy(retName, Payment.ID_PROPERTY); + return this; + } + + public PaymentRequest groupByIdWithFunction(String retName, AggrFunction function){ + groupBy(retName, Payment.ID_PROPERTY, function); + return this; + } + public PaymentRequest groupByCustomerOrderWith(CustomerOrderRequest subRequest){ + groupBy(Payment.CUSTOMER_ORDER_PROPERTY, subRequest); + return this; + } + public PaymentRequest groupByCustomerOrder(){ + groupBy(Payment.CUSTOMER_ORDER_PROPERTY); + return this; + } + + public PaymentRequest groupByCustomerOrderAs(String retName){ + groupBy(retName, Payment.CUSTOMER_ORDER_PROPERTY); + return this; + } + + public PaymentRequest groupByCustomerOrderWithFunction(String retName, AggrFunction function){ + groupBy(retName, Payment.CUSTOMER_ORDER_PROPERTY, function); + return this; + } + + public PaymentRequest groupByReferenceCode(){ + groupBy(Payment.REFERENCE_CODE_PROPERTY); + return this; + } + + public PaymentRequest groupByReferenceCodeAs(String retName){ + groupBy(retName, Payment.REFERENCE_CODE_PROPERTY); + return this; + } + + public PaymentRequest groupByReferenceCodeWithFunction(String retName, AggrFunction function){ + groupBy(retName, Payment.REFERENCE_CODE_PROPERTY, function); + return this; + } + + public PaymentRequest groupByVersion(){ + groupBy(Payment.VERSION_PROPERTY); + return this; + } + + public PaymentRequest groupByVersionAs(String retName){ + groupBy(retName, Payment.VERSION_PROPERTY); + return this; + } + + public PaymentRequest groupByVersionWithFunction(String retName, AggrFunction function){ + groupBy(retName, Payment.VERSION_PROPERTY, function); + return this; + } + + + + public PaymentRequest orderByIdAscending(){ + addOrderByAscending(Payment.ID_PROPERTY); + return this; + } + + public PaymentRequest orderByIdDescending(){ + addOrderByDescending(Payment.ID_PROPERTY); + return this; + } + + public PaymentRequest orderByCustomerOrderAscending(){ + addOrderByAscending(Payment.CUSTOMER_ORDER_PROPERTY); + return this; + } + + public PaymentRequest orderByCustomerOrderDescending(){ + addOrderByDescending(Payment.CUSTOMER_ORDER_PROPERTY); + return this; + } + + public PaymentRequest orderByReferenceCodeAscending(){ + addOrderByAscending(Payment.REFERENCE_CODE_PROPERTY); + return this; + } + + public PaymentRequest orderByReferenceCodeDescending(){ + addOrderByDescending(Payment.REFERENCE_CODE_PROPERTY); + return this; + } + public PaymentRequest orderByReferenceCodeAscendingUsingGBK(){ + addOrderByAscendingUsingGBK(Payment.REFERENCE_CODE_PROPERTY); + return this; + } + + public PaymentRequest orderByReferenceCodeDescendingUsingGBK(){ + addOrderByDescendingUsingGBK(Payment.REFERENCE_CODE_PROPERTY); + return this; + } + public PaymentRequest orderByVersionAscending(){ + addOrderByAscending(Payment.VERSION_PROPERTY); + return this; + } + + public PaymentRequest orderByVersionDescending(){ + addOrderByDescending(Payment.VERSION_PROPERTY); + return this; + } + + + public PaymentRequest statsFromPaymentAttemptsAs(String name, PaymentAttemptRequest subRequest){ + return statsFromPaymentAttemptsAs(name, subRequest, false); + } + + public PaymentRequest statsFromPaymentAttemptsAs(String name, PaymentAttemptRequest subRequest, boolean singleResult){ + subRequest.setPartitionProperty(PaymentAttempt.PAYMENT_PROPERTY); + addAggregateDynamicProperty(name, subRequest, singleResult); + return this; + } + + public PaymentRequest statsFromPaymentAttempts(PaymentAttemptRequest subRequest){ + return statsFromPaymentAttemptsAs(REFINEMENTS, subRequest); + } + public CustomerOrderRequest rollUpToCustomerOrder(){ + CustomerOrderRequest customerOrder = Q.customerOrders().unlimited(); + this.withCustomerOrderMatching(customerOrder) + .groupByCustomerOrderWith(customerOrder); + return customerOrder; + } + + + + public PaymentRequest countPaymentAttempts(){ + return countPaymentAttemptsAs("Count"); + } + + public PaymentRequest countPaymentAttemptsAs(String name){ + return countPaymentAttemptsWith(name, Q.paymentAttempts().unlimited()); + } + + public PaymentRequest countPaymentAttemptsWith(String name, PaymentAttemptRequest subRequest){ + return statsFromPaymentAttemptsAs(name, subRequest.count(), true); + } + + public PaymentRequest facetByCustomerOrderAs(String facetName, CustomerOrderRequest customerOrder){ + return facetByCustomerOrderAs(facetName, customerOrder, true); + } + + public PaymentRequest facetByCustomerOrderAs(String facetName, CustomerOrderRequest customerOrder, boolean includeAllFacets){ + addFacet(facetName, Payment.CUSTOMER_ORDER_PROPERTY, customerOrder, includeAllFacets); + return this; + } + + + /** + * get topN records + * @param topN records number + */ + public PaymentRequest top(int topN) { + super.top(topN); + return this; + } + + /** Cross-runtime bounded-query alias. */ + public PaymentRequest limit(int limit) { + return top(limit); + } + + /** + * get records from offset(inclusive) to offset+size(exclusive) + * @param offset record offset + * @param size records number + */ + public PaymentRequest offset(int offset, int size) { + super.offset(offset, size); + return this; + } + + /** + * retrieve all records + */ + public PaymentRequest unlimited() { + super.unlimited(); + return this; + } + + /** + * get records of one page + * @param pageNumber page number(1-based) + * @param pageSize page size + */ + public PaymentRequest page(int pageNumber, int pageSize) { + int offset = (pageNumber - 1) * pageSize; + return offset(offset, pageSize); + } + + /** + * get records of one page, default page size is 10 + * @param pageNumber page number(1-based) + */ + public PaymentRequest page(int pageNumber) { + return page(pageNumber, 10); + } +} \ No newline at end of file diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/paymentattempt/PaymentAttempt.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/paymentattempt/PaymentAttempt.java new file mode 100644 index 00000000..0f837212 --- /dev/null +++ b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/paymentattempt/PaymentAttempt.java @@ -0,0 +1,105 @@ + +package com.teaql.tracechainservice.paymentattempt; + +import com.teaql.tracechainservice.payment.Payment; +import io.teaql.core.Audited; +import io.teaql.core.BaseEntity; +import io.teaql.core.EntityStatus; +import io.teaql.core.FrameworkInternal; +import io.teaql.core.RemoteInput; +import java.util.Objects; + +/** + * [TEAQL AI WARNING] + * TeaQL was explicitly designed to PREVENT AI hallucinations and random guessing. + * DO NOT GUESS METHOD NAMES! + * The methods listed below are the ONLY valid ways to interact with this entity. + * If you encounter compilation errors (e.g., method not found), DO NOT guess another method name. + * Read the method signatures in this file before proceeding. + */ +public class PaymentAttempt extends BaseEntity implements RemoteInput { + public static String INTERNAL_TYPE = "PaymentAttempt"; + + + public static final String PAYMENT_PROPERTY = "payment"; + public static final String REFERENCE_CODE_PROPERTY = "referenceCode"; + private Payment payment; + + private String referenceCode; + + + public Payment getPayment(){ + return this.payment; + } + + public String getReferenceCode(){ + return this.referenceCode; + } + + public PaymentAttempt updatePayment(Payment payment){ + if(Objects.equals(this.payment, payment)){ + return this; + } + handleUpdate(PAYMENT_PROPERTY, getPayment(), payment); + this.payment = payment; + return this; + } + + public PaymentAttempt updateReferenceCode(String referenceCode){ + referenceCode = (referenceCode == null ? null : referenceCode.trim()); + if(Objects.equals(this.referenceCode, referenceCode)){ + return this; + } + handleUpdate(REFERENCE_CODE_PROPERTY, getReferenceCode(), referenceCode); + this.referenceCode = referenceCode; + return this; + } + + + public static PaymentAttempt refer(Long id){ + PaymentAttempt refer = new PaymentAttempt(); + refer.__internalSet("id", id); + refer.set$status(EntityStatus.REFER); + return refer; + } + @Override + public String typeName(){ + return INTERNAL_TYPE; + } + + public PaymentAttempt comment(String comment){ + this.setComment(comment); + return this; + } + + @Override + @SuppressWarnings("unchecked") + public Audited auditAs(String action) { + return super.auditAs(action); + } + + // ===== Framework Internal: generated switch dispatch ===== + @Override + @FrameworkInternal + public void __internalSet(String property, Object value) { + markPropertyLoaded(property); + switch (property) { + case "payment": this.payment = (Payment) value; break; + + case "referenceCode": this.referenceCode = (value == null ? null : ((String)value).trim()); break; + + default: super.__internalSet(property, value); + } + } + + @Override + @FrameworkInternal + public Object __internalGet(String property) { + switch (property) { + case "payment": return this.payment; + case "referenceCode": return this.referenceCode; + default: return super.__internalGet(property); + } + } + +} \ No newline at end of file diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/paymentattempt/PaymentAttemptChecker.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/paymentattempt/PaymentAttemptChecker.java new file mode 100644 index 00000000..9807decc --- /dev/null +++ b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/paymentattempt/PaymentAttemptChecker.java @@ -0,0 +1,56 @@ + +package com.teaql.tracechainservice.paymentattempt; + +import com.teaql.tracechainservice.payment.Payment; +import com.teaql.tracechainservice.payment.PaymentChecker; +import io.teaql.core.UserContext; +import io.teaql.core.checker.Checker; +import io.teaql.core.checker.ObjectLocation; + +public class PaymentAttemptChecker implements Checker{ + + public String type(){ + return PaymentAttempt.INTERNAL_TYPE; + } + + public void checkAndFix(UserContext _context, PaymentAttempt paymentAttempt, ObjectLocation _parentLocation){ + if(needCheck(_context, paymentAttempt)){ + markAsChecked(_context, paymentAttempt); + doCheck(_context, paymentAttempt, _parentLocation); + } + } + + public void doCheck(UserContext _context, PaymentAttempt paymentAttempt, ObjectLocation _parentLocation){ + if((paymentAttempt == null)){ + return; + } + if(paymentAttempt.newItem()){ + }else if(paymentAttempt.updateItem()){ + if(!paymentAttempt.isPropertyLoaded("payment")){ + invalidTypeCheck(_context, newLocation(_parentLocation, "payment"), "Mutation requires a fully loaded entity"); + } + if(!paymentAttempt.isPropertyLoaded("referenceCode")){ + invalidTypeCheck(_context, newLocation(_parentLocation, "reference_code"), "Mutation requires a fully loaded entity"); + } + + } + checkPayment(_context, paymentAttempt.getProperty(PaymentAttempt.PAYMENT_PROPERTY), newLocation(_parentLocation, "payment")); + checkReferenceCode(_context, paymentAttempt.getProperty(PaymentAttempt.REFERENCE_CODE_PROPERTY), newLocation(_parentLocation, "reference_code")); + } + + public void checkPayment(UserContext _context, Payment payment, ObjectLocation _parentLocation){ + requiredCheck(_context, _parentLocation, payment); + if((payment == null)){ + return; + } + new PaymentChecker().checkAndFix(_context, payment, _parentLocation); + } + public void checkReferenceCode(UserContext _context, String referenceCode, ObjectLocation _parentLocation){ + requiredCheck(_context, _parentLocation, referenceCode); + if((referenceCode == null)){ + return; + } + maxStringCheck(_context, _parentLocation, 100, referenceCode); + + } +} \ No newline at end of file diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/paymentattempt/PaymentAttemptExpression.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/paymentattempt/PaymentAttemptExpression.java new file mode 100644 index 00000000..0739623e --- /dev/null +++ b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/paymentattempt/PaymentAttemptExpression.java @@ -0,0 +1,53 @@ + +package com.teaql.tracechainservice.paymentattempt; + +import com.teaql.tracechainservice.payment.Payment; +import com.teaql.tracechainservice.payment.PaymentExpression; +import io.teaql.core.UserContext; +import io.teaql.core.value.BaseEntityExpression; +import io.teaql.core.value.Expression; +import io.teaql.core.value.ExpressionAdaptor; +import java.util.function.Function; + +public class PaymentAttemptExpression extends ExpressionAdaptor implements BaseEntityExpression { + public PaymentAttemptExpression(Expression expression){ + super(expression); + } + + public PaymentAttemptExpression(Expression expression, Function function){ + super(expression, function); + } + + public PaymentAttemptExpression updateId(Long id){ + return new PaymentAttemptExpression(this, $it -> {((PaymentAttempt)$it).__internalSet("id", id); return this;}); + } + + public PaymentAttemptExpression save(UserContext userContext){ + return new PaymentAttemptExpression(this, $it -> ((PaymentAttempt)$it).auditAs("Saved by Expression").save(userContext)); + } + + public PaymentAttemptExpression save(String intent, UserContext userContext){ + return new PaymentAttemptExpression(this, $it -> ((PaymentAttempt)$it).auditAs(intent).save(userContext)); + } + + public boolean isNull() { + return resolve() == null; + } + + + public PaymentExpression getPayment(){ + return new PaymentExpression(loaded("payment", PaymentAttempt::getPayment)); + } + + public PaymentAttemptExpression updatePayment(Payment payment){ + return new PaymentAttemptExpression(this, $it -> ((PaymentAttempt)$it).updatePayment(payment)); + } + + public Expression getReferenceCode(){ + return loaded("referenceCode", PaymentAttempt::getReferenceCode); + } + public PaymentAttemptExpression updateReferenceCode(String referenceCode){ + return new PaymentAttemptExpression(this, $it -> ((PaymentAttempt)$it).updateReferenceCode(referenceCode)); + } + +} \ No newline at end of file diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/paymentattempt/PaymentAttemptListExpression.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/paymentattempt/PaymentAttemptListExpression.java new file mode 100644 index 00000000..6178260e --- /dev/null +++ b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/paymentattempt/PaymentAttemptListExpression.java @@ -0,0 +1,25 @@ + +package com.teaql.tracechainservice.paymentattempt; + +import io.teaql.core.SmartList; +import io.teaql.core.value.Expression; +import io.teaql.core.value.SmartListExpression; +import java.util.function.Function; + +public class PaymentAttemptListExpression extends SmartListExpression { + public PaymentAttemptListExpression(Expression> expression){ + super(expression); + } + + public PaymentAttemptListExpression(Expression expression, Function> function){ + super(expression, function); + } + + public PaymentAttemptExpression first() { + return new PaymentAttemptExpression(super.first()); + } + + public PaymentAttemptExpression get(int index) { + return new PaymentAttemptExpression(super.get(index)); + } +} \ No newline at end of file diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/paymentattempt/PaymentAttemptRequest.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/paymentattempt/PaymentAttemptRequest.java new file mode 100644 index 00000000..b4408701 --- /dev/null +++ b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/paymentattempt/PaymentAttemptRequest.java @@ -0,0 +1,618 @@ + +package com.teaql.tracechainservice.paymentattempt; + +import com.teaql.tracechainservice.Q; +import com.teaql.tracechainservice.payment.Payment; +import com.teaql.tracechainservice.payment.PaymentRequest; +import io.teaql.core.AggrFunction; +import io.teaql.core.BaseRequest; +import io.teaql.core.PropertyReference; +import io.teaql.core.SearchCriteria; +import io.teaql.core.SubQuerySearchCriteria; +import io.teaql.core.criteria.Operator; +import io.teaql.core.criteria.TwoOperatorCriteria; + +public class PaymentAttemptRequest extends BaseRequest { + + /** + * @deprecated AI agents and business code must use the generated Q facade + * instead of constructing request builders directly. + */ + @Deprecated + @SuppressWarnings("unchecked") + public PaymentAttemptRequest(Class returnType){ + super(returnType, () -> (T) new PaymentAttempt()); + selectId(); + selectVersion(); + } + + public PaymentAttemptRequest comment(String comment){ + super.internalComment(comment); + return this; + } + + // purpose() 继承自 BaseRequest,返回 ExecutableRequest(终结方法) + + public PaymentAttemptRequest returnType(Class returnType){ + super.setReturnType(returnType); + return this; + } + + public PaymentAttemptRequest enableAggregationCache(long cacheExpiredMillis){ + super.enableAggregationCache(); + super.aggregateCacheTime(cacheExpiredMillis); + return this; + } + + public PaymentAttemptRequest enableAggregationCache(){ + return enableAggregationCache(0l); + } + + + public PaymentAttemptRequest propagateAggregationCache(long cacheExpiredMillis){ + super.propagateAggregationCache(cacheExpiredMillis); + return this; + } + + /** + * Accept best-effort stateful seek optimization for browsing consecutive pages. + * Do not use this for business processing that must visit every row exactly once. + */ + public PaymentAttemptRequest optimizeForContinuousPageFetch(){ + super.optimizeForContinuousPageFetch(); + return this; + } + + public PaymentAttemptRequest optimizeForContinuousPageFetch(String namespace, int ttlSeconds){ + super.optimizeForContinuousPageFetch(namespace, ttlSeconds); + return this; + } + + public PaymentAttemptRequest optimizePaginationWithIdSet(){ + super.optimizePaginationWithIdSet(); + return this; + } + + public PaymentAttemptRequest optimizePaginationWithIdSet( + String namespace, int ttlSeconds, int maxIds){ + super.optimizePaginationWithIdSet(namespace, ttlSeconds, maxIds); + return this; + } + + public PaymentAttemptRequest topNProbeParentThreshold(int threshold){ + super.topNProbeParentThreshold(threshold); + return this; + } + + public PaymentAttemptRequest appendSearchCriteria(SearchCriteria searchCriteria){ + return (PaymentAttemptRequest)super.appendSearchCriteria(searchCriteria); + } + + public PaymentAttemptRequest filter(String property1, Operator operator, String property2){ + return appendSearchCriteria(new TwoOperatorCriteria(operator, new PropertyReference(property1), new PropertyReference(property2))); + } + + + public PaymentAttemptRequest matchingAnyOf(PaymentAttemptRequest paymentAttempt){ + super.internalMatchAny(paymentAttempt); + return this; + } + + public PaymentAttemptRequest enhanceChildrenIfNeeded(){ + return this; + } + + public PaymentAttemptRequest withDeletedRows(){ + super.withDeletedRows(); + return this; + } + + public PaymentAttemptRequest deletedRowsOnly(){ + super.deletedRowsOnly(); + return this; + } + + public PaymentAttemptRequest selectSelf(){ + super.selectSelf(); + return selectId().selectPaymentIdOnly().selectReferenceCode().selectVersion(); + } + + public PaymentAttemptRequest selectSelfFields(){ + return selectSelf(); + } + + public PaymentAttemptRequest selectAll(){ + super.selectAll(); + return selectId().selectPayment().selectReferenceCode().selectVersion(); + } + + public PaymentAttemptRequest selectChildren(){ + super.selectAny(); + return selectId().selectPayment().selectReferenceCode().selectVersion(); + } + + + public PaymentAttemptRequest selectId(){ + selectProperty(PaymentAttempt.ID_PROPERTY); + return this; + } + + /** + * fill the id with customized rawSqlSegment, TEAQL uses ({rawSqlSegment} AS id) to fetch id property. + * @param rawSqlSegment customized rawSqlSegment + */ + + + + + public PaymentAttemptRequest unselectId(){ + unselectProperty(PaymentAttempt.ID_PROPERTY); + return this; + } + public PaymentAttemptRequest selectPaymentIdOnly(){ + selectProperty(PaymentAttempt.PAYMENT_PROPERTY); + return this; + } + + public PaymentAttemptRequest selectPayment(){ + return selectPaymentWith(Q.payments().unlimited().selectSelf()); + } + + public PaymentAttemptRequest selectPaymentWith(PaymentRequest payment){ + selectProperty(PaymentAttempt.PAYMENT_PROPERTY); + enhanceRelation(PaymentAttempt.PAYMENT_PROPERTY, payment); + return this; + } + + public PaymentAttemptRequest unselectPayment(){ + unselectProperty(PaymentAttempt.PAYMENT_PROPERTY); + return this; + } + public PaymentAttemptRequest selectReferenceCode(){ + selectProperty(PaymentAttempt.REFERENCE_CODE_PROPERTY); + return this; + } + + /** + * fill the referenceCode with customized rawSqlSegment, TEAQL uses ({rawSqlSegment} AS referenceCode) to fetch referenceCode property. + * @param rawSqlSegment customized rawSqlSegment + */ + + + + + public PaymentAttemptRequest unselectReferenceCode(){ + unselectProperty(PaymentAttempt.REFERENCE_CODE_PROPERTY); + return this; + } + public PaymentAttemptRequest selectVersion(){ + selectProperty(PaymentAttempt.VERSION_PROPERTY); + return this; + } + + /** + * fill the version with customized rawSqlSegment, TEAQL uses ({rawSqlSegment} AS version) to fetch version property. + * @param rawSqlSegment customized rawSqlSegment + */ + + + + + public PaymentAttemptRequest unselectVersion(){ + unselectProperty(PaymentAttempt.VERSION_PROPERTY); + return this; + } + + public PaymentAttemptRequest withId(Operator operator, Object... values){ + return appendSearchCriteria(createIdCriteria(operator, values)); + } + + public SearchCriteria createIdCriteria(Operator operator, Object... values) { + return createBasicSearchCriteria(PaymentAttempt.ID_PROPERTY, operator, values); + } + + public PaymentAttemptRequest withIdIsNot(Long id){ + return withId(Operator.NOT_EQUAL, id); + } + + public PaymentAttemptRequest withIdIn(Long... id){ + return withId(Operator.IN, (Object[])id); + } + + public PaymentAttemptRequest withIdNotIn(Long... id){ + return withId(Operator.NOT_IN, (Object[])id); + } + public PaymentAttemptRequest withIdIs(Long id){ + return withId(Operator.EQUAL, id); + } + + + + public PaymentAttemptRequest filterByPayment(Payment... payment){ + if (payment == null || payment.length == 0) { + throw new IllegalArgumentException("filterByPayment parameter payment cannot be empty"); + } + return appendSearchCriteria(createPaymentCriteria(Operator.EQUAL, (Object[])payment)); + } + + public PaymentAttemptRequest withPayment(Operator operator, Object... values){ + return appendSearchCriteria(createPaymentCriteria(operator, values)); + } + + public PaymentAttemptRequest withPaymentIsUnknown(){ + return withPayment(Operator.IS_NULL); + } + + public PaymentAttemptRequest withPaymentIsKnown(){ + return withPayment(Operator.IS_NOT_NULL); + } + + public SearchCriteria createPaymentCriteria(Operator operator, Object... values) { + return createBasicSearchCriteria(PaymentAttempt.PAYMENT_PROPERTY, operator, values); + } + + public PaymentAttemptRequest filterByPayment(Long payment){ + if(payment == null){ + return this; + } + return withPayment(Operator.EQUAL, payment); + } + public PaymentAttemptRequest withPaymentMatching(PaymentRequest payment){ + return appendSearchCriteria(new SubQuerySearchCriteria(PaymentAttempt.PAYMENT_PROPERTY, payment, Payment.ID_PROPERTY)); + } + + public PaymentAttemptRequest withoutPaymentMatching(PaymentRequest payment){ + return appendSearchCriteria(SearchCriteria.not( + new SubQuerySearchCriteria(PaymentAttempt.PAYMENT_PROPERTY, payment, Payment.ID_PROPERTY))); + } + + public PaymentAttemptRequest filterByReferenceCode(String... referenceCode){ + if (referenceCode == null || referenceCode.length == 0) { + throw new IllegalArgumentException("filterByReferenceCode parameter referenceCode cannot be empty"); + } + return appendSearchCriteria(createReferenceCodeCriteria(Operator.EQUAL, (Object[])referenceCode)); + } + + public PaymentAttemptRequest withReferenceCode(Operator operator, Object... values){ + return appendSearchCriteria(createReferenceCodeCriteria(operator, values)); + } + + public PaymentAttemptRequest withReferenceCodeIsUnknown(){ + return withReferenceCode(Operator.IS_NULL); + } + + public PaymentAttemptRequest withReferenceCodeIsKnown(){ + return withReferenceCode(Operator.IS_NOT_NULL); + } + + public SearchCriteria createReferenceCodeCriteria(Operator operator, Object... values) { + return createBasicSearchCriteria(PaymentAttempt.REFERENCE_CODE_PROPERTY, operator, values); + } + + public PaymentAttemptRequest withReferenceCodeIsNot(String referenceCode){ + return withReferenceCode(Operator.NOT_EQUAL, referenceCode); + } + + public PaymentAttemptRequest withReferenceCodeIn(String... referenceCode){ + return withReferenceCode(Operator.IN, (Object[])referenceCode); + } + + public PaymentAttemptRequest withReferenceCodeNotIn(String... referenceCode){ + return withReferenceCode(Operator.NOT_IN, (Object[])referenceCode); + } + public PaymentAttemptRequest withReferenceCodeGreaterThan(String referenceCode){ + return withReferenceCode(Operator.GREATER_THAN, referenceCode); + } + + public PaymentAttemptRequest withReferenceCodeGreaterThanOrEqualTo(String referenceCode){ + return withReferenceCode(Operator.GREATER_THAN_OR_EQUAL, referenceCode); + } + + public PaymentAttemptRequest withReferenceCodeLessThan(String referenceCode){ + return withReferenceCode(Operator.LESS_THAN, referenceCode); + } + + public PaymentAttemptRequest withReferenceCodeLessThanOrEqualTo(String referenceCode){ + return withReferenceCode(Operator.LESS_THAN_OR_EQUAL, referenceCode); + } + + public PaymentAttemptRequest withReferenceCodeBetween(String startOfReferenceCode, String endOfReferenceCode){ + return withReferenceCode(Operator.BETWEEN, startOfReferenceCode, endOfReferenceCode); + } + public PaymentAttemptRequest withReferenceCodeStartingWith(String referenceCode){ + return withReferenceCode(Operator.BEGIN_WITH, referenceCode); + } + public PaymentAttemptRequest withReferenceCodeContaining(String referenceCode){ + return withReferenceCode(Operator.CONTAIN, referenceCode); + } + + public PaymentAttemptRequest withReferenceCodeNotContaining(String referenceCode){ + return withReferenceCode(Operator.NOT_CONTAIN, referenceCode); + } + + public PaymentAttemptRequest withReferenceCodeNotStartingWith(String referenceCode){ + return withReferenceCode(Operator.NOT_BEGIN_WITH, referenceCode); + } + + public PaymentAttemptRequest withReferenceCodeEndingWith(String referenceCode){ + return withReferenceCode(Operator.END_WITH, referenceCode); + } + + public PaymentAttemptRequest withReferenceCodeNotEndingWith(String referenceCode){ + return withReferenceCode(Operator.NOT_END_WITH, referenceCode); + } + + public PaymentAttemptRequest withReferenceCodeIs(String referenceCode){ + return withReferenceCode(Operator.EQUAL, referenceCode); + } + + public PaymentAttemptRequest withReferenceCodeSoundingLike(String referenceCode){ + return withReferenceCode(Operator.SOUNDS_LIKE, referenceCode); + } + + + + public PaymentAttemptRequest filterByVersion(Long... version){ + if (version == null || version.length == 0) { + throw new IllegalArgumentException("filterByVersion parameter version cannot be empty"); + } + return appendSearchCriteria(createVersionCriteria(Operator.EQUAL, (Object[])version)); + } + + public PaymentAttemptRequest withVersion(Operator operator, Object... values){ + return appendSearchCriteria(createVersionCriteria(operator, values)); + } + + public PaymentAttemptRequest withVersionIsUnknown(){ + return withVersion(Operator.IS_NULL); + } + + public PaymentAttemptRequest withVersionIsKnown(){ + return withVersion(Operator.IS_NOT_NULL); + } + + public SearchCriteria createVersionCriteria(Operator operator, Object... values) { + return createBasicSearchCriteria(PaymentAttempt.VERSION_PROPERTY, operator, values); + } + + public PaymentAttemptRequest withVersionIs(Long version){ + return withVersion(Operator.EQUAL, version); + } + + public PaymentAttemptRequest withVersionIsNot(Long version){ + return withVersion(Operator.NOT_EQUAL, version); + } + + public PaymentAttemptRequest withVersionIn(Long... version){ + return withVersion(Operator.IN, (Object[])version); + } + + public PaymentAttemptRequest withVersionNotIn(Long... version){ + return withVersion(Operator.NOT_IN, (Object[])version); + } + public PaymentAttemptRequest withVersionGreaterThan(Long version){ + return withVersion(Operator.GREATER_THAN, version); + } + + public PaymentAttemptRequest withVersionGreaterThanOrEqualTo(Long version){ + return withVersion(Operator.GREATER_THAN_OR_EQUAL, version); + } + + public PaymentAttemptRequest withVersionLessThan(Long version){ + return withVersion(Operator.LESS_THAN, version); + } + + public PaymentAttemptRequest withVersionLessThanOrEqualTo(Long version){ + return withVersion(Operator.LESS_THAN_OR_EQUAL, version); + } + + public PaymentAttemptRequest withVersionBetween(Long startOfVersion, Long endOfVersion){ + return withVersion(Operator.BETWEEN, startOfVersion, endOfVersion); + } + + + + public PaymentAttemptRequest count(){ + super.count(); + return this; + } + public PaymentAttemptRequest countAs(String retName){ + super.count(retName); + return this; + } + public PaymentAttemptRequest groupByPaymentWithDetails(){ + return groupByPaymentWithDetails(Q.payments().unlimited()); + } + + public PaymentAttemptRequest groupByPaymentWithDetails(PaymentRequest subRequest){ + aggregate(PaymentAttempt.PAYMENT_PROPERTY, subRequest); + return this; + } + + + + + public PaymentAttemptRequest groupById(){ + groupBy(PaymentAttempt.ID_PROPERTY); + return this; + } + + public PaymentAttemptRequest groupByIdAs(String retName){ + groupBy(retName, PaymentAttempt.ID_PROPERTY); + return this; + } + + public PaymentAttemptRequest groupByIdWithFunction(String retName, AggrFunction function){ + groupBy(retName, PaymentAttempt.ID_PROPERTY, function); + return this; + } + public PaymentAttemptRequest groupByPaymentWith(PaymentRequest subRequest){ + groupBy(PaymentAttempt.PAYMENT_PROPERTY, subRequest); + return this; + } + public PaymentAttemptRequest groupByPayment(){ + groupBy(PaymentAttempt.PAYMENT_PROPERTY); + return this; + } + + public PaymentAttemptRequest groupByPaymentAs(String retName){ + groupBy(retName, PaymentAttempt.PAYMENT_PROPERTY); + return this; + } + + public PaymentAttemptRequest groupByPaymentWithFunction(String retName, AggrFunction function){ + groupBy(retName, PaymentAttempt.PAYMENT_PROPERTY, function); + return this; + } + + public PaymentAttemptRequest groupByReferenceCode(){ + groupBy(PaymentAttempt.REFERENCE_CODE_PROPERTY); + return this; + } + + public PaymentAttemptRequest groupByReferenceCodeAs(String retName){ + groupBy(retName, PaymentAttempt.REFERENCE_CODE_PROPERTY); + return this; + } + + public PaymentAttemptRequest groupByReferenceCodeWithFunction(String retName, AggrFunction function){ + groupBy(retName, PaymentAttempt.REFERENCE_CODE_PROPERTY, function); + return this; + } + + public PaymentAttemptRequest groupByVersion(){ + groupBy(PaymentAttempt.VERSION_PROPERTY); + return this; + } + + public PaymentAttemptRequest groupByVersionAs(String retName){ + groupBy(retName, PaymentAttempt.VERSION_PROPERTY); + return this; + } + + public PaymentAttemptRequest groupByVersionWithFunction(String retName, AggrFunction function){ + groupBy(retName, PaymentAttempt.VERSION_PROPERTY, function); + return this; + } + + + + public PaymentAttemptRequest orderByIdAscending(){ + addOrderByAscending(PaymentAttempt.ID_PROPERTY); + return this; + } + + public PaymentAttemptRequest orderByIdDescending(){ + addOrderByDescending(PaymentAttempt.ID_PROPERTY); + return this; + } + + public PaymentAttemptRequest orderByPaymentAscending(){ + addOrderByAscending(PaymentAttempt.PAYMENT_PROPERTY); + return this; + } + + public PaymentAttemptRequest orderByPaymentDescending(){ + addOrderByDescending(PaymentAttempt.PAYMENT_PROPERTY); + return this; + } + + public PaymentAttemptRequest orderByReferenceCodeAscending(){ + addOrderByAscending(PaymentAttempt.REFERENCE_CODE_PROPERTY); + return this; + } + + public PaymentAttemptRequest orderByReferenceCodeDescending(){ + addOrderByDescending(PaymentAttempt.REFERENCE_CODE_PROPERTY); + return this; + } + public PaymentAttemptRequest orderByReferenceCodeAscendingUsingGBK(){ + addOrderByAscendingUsingGBK(PaymentAttempt.REFERENCE_CODE_PROPERTY); + return this; + } + + public PaymentAttemptRequest orderByReferenceCodeDescendingUsingGBK(){ + addOrderByDescendingUsingGBK(PaymentAttempt.REFERENCE_CODE_PROPERTY); + return this; + } + public PaymentAttemptRequest orderByVersionAscending(){ + addOrderByAscending(PaymentAttempt.VERSION_PROPERTY); + return this; + } + + public PaymentAttemptRequest orderByVersionDescending(){ + addOrderByDescending(PaymentAttempt.VERSION_PROPERTY); + return this; + } + + + public PaymentRequest rollUpToPayment(){ + PaymentRequest payment = Q.payments().unlimited(); + this.withPaymentMatching(payment) + .groupByPaymentWith(payment); + return payment; + } + + + + + public PaymentAttemptRequest facetByPaymentAs(String facetName, PaymentRequest payment){ + return facetByPaymentAs(facetName, payment, true); + } + + public PaymentAttemptRequest facetByPaymentAs(String facetName, PaymentRequest payment, boolean includeAllFacets){ + addFacet(facetName, PaymentAttempt.PAYMENT_PROPERTY, payment, includeAllFacets); + return this; + } + + + /** + * get topN records + * @param topN records number + */ + public PaymentAttemptRequest top(int topN) { + super.top(topN); + return this; + } + + /** Cross-runtime bounded-query alias. */ + public PaymentAttemptRequest limit(int limit) { + return top(limit); + } + + /** + * get records from offset(inclusive) to offset+size(exclusive) + * @param offset record offset + * @param size records number + */ + public PaymentAttemptRequest offset(int offset, int size) { + super.offset(offset, size); + return this; + } + + /** + * retrieve all records + */ + public PaymentAttemptRequest unlimited() { + super.unlimited(); + return this; + } + + /** + * get records of one page + * @param pageNumber page number(1-based) + * @param pageSize page size + */ + public PaymentAttemptRequest page(int pageNumber, int pageSize) { + int offset = (pageNumber - 1) * pageSize; + return offset(offset, pageSize); + } + + /** + * get records of one page, default page size is 10 + * @param pageNumber page number(1-based) + */ + public PaymentAttemptRequest page(int pageNumber) { + return page(pageNumber, 10); + } +} \ No newline at end of file diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/platform/Platform.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/platform/Platform.java new file mode 100644 index 00000000..8497063a --- /dev/null +++ b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/platform/Platform.java @@ -0,0 +1,107 @@ + +package com.teaql.tracechainservice.platform; + +import com.teaql.tracechainservice.customerorder.CustomerOrder; +import io.teaql.core.Audited; +import io.teaql.core.BaseEntity; +import io.teaql.core.EntityStatus; +import io.teaql.core.FrameworkInternal; +import io.teaql.core.RemoteInput; +import io.teaql.core.SmartList; +import java.util.Objects; + +/** + * [TEAQL AI WARNING] + * TeaQL was explicitly designed to PREVENT AI hallucinations and random guessing. + * DO NOT GUESS METHOD NAMES! + * The methods listed below are the ONLY valid ways to interact with this entity. + * If you encounter compilation errors (e.g., method not found), DO NOT guess another method name. + * Read the method signatures in this file before proceeding. + */ +public class Platform extends BaseEntity implements RemoteInput { + public static String INTERNAL_TYPE = "Platform"; + + + public static final String NAME_PROPERTY = "name"; + public static final String CUSTOMER_ORDER_LIST_PROPERTY = "customerOrderList"; + private String name; + + private SmartList customerOrderList; + + public String getName(){ + return this.name; + } + + public SmartList getCustomerOrderList(){ + return this.customerOrderList; + } + public Platform updateName(String name){ + name = (name == null ? null : name.trim()); + if(Objects.equals(this.name, name)){ + return this; + } + handleUpdate(NAME_PROPERTY, getName(), name); + this.name = name; + return this; + } + + public Platform addCustomerOrder(CustomerOrder customerOrder){ + if (customerOrder == null){ + return this; + } + + if(null == this.customerOrderList){ + this.customerOrderList = new SmartList<>(); + } + + this.customerOrderList.add(customerOrder); + customerOrder.updatePlatform(this); + return this; + } + + public static Platform refer(Long id){ + Platform refer = new Platform(); + refer.__internalSet("id", id); + refer.set$status(EntityStatus.REFER); + return refer; + } + @Override + public String typeName(){ + return INTERNAL_TYPE; + } + + public Platform comment(String comment){ + this.setComment(comment); + return this; + } + + @Override + @SuppressWarnings("unchecked") + public Audited auditAs(String action) { + return super.auditAs(action); + } + + // ===== Framework Internal: generated switch dispatch ===== + @Override + @FrameworkInternal + public void __internalSet(String property, Object value) { + markPropertyLoaded(property); + switch (property) { + case "name": this.name = (value == null ? null : ((String)value).trim()); break; + + case "customerOrderList": this.customerOrderList = (SmartList) value; break; + default: super.__internalSet(property, value); + } + } + + @Override + @FrameworkInternal + public Object __internalGet(String property) { + switch (property) { + case "name": return this.name; + case "customerOrderList": return this.customerOrderList; + default: return super.__internalGet(property); + } + } + +} \ No newline at end of file diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/platform/PlatformChecker.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/platform/PlatformChecker.java new file mode 100644 index 00000000..e3fbdd92 --- /dev/null +++ b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/platform/PlatformChecker.java @@ -0,0 +1,49 @@ + +package com.teaql.tracechainservice.platform; + +import com.teaql.tracechainservice.customerorder.CustomerOrder; +import com.teaql.tracechainservice.customerorder.CustomerOrderChecker; +import io.teaql.core.UserContext; +import io.teaql.core.checker.Checker; +import io.teaql.core.checker.ObjectLocation; + +public class PlatformChecker implements Checker{ + + public String type(){ + return Platform.INTERNAL_TYPE; + } + + public void checkAndFix(UserContext _context, Platform platform, ObjectLocation _parentLocation){ + if(needCheck(_context, platform)){ + markAsChecked(_context, platform); + doCheck(_context, platform, _parentLocation); + } + } + + public void doCheck(UserContext _context, Platform platform, ObjectLocation _parentLocation){ + if((platform == null)){ + return; + } + if(platform.newItem()){ + }else if(platform.updateItem()){ + if(!platform.isPropertyLoaded("name")){ + invalidTypeCheck(_context, newLocation(_parentLocation, "name"), "Mutation requires a fully loaded entity"); + } + + } + checkName(_context, platform.getProperty(Platform.NAME_PROPERTY), newLocation(_parentLocation, "name")); + for(int i = 0; platform.getCustomerOrderList() != null && i < platform.getCustomerOrderList().size(); i++){ + CustomerOrder customerOrder = platform.getCustomerOrderList().get(i); + new CustomerOrderChecker().checkAndFix(_context, customerOrder, newLocation(_parentLocation, "customer_order_list", i)); + } + } + + public void checkName(UserContext _context, String name, ObjectLocation _parentLocation){ + requiredCheck(_context, _parentLocation, name); + if((name == null)){ + return; + } + maxStringCheck(_context, _parentLocation, 100, name); + + } +} \ No newline at end of file diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/platform/PlatformExpression.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/platform/PlatformExpression.java new file mode 100644 index 00000000..e6c1617d --- /dev/null +++ b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/platform/PlatformExpression.java @@ -0,0 +1,51 @@ + +package com.teaql.tracechainservice.platform; + +import com.teaql.tracechainservice.customerorder.CustomerOrder; +import com.teaql.tracechainservice.customerorder.CustomerOrderListExpression; +import io.teaql.core.UserContext; +import io.teaql.core.value.BaseEntityExpression; +import io.teaql.core.value.Expression; +import io.teaql.core.value.ExpressionAdaptor; +import java.util.function.Function; + +public class PlatformExpression extends ExpressionAdaptor implements BaseEntityExpression { + public PlatformExpression(Expression expression){ + super(expression); + } + + public PlatformExpression(Expression expression, Function function){ + super(expression, function); + } + + public PlatformExpression updateId(Long id){ + return new PlatformExpression(this, $it -> {((Platform)$it).__internalSet("id", id); return this;}); + } + + public PlatformExpression save(UserContext userContext){ + return new PlatformExpression(this, $it -> ((Platform)$it).auditAs("Saved by Expression").save(userContext)); + } + + public PlatformExpression save(String intent, UserContext userContext){ + return new PlatformExpression(this, $it -> ((Platform)$it).auditAs(intent).save(userContext)); + } + + public boolean isNull() { + return resolve() == null; + } + + + public Expression getName(){ + return loaded("name", Platform::getName); + } + public PlatformExpression updateName(String name){ + return new PlatformExpression(this, $it -> ((Platform)$it).updateName(name)); + } + + public CustomerOrderListExpression getCustomerOrderList(){ + return new CustomerOrderListExpression(loaded("customerOrderList", Platform::getCustomerOrderList)); + } + public PlatformExpression addCustomerOrder(CustomerOrder customerOrder){ + return new PlatformExpression(this, $it -> ((Platform)$it).addCustomerOrder(customerOrder)); + } +} \ No newline at end of file diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/platform/PlatformListExpression.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/platform/PlatformListExpression.java new file mode 100644 index 00000000..f3274358 --- /dev/null +++ b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/platform/PlatformListExpression.java @@ -0,0 +1,25 @@ + +package com.teaql.tracechainservice.platform; + +import io.teaql.core.SmartList; +import io.teaql.core.value.Expression; +import io.teaql.core.value.SmartListExpression; +import java.util.function.Function; + +public class PlatformListExpression extends SmartListExpression { + public PlatformListExpression(Expression> expression){ + super(expression); + } + + public PlatformListExpression(Expression expression, Function> function){ + super(expression, function); + } + + public PlatformExpression first() { + return new PlatformExpression(super.first()); + } + + public PlatformExpression get(int index) { + return new PlatformExpression(super.get(index)); + } +} \ No newline at end of file diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/platform/PlatformRequest.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/platform/PlatformRequest.java new file mode 100644 index 00000000..de81ac80 --- /dev/null +++ b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/platform/PlatformRequest.java @@ -0,0 +1,557 @@ + +package com.teaql.tracechainservice.platform; + +import com.teaql.tracechainservice.Q; +import com.teaql.tracechainservice.customerorder.CustomerOrder; +import com.teaql.tracechainservice.customerorder.CustomerOrderRequest; +import io.teaql.core.AggrFunction; +import io.teaql.core.BaseRequest; +import io.teaql.core.PropertyReference; +import io.teaql.core.SearchCriteria; +import io.teaql.core.SubQuerySearchCriteria; +import io.teaql.core.criteria.Operator; +import io.teaql.core.criteria.TwoOperatorCriteria; + +public class PlatformRequest extends BaseRequest { + + /** + * @deprecated AI agents and business code must use the generated Q facade + * instead of constructing request builders directly. + */ + @Deprecated + @SuppressWarnings("unchecked") + public PlatformRequest(Class returnType){ + super(returnType, () -> (T) new Platform()); + selectId(); + selectVersion(); + } + + public PlatformRequest comment(String comment){ + super.internalComment(comment); + return this; + } + + // purpose() 继承自 BaseRequest,返回 ExecutableRequest(终结方法) + + public PlatformRequest returnType(Class returnType){ + super.setReturnType(returnType); + return this; + } + + public PlatformRequest enableAggregationCache(long cacheExpiredMillis){ + super.enableAggregationCache(); + super.aggregateCacheTime(cacheExpiredMillis); + return this; + } + + public PlatformRequest enableAggregationCache(){ + return enableAggregationCache(0l); + } + + + public PlatformRequest propagateAggregationCache(long cacheExpiredMillis){ + super.propagateAggregationCache(cacheExpiredMillis); + return this; + } + + /** + * Accept best-effort stateful seek optimization for browsing consecutive pages. + * Do not use this for business processing that must visit every row exactly once. + */ + public PlatformRequest optimizeForContinuousPageFetch(){ + super.optimizeForContinuousPageFetch(); + return this; + } + + public PlatformRequest optimizeForContinuousPageFetch(String namespace, int ttlSeconds){ + super.optimizeForContinuousPageFetch(namespace, ttlSeconds); + return this; + } + + public PlatformRequest optimizePaginationWithIdSet(){ + super.optimizePaginationWithIdSet(); + return this; + } + + public PlatformRequest optimizePaginationWithIdSet( + String namespace, int ttlSeconds, int maxIds){ + super.optimizePaginationWithIdSet(namespace, ttlSeconds, maxIds); + return this; + } + + public PlatformRequest topNProbeParentThreshold(int threshold){ + super.topNProbeParentThreshold(threshold); + return this; + } + + public PlatformRequest appendSearchCriteria(SearchCriteria searchCriteria){ + return (PlatformRequest)super.appendSearchCriteria(searchCriteria); + } + + public PlatformRequest filter(String property1, Operator operator, String property2){ + return appendSearchCriteria(new TwoOperatorCriteria(operator, new PropertyReference(property1), new PropertyReference(property2))); + } + + + public PlatformRequest matchingAnyOf(PlatformRequest platform){ + super.internalMatchAny(platform); + return this; + } + + public PlatformRequest enhanceChildrenIfNeeded(){ + return this; + } + + public PlatformRequest withDeletedRows(){ + super.withDeletedRows(); + return this; + } + + public PlatformRequest deletedRowsOnly(){ + super.deletedRowsOnly(); + return this; + } + + public PlatformRequest selectSelf(){ + super.selectSelf(); + return selectId().selectName().selectVersion(); + } + + public PlatformRequest selectSelfFields(){ + return selectSelf(); + } + + public PlatformRequest selectAll(){ + super.selectAll(); + return selectId().selectName().selectVersion(); + } + + public PlatformRequest selectChildren(){ + super.selectAny(); + selectCustomerOrderList(); + return selectId().selectName().selectVersion(); + } + + + public PlatformRequest selectId(){ + selectProperty(Platform.ID_PROPERTY); + return this; + } + + /** + * fill the id with customized rawSqlSegment, TEAQL uses ({rawSqlSegment} AS id) to fetch id property. + * @param rawSqlSegment customized rawSqlSegment + */ + + + + + public PlatformRequest unselectId(){ + unselectProperty(Platform.ID_PROPERTY); + return this; + } + public PlatformRequest selectName(){ + selectProperty(Platform.NAME_PROPERTY); + return this; + } + + /** + * fill the name with customized rawSqlSegment, TEAQL uses ({rawSqlSegment} AS name) to fetch name property. + * @param rawSqlSegment customized rawSqlSegment + */ + + + + + public PlatformRequest unselectName(){ + unselectProperty(Platform.NAME_PROPERTY); + return this; + } + public PlatformRequest selectVersion(){ + selectProperty(Platform.VERSION_PROPERTY); + return this; + } + + /** + * fill the version with customized rawSqlSegment, TEAQL uses ({rawSqlSegment} AS version) to fetch version property. + * @param rawSqlSegment customized rawSqlSegment + */ + + + + + public PlatformRequest unselectVersion(){ + unselectProperty(Platform.VERSION_PROPERTY); + return this; + } + public PlatformRequest selectCustomerOrderList(){ + return selectCustomerOrderListWith(Q.customerOrders().selectSelf()); + } + + public PlatformRequest selectCustomerOrderListWith(CustomerOrderRequest customerOrderList){ + enhanceRelation(Platform.CUSTOMER_ORDER_LIST_PROPERTY, customerOrderList); + return this; + } + + public PlatformRequest withId(Operator operator, Object... values){ + return appendSearchCriteria(createIdCriteria(operator, values)); + } + + public SearchCriteria createIdCriteria(Operator operator, Object... values) { + return createBasicSearchCriteria(Platform.ID_PROPERTY, operator, values); + } + + public PlatformRequest withIdIsNot(Long id){ + return withId(Operator.NOT_EQUAL, id); + } + + public PlatformRequest withIdIn(Long... id){ + return withId(Operator.IN, (Object[])id); + } + + public PlatformRequest withIdNotIn(Long... id){ + return withId(Operator.NOT_IN, (Object[])id); + } + public PlatformRequest withIdIs(Long id){ + return withId(Operator.EQUAL, id); + } + + + + public PlatformRequest filterByName(String... name){ + if (name == null || name.length == 0) { + throw new IllegalArgumentException("filterByName parameter name cannot be empty"); + } + return appendSearchCriteria(createNameCriteria(Operator.EQUAL, (Object[])name)); + } + + public PlatformRequest withName(Operator operator, Object... values){ + return appendSearchCriteria(createNameCriteria(operator, values)); + } + + public PlatformRequest withNameIsUnknown(){ + return withName(Operator.IS_NULL); + } + + public PlatformRequest withNameIsKnown(){ + return withName(Operator.IS_NOT_NULL); + } + + public SearchCriteria createNameCriteria(Operator operator, Object... values) { + return createBasicSearchCriteria(Platform.NAME_PROPERTY, operator, values); + } + + public PlatformRequest withNameIsNot(String name){ + return withName(Operator.NOT_EQUAL, name); + } + + public PlatformRequest withNameIn(String... name){ + return withName(Operator.IN, (Object[])name); + } + + public PlatformRequest withNameNotIn(String... name){ + return withName(Operator.NOT_IN, (Object[])name); + } + public PlatformRequest withNameGreaterThan(String name){ + return withName(Operator.GREATER_THAN, name); + } + + public PlatformRequest withNameGreaterThanOrEqualTo(String name){ + return withName(Operator.GREATER_THAN_OR_EQUAL, name); + } + + public PlatformRequest withNameLessThan(String name){ + return withName(Operator.LESS_THAN, name); + } + + public PlatformRequest withNameLessThanOrEqualTo(String name){ + return withName(Operator.LESS_THAN_OR_EQUAL, name); + } + + public PlatformRequest withNameBetween(String startOfName, String endOfName){ + return withName(Operator.BETWEEN, startOfName, endOfName); + } + public PlatformRequest withNameStartingWith(String name){ + return withName(Operator.BEGIN_WITH, name); + } + public PlatformRequest withNameContaining(String name){ + return withName(Operator.CONTAIN, name); + } + + public PlatformRequest withNameNotContaining(String name){ + return withName(Operator.NOT_CONTAIN, name); + } + + public PlatformRequest withNameNotStartingWith(String name){ + return withName(Operator.NOT_BEGIN_WITH, name); + } + + public PlatformRequest withNameEndingWith(String name){ + return withName(Operator.END_WITH, name); + } + + public PlatformRequest withNameNotEndingWith(String name){ + return withName(Operator.NOT_END_WITH, name); + } + + public PlatformRequest withNameIs(String name){ + return withName(Operator.EQUAL, name); + } + + public PlatformRequest withNameSoundingLike(String name){ + return withName(Operator.SOUNDS_LIKE, name); + } + + + + public PlatformRequest filterByVersion(Long... version){ + if (version == null || version.length == 0) { + throw new IllegalArgumentException("filterByVersion parameter version cannot be empty"); + } + return appendSearchCriteria(createVersionCriteria(Operator.EQUAL, (Object[])version)); + } + + public PlatformRequest withVersion(Operator operator, Object... values){ + return appendSearchCriteria(createVersionCriteria(operator, values)); + } + + public PlatformRequest withVersionIsUnknown(){ + return withVersion(Operator.IS_NULL); + } + + public PlatformRequest withVersionIsKnown(){ + return withVersion(Operator.IS_NOT_NULL); + } + + public SearchCriteria createVersionCriteria(Operator operator, Object... values) { + return createBasicSearchCriteria(Platform.VERSION_PROPERTY, operator, values); + } + + public PlatformRequest withVersionIs(Long version){ + return withVersion(Operator.EQUAL, version); + } + + public PlatformRequest withVersionIsNot(Long version){ + return withVersion(Operator.NOT_EQUAL, version); + } + + public PlatformRequest withVersionIn(Long... version){ + return withVersion(Operator.IN, (Object[])version); + } + + public PlatformRequest withVersionNotIn(Long... version){ + return withVersion(Operator.NOT_IN, (Object[])version); + } + public PlatformRequest withVersionGreaterThan(Long version){ + return withVersion(Operator.GREATER_THAN, version); + } + + public PlatformRequest withVersionGreaterThanOrEqualTo(Long version){ + return withVersion(Operator.GREATER_THAN_OR_EQUAL, version); + } + + public PlatformRequest withVersionLessThan(Long version){ + return withVersion(Operator.LESS_THAN, version); + } + + public PlatformRequest withVersionLessThanOrEqualTo(Long version){ + return withVersion(Operator.LESS_THAN_OR_EQUAL, version); + } + + public PlatformRequest withVersionBetween(Long startOfVersion, Long endOfVersion){ + return withVersion(Operator.BETWEEN, startOfVersion, endOfVersion); + } + + + public PlatformRequest withCustomerOrderListMatching(CustomerOrderRequest customerOrderRequest){ + return appendSearchCriteria(new SubQuerySearchCriteria(Platform.ID_PROPERTY, customerOrderRequest, CustomerOrder.PLATFORM_PROPERTY)); + } + + public PlatformRequest withoutCustomerOrderListMatching(CustomerOrderRequest customerOrderRequest){ + return appendSearchCriteria(SearchCriteria.not(new SubQuerySearchCriteria(Platform.ID_PROPERTY, customerOrderRequest, CustomerOrder.PLATFORM_PROPERTY))); + } + + public PlatformRequest haveCustomerOrders(){ + return withCustomerOrderListMatching(Q.customerOrders().unlimited()); + } + + public PlatformRequest haveNoCustomerOrders(){ + return withoutCustomerOrderListMatching(Q.customerOrders().unlimited()); + } + + public PlatformRequest count(){ + super.count(); + return this; + } + public PlatformRequest countAs(String retName){ + super.count(retName); + return this; + } + public PlatformRequest groupByCustomerOrdersWithDetails(CustomerOrderRequest subRequest){ + aggregate(Platform.CUSTOMER_ORDER_LIST_PROPERTY, subRequest); + return this; + } + + public PlatformRequest groupById(){ + groupBy(Platform.ID_PROPERTY); + return this; + } + + public PlatformRequest groupByIdAs(String retName){ + groupBy(retName, Platform.ID_PROPERTY); + return this; + } + + public PlatformRequest groupByIdWithFunction(String retName, AggrFunction function){ + groupBy(retName, Platform.ID_PROPERTY, function); + return this; + } + + public PlatformRequest groupByName(){ + groupBy(Platform.NAME_PROPERTY); + return this; + } + + public PlatformRequest groupByNameAs(String retName){ + groupBy(retName, Platform.NAME_PROPERTY); + return this; + } + + public PlatformRequest groupByNameWithFunction(String retName, AggrFunction function){ + groupBy(retName, Platform.NAME_PROPERTY, function); + return this; + } + + public PlatformRequest groupByVersion(){ + groupBy(Platform.VERSION_PROPERTY); + return this; + } + + public PlatformRequest groupByVersionAs(String retName){ + groupBy(retName, Platform.VERSION_PROPERTY); + return this; + } + + public PlatformRequest groupByVersionWithFunction(String retName, AggrFunction function){ + groupBy(retName, Platform.VERSION_PROPERTY, function); + return this; + } + + + + public PlatformRequest orderByIdAscending(){ + addOrderByAscending(Platform.ID_PROPERTY); + return this; + } + + public PlatformRequest orderByIdDescending(){ + addOrderByDescending(Platform.ID_PROPERTY); + return this; + } + + public PlatformRequest orderByNameAscending(){ + addOrderByAscending(Platform.NAME_PROPERTY); + return this; + } + + public PlatformRequest orderByNameDescending(){ + addOrderByDescending(Platform.NAME_PROPERTY); + return this; + } + public PlatformRequest orderByNameAscendingUsingGBK(){ + addOrderByAscendingUsingGBK(Platform.NAME_PROPERTY); + return this; + } + + public PlatformRequest orderByNameDescendingUsingGBK(){ + addOrderByDescendingUsingGBK(Platform.NAME_PROPERTY); + return this; + } + public PlatformRequest orderByVersionAscending(){ + addOrderByAscending(Platform.VERSION_PROPERTY); + return this; + } + + public PlatformRequest orderByVersionDescending(){ + addOrderByDescending(Platform.VERSION_PROPERTY); + return this; + } + + + public PlatformRequest statsFromCustomerOrdersAs(String name, CustomerOrderRequest subRequest){ + return statsFromCustomerOrdersAs(name, subRequest, false); + } + + public PlatformRequest statsFromCustomerOrdersAs(String name, CustomerOrderRequest subRequest, boolean singleResult){ + subRequest.setPartitionProperty(CustomerOrder.PLATFORM_PROPERTY); + addAggregateDynamicProperty(name, subRequest, singleResult); + return this; + } + + public PlatformRequest statsFromCustomerOrders(CustomerOrderRequest subRequest){ + return statsFromCustomerOrdersAs(REFINEMENTS, subRequest); + } + public PlatformRequest countCustomerOrders(){ + return countCustomerOrdersAs("Count"); + } + + public PlatformRequest countCustomerOrdersAs(String name){ + return countCustomerOrdersWith(name, Q.customerOrders().unlimited()); + } + + public PlatformRequest countCustomerOrdersWith(String name, CustomerOrderRequest subRequest){ + return statsFromCustomerOrdersAs(name, subRequest.count(), true); + } + + + + /** + * get topN records + * @param topN records number + */ + public PlatformRequest top(int topN) { + super.top(topN); + return this; + } + + /** Cross-runtime bounded-query alias. */ + public PlatformRequest limit(int limit) { + return top(limit); + } + + /** + * get records from offset(inclusive) to offset+size(exclusive) + * @param offset record offset + * @param size records number + */ + public PlatformRequest offset(int offset, int size) { + super.offset(offset, size); + return this; + } + + /** + * retrieve all records + */ + public PlatformRequest unlimited() { + super.unlimited(); + return this; + } + + /** + * get records of one page + * @param pageNumber page number(1-based) + * @param pageSize page size + */ + public PlatformRequest page(int pageNumber, int pageSize) { + int offset = (pageNumber - 1) * pageSize; + return offset(offset, pageSize); + } + + /** + * get records of one page, default page size is 10 + * @param pageNumber page number(1-based) + */ + public PlatformRequest page(int pageNumber) { + return page(pageNumber, 10); + } +} \ No newline at end of file diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/sampledata/SampleDataService.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/sampledata/SampleDataService.java new file mode 100644 index 00000000..e69de29b diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/shipment/Shipment.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/shipment/Shipment.java new file mode 100644 index 00000000..4640250c --- /dev/null +++ b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/shipment/Shipment.java @@ -0,0 +1,105 @@ + +package com.teaql.tracechainservice.shipment; + +import com.teaql.tracechainservice.customerorder.CustomerOrder; +import io.teaql.core.Audited; +import io.teaql.core.BaseEntity; +import io.teaql.core.EntityStatus; +import io.teaql.core.FrameworkInternal; +import io.teaql.core.RemoteInput; +import java.util.Objects; + +/** + * [TEAQL AI WARNING] + * TeaQL was explicitly designed to PREVENT AI hallucinations and random guessing. + * DO NOT GUESS METHOD NAMES! + * The methods listed below are the ONLY valid ways to interact with this entity. + * If you encounter compilation errors (e.g., method not found), DO NOT guess another method name. + * Read the method signatures in this file before proceeding. + */ +public class Shipment extends BaseEntity implements RemoteInput { + public static String INTERNAL_TYPE = "Shipment"; + + + public static final String CUSTOMER_ORDER_PROPERTY = "customerOrder"; + public static final String REFERENCE_CODE_PROPERTY = "referenceCode"; + private CustomerOrder customerOrder; + + private String referenceCode; + + + public CustomerOrder getCustomerOrder(){ + return this.customerOrder; + } + + public String getReferenceCode(){ + return this.referenceCode; + } + + public Shipment updateCustomerOrder(CustomerOrder customerOrder){ + if(Objects.equals(this.customerOrder, customerOrder)){ + return this; + } + handleUpdate(CUSTOMER_ORDER_PROPERTY, getCustomerOrder(), customerOrder); + this.customerOrder = customerOrder; + return this; + } + + public Shipment updateReferenceCode(String referenceCode){ + referenceCode = (referenceCode == null ? null : referenceCode.trim()); + if(Objects.equals(this.referenceCode, referenceCode)){ + return this; + } + handleUpdate(REFERENCE_CODE_PROPERTY, getReferenceCode(), referenceCode); + this.referenceCode = referenceCode; + return this; + } + + + public static Shipment refer(Long id){ + Shipment refer = new Shipment(); + refer.__internalSet("id", id); + refer.set$status(EntityStatus.REFER); + return refer; + } + @Override + public String typeName(){ + return INTERNAL_TYPE; + } + + public Shipment comment(String comment){ + this.setComment(comment); + return this; + } + + @Override + @SuppressWarnings("unchecked") + public Audited auditAs(String action) { + return super.auditAs(action); + } + + // ===== Framework Internal: generated switch dispatch ===== + @Override + @FrameworkInternal + public void __internalSet(String property, Object value) { + markPropertyLoaded(property); + switch (property) { + case "customerOrder": this.customerOrder = (CustomerOrder) value; break; + + case "referenceCode": this.referenceCode = (value == null ? null : ((String)value).trim()); break; + + default: super.__internalSet(property, value); + } + } + + @Override + @FrameworkInternal + public Object __internalGet(String property) { + switch (property) { + case "customerOrder": return this.customerOrder; + case "referenceCode": return this.referenceCode; + default: return super.__internalGet(property); + } + } + +} \ No newline at end of file diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/shipment/ShipmentChecker.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/shipment/ShipmentChecker.java new file mode 100644 index 00000000..5ab1092c --- /dev/null +++ b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/shipment/ShipmentChecker.java @@ -0,0 +1,56 @@ + +package com.teaql.tracechainservice.shipment; + +import com.teaql.tracechainservice.customerorder.CustomerOrder; +import com.teaql.tracechainservice.customerorder.CustomerOrderChecker; +import io.teaql.core.UserContext; +import io.teaql.core.checker.Checker; +import io.teaql.core.checker.ObjectLocation; + +public class ShipmentChecker implements Checker{ + + public String type(){ + return Shipment.INTERNAL_TYPE; + } + + public void checkAndFix(UserContext _context, Shipment shipment, ObjectLocation _parentLocation){ + if(needCheck(_context, shipment)){ + markAsChecked(_context, shipment); + doCheck(_context, shipment, _parentLocation); + } + } + + public void doCheck(UserContext _context, Shipment shipment, ObjectLocation _parentLocation){ + if((shipment == null)){ + return; + } + if(shipment.newItem()){ + }else if(shipment.updateItem()){ + if(!shipment.isPropertyLoaded("customerOrder")){ + invalidTypeCheck(_context, newLocation(_parentLocation, "customer_order"), "Mutation requires a fully loaded entity"); + } + if(!shipment.isPropertyLoaded("referenceCode")){ + invalidTypeCheck(_context, newLocation(_parentLocation, "reference_code"), "Mutation requires a fully loaded entity"); + } + + } + checkCustomerOrder(_context, shipment.getProperty(Shipment.CUSTOMER_ORDER_PROPERTY), newLocation(_parentLocation, "customer_order")); + checkReferenceCode(_context, shipment.getProperty(Shipment.REFERENCE_CODE_PROPERTY), newLocation(_parentLocation, "reference_code")); + } + + public void checkCustomerOrder(UserContext _context, CustomerOrder customerOrder, ObjectLocation _parentLocation){ + requiredCheck(_context, _parentLocation, customerOrder); + if((customerOrder == null)){ + return; + } + new CustomerOrderChecker().checkAndFix(_context, customerOrder, _parentLocation); + } + public void checkReferenceCode(UserContext _context, String referenceCode, ObjectLocation _parentLocation){ + requiredCheck(_context, _parentLocation, referenceCode); + if((referenceCode == null)){ + return; + } + maxStringCheck(_context, _parentLocation, 100, referenceCode); + + } +} \ No newline at end of file diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/shipment/ShipmentExpression.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/shipment/ShipmentExpression.java new file mode 100644 index 00000000..263812a7 --- /dev/null +++ b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/shipment/ShipmentExpression.java @@ -0,0 +1,53 @@ + +package com.teaql.tracechainservice.shipment; + +import com.teaql.tracechainservice.customerorder.CustomerOrder; +import com.teaql.tracechainservice.customerorder.CustomerOrderExpression; +import io.teaql.core.UserContext; +import io.teaql.core.value.BaseEntityExpression; +import io.teaql.core.value.Expression; +import io.teaql.core.value.ExpressionAdaptor; +import java.util.function.Function; + +public class ShipmentExpression extends ExpressionAdaptor implements BaseEntityExpression { + public ShipmentExpression(Expression expression){ + super(expression); + } + + public ShipmentExpression(Expression expression, Function function){ + super(expression, function); + } + + public ShipmentExpression updateId(Long id){ + return new ShipmentExpression(this, $it -> {((Shipment)$it).__internalSet("id", id); return this;}); + } + + public ShipmentExpression save(UserContext userContext){ + return new ShipmentExpression(this, $it -> ((Shipment)$it).auditAs("Saved by Expression").save(userContext)); + } + + public ShipmentExpression save(String intent, UserContext userContext){ + return new ShipmentExpression(this, $it -> ((Shipment)$it).auditAs(intent).save(userContext)); + } + + public boolean isNull() { + return resolve() == null; + } + + + public CustomerOrderExpression getCustomerOrder(){ + return new CustomerOrderExpression(loaded("customerOrder", Shipment::getCustomerOrder)); + } + + public ShipmentExpression updateCustomerOrder(CustomerOrder customerOrder){ + return new ShipmentExpression(this, $it -> ((Shipment)$it).updateCustomerOrder(customerOrder)); + } + + public Expression getReferenceCode(){ + return loaded("referenceCode", Shipment::getReferenceCode); + } + public ShipmentExpression updateReferenceCode(String referenceCode){ + return new ShipmentExpression(this, $it -> ((Shipment)$it).updateReferenceCode(referenceCode)); + } + +} \ No newline at end of file diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/shipment/ShipmentListExpression.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/shipment/ShipmentListExpression.java new file mode 100644 index 00000000..01969ef0 --- /dev/null +++ b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/shipment/ShipmentListExpression.java @@ -0,0 +1,25 @@ + +package com.teaql.tracechainservice.shipment; + +import io.teaql.core.SmartList; +import io.teaql.core.value.Expression; +import io.teaql.core.value.SmartListExpression; +import java.util.function.Function; + +public class ShipmentListExpression extends SmartListExpression { + public ShipmentListExpression(Expression> expression){ + super(expression); + } + + public ShipmentListExpression(Expression expression, Function> function){ + super(expression, function); + } + + public ShipmentExpression first() { + return new ShipmentExpression(super.first()); + } + + public ShipmentExpression get(int index) { + return new ShipmentExpression(super.get(index)); + } +} \ No newline at end of file diff --git a/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/shipment/ShipmentRequest.java b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/shipment/ShipmentRequest.java new file mode 100644 index 00000000..2096b032 --- /dev/null +++ b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/shipment/ShipmentRequest.java @@ -0,0 +1,618 @@ + +package com.teaql.tracechainservice.shipment; + +import com.teaql.tracechainservice.Q; +import com.teaql.tracechainservice.customerorder.CustomerOrder; +import com.teaql.tracechainservice.customerorder.CustomerOrderRequest; +import io.teaql.core.AggrFunction; +import io.teaql.core.BaseRequest; +import io.teaql.core.PropertyReference; +import io.teaql.core.SearchCriteria; +import io.teaql.core.SubQuerySearchCriteria; +import io.teaql.core.criteria.Operator; +import io.teaql.core.criteria.TwoOperatorCriteria; + +public class ShipmentRequest extends BaseRequest { + + /** + * @deprecated AI agents and business code must use the generated Q facade + * instead of constructing request builders directly. + */ + @Deprecated + @SuppressWarnings("unchecked") + public ShipmentRequest(Class returnType){ + super(returnType, () -> (T) new Shipment()); + selectId(); + selectVersion(); + } + + public ShipmentRequest comment(String comment){ + super.internalComment(comment); + return this; + } + + // purpose() 继承自 BaseRequest,返回 ExecutableRequest(终结方法) + + public ShipmentRequest returnType(Class returnType){ + super.setReturnType(returnType); + return this; + } + + public ShipmentRequest enableAggregationCache(long cacheExpiredMillis){ + super.enableAggregationCache(); + super.aggregateCacheTime(cacheExpiredMillis); + return this; + } + + public ShipmentRequest enableAggregationCache(){ + return enableAggregationCache(0l); + } + + + public ShipmentRequest propagateAggregationCache(long cacheExpiredMillis){ + super.propagateAggregationCache(cacheExpiredMillis); + return this; + } + + /** + * Accept best-effort stateful seek optimization for browsing consecutive pages. + * Do not use this for business processing that must visit every row exactly once. + */ + public ShipmentRequest optimizeForContinuousPageFetch(){ + super.optimizeForContinuousPageFetch(); + return this; + } + + public ShipmentRequest optimizeForContinuousPageFetch(String namespace, int ttlSeconds){ + super.optimizeForContinuousPageFetch(namespace, ttlSeconds); + return this; + } + + public ShipmentRequest optimizePaginationWithIdSet(){ + super.optimizePaginationWithIdSet(); + return this; + } + + public ShipmentRequest optimizePaginationWithIdSet( + String namespace, int ttlSeconds, int maxIds){ + super.optimizePaginationWithIdSet(namespace, ttlSeconds, maxIds); + return this; + } + + public ShipmentRequest topNProbeParentThreshold(int threshold){ + super.topNProbeParentThreshold(threshold); + return this; + } + + public ShipmentRequest appendSearchCriteria(SearchCriteria searchCriteria){ + return (ShipmentRequest)super.appendSearchCriteria(searchCriteria); + } + + public ShipmentRequest filter(String property1, Operator operator, String property2){ + return appendSearchCriteria(new TwoOperatorCriteria(operator, new PropertyReference(property1), new PropertyReference(property2))); + } + + + public ShipmentRequest matchingAnyOf(ShipmentRequest shipment){ + super.internalMatchAny(shipment); + return this; + } + + public ShipmentRequest enhanceChildrenIfNeeded(){ + return this; + } + + public ShipmentRequest withDeletedRows(){ + super.withDeletedRows(); + return this; + } + + public ShipmentRequest deletedRowsOnly(){ + super.deletedRowsOnly(); + return this; + } + + public ShipmentRequest selectSelf(){ + super.selectSelf(); + return selectId().selectCustomerOrderIdOnly().selectReferenceCode().selectVersion(); + } + + public ShipmentRequest selectSelfFields(){ + return selectSelf(); + } + + public ShipmentRequest selectAll(){ + super.selectAll(); + return selectId().selectCustomerOrder().selectReferenceCode().selectVersion(); + } + + public ShipmentRequest selectChildren(){ + super.selectAny(); + return selectId().selectCustomerOrder().selectReferenceCode().selectVersion(); + } + + + public ShipmentRequest selectId(){ + selectProperty(Shipment.ID_PROPERTY); + return this; + } + + /** + * fill the id with customized rawSqlSegment, TEAQL uses ({rawSqlSegment} AS id) to fetch id property. + * @param rawSqlSegment customized rawSqlSegment + */ + + + + + public ShipmentRequest unselectId(){ + unselectProperty(Shipment.ID_PROPERTY); + return this; + } + public ShipmentRequest selectCustomerOrderIdOnly(){ + selectProperty(Shipment.CUSTOMER_ORDER_PROPERTY); + return this; + } + + public ShipmentRequest selectCustomerOrder(){ + return selectCustomerOrderWith(Q.customerOrders().unlimited().selectSelf()); + } + + public ShipmentRequest selectCustomerOrderWith(CustomerOrderRequest customerOrder){ + selectProperty(Shipment.CUSTOMER_ORDER_PROPERTY); + enhanceRelation(Shipment.CUSTOMER_ORDER_PROPERTY, customerOrder); + return this; + } + + public ShipmentRequest unselectCustomerOrder(){ + unselectProperty(Shipment.CUSTOMER_ORDER_PROPERTY); + return this; + } + public ShipmentRequest selectReferenceCode(){ + selectProperty(Shipment.REFERENCE_CODE_PROPERTY); + return this; + } + + /** + * fill the referenceCode with customized rawSqlSegment, TEAQL uses ({rawSqlSegment} AS referenceCode) to fetch referenceCode property. + * @param rawSqlSegment customized rawSqlSegment + */ + + + + + public ShipmentRequest unselectReferenceCode(){ + unselectProperty(Shipment.REFERENCE_CODE_PROPERTY); + return this; + } + public ShipmentRequest selectVersion(){ + selectProperty(Shipment.VERSION_PROPERTY); + return this; + } + + /** + * fill the version with customized rawSqlSegment, TEAQL uses ({rawSqlSegment} AS version) to fetch version property. + * @param rawSqlSegment customized rawSqlSegment + */ + + + + + public ShipmentRequest unselectVersion(){ + unselectProperty(Shipment.VERSION_PROPERTY); + return this; + } + + public ShipmentRequest withId(Operator operator, Object... values){ + return appendSearchCriteria(createIdCriteria(operator, values)); + } + + public SearchCriteria createIdCriteria(Operator operator, Object... values) { + return createBasicSearchCriteria(Shipment.ID_PROPERTY, operator, values); + } + + public ShipmentRequest withIdIsNot(Long id){ + return withId(Operator.NOT_EQUAL, id); + } + + public ShipmentRequest withIdIn(Long... id){ + return withId(Operator.IN, (Object[])id); + } + + public ShipmentRequest withIdNotIn(Long... id){ + return withId(Operator.NOT_IN, (Object[])id); + } + public ShipmentRequest withIdIs(Long id){ + return withId(Operator.EQUAL, id); + } + + + + public ShipmentRequest filterByCustomerOrder(CustomerOrder... customerOrder){ + if (customerOrder == null || customerOrder.length == 0) { + throw new IllegalArgumentException("filterByCustomerOrder parameter customerOrder cannot be empty"); + } + return appendSearchCriteria(createCustomerOrderCriteria(Operator.EQUAL, (Object[])customerOrder)); + } + + public ShipmentRequest withCustomerOrder(Operator operator, Object... values){ + return appendSearchCriteria(createCustomerOrderCriteria(operator, values)); + } + + public ShipmentRequest withCustomerOrderIsUnknown(){ + return withCustomerOrder(Operator.IS_NULL); + } + + public ShipmentRequest withCustomerOrderIsKnown(){ + return withCustomerOrder(Operator.IS_NOT_NULL); + } + + public SearchCriteria createCustomerOrderCriteria(Operator operator, Object... values) { + return createBasicSearchCriteria(Shipment.CUSTOMER_ORDER_PROPERTY, operator, values); + } + + public ShipmentRequest filterByCustomerOrder(Long customerOrder){ + if(customerOrder == null){ + return this; + } + return withCustomerOrder(Operator.EQUAL, customerOrder); + } + public ShipmentRequest withCustomerOrderMatching(CustomerOrderRequest customerOrder){ + return appendSearchCriteria(new SubQuerySearchCriteria(Shipment.CUSTOMER_ORDER_PROPERTY, customerOrder, CustomerOrder.ID_PROPERTY)); + } + + public ShipmentRequest withoutCustomerOrderMatching(CustomerOrderRequest customerOrder){ + return appendSearchCriteria(SearchCriteria.not( + new SubQuerySearchCriteria(Shipment.CUSTOMER_ORDER_PROPERTY, customerOrder, CustomerOrder.ID_PROPERTY))); + } + + public ShipmentRequest filterByReferenceCode(String... referenceCode){ + if (referenceCode == null || referenceCode.length == 0) { + throw new IllegalArgumentException("filterByReferenceCode parameter referenceCode cannot be empty"); + } + return appendSearchCriteria(createReferenceCodeCriteria(Operator.EQUAL, (Object[])referenceCode)); + } + + public ShipmentRequest withReferenceCode(Operator operator, Object... values){ + return appendSearchCriteria(createReferenceCodeCriteria(operator, values)); + } + + public ShipmentRequest withReferenceCodeIsUnknown(){ + return withReferenceCode(Operator.IS_NULL); + } + + public ShipmentRequest withReferenceCodeIsKnown(){ + return withReferenceCode(Operator.IS_NOT_NULL); + } + + public SearchCriteria createReferenceCodeCriteria(Operator operator, Object... values) { + return createBasicSearchCriteria(Shipment.REFERENCE_CODE_PROPERTY, operator, values); + } + + public ShipmentRequest withReferenceCodeIsNot(String referenceCode){ + return withReferenceCode(Operator.NOT_EQUAL, referenceCode); + } + + public ShipmentRequest withReferenceCodeIn(String... referenceCode){ + return withReferenceCode(Operator.IN, (Object[])referenceCode); + } + + public ShipmentRequest withReferenceCodeNotIn(String... referenceCode){ + return withReferenceCode(Operator.NOT_IN, (Object[])referenceCode); + } + public ShipmentRequest withReferenceCodeGreaterThan(String referenceCode){ + return withReferenceCode(Operator.GREATER_THAN, referenceCode); + } + + public ShipmentRequest withReferenceCodeGreaterThanOrEqualTo(String referenceCode){ + return withReferenceCode(Operator.GREATER_THAN_OR_EQUAL, referenceCode); + } + + public ShipmentRequest withReferenceCodeLessThan(String referenceCode){ + return withReferenceCode(Operator.LESS_THAN, referenceCode); + } + + public ShipmentRequest withReferenceCodeLessThanOrEqualTo(String referenceCode){ + return withReferenceCode(Operator.LESS_THAN_OR_EQUAL, referenceCode); + } + + public ShipmentRequest withReferenceCodeBetween(String startOfReferenceCode, String endOfReferenceCode){ + return withReferenceCode(Operator.BETWEEN, startOfReferenceCode, endOfReferenceCode); + } + public ShipmentRequest withReferenceCodeStartingWith(String referenceCode){ + return withReferenceCode(Operator.BEGIN_WITH, referenceCode); + } + public ShipmentRequest withReferenceCodeContaining(String referenceCode){ + return withReferenceCode(Operator.CONTAIN, referenceCode); + } + + public ShipmentRequest withReferenceCodeNotContaining(String referenceCode){ + return withReferenceCode(Operator.NOT_CONTAIN, referenceCode); + } + + public ShipmentRequest withReferenceCodeNotStartingWith(String referenceCode){ + return withReferenceCode(Operator.NOT_BEGIN_WITH, referenceCode); + } + + public ShipmentRequest withReferenceCodeEndingWith(String referenceCode){ + return withReferenceCode(Operator.END_WITH, referenceCode); + } + + public ShipmentRequest withReferenceCodeNotEndingWith(String referenceCode){ + return withReferenceCode(Operator.NOT_END_WITH, referenceCode); + } + + public ShipmentRequest withReferenceCodeIs(String referenceCode){ + return withReferenceCode(Operator.EQUAL, referenceCode); + } + + public ShipmentRequest withReferenceCodeSoundingLike(String referenceCode){ + return withReferenceCode(Operator.SOUNDS_LIKE, referenceCode); + } + + + + public ShipmentRequest filterByVersion(Long... version){ + if (version == null || version.length == 0) { + throw new IllegalArgumentException("filterByVersion parameter version cannot be empty"); + } + return appendSearchCriteria(createVersionCriteria(Operator.EQUAL, (Object[])version)); + } + + public ShipmentRequest withVersion(Operator operator, Object... values){ + return appendSearchCriteria(createVersionCriteria(operator, values)); + } + + public ShipmentRequest withVersionIsUnknown(){ + return withVersion(Operator.IS_NULL); + } + + public ShipmentRequest withVersionIsKnown(){ + return withVersion(Operator.IS_NOT_NULL); + } + + public SearchCriteria createVersionCriteria(Operator operator, Object... values) { + return createBasicSearchCriteria(Shipment.VERSION_PROPERTY, operator, values); + } + + public ShipmentRequest withVersionIs(Long version){ + return withVersion(Operator.EQUAL, version); + } + + public ShipmentRequest withVersionIsNot(Long version){ + return withVersion(Operator.NOT_EQUAL, version); + } + + public ShipmentRequest withVersionIn(Long... version){ + return withVersion(Operator.IN, (Object[])version); + } + + public ShipmentRequest withVersionNotIn(Long... version){ + return withVersion(Operator.NOT_IN, (Object[])version); + } + public ShipmentRequest withVersionGreaterThan(Long version){ + return withVersion(Operator.GREATER_THAN, version); + } + + public ShipmentRequest withVersionGreaterThanOrEqualTo(Long version){ + return withVersion(Operator.GREATER_THAN_OR_EQUAL, version); + } + + public ShipmentRequest withVersionLessThan(Long version){ + return withVersion(Operator.LESS_THAN, version); + } + + public ShipmentRequest withVersionLessThanOrEqualTo(Long version){ + return withVersion(Operator.LESS_THAN_OR_EQUAL, version); + } + + public ShipmentRequest withVersionBetween(Long startOfVersion, Long endOfVersion){ + return withVersion(Operator.BETWEEN, startOfVersion, endOfVersion); + } + + + + public ShipmentRequest count(){ + super.count(); + return this; + } + public ShipmentRequest countAs(String retName){ + super.count(retName); + return this; + } + public ShipmentRequest groupByCustomerOrderWithDetails(){ + return groupByCustomerOrderWithDetails(Q.customerOrders().unlimited()); + } + + public ShipmentRequest groupByCustomerOrderWithDetails(CustomerOrderRequest subRequest){ + aggregate(Shipment.CUSTOMER_ORDER_PROPERTY, subRequest); + return this; + } + + + + + public ShipmentRequest groupById(){ + groupBy(Shipment.ID_PROPERTY); + return this; + } + + public ShipmentRequest groupByIdAs(String retName){ + groupBy(retName, Shipment.ID_PROPERTY); + return this; + } + + public ShipmentRequest groupByIdWithFunction(String retName, AggrFunction function){ + groupBy(retName, Shipment.ID_PROPERTY, function); + return this; + } + public ShipmentRequest groupByCustomerOrderWith(CustomerOrderRequest subRequest){ + groupBy(Shipment.CUSTOMER_ORDER_PROPERTY, subRequest); + return this; + } + public ShipmentRequest groupByCustomerOrder(){ + groupBy(Shipment.CUSTOMER_ORDER_PROPERTY); + return this; + } + + public ShipmentRequest groupByCustomerOrderAs(String retName){ + groupBy(retName, Shipment.CUSTOMER_ORDER_PROPERTY); + return this; + } + + public ShipmentRequest groupByCustomerOrderWithFunction(String retName, AggrFunction function){ + groupBy(retName, Shipment.CUSTOMER_ORDER_PROPERTY, function); + return this; + } + + public ShipmentRequest groupByReferenceCode(){ + groupBy(Shipment.REFERENCE_CODE_PROPERTY); + return this; + } + + public ShipmentRequest groupByReferenceCodeAs(String retName){ + groupBy(retName, Shipment.REFERENCE_CODE_PROPERTY); + return this; + } + + public ShipmentRequest groupByReferenceCodeWithFunction(String retName, AggrFunction function){ + groupBy(retName, Shipment.REFERENCE_CODE_PROPERTY, function); + return this; + } + + public ShipmentRequest groupByVersion(){ + groupBy(Shipment.VERSION_PROPERTY); + return this; + } + + public ShipmentRequest groupByVersionAs(String retName){ + groupBy(retName, Shipment.VERSION_PROPERTY); + return this; + } + + public ShipmentRequest groupByVersionWithFunction(String retName, AggrFunction function){ + groupBy(retName, Shipment.VERSION_PROPERTY, function); + return this; + } + + + + public ShipmentRequest orderByIdAscending(){ + addOrderByAscending(Shipment.ID_PROPERTY); + return this; + } + + public ShipmentRequest orderByIdDescending(){ + addOrderByDescending(Shipment.ID_PROPERTY); + return this; + } + + public ShipmentRequest orderByCustomerOrderAscending(){ + addOrderByAscending(Shipment.CUSTOMER_ORDER_PROPERTY); + return this; + } + + public ShipmentRequest orderByCustomerOrderDescending(){ + addOrderByDescending(Shipment.CUSTOMER_ORDER_PROPERTY); + return this; + } + + public ShipmentRequest orderByReferenceCodeAscending(){ + addOrderByAscending(Shipment.REFERENCE_CODE_PROPERTY); + return this; + } + + public ShipmentRequest orderByReferenceCodeDescending(){ + addOrderByDescending(Shipment.REFERENCE_CODE_PROPERTY); + return this; + } + public ShipmentRequest orderByReferenceCodeAscendingUsingGBK(){ + addOrderByAscendingUsingGBK(Shipment.REFERENCE_CODE_PROPERTY); + return this; + } + + public ShipmentRequest orderByReferenceCodeDescendingUsingGBK(){ + addOrderByDescendingUsingGBK(Shipment.REFERENCE_CODE_PROPERTY); + return this; + } + public ShipmentRequest orderByVersionAscending(){ + addOrderByAscending(Shipment.VERSION_PROPERTY); + return this; + } + + public ShipmentRequest orderByVersionDescending(){ + addOrderByDescending(Shipment.VERSION_PROPERTY); + return this; + } + + + public CustomerOrderRequest rollUpToCustomerOrder(){ + CustomerOrderRequest customerOrder = Q.customerOrders().unlimited(); + this.withCustomerOrderMatching(customerOrder) + .groupByCustomerOrderWith(customerOrder); + return customerOrder; + } + + + + + public ShipmentRequest facetByCustomerOrderAs(String facetName, CustomerOrderRequest customerOrder){ + return facetByCustomerOrderAs(facetName, customerOrder, true); + } + + public ShipmentRequest facetByCustomerOrderAs(String facetName, CustomerOrderRequest customerOrder, boolean includeAllFacets){ + addFacet(facetName, Shipment.CUSTOMER_ORDER_PROPERTY, customerOrder, includeAllFacets); + return this; + } + + + /** + * get topN records + * @param topN records number + */ + public ShipmentRequest top(int topN) { + super.top(topN); + return this; + } + + /** Cross-runtime bounded-query alias. */ + public ShipmentRequest limit(int limit) { + return top(limit); + } + + /** + * get records from offset(inclusive) to offset+size(exclusive) + * @param offset record offset + * @param size records number + */ + public ShipmentRequest offset(int offset, int size) { + super.offset(offset, size); + return this; + } + + /** + * retrieve all records + */ + public ShipmentRequest unlimited() { + super.unlimited(); + return this; + } + + /** + * get records of one page + * @param pageNumber page number(1-based) + * @param pageSize page size + */ + public ShipmentRequest page(int pageNumber, int pageSize) { + int offset = (pageNumber - 1) * pageSize; + return offset(offset, pageSize); + } + + /** + * get records of one page, default page size is 10 + * @param pageNumber page number(1-based) + */ + public ShipmentRequest page(int pageNumber) { + return page(pageNumber, 10); + } +} \ No newline at end of file diff --git a/examples/trace-chain/lib/src/main/resources/META-INF/services/io.teaql.core.meta.EntityMetaAssembler b/examples/trace-chain/lib/src/main/resources/META-INF/services/io.teaql.core.meta.EntityMetaAssembler new file mode 100644 index 00000000..ccb7914c --- /dev/null +++ b/examples/trace-chain/lib/src/main/resources/META-INF/services/io.teaql.core.meta.EntityMetaAssembler @@ -0,0 +1,2 @@ + +com.teaql.tracechainservice.EntityMetaRegistry \ No newline at end of file diff --git a/examples/trace-chain/model.xml b/examples/trace-chain/model.xml new file mode 100644 index 00000000..af681a9c --- /dev/null +++ b/examples/trace-chain/model.xml @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/examples/trace-chain/pom.xml b/examples/trace-chain/pom.xml new file mode 100644 index 00000000..c2134168 --- /dev/null +++ b/examples/trace-chain/pom.xml @@ -0,0 +1,26 @@ + + + 4.0.0 + + io.teaql + teaql-java-parent + 1.553-RELEASE + ../../pom.xml + + teaql-trace-chain-example + Generated API Trace Chain SQLite acceptance + 21 + + com.teaqltrace-chain-service-core0.0.1-SNAPSHOT + io.teaqlteaql-runtime + io.teaqlteaql-sqlite + io.teaqlteaql-provider-jdbc + junitjunittest + + + org.apache.maven.pluginsmaven-surefire-plugin3.2.5 + ${teaql.trace.database} + + diff --git a/examples/trace-chain/src/test/java/io/teaql/examples/tracechain/GeneratedAggregateTraceExampleTest.java b/examples/trace-chain/src/test/java/io/teaql/examples/tracechain/GeneratedAggregateTraceExampleTest.java new file mode 100644 index 00000000..428d4163 --- /dev/null +++ b/examples/trace-chain/src/test/java/io/teaql/examples/tracechain/GeneratedAggregateTraceExampleTest.java @@ -0,0 +1,182 @@ +package io.teaql.examples.tracechain; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.teaql.tracechainservice.E; +import com.teaql.tracechainservice.Q; +import com.teaql.tracechainservice.customerorder.CustomerOrder; +import io.teaql.core.ExecutionMetadata; +import io.teaql.core.TraceKind; +import io.teaql.core.TraceNode; +import io.teaql.runtime.LogPrivacy; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.Test; +import static org.junit.Assert.*; + +/** Application-owned TC-SQL-10: generated Q/E plus inherited runtime aggregate APIs. */ +public class GeneratedAggregateTraceExampleTest { + private static final String PRIVATE = "JAVA-PRIVATE-AGGREGATE"; + private static final String COMMENT = "inspect " + PRIVATE; + private static final String PURPOSE = "verify original aggregate ancestry"; + + @Test public void generatedAggregateRetainsRawPathsPrivacyAndNumericPartition() throws Exception { + for (boolean nested : List.of(false, true)) for (boolean logging : List.of(false, true)) { + var fixture = new GeneratedTraceChainExampleTest.Fixture(logging); + var graph = fixture.saveNormativeGraph(); + graph.kept().updateName(PRIVATE); + graph.order().auditAs("seed private aggregate operand").save(fixture.context); + fixture.clear(); fixture.queryResults.clear(); + + var count = Q.orderItems().withNameIs(PRIVATE).limit(10); + // Public BaseRequest contract, exercised by DerivedQueryTraceSqliteTest. + // Partition by the real forward model edge; runtime resolves the reverse edge. + count.setPartitionProperty("customerOrder"); count.count("count"); + var owner = Q.customerOrders().withIdIs(graph.order().getId()).limit(1); + owner.addSingleAggregateDynamicProperty("selectedItemCount", count); + CustomerOrder result = nested + ? E.payment(Q.payments().withIdIs(graph.payment().getId()).limit(1) + .selectCustomerOrderWith(owner).comment(COMMENT).purpose(PURPOSE) + .executeForOne(fixture.context)).getCustomerOrder().eval() + : owner.comment(COMMENT).purpose(PURPOSE).executeForOne(fixture.context); + assertEquals(graph.order().getId(), E.customerOrder(result).getId().eval()); + assertEquals("related aggregate must retain its actual scoped count", 1, + ((Number) result.getDynamicProperty("selectedItemCount")).intValue()); + var routes = nested ? List.of(List.of(), List.of("customerOrder"), + List.of("customerOrder", "orderItemList")) + : List.of(List.of(), List.of("orderItemList")); + var raw = assertFacts(fixture, nested ? "Payment" : "CustomerOrder", routes, COMMENT, + PURPOSE, "inspect [REDACTED]", logging); + assertEquals(1, raw.stream().filter(f -> f.getParameterizedQuery().toUpperCase(java.util.Locale.ROOT).contains("COUNT(")).count()); + emit("JAVA_AGGREGATE_OBSERVED", Map.of("nested", nested, "logging", logging, + "count", 1, "raw", observations(raw), "safe", observations(fixture.sql), + "mutationCommands", fixture.commands.size(), "committedAudits", fixture.audit.size())); + + fixture.clear(); fixture.queryResults.clear(); + // Identity is a numeric partition, not a model relation. It cannot add a relation frame. + var selfCount = Q.customerOrders().withIdIs(graph.order().getId()).limit(1); + selfCount.setPartitionProperty("id"); selfCount.count("count"); + var numericOwner = Q.customerOrders().withIdIs(graph.order().getId()).limit(1); + numericOwner.addSingleAggregateDynamicProperty("selfCount", selfCount); + var numeric = nested + ? E.payment(Q.payments().withIdIs(graph.payment().getId()).limit(1) + .selectCustomerOrderWith(numericOwner).comment(COMMENT).purpose("numeric partition has no edge") + .executeForOne(fixture.context)).getCustomerOrder().eval() + : numericOwner.comment(COMMENT).purpose("numeric partition has no edge").executeForOne(fixture.context); + assertEquals(1, ((Number) numeric.getDynamicProperty("selfCount")).intValue()); + var numericRoutes = nested ? List.of(List.of(), List.of("customerOrder"), List.of("customerOrder")) + : List.of(List.of(), List.of()); + var numericRaw = assertFacts(fixture, nested ? "Payment" : "CustomerOrder", numericRoutes, + COMMENT, "numeric partition has no edge", COMMENT, logging); + assertEquals(1, numericRaw.stream().filter(f -> f.getParameterizedQuery().toUpperCase(java.util.Locale.ROOT).contains("COUNT(")).count()); + emit("JAVA_AGGREGATE_NUMERIC", Map.of("nested", nested, "logging", logging, + "count", 1, "raw", observations(numericRaw), "safe", observations(fixture.sql))); + } + } + + @Test public void generatedCountAndMembershipSurviveFilteredForwardDetail() throws Exception { + for (boolean nested : List.of(false, true)) for (boolean logging : List.of(false, true)) + for (boolean filtered : List.of(false, true)) { + var fixture = new GeneratedTraceChainExampleTest.Fixture(logging); + var graph = fixture.saveNormativeGraph(); + graph.kept().updateName(PRIVATE); + var extra = Q.orderItems().comment("prepare second visible member") + .purpose("verify full aggregate membership").newEntity(fixture.context); + extra.updateName("Public aggregate member"); graph.order().addOrderItem(extra); + graph.order().auditAs("seed independently scoped aggregate members").save(fixture.context); + fixture.clear(); fixture.queryResults.clear(); + var count = Q.orderItems().withNameIs(PRIVATE).limit(10); + count.setPartitionProperty("customerOrder"); count.count("count"); + var detail = Q.customerOrders().withIdIs(filtered ? -1L : graph.order().getId()).limit(1); + var owner = Q.customerOrders().withIdIs(graph.order().getId()).limit(1) + .selectOrderItemListWith(Q.orderItems().orderByIdAscending().limit(10) + .selectCustomerOrderWith(detail)); + owner.addSingleAggregateDynamicProperty("selectedItemCount", count); + var result = nested + ? E.payment(Q.payments().withIdIs(graph.payment().getId()).limit(1) + .selectCustomerOrderWith(owner).comment(COMMENT).purpose(PURPOSE) + .executeForOne(fixture.context)).getCustomerOrder().eval() + : owner.comment(COMMENT).purpose(PURPOSE).executeForOne(fixture.context); + var items = E.customerOrder(result).getOrderItemList().eval(); + assertEquals("unfetched forward detail cannot erase list membership", 2, items.size()); + assertEquals(1, ((Number) result.getDynamicProperty("selectedItemCount")).intValue()); + for (var item : items) { + var identity = E.orderItem(item).getCustomerOrder().eval(); + assertNotNull(identity); + assertEquals(graph.order().getId(), E.customerOrder(identity).getId().eval()); + if (filtered) assertThrows(io.teaql.core.value.TeaQLNotLoadedException.class, + () -> E.customerOrder(identity).getDescription().eval()); + else assertEquals(graph.order().getDescription(), E.customerOrder(identity).getDescription().eval()); + } + var routes = nested ? List.of(List.of(), List.of("customerOrder"), + List.of("customerOrder", "orderItemList"), List.of("customerOrder", "orderItemList", "customerOrder"), + List.of("customerOrder", "orderItemList")) + : List.of(List.of(), List.of("orderItemList"), List.of("orderItemList", "customerOrder"), List.of("orderItemList")); + var raw = assertFacts(fixture, nested ? "Payment" : "CustomerOrder", routes, COMMENT, + PURPOSE, "inspect [REDACTED]", logging); + emit("JAVA_AGGREGATE_MEMBERSHIP", Map.of("nested", nested, "logging", logging, + "filtered", filtered, "count", 1, "members", 2, "rootID", graph.order().getId(), + "foreignIDs", items.stream().map(i -> E.customerOrder(E.orderItem(i).getCustomerOrder().eval()).getId().eval()).toList(), + "detail", filtered ? "NotLoaded" : "Loaded", "raw", observations(raw), "safe", observations(fixture.sql))); + if (filtered) { + fixture.clear(); fixture.queryResults.clear(); + var full = Q.orderItems().withIdIs(graph.kept().getId()).limit(1) + .selectCustomerOrderWith(Q.customerOrders().limit(1)) + .comment("independent full detail").purpose("verify edge-owned view").executeForOne(fixture.context); + assertEquals(graph.order().getDescription(), E.customerOrder(E.orderItem(full).getCustomerOrder().eval()).getDescription().eval()); + for (var item : items) assertThrows(io.teaql.core.value.TeaQLNotLoadedException.class, + () -> E.customerOrder(E.orderItem(item).getCustomerOrder().eval()).getDescription().eval()); + var visibleRaw = assertFacts(fixture, "OrderItem", List.of(List.of(), List.of("customerOrder")), + "independent full detail", "verify edge-owned view", "independent full detail", logging); + emit("JAVA_AGGREGATE_FORWARD", Map.of("nested", nested, "logging", logging, + "originalDetailsStillNotLoaded", true, "fullDetailIndependent", true, "raw", observations(visibleRaw))); + } + } + } + + private static List assertFacts(GeneratedTraceChainExampleTest.Fixture fixture, + String root, List> routes, String comment, String purpose, String safeComment, boolean logging) { + var raw = fixture.queryResults.get(fixture.queryResults.size() - 1).statements(); + assertEquals(routes.size(), raw.size()); assertEquals(logging ? raw.size() : 0, fixture.sql.size()); + for (int i = 0; i < raw.size(); i++) { + var expected = new ArrayList<>(List.of(new TraceNode(TraceKind.OPERATION, root, null, "query"), + new TraceNode(TraceKind.REQUEST, root, null, ""))); + String parent = root; + for (String relation : routes.get(i)) { + expected.add(new TraceNode(TraceKind.RELATION, relation, null, parent + "." + relation)); + parent = relation.equals("orderItemList") ? "OrderItem" : "CustomerOrder"; + } + expected.add(new TraceNode(TraceKind.PROVIDER, "sqlite", null, "")); + expected.add(new TraceNode(TraceKind.SQL, "select", null, "")); + assertFact(raw.get(i), expected, comment, purpose); + assertFact(LogPrivacy.sql(raw.get(i), false), expected, safeComment, purpose); + if (logging) assertFact(fixture.sql.get(i), expected, safeComment, purpose); + } + assertTrue(fixture.commands.isEmpty()); assertTrue(fixture.audit.isEmpty()); + assertTrue(fixture.context.getTraceChain().isEmpty()); return raw; + } + + private static void assertFact(ExecutionMetadata fact, List expected, String comment, String purpose) { + assertEquals(expected, fact.getTraceChain()); assertEquals(comment, fact.getComment()); + assertEquals(purpose, fact.getPurpose()); assertEquals("success", fact.getExecutionOutcome()); + assertTrue(fact.getParameterizedQuery().startsWith("SELECT")); + } + + private static List> observations(List facts) { + return facts.stream().map(f -> { + Map row = new LinkedHashMap<>(); + row.put("path", f.getTraceChain().stream().map(n -> { + Map node = new LinkedHashMap<>(); + node.put("kind", n.getKind().name()); node.put("name", n.getName()); + node.put("entityId", n.getEntityId()); node.put("comment", n.getComment()); return node; + }).toList()); + row.put("comment", f.getComment()); row.put("purpose", f.getPurpose()); + row.put("outcome", f.getExecutionOutcome()); row.put("sql", f.getParameterizedQuery()); return row; + }).toList(); + } + + private static void emit(String marker, Object value) throws Exception { + System.out.println(marker + " " + new ObjectMapper().writeValueAsString(value)); + } +} diff --git a/examples/trace-chain/src/test/java/io/teaql/examples/tracechain/GeneratedFacetTraceExampleTest.java b/examples/trace-chain/src/test/java/io/teaql/examples/tracechain/GeneratedFacetTraceExampleTest.java new file mode 100644 index 00000000..b1e9c528 --- /dev/null +++ b/examples/trace-chain/src/test/java/io/teaql/examples/tracechain/GeneratedFacetTraceExampleTest.java @@ -0,0 +1,213 @@ +package io.teaql.examples.tracechain; + +import com.teaql.tracechainservice.E; +import com.teaql.tracechainservice.Q; +import com.teaql.tracechainservice.customerorder.CustomerOrder; +import com.teaql.tracechainservice.payment.Payment; +import io.teaql.core.Entity; +import io.teaql.core.ExecutionMetadata; +import io.teaql.core.SmartList; +import io.teaql.core.TraceKind; +import io.teaql.core.TraceNode; +import io.teaql.runtime.LogPrivacy; +import java.util.List; +import org.junit.Test; +import static org.junit.Assert.*; + +/** TC-SQL-09: check returned nested metadata, not just successful facet SQL. */ +public class GeneratedFacetTraceExampleTest { + @Test public void loadedForwardRelationFacetsBelongToEachReferencedEntity() throws Exception { + for (boolean logging : List.of(false, true)) { + var fixture = new GeneratedTraceChainExampleTest.Fixture(logging); + var first = fixture.saveNormativeGraph(); + var second = Q.customerOrders().comment("prepare a distinct facet owner") + .purpose("verify per-parent metadata").newEntity(fixture.context); + second.updatePlatform(E.customerOrder(first.order()).getPlatform().eval()); + second.updateOrderNumber("LOADED-FACET-ORDER-" + fixture.base); + second.updateDescription("Distinct loaded facet owner"); + var payment = Q.payments().comment("prepare a distinct loaded payment") + .purpose("verify per-parent metadata").newEntity(fixture.context); + payment.updateReferenceCode("LOADED-FACET-PAYMENT-" + fixture.base); + var attempt = Q.paymentAttempts().comment("prepare a distinct related attempt") + .purpose("verify per-parent metadata").newEntity(fixture.context); + attempt.updateReferenceCode("LOADED-FACET-ATTEMPT-" + fixture.base); + payment.addPaymentAttempt(attempt); + second.addPayment(payment); + second.auditAs("seed independent loaded facet owners").save(fixture.context); + fixture.clear(); fixture.queryResults.clear(); + String comment = "load independently scoped related facets"; + String purpose = "verify returned metadata belongs to each loaded payment"; + var rows = Q.paymentAttempts().withIdIn(first.attempt().getId(), attempt.getId()) + .orderByIdDescending().limit(2) + .selectPaymentWith(Q.payments().limit(2) + .facetByCustomerOrderAs("orders", Q.customerOrders().limit(2), false)) + .comment(comment).purpose(purpose).executeForList(fixture.context); + assertEquals(2, rows.size()); + for (var row : rows) { + var expected = row.getId().equals(first.attempt().getId()) ? first.order().getId() : second.getId(); + var loadedPayment = E.paymentAttempt(row).getPayment().eval(); + var orders = loadedPayment.getQueryFacet("orders"); + assertNotNull("loaded forward relation must retain requested Facet metadata", orders); + assertEquals("Facets must not leak membership from a different loaded parent", 1, orders.size()); + assertEquals(expected, E.customerOrder((CustomerOrder) orders.get(0)).getId().eval()); + assertEquals(1, count((Entity) orders.get(0))); + } + var raw = fixture.queryResults.get(fixture.queryResults.size() - 1).statements(); + var routes = List.of(List.of(), List.of("payment"), List.of("payment"), + List.of("payment", "customerOrder"), List.of("payment"), List.of("payment"), + List.of("payment", "customerOrder")); + assertEquals(routes.size(), raw.size()); + for (int i = 0; i < raw.size(); i++) assertPath(raw.get(i), routes.get(i), comment, purpose); + assertEquals(logging ? raw.size() : 0, fixture.sql.size()); + assertTrue(fixture.context.getTraceChain().isEmpty()); + System.out.printf("JAVA_LOADED_FACET logging=%s parents=2 independentMembership=true physicalStatements=7 safeSinkStatements=%d%n", + logging, fixture.sql.size()); + } + } + + @Test public void nestedFacetMetadataRemainsAttachedToTheReturnedFacetCollection() throws Exception { + var fixture = new GeneratedTraceChainExampleTest.Fixture(); + var graph = fixture.saveNormativeGraph(); + fixture.clear(); + var rows = Q.paymentAttempts().withIdIs(graph.attempt().getId()).limit(1) + .facetByPaymentAs("payments", Q.payments().withIdIs(graph.payment().getId()).limit(1) + .facetByCustomerOrderAs("orders", Q.customerOrders().withIdIs(graph.order().getId()).limit(1))) + .comment("load nested facet metadata").purpose("verify the returned collection, not only SQL").executeForList(fixture.context); + assertEquals(1, rows.size()); + var payments = rows.getFacet("payments"); + assertNotNull(payments); assertEquals(1, payments.size()); + var orders = payments.getFacet("orders"); + assertNotNull("nested Facet metadata must survive materialization", orders); + assertEquals(1, orders.size()); + assertEquals(graph.order().getId(), E.customerOrder((CustomerOrder) orders.get(0)).getId().eval()); + assertEquals(1, count((CustomerOrder) orders.get(0))); + System.out.println("JAVA_NESTED_FACET_CARRIER returned nested metadata and count verified"); + } + + @Test public void loadedForwardFacetsPreserveRequestedEmptyAndNeverBecomeWrites() throws Exception { + for (boolean logging : List.of(false, true)) { + for (boolean includeAll : List.of(false, true)) { + for (boolean targetExists : List.of(false, true)) { + var fixture = new GeneratedTraceChainExampleTest.Fixture(logging); + var graph = fixture.saveNormativeGraph(); + fixture.clear(); fixture.queryResults.clear(); + String comment = "load explicitly bounded related facet"; + String purpose = "verify empty results and query-only metadata"; + var row = Q.paymentAttempts().withIdIs(graph.attempt().getId()).limit(1) + .selectPaymentWith(Q.payments().limit(1) + .facetByCustomerOrderAs("orders", Q.customerOrders() + .withIdIs(targetExists ? graph.order().getId() : -1L).limit(1), includeAll)) + .comment(comment).purpose(purpose).executeForOne(fixture.context); + var payment = E.paymentAttempt(row).getPayment().eval(); + var orders = payment.getQueryFacet("orders"); + assertNotNull("a requested empty Facet is loaded, not missing", orders); + assertEquals(targetExists ? 1 : 0, orders.size()); + assertEquals("unfetched related detail cannot erase FK identity", graph.order().getId(), + E.customerOrder(E.payment(payment).getCustomerOrder().eval()).getId().eval()); + var raw = fixture.queryResults.get(fixture.queryResults.size() - 1).statements(); + var routes = List.of(List.of(), List.of("payment"), List.of("payment"), + List.of("payment", "customerOrder")); + assertEquals(routes.size(), raw.size()); + for (int i = 0; i < raw.size(); i++) assertPath(raw.get(i), routes.get(i), comment, purpose); + assertEquals(logging ? raw.size() : 0, fixture.sql.size()); + fixture.clear(); + payment.updateReferenceCode("LOADED-FACET-SAVE-" + fixture.base); + payment.auditAs("update one payment without persisting query metadata").save(fixture.context); + assertEquals("Facet sidecar cannot become a graph write", 1, fixture.commands.size()); + assertEquals("Payment", fixture.commands.get(0).getEntity().typeName()); + assertTrue(fixture.context.getTraceChain().isEmpty()); + System.out.printf("JAVA_LOADED_EMPTY_FACET logging=%s includeAll=%s targetExists=%s facetRows=%d physicalStatements=4 mutationCommands=1%n", + logging, includeAll, targetExists, orders.size()); + } + } + } + } + + @Test public void nestedFacetCountsSurviveMaterializationBeyondTheVisiblePage() throws Exception { + for (boolean logging : List.of(false, true)) { + for (boolean includeAll : List.of(false, true)) { + var fixture = new GeneratedTraceChainExampleTest.Fixture(logging); + var graph = fixture.saveNormativeGraph(); + var payment = Q.payments().comment("prepare another facet member") + .purpose("verify full membership beyond one visible row").newEntity(fixture.context); + payment.updateReferenceCode("FACET-PAYMENT-" + fixture.base); + for (int i = 0; i < 2; i++) { + var attempt = Q.paymentAttempts().comment("prepare a counted attempt") + .purpose("verify full facet membership").newEntity(fixture.context); + attempt.updateReferenceCode("FACET-ATTEMPT-" + fixture.base + "-" + i); + payment.addPaymentAttempt(attempt); + } + graph.order().addPayment(payment); + graph.order().auditAs("seed nested facet membership").save(fixture.context); + long orderId = E.customerOrder(graph.order()).getId().eval(); + long paymentId = E.payment(payment).getId().eval(); + fixture.clear(); fixture.queryResults.clear(); + String comment = "load bounded nested payment facets"; + String purpose = "verify returned full counts and original query ancestry"; + var rows = Q.paymentAttempts() + .withPaymentMatching(Q.payments().filterByCustomerOrder(orderId)) + .orderByIdDescending().limit(1) + .facetByPaymentAs("payments", Q.payments().filterByCustomerOrder(orderId) + .orderByIdDescending().limit(1) + .facetByCustomerOrderAs("orders", Q.customerOrders().withIdIs(orderId).limit(1), includeAll), includeAll) + .comment(comment).purpose(purpose).executeForList(fixture.context); + assertEquals("bounded visible attempt page", 1, rows.size()); + var payments = rows.getFacet("payments"); + assertNotNull("first-level facet metadata", payments); + assertEquals(1, payments.size()); + var selected = (Payment) payments.get(0); + assertEquals(paymentId, E.payment(selected).getId().eval().longValue()); + assertEquals("first-level count is not the one-row visible page", 2, count(selected)); + var orders = payments.getFacet("orders"); + assertNotNull("nested Facet metadata must survive materialization", orders); + assertEquals(1, orders.size()); + var order = (CustomerOrder) orders.get(0); + assertEquals(orderId, E.customerOrder(order).getId().eval().longValue()); + assertEquals("nested count uses both filtered payments, not the visible payment page", 2, count(order)); + var raw = fixture.queryResults.get(fixture.queryResults.size() - 1).statements(); + // The page and its COUNT each materialize the predicate. Both + // real SELECTs must belong to the originating collector. + var routes = List.of(List.of("payment"), List.of(), List.of("payment"), + List.of(), List.of("payment"), List.of("payment"), + List.of("payment", "customerOrder")); + assertEquals(routes.size(), raw.size()); + for (int i = 0; i < raw.size(); i++) { + assertPath(raw.get(i), routes.get(i), comment, purpose); + assertEquals("real SELECT completed", "success", raw.get(i).getExecutionOutcome()); + var safe = LogPrivacy.sql(raw.get(i), false); + assertPath(safe, routes.get(i), comment, purpose); + if (i == 3 || i == 5) { + assertTrue("physical membership COUNT", raw.get(i).getParameterizedQuery().toUpperCase().contains("COUNT(")); + assertFalse("membership count must not reuse the visible page limit", + raw.get(i).getParameterizedQuery().toUpperCase().contains("LIMIT 1")); + } + } + assertEquals(logging ? raw.size() : 0, fixture.sql.size()); + for (int i = 0; i < fixture.sql.size(); i++) assertPath(fixture.sql.get(i), routes.get(i), comment, purpose); + assertTrue(fixture.context.getTraceChain().isEmpty()); + System.out.printf("JAVA_NESTED_FACET {\"logging\":%s,\"includeAll\":%s,\"visible\":1,\"paymentCount\":2,\"orderCount\":2,\"physicalStatements\":7,\"safeSinkStatements\":%d}%n", + logging, includeAll, fixture.sql.size()); + } + } + } + + private static int count(Entity entity) { + return ((Number) entity.getDynamicProperty("count")).intValue(); + } + + private static void assertPath(ExecutionMetadata fact, List route, String comment, String purpose) { + var expected = new java.util.ArrayList<>(List.of( + new TraceNode(TraceKind.OPERATION, "PaymentAttempt", null, "query"), + new TraceNode(TraceKind.REQUEST, "PaymentAttempt", null, ""))); + String owner = "PaymentAttempt"; + for (String edge : route) { + expected.add(new TraceNode(TraceKind.RELATION, edge, null, owner + "." + edge)); + owner = edge.equals("payment") ? "Payment" : "CustomerOrder"; + } + expected.add(new TraceNode(TraceKind.PROVIDER, "sqlite", null, "")); + expected.add(new TraceNode(TraceKind.SQL, "select", null, "")); + assertEquals("complete canonical physical path", expected, fact.getTraceChain()); + assertEquals(comment, fact.getComment()); + assertEquals(purpose, fact.getPurpose()); + } +} diff --git a/examples/trace-chain/src/test/java/io/teaql/examples/tracechain/GeneratedReverseFacetTraceExampleTest.java b/examples/trace-chain/src/test/java/io/teaql/examples/tracechain/GeneratedReverseFacetTraceExampleTest.java new file mode 100644 index 00000000..1bf495d7 --- /dev/null +++ b/examples/trace-chain/src/test/java/io/teaql/examples/tracechain/GeneratedReverseFacetTraceExampleTest.java @@ -0,0 +1,98 @@ +package io.teaql.examples.tracechain; + +import com.teaql.tracechainservice.E; +import com.teaql.tracechainservice.Q; +import com.teaql.tracechainservice.customerorder.CustomerOrder; +import io.teaql.core.ExecutionMetadata; +import io.teaql.core.TraceKind; +import io.teaql.core.TraceNode; +import io.teaql.runtime.LogPrivacy; +import java.util.ArrayList; +import java.util.List; +import org.junit.Test; +import static org.junit.Assert.*; + +/** Application-owned TC-SQL-09 acceptance; no generated-library inspection or edits. */ +public class GeneratedReverseFacetTraceExampleTest { + @Test public void generatedReverseCollectionsRetainScopedFacetsFullCountsAndLoadedEmpty() throws Exception { + for (boolean logging : List.of(false, true)) for (boolean includeAll : List.of(false, true)) { + var fixture = new GeneratedTraceChainExampleTest.Fixture(logging); + var graph = fixture.saveNormativeGraph(); + var extra = Q.payments().comment("prepare second child of first parent") + .purpose("verify full membership beyond child page").newEntity(fixture.context); + extra.updateReferenceCode("REVERSE-EXTRA-" + fixture.base); + graph.order().addPayment(extra); + graph.order().auditAs("seed two members for first parent").save(fixture.context); + var second = newOrder(fixture, graph.order(), "SECOND"); + var payment = Q.payments().comment("prepare child of second parent") + .purpose("verify independent relation membership").newEntity(fixture.context); + payment.updateReferenceCode("REVERSE-SECOND-" + fixture.base); + second.addPayment(payment); + second.auditAs("seed independently scoped second parent").save(fixture.context); + var empty = newOrder(fixture, graph.order(), "EMPTY"); + empty.auditAs("seed parent with no payments").save(fixture.context); + var ids = new Long[]{graph.order().getId(), second.getId(), empty.getId()}; + fixture.clear(); fixture.queryResults.clear(); + String comment = "load independently scoped reverse collection facets"; + String purpose = "verify generated Q/E, full counts and requested empty"; + // Exact selector from local field Assist at generator 218785e2. + var rows = Q.customerOrders().withIdIn(ids).orderByIdAscending().limit(3) + .selectPaymentListWith(Q.payments().orderByIdAscending().limit(1) + .facetByCustomerOrderAs("orders", Q.customerOrders().withIdIn(ids).limit(3), includeAll)) + .comment(comment).purpose(purpose).executeForList(fixture.context); + assertEquals(3, rows.size()); + for (var parent : rows) { + long parentId = E.customerOrder(parent).getId().eval(); + int fullCount = parentId == ids[0] ? 2 : parentId == ids[1] ? 1 : 0; + var children = E.customerOrder(parent).getPaymentList().eval(); + assertNotNull("requested empty reverse collection is loaded, not NotLoaded", children); + assertEquals(Math.min(1, fullCount), E.customerOrder(parent).getPaymentList().size().eval().intValue()); + var facets = children.getFacet("orders"); + assertNotNull("returned generated collection retains requested Facet metadata", facets); + assertEquals(includeAll ? 3 : fullCount == 0 ? 0 : 1, facets.size()); + for (var value : facets) { + var target = (CustomerOrder) value; + long targetId = E.customerOrder(target).getId().eval(); + int expected = targetId == parentId ? fullCount : 0; + assertEquals("counts cover full scoped membership, not the visible child page", + expected, ((Number) target.getDynamicProperty("count")).intValue()); + } + } + var raw = fixture.queryResults.get(fixture.queryResults.size() - 1).statements(); + assertEquals("root plus three independently scoped child/count/target queries", 10, raw.size()); + for (int i = 0; i < raw.size(); i++) { + var expected = new ArrayList<>(List.of( + new TraceNode(TraceKind.OPERATION, "CustomerOrder", null, "query"), + new TraceNode(TraceKind.REQUEST, "CustomerOrder", null, ""))); + if (i > 0) expected.add(new TraceNode(TraceKind.RELATION, "paymentList", null, "CustomerOrder.paymentList")); + if (i > 0 && i % 3 == 0) expected.add(new TraceNode(TraceKind.RELATION, "customerOrder", null, "Payment.customerOrder")); + expected.add(new TraceNode(TraceKind.PROVIDER, "sqlite", null, "")); + expected.add(new TraceNode(TraceKind.SQL, "select", null, "")); + assertFact(raw.get(i), expected, comment, purpose); + assertFact(LogPrivacy.sql(raw.get(i), false), expected, comment, purpose); + if (logging) assertFact(fixture.sql.get(i), expected, comment, purpose); + } + assertEquals(logging ? 10 : 0, fixture.sql.size()); + assertEquals("query metadata cannot schedule writes", 0, fixture.commands.size()); + assertEquals(0, fixture.audit.size()); + assertTrue(fixture.context.getTraceChain().isEmpty()); + System.out.printf("JAVA_GENERATED_REVERSE_FACET logging=%s includeAll=%s parents=3 fullCounts=true requestedEmpty=true physicalStatements=10 safeSinkStatements=%d mutationCommands=0%n", + logging, includeAll, fixture.sql.size()); + } + } + + private static CustomerOrder newOrder(GeneratedTraceChainExampleTest.Fixture fixture, CustomerOrder first, String suffix) throws Exception { + var order = Q.customerOrders().comment("prepare distinct reverse collection parent") + .purpose("verify independently scoped membership").newEntity(fixture.context); + order.updatePlatform(E.customerOrder(first).getPlatform().eval()); + order.updateOrderNumber("REVERSE-" + suffix + "-" + fixture.base); + order.updateDescription("Reverse Facet " + suffix); + return order; + } + + private static void assertFact(ExecutionMetadata fact, List path, String comment, String purpose) { + assertEquals("complete physical ancestry", path, fact.getTraceChain()); + assertEquals(comment, fact.getComment()); assertEquals(purpose, fact.getPurpose()); + assertEquals("success", fact.getExecutionOutcome()); + } +} diff --git a/examples/trace-chain/src/test/java/io/teaql/examples/tracechain/GeneratedTraceChainExampleTest.java b/examples/trace-chain/src/test/java/io/teaql/examples/tracechain/GeneratedTraceChainExampleTest.java new file mode 100644 index 00000000..dece0c3d --- /dev/null +++ b/examples/trace-chain/src/test/java/io/teaql/examples/tracechain/GeneratedTraceChainExampleTest.java @@ -0,0 +1,1156 @@ +package io.teaql.examples.tracechain; + +import java.util.ArrayList; + +import com.teaql.tracechainservice.E; +import com.teaql.tracechainservice.Q; +import com.teaql.tracechainservice.GeneratedRuntimeModule; +import com.teaql.tracechainservice.customerorder.CustomerOrder; +import com.teaql.tracechainservice.orderitem.OrderItem; +import com.teaql.tracechainservice.payment.Payment; +import com.teaql.tracechainservice.paymentattempt.PaymentAttempt; +import com.teaql.tracechainservice.shipment.Shipment; +import io.teaql.core.*; +import io.teaql.core.meta.EntityMetaFactory; +import io.teaql.core.meta.SimpleEntityMetaFactory; +import io.teaql.core.sql.portable.IdSpaceIdGenerator; +import io.teaql.core.sqlite.SqliteDataServiceExecutor; +import io.teaql.provider.jdbc.JdbcSqlExecutor; +import io.teaql.runtime.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import org.junit.Test; +import org.sqlite.SQLiteDataSource; +import static org.junit.Assert.*; + +/** #202: generated public APIs -> real SQLite -> physical SQL and committed safe audit. */ +public class GeneratedTraceChainExampleTest { + @Test public void loadedSiblingPrivacyAcrossTypesAndRepeatedSaves() throws Exception { + var fixture = new Fixture(); + var graph = fixture.saveNormativeGraph(); + var oldValue = "JAVA-PRIVATE-OLD-" + fixture.base; + graph.kept.updateName(oldValue); + graph.order.auditAs("seed private loaded value").save(fixture.context); + var root = Q.customerOrders().withIdIs(E.customerOrder(graph.order).getId().eval()) + .selectOrderItemListWith(Q.orderItems().limit(2)).limit(1) + .comment("load private graph").purpose("verify complete entity mutation provenance").executeForOne(fixture.context); + var child = root.getOrderItemList().get(0); + assertEquals(oldValue, E.orderItem(child).getName().eval()); + for (int round = 0; round < 3; round++) { + var next = "JAVA-PRIVATE-NEW-" + fixture.base + "-" + round; + root.updateDescription("privacy revision " + round); + child.updateName(next); + if (round == 2) { + fixture.clear(); fixture.failReadback = true; + assertThrows(RuntimeException.class, () -> root.auditAs("failed save " + next).save(fixture.context)); + fixture.failReadback = false; + assertTrue(fixture.audit.isEmpty()); + for (var fact : fixture.sql) assertPrivateIntent(fact, next); + var unchanged = Q.orderItems().withIdIs(E.orderItem(child).getId().eval()).limit(1) + .comment("verify failed transaction").purpose("rollback retains persisted old value").executeForOne(fixture.context); + assertEquals(oldValue, E.orderItem(unchanged).getName().eval()); + } + fixture.clear(); + var reason = "page 1 replace " + oldValue + " with " + next; + root.auditAs(reason).save(fixture.context); + assertEquals(2, fixture.commands.size()); + assertEquals(4, fixture.sql.size()); + assertEquals(2, fixture.audit.size()); + assertPrivateChain(fixture, root.getId(), reason, "page 1 replace [REDACTED] with [REDACTED]"); + for (var secret : List.of(oldValue, next)) { + for (var fact : fixture.sql) { + assertPrivateIntent(fact, secret); + assertTrue("public intent must remain", (String.valueOf(fact.getComment()) + fact.getAuditReason()).contains("page 1")); + } + for (var fact : fixture.audit) + assertFalse("cross-type committed audit leaked old/new sibling", fact.traceChain().stream().anyMatch(node -> String.valueOf(node.getComment()).contains(secret))); + } + var persisted = Q.orderItems().withIdIs(E.orderItem(child).getId().eval()).limit(1) + .comment("reload private value").purpose("verify privacy does not change stored state").executeForOne(fixture.context); + assertEquals(next, E.orderItem(persisted).getName().eval()); + oldValue = next; + } + root.updateDescription("remove private child"); child.markForDeletion(); + fixture.clear(); root.auditAs("remove " + oldValue).save(fixture.context); + assertPrivateChain(fixture, root.getId(), "remove " + oldValue, "remove [REDACTED]"); + for (var fact : fixture.sql) assertPrivateIntent(fact, oldValue); + for (var fact : fixture.audit) for (var node : fact.traceChain()) assertFalse(String.valueOf(node.getComment()).contains(oldValue)); + assertNull(Q.orderItems().withIdIs(E.orderItem(child).getId().eval()).limit(1) + .comment("verify deletion").purpose("normal query excludes deleted child").executeForOne(fixture.context)); + fixture.clear(); + Q.customerOrders().withIdIs(E.customerOrder(root).getId().eval()).limit(1).comment(oldValue) + .purpose("independent query must not inherit mutation secrets").executeForOne(fixture.context); + assertEquals(oldValue, fixture.sql.get(0).getComment()); + System.out.println("PASS Java generated cross-type loaded privacy: repeated saves, rollback retry, delete and independent intent"); + System.out.println("PASS Java generated privacy retains complete raw command and safe SQL/audit root lineage"); + } + private static void assertPrivateChain(Fixture fixture, long rootId, String reason, String safeReason) { + var raw = List.of(new TraceNode(TraceKind.AUDIT_REASON, "CustomerOrder", rootId, reason)); + var safe = List.of(new TraceNode(TraceKind.AUDIT_REASON, "CustomerOrder", rootId, safeReason)); + assertEquals(2, fixture.commands.size()); + assertEquals(4, fixture.sql.size()); + assertEquals(2, fixture.audit.size()); + for (var command : fixture.commands) { + assertEquals("trusted command keeps complete root intent", raw, command.getTraceChain()); + assertEquals(reason, command.intent().comment()); + } + for (var statement : fixture.sql) { + assertEquals("safe SQL keeps complete typed root lineage", safe, statement.getMutationLineage()); + assertEquals("safe SQL keeps masked root intent", safeReason, statement.getAuditReason()); + } + for (var event : fixture.audit) + assertEquals("safe committed audit keeps complete typed root lineage", safe, event.traceChain()); + } + private static void assertPrivateIntent(ExecutionMetadata fact, String secret) { + for (var text : List.of(String.valueOf(fact.getComment()), String.valueOf(fact.getPurpose()), + String.valueOf(fact.getAuditReason()), String.valueOf(fact.getMutationLineage()), + String.valueOf(fact.getTraceChain()))) + assertFalse("cross-type SQL intent leaked sibling value", text.contains(secret)); + } + private static void verifyReturnedStatements(MutationResult result, EntityPersistenceMutation request) { + var statements = result.statements(); + assertEquals("actual write and authoritative readback retained independently of sinks", 2, statements.size()); + var write = statements.get(0); + var read = statements.get(1); + assertEquals(DataServiceOperation.MUTATION, write.getOperation()); + assertEquals(DataServiceOperation.QUERY, read.getOperation()); + assertEquals("derived readback retains captured request comment", request.intent().comment(), read.getComment()); + assertEquals("physical write retains captured root audit reason", request.intent().comment(), write.getAuditReason()); + assertEquals("derived readback retains captured root audit reason", request.intent().comment(), read.getAuditReason()); + assertEquals(request.getTraceChain(), write.getMutationLineage()); + assertEquals(write.getMutationLineage(), read.getMutationLineage()); + assertEquals(TraceKind.REQUEST, read.getTraceChain().get(1).getKind()); + assertEquals(request.getTraceChain().get(0).getName(), read.getTraceChain().get(0).getName()); + assertEquals("select", read.getStatementOperation()); + assertEquals(Long.valueOf(1), write.getAffectedRows()); + assertEquals(Integer.valueOf(1), read.getResultCount()); + assertEquals(request.intent().readbackIntent().purpose(), read.getPurpose()); + } + + static final class Fixture { + final List sql = new CopyOnWriteArrayList<>(); + final List queryResults = new CopyOnWriteArrayList<>(); + final List> cursors = new CopyOnWriteArrayList<>(); + final List audit = new CopyOnWriteArrayList<>(); + final List commands = new CopyOnWriteArrayList<>(); + final List itemInsertBatchSizes = new CopyOnWriteArrayList<>(); + final List itemUpdateBatchSizes = new CopyOnWriteArrayList<>(); + final List itemDeleteBatchSizes = new CopyOnWriteArrayList<>(); + final List itemRecoverBatchSizes = new CopyOnWriteArrayList<>(); + final DefaultUserContext context; + final JdbcSqlExecutor driver; + volatile boolean failReadback; + volatile boolean serializeTransactions; + volatile Consumer checkerBegin; + volatile Consumer checkerFinish; + volatile java.util.function.BiConsumer queryBegin; + volatile Consumer streamOpen; + final long base; + + Fixture() throws Exception { + this(true); + } + + Fixture(boolean logging) throws Exception { + String configured = System.getProperty("teaql.trace.database", ""); + Path database = configured.isBlank() ? Files.createTempFile("teaql-generated-trace-", ".db") + : Path.of(configured).toAbsolutePath(); + var source = new SQLiteDataSource(); + source.setUrl("jdbc:sqlite:" + database); + driver = new JdbcSqlExecutor(source) { + @Override public void executeInTransaction(Runnable action) { + // SQLite has one writer. Only physical transactions serialize; + // the tests still overlap the real generated Checker invocations. + if (serializeTransactions) { + synchronized (this) { super.executeInTransaction(action); } + } else super.executeInTransaction(action); + } + @Override public int[] batchUpdate(String text, List rows) { + if (text.startsWith("INSERT INTO order_item_data")) itemInsertBatchSizes.add(rows.size()); + if (text.startsWith("UPDATE order_item_data") && !rows.isEmpty()) { + assertTrue("committed audit must wait for all member writes", audit.isEmpty()); + Object[] first = rows.get(0); + if (first.length != 3) itemUpdateBatchSizes.add(rows.size()); + else if (((Number) first[0]).longValue() < 0) itemDeleteBatchSizes.add(rows.size()); + else itemRecoverBatchSizes.add(rows.size()); + } + return super.batchUpdate(text, rows); + } + @Override public List> queryForList(String sql, Object[] args) { + if (failReadback && sql.startsWith("SELECT * FROM") && sql.contains("customer_order_data")) + execute("DROP TABLE customer_order_data"); + return super.queryForList(sql, args); + } + @Override public java.util.stream.Stream> queryForStream(String sql, Object[] args) { + if (streamOpen != null) streamOpen.accept(Fixture.this.context); + return super.queryForStream(sql, args); + } + }; + var ids = new IdSpaceIdGenerator(new IdDatabase(driver)); + var metadata = new SimpleEntityMetaFactory(); + var provider = new SqliteDataServiceExecutor("sqlite", driver, source) { + @Override public QueryCursor queryForCursor(UserContext caller, QueryRequest request) { + var cursor = super.queryForCursor(caller, request); + cursors.add(cursor); + return cursor; + } + @Override public QueryResult query(UserContext caller, QueryRequest request) { + if (queryBegin != null) queryBegin.accept(caller, request); + var result = super.query(caller, request); + queryResults.add(result); + return result; + } + @Override public MutationResult mutate(UserContext caller, PersistenceMutation mutation) { + commands.add((EntityPersistenceMutation) mutation); + var result = super.mutate(caller, mutation); + verifyReturnedStatements(result, (EntityPersistenceMutation) mutation); + return result; + } + @Override public List mutateBatch(UserContext caller, MutationBatchRequest request) { + request.items().forEach(item -> commands.add((EntityPersistenceMutation) item)); + var results = super.mutateBatch(caller, request); + for (int i = 0; i < results.size(); i++) + verifyReturnedStatements(results.get(i), (EntityPersistenceMutation) request.items().get(i)); + return results; + } + }; + var runtime = TeaQLRuntime.builder().metadata(metadata) + .dataService("default", provider).dataService("sqlite", provider) + .queryExecutionLogging(logging).mutationExecutionLogging(logging) + .idGenerationService(ids).logSink((caller, entry) -> sql.add(entry)).build() + .install(GeneratedRuntimeModule.module()); // Real generated checkers, no bypass. + EntityMetaFactory.registerGlobal(metadata); + context = new DefaultUserContext(runtime) { + @Override public void beginFixEvidence() { + super.beginFixEvidence(); + if (checkerBegin != null) checkerBegin.accept(this); + } + @Override public void finishFixEvidence() { + if (checkerFinish != null) checkerFinish.accept(this); + super.finishFixEvidence(); + } + }; + context.putAttribute(AppAuditEventSink.class.getName(), (AppAuditEventSink) (caller, event) -> audit.add(event)); + context.ensureSchema(); + var initialBootstrapQueries = List.copyOf(queryResults); + var initialBootstrapCommands = List.copyOf(commands); + var initialBootstrapAudit = List.copyOf(audit); + clear(); queryResults.clear(); + context.ensureSchema(); + assertBootstrapIntent(initialBootstrapQueries, initialBootstrapCommands, initialBootstrapAudit, logging); + var previous = Q.customerOrders().orderByIdDescending().limit(1) + .comment("what: select the previous fixture identity") + .purpose("why: replay without deleting the database").executeForOne(context); + base = previous == null ? 100 : E.customerOrder(previous).getId().eval() + 1000; + ids.ensureFloor("CustomerOrder", base - 1); + ids.ensureFloor("Payment", base - 1); + ids.ensureFloor("OrderItem", base + 100); + ids.ensureFloor("PaymentAttempt", base + 300); + ids.ensureFloor("Shipment", base + 400); + clear(); + } + + void assertBootstrapIntent(List initialQueries, List initialCommands, + List initialAudit, boolean logging) { + assertFalse("generated bootstrap must issue an observed lookup", initialQueries.isEmpty()); + assertFalse("repeated bootstrap must issue an observed lookup", queryResults.isEmpty()); + assertTrue("repeated schema initialization must not repeat seed writes", commands.isEmpty()); + assertTrue("repeated schema initialization must not repeat committed audit", audit.isEmpty()); + var firstIntent = initialQueries.get(0).statements().get(0).getComment(); + var firstPurpose = initialQueries.get(0).statements().get(0).getPurpose(); + assertNotNull(firstIntent); assertFalse(firstIntent.isBlank()); + assertNotNull(firstPurpose); assertFalse(firstPurpose.isBlank()); + for (var result : queryResults) { + assertEquals(1, result.statements().size()); + var fact = result.statements().get(0); + assertEquals("generated bootstrap owns a stable lookup comment", firstIntent, fact.getComment()); + assertEquals("generated bootstrap owns a stable lookup purpose", firstPurpose, fact.getPurpose()); + assertEquals(List.of(TraceKind.OPERATION, TraceKind.REQUEST, TraceKind.PROVIDER, TraceKind.SQL), + fact.getTraceChain().stream().map(TraceNode::getKind).toList()); + assertEquals("Platform", fact.getTraceChain().get(0).getName()); + } + assertEquals(initialCommands.size(), initialAudit.size()); + for (var command : initialCommands) { + assertFalse(command.intent().comment().isBlank()); + assertEquals(1, command.getTraceChain().size()); + assertEquals(TraceKind.AUDIT_REASON, command.getTraceChain().get(0).getKind()); + assertEquals(command.intent().comment(), command.getTraceChain().get(0).getComment()); + } + if (logging) assertEquals(queryResults.size(), sql.size()); + else assertTrue(sql.isEmpty()); + System.out.println("TC-REQ-09 JAVA GENERATED BOOTSTRAP PASSED logging=" + logging + + " first_writes=" + initialCommands.size() + " repeat_writes=0 comment=" + firstIntent); + } + + void clear() { + sql.clear(); audit.clear(); commands.clear(); itemInsertBatchSizes.clear(); + itemUpdateBatchSizes.clear(); itemDeleteBatchSizes.clear(); itemRecoverBatchSizes.clear(); + } + + Graph saveNormativeGraph() { + var platform = Q.platforms().withIdIs(1L).limit(1) + .comment("what: reuse the seeded root") + .purpose("why: do not recreate the bootstrap Platform").executeForOne(context); + assertNotNull(platform); + CustomerOrder order = Q.customerOrders().comment("what: prepare the fixture order") + .purpose("why: verify generated graph persistence").newEntity(context); + order.updatePlatform(platform); + order.updateOrderNumber("TRACE-ORDER-" + base); + order.updateDescription("Draft fixture"); + OrderItem kept = Q.orderItems().comment("what: prepare the available item") + .purpose("why: verify generated graph persistence").newEntity(context); + kept.updateName("Available item"); + OrderItem removed = Q.orderItems().comment("what: prepare the unavailable item") + .purpose("why: seed an existing deletion target").newEntity(context); + removed.updateName("Unavailable item"); + order.addOrderItem(kept).addOrderItem(removed); + order.auditAs("seed existing order items").save(context); + assertEquals(Long.valueOf(base), E.customerOrder(order).getId().eval()); + assertEquals(Long.valueOf(base + 101), E.orderItem(kept).getId().eval()); + assertEquals(Long.valueOf(base + 102), E.orderItem(removed).getId().eval()); + + Payment payment = Q.payments().comment("what: prepare payment") + .purpose("why: persist payment in the order transaction").newEntity(context); + payment.updateReferenceCode("TRACE-PAYMENT-" + base); + payment.comment("authorize payment"); + PaymentAttempt attempt = Q.paymentAttempts().comment("what: prepare a payment attempt") + .purpose("why: verify inherited grandchild responsibility").newEntity(context); + attempt.updateReferenceCode("TRACE-ATTEMPT-" + base); + payment.addPaymentAttempt(attempt); + Shipment shipment = Q.shipments().comment("what: prepare shipment") + .purpose("why: verify isolated sibling responsibility").newEntity(context); + shipment.updateReferenceCode("TRACE-SHIPMENT-" + base); + shipment.comment("dispatch shipment"); + order.addPayment(payment).addShipment(shipment); + order.updateDescription("Submitted fixture"); + kept.updateName("Confirmed item"); + removed.markForDeletion(); + removed.comment("remove unavailable item"); + clear(); + order.auditAs("submit order").save(context); + return new Graph(order, kept, removed, payment, attempt, shipment); + } + } + + record Graph(CustomerOrder order, OrderItem kept, OrderItem removed, Payment payment, + PaymentAttempt attempt, Shipment shipment) {} + + private static void await(CountDownLatch latch) { + try { assertTrue("generated execution must reach checkpoint", latch.await(10, TimeUnit.SECONDS)); } + catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new AssertionError(interrupted); + } + } + + private static Throwable saveFailure(CustomerOrder order, UserContext context, String reason) { + try { order.auditAs(reason).save(context); return null; } + catch (Throwable failure) { return failure; } + } + + @Test public void overlappingGeneratedCheckersKeepValidAndInvalidRequestsIndependent() throws Exception { + var fixture = new Fixture(); + var platform = Q.platforms().withIdIs(1L).limit(1).comment("what: reuse root") + .purpose("why: prepare independent generated graph saves").executeForOne(fixture.context); + var valid = Q.customerOrders().comment("what: prepare valid order") + .purpose("why: exercise the actual generated Checker").newEntity(fixture.context); + valid.updatePlatform(platform); + valid.updateOrderNumber("TRACE-CONCURRENT-" + fixture.base); + valid.updateDescription("Valid overlapping request"); + var invalid = Q.customerOrders().comment("what: prepare incomplete order") + .purpose("why: require an independent Checker rejection").newEntity(fixture.context); + invalid.updatePlatform(platform); + invalid.updateDescription("Invalid overlapping request"); + assertNotSame(valid.getEntityMutationLedger(), invalid.getEntityMutationLedger()); + var validThread = new AtomicReference(); + var validEntered = new CountDownLatch(1); + var invalidFinishing = new CountDownLatch(1); + var validFinished = new CountDownLatch(1); + fixture.checkerBegin = caller -> { + assertSame(fixture.context, caller); + if (Thread.currentThread() == validThread.get()) { + validEntered.countDown(); + await(invalidFinishing); + } else await(validEntered); + }; + fixture.checkerFinish = caller -> { + if (Thread.currentThread() != validThread.get()) { + invalidFinishing.countDown(); + await(validFinished); + } + }; + fixture.clear(); + var workers = Executors.newFixedThreadPool(2); + try { + var first = workers.submit(() -> { + validThread.set(Thread.currentThread()); + try { return saveFailure(valid, fixture.context, "accept overlapping valid order"); } + finally { validFinished.countDown(); } + }); + // The first run must enter its initialized Checker before the second + // can initialize its own state. No timing sleeps or fake checkers. + await(validEntered); + var second = workers.submit(() -> saveFailure(invalid, fixture.context, "reject overlapping incomplete order")); + Throwable accepted = first.get(20, TimeUnit.SECONDS); + Throwable rejected = second.get(20, TimeUnit.SECONDS); + assertNull("valid request must not inherit another check's violations: " + accepted, accepted); + assertTrue(rejected instanceof io.teaql.core.checker.CheckException); + assertTrue(((io.teaql.core.checker.CheckException) rejected).getViolates().stream() + .anyMatch(value -> value.getLocation().modelPath().endsWith("order_number"))); + assertEquals(1, fixture.commands.size()); + assertEquals(1, fixture.audit.size()); + var lineage = List.of(new TraceNode(TraceKind.AUDIT_REASON, "CustomerOrder", valid.getId(), "accept overlapping valid order")); + assertEquals(lineage, fixture.commands.get(0).getTraceChain()); + assertEquals(lineage, fixture.audit.get(0).traceChain()); + assertTrue(fixture.sql.stream().allMatch(entry -> entry.getMutationLineage().equals(lineage))); + assertNull("Checker rejects before allocation", invalid.getId()); + } finally { + validEntered.countDown(); invalidFinishing.countDown(); validFinished.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(10, TimeUnit.SECONDS)); + fixture.checkerBegin = null; fixture.checkerFinish = null; + } + var loaded = Q.customerOrders().withIdIs(valid.getId()).limit(1) + .comment("what: reload accepted concurrent request") + .purpose("why: prove generated Q/E observe its committed row").executeForOne(fixture.context); + assertEquals("TRACE-CONCURRENT-" + fixture.base, E.customerOrder(loaded).getOrderNumber().eval()); + assertTrue(fixture.context.getTraceChain().isEmpty()); + System.out.println("PASS Java generated overlapping Checker: valid commits, invalid rejected before provider"); + } + + @Test public void independentGeneratedGraphsShareOneContextWithoutTraceOrAuditCrossTalk() throws Exception { + var fixture = new Fixture(); + var platform = Q.platforms().withIdIs(1L).limit(1).comment("what: reuse root") + .purpose("why: prepare two independent graphs").executeForOne(fixture.context); + var platformLedger = platform.getEntityMutationLedger(); + var orders = new java.util.ArrayList(); + for (String suffix : List.of("alpha", "beta")) { + var order = Q.customerOrders().comment("what: prepare " + suffix) + .purpose("why: exercise concurrent graph ownership").newEntity(fixture.context); + order.updatePlatform(platform); + order.updateOrderNumber("TRACE-PARALLEL-" + fixture.base + "-" + suffix); + order.updateDescription("Parallel graph " + suffix); + var item = Q.orderItems().comment("what: prepare " + suffix + " item") + .purpose("why: exercise local child responsibility").newEntity(fixture.context); + item.updateName("Parallel entry " + suffix); + item.comment("append " + suffix); + order.addOrderItem(item); + orders.add(order); + } + assertNotSame(orders.get(0).getEntityMutationLedger(), orders.get(1).getEntityMutationLedger()); + var checkpoint = new CyclicBarrier(2); + fixture.checkerBegin = caller -> { + assertSame(fixture.context, caller); + try { checkpoint.await(10, TimeUnit.SECONDS); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new AssertionError(interrupted); } + catch (Exception failure) { throw new AssertionError(failure); } + }; + fixture.serializeTransactions = true; + fixture.clear(); + var workers = Executors.newFixedThreadPool(2); + try { + var first = workers.submit(() -> saveFailure(orders.get(0), fixture.context, "save alpha graph")); + var second = workers.submit(() -> saveFailure(orders.get(1), fixture.context, "save beta graph")); + Throwable alpha = first.get(20, TimeUnit.SECONDS); + Throwable beta = second.get(20, TimeUnit.SECONDS); + if (alpha != null) throw new AssertionError("alpha graph failed", alpha); + if (beta != null) throw new AssertionError("beta graph failed", beta); + } finally { + workers.shutdownNow(); + assertTrue(workers.awaitTermination(10, TimeUnit.SECONDS)); + fixture.checkerBegin = null; + } + assertEquals(4, fixture.commands.size()); + assertEquals(4, fixture.audit.size()); + assertSame("shared read-only relation retains its independent ledger", platformLedger, platform.getEntityMutationLedger()); + for (int index = 0; index < orders.size(); index++) { + var order = orders.get(index); + String suffix = index == 0 ? "alpha" : "beta"; + var graphCommands = fixture.commands.stream().filter(value -> + value.getTraceChain().get(0).getEntityId().equals(order.getId())).toList(); + assertEquals(2, graphCommands.size()); + for (var command : graphCommands) { + var expected = command.getEntity().typeName().equals("CustomerOrder") + ? List.of("save " + suffix + " graph") + : List.of("save " + suffix + " graph", "append " + suffix); + assertEquals(expected, command.getTraceChain().stream().map(TraceNode::getComment).toList()); + var event = fixture.audit.stream().filter(value -> value.entityType().equals(command.getEntity().typeName()) + && value.entityId().equals(command.getEntity().getId())).findFirst().orElseThrow(); + assertEquals(command.getTraceChain(), event.traceChain()); + assertTrue(fixture.sql.stream().anyMatch(value -> value.getOperation() == DataServiceOperation.MUTATION + && value.getMutationLineage().equals(command.getTraceChain()))); + assertTrue(fixture.sql.stream().anyMatch(value -> value.getOperation() == DataServiceOperation.QUERY + && value.getMutationLineage().equals(command.getTraceChain()))); + } + var loaded = Q.customerOrders().withIdIs(order.getId()).limit(1) + .selectOrderItemListWith(Q.orderItems().limit(10)) + .comment("what: reload " + suffix + " graph") + .purpose("why: verify independent commits through generated Q/E").executeForOne(fixture.context); + assertEquals(Integer.valueOf(1), E.customerOrder(loaded).getOrderItemList().size().eval()); + assertEquals("TRACE-PARALLEL-" + fixture.base + "-" + suffix, E.customerOrder(loaded).getOrderNumber().eval()); + } + assertTrue(fixture.context.getTraceChain().isEmpty()); + System.out.println("PASS Java generated concurrent graphs: same Context, independent ledgers and per-item SQL/audit lineage"); + } + + @Test public void generatedSameTypePreparedBatchKeepsItemReasonsAndCompleteLedgerReplacement() throws Exception { + var fixture = new Fixture(); + var platform = Q.platforms().withIdIs(1L).limit(1) + .comment("what: reuse the bootstrap root for batch acceptance") + .purpose("why: keep fixture writes inside generated APIs").executeForOne(fixture.context); + var order = Q.customerOrders().comment("what: initialize the batch order") + .purpose("why: verify generated prepared graph persistence").newEntity(fixture.context); + order.updatePlatform(platform); + order.updateOrderNumber("TRACE-BATCH-" + fixture.base); + order.updateDescription("Prepared batch fixture"); + var first = Q.orderItems().comment("what: initialize entry alpha") + .purpose("why: verify per-item responsibility").newEntity(fixture.context); + first.updateName("Batch entry alpha"); + first.comment("append alpha"); + var second = Q.orderItems().comment("what: initialize entry beta") + .purpose("why: verify independent item responsibility").newEntity(fixture.context); + second.updateName("Batch entry beta"); + second.comment("append beta"); + order.addOrderItem(first).addOrderItem(second); + fixture.clear(); + order.auditAs("compose generated batch").save(fixture.context); + long orderId = E.customerOrder(order).getId().eval(); + long firstId = E.orderItem(first).getId().eval(); + long secondId = E.orderItem(second).getId().eval(); + + assertEquals("one actual two-row prepared JDBC insert", List.of(2), fixture.itemInsertBatchSizes); + assertEquals(3, fixture.commands.size()); + assertEquals(3, fixture.audit.size()); + var expectedFirst = List.of(new TraceNode(TraceKind.AUDIT_REASON, "CustomerOrder", orderId, "compose generated batch"), + new TraceNode(TraceKind.AUDIT_REASON, "OrderItem", firstId, "append alpha")); + var expectedSecond = List.of(new TraceNode(TraceKind.AUDIT_REASON, "CustomerOrder", orderId, "compose generated batch"), + new TraceNode(TraceKind.AUDIT_REASON, "OrderItem", secondId, "append beta")); + for (long id : List.of(firstId, secondId)) { + var expected = id == firstId ? expectedFirst : expectedSecond; + var command = fixture.commands.stream().filter(value -> value.getEntity().typeName().equals("OrderItem") + && value.getEntity().getId().equals(id)).findFirst().orElseThrow(); + assertEquals(expected, command.getTraceChain()); + var event = fixture.audit.stream().filter(value -> value.entityType().equals("OrderItem") + && value.entityId().equals(id)).findFirst().orElseThrow(); + assertEquals(expected, event.traceChain()); + var writes = fixture.sql.stream().filter(value -> value.getOperation() == DataServiceOperation.MUTATION + && value.getMutationLineage().equals(expected)).toList(); + assertEquals(1, writes.size()); + assertEquals("success", writes.get(0).getExecutionOutcome()); + assertEquals(Long.valueOf(1), writes.get(0).getAffectedRows()); + assertTrue(fixture.sql.stream().anyMatch(value -> value.getOperation() == DataServiceOperation.QUERY + && value.getMutationLineage().equals(expected))); + } + var loaded = Q.customerOrders().withIdIs(orderId).limit(1) + .selectOrderItemListWith(Q.orderItems().orderByIdAscending().limit(10)) + .comment("what: reload the batch through generated relations") + .purpose("why: independently validate FK association and scalar readback").executeForOne(fixture.context); + assertEquals(Integer.valueOf(2), E.customerOrder(loaded).getOrderItemList().size().eval()); + assertEquals("Batch entry alpha", E.orderItem(first).getName().eval()); + assertEquals("Batch entry beta", E.orderItem(second).getName().eval()); + assertEquals(Long.valueOf(1), E.orderItem(first).getVersion().eval()); + assertEquals(Long.valueOf(1), E.orderItem(second).getVersion().eval()); + + // Java assigns IDs at graph-save time. After the generated insert/readback, + // prove complete-ledger replacement on an identified existing child. + order.updateDescription("Ledger override fixture"); + second.updateName("Updated beta entry"); + second.comment("local fallback must not be appended"); + var complete = List.of(new TraceNode(TraceKind.AUDIT_REASON, "CustomerOrder", orderId, "delegated batch root"), + new TraceNode(TraceKind.AUDIT_REASON, "OrderItem", secondId, "delegated beta")); + second.setTraceChain(complete); + var sibling = Q.orderItems().comment("what: initialize an unannotated sibling") + .purpose("why: prove complete ledger replacement is scoped to one typed key").newEntity(fixture.context); + sibling.updateName("Unannotated ledger sibling"); + order.addOrderItem(sibling); + fixture.clear(); + order.auditAs("replacement graph fallback").save(fixture.context); + var overrideCommand = fixture.commands.stream().filter(value -> value.getEntity().typeName().equals("OrderItem") + && value.getEntity().getId().equals(secondId)).findFirst().orElseThrow(); + assertEquals(complete, overrideCommand.getTraceChain()); + var overrideAudit = fixture.audit.stream().filter(value -> value.entityType().equals("OrderItem") + && value.entityId().equals(secondId)).findFirst().orElseThrow(); + assertEquals(complete, overrideAudit.traceChain()); + assertTrue(fixture.sql.stream().anyMatch(value -> value.getOperation() == DataServiceOperation.MUTATION + && value.getMutationLineage().equals(complete))); + assertTrue(fixture.sql.stream().anyMatch(value -> value.getOperation() == DataServiceOperation.QUERY + && value.getMutationLineage().equals(complete))); + assertNotNull("new fallback sibling receives its assigned ID", sibling.getId()); + assertTrue(sibling.getId() > 0); + var fallback = List.of(new TraceNode(TraceKind.AUDIT_REASON, "CustomerOrder", orderId, "replacement graph fallback")); + var expectedByIdentity = java.util.Map.of( + new GraphIdentity("CustomerOrder", orderId), fallback, + new GraphIdentity("OrderItem", secondId), complete, + new GraphIdentity("OrderItem", sibling.getId()), fallback); + assertEquals(3, fixture.commands.size()); + assertEquals(3, fixture.audit.size()); + assertExactGraphIdentities("ledger override commands", expectedByIdentity.keySet(), fixture.commands.stream() + .map(value -> new GraphIdentity(value.getEntity().typeName(), value.getEntity().getId())).toList()); + assertExactGraphIdentities("ledger override committed audit", expectedByIdentity.keySet(), fixture.audit.stream() + .map(value -> new GraphIdentity(value.entityType(), ((Number)value.entityId()).longValue())).toList()); + for (var command : fixture.commands) { + var identity = new GraphIdentity(command.getEntity().typeName(), command.getEntity().getId()); + var expected = expectedByIdentity.get(identity); + assertEquals("complete ledger replaces only its own key: " + identity, expected, command.getTraceChain()); + var event = fixture.audit.stream().filter(value -> value.entityType().equals(identity.entity()) + && value.entityId().equals(identity.id())).findFirst().orElseThrow(); + assertEquals("sibling fallback at committed audit: " + identity, expected, event.traceChain()); + var writes = fixture.sql.stream().filter(value -> value.getOperation() == DataServiceOperation.MUTATION + && value.getMutationLineage().equals(expected) + && value.getTraceChain().stream().anyMatch(node -> node.getKind() == TraceKind.ENTITY + && node.getName().equals(identity.entity()))).toList(); + assertEquals("one physical write for ledger key " + identity, 1, writes.size()); + assertEquals("success", writes.get(0).getExecutionOutcome()); + assertTrue("readback retains this key's lineage: " + identity, fixture.sql.stream().anyMatch(value -> + value.getOperation() == DataServiceOperation.QUERY && value.getMutationLineage().equals(expected))); + } + assertEquals(3, fixture.sql.stream().filter(value -> value.getOperation() == DataServiceOperation.MUTATION).count()); + System.out.println("PASS Java generated ledger override: one typed key replaces fallback; new sibling inherits only graph root at command/SQL/audit"); + System.out.println("PASS Java generated prepared batch: per-item lineage and complete ledger replacement"); + } + + private record GraphIdentity(String entity, Long id) {} + + private static String identityJson(List identities) { + return identities.stream().map(value -> "{\"entity\":\"" + value.entity() + "\",\"id\":" + value.id() + "}") + .collect(java.util.stream.Collectors.joining(",", "[", "]")); + } + + private static void assertExactGraphIdentities(String boundary, Set expected, + List actual) { + assertEquals(boundary + " count", expected.size(), actual.size()); + var distinct = new HashSet<>(actual); + assertEquals(boundary + " must not repeat an entity", actual.size(), distinct.size()); + assertEquals(boundary + " exact typed identities", expected, distinct); + } + + @Test public void graphIdentityGuardRejectsDuplicatesMissingEntitiesAndTypeCollapse() { + var expected = Set.of(new GraphIdentity("CustomerOrder", 100L), new GraphIdentity("OrderItem", 201L), + new GraphIdentity("OrderItem", 202L), new GraphIdentity("Payment", 100L), + new GraphIdentity("PaymentAttempt", 401L), new GraphIdentity("Shipment", 501L)); + var correct = new ArrayList<>(expected); + assertExactGraphIdentities("control", expected, correct); + var duplicate = new ArrayList<>(correct); + duplicate.set(duplicate.indexOf(new GraphIdentity("OrderItem", 202L)), new GraphIdentity("OrderItem", 201L)); + assertThrows(AssertionError.class, () -> assertExactGraphIdentities("duplicate control", expected, duplicate)); + var unknown = new ArrayList<>(correct); + unknown.set(unknown.indexOf(new GraphIdentity("Shipment", 501L)), new GraphIdentity("Shipment", 999L)); + assertThrows(AssertionError.class, () -> assertExactGraphIdentities("missing control", expected, unknown)); + var collapsed = new ArrayList<>(correct); + collapsed.set(collapsed.indexOf(new GraphIdentity("Payment", 100L)), new GraphIdentity("CustomerOrder", 100L)); + assertThrows(AssertionError.class, () -> assertExactGraphIdentities("type collapse control", expected, collapsed)); + System.out.println("PASS Java graph identity controls: duplicate, missing and equal-ID type collapse rejected"); + } + + @Test public void generatedNormativeGraphHasPerItemPhysicalSqlAndCommittedAudit() throws Exception { + var fixture = new Fixture(); + Graph graph = fixture.saveNormativeGraph(); + assertEquals("six physical entity commands", 6, fixture.commands.size()); + assertEquals("six committed entity events", 6, fixture.audit.size()); + assertEquals("same numeric ID must not collapse different types", graph.order.getId(), graph.payment.getId()); + assertTrue("mutation planning must not leave an ambient trace", fixture.context.getTraceChain().isEmpty()); + var identities = Set.of(new GraphIdentity("CustomerOrder", graph.order.getId()), + new GraphIdentity("OrderItem", graph.kept.getId()), new GraphIdentity("OrderItem", graph.removed.getId()), + new GraphIdentity("Payment", graph.payment.getId()), new GraphIdentity("PaymentAttempt", graph.attempt.getId()), + new GraphIdentity("Shipment", graph.shipment.getId())); + assertExactGraphIdentities("actual commands", identities, fixture.commands.stream() + .map(value -> new GraphIdentity(value.getEntity().typeName(), value.getEntity().getId())).toList()); + assertExactGraphIdentities("committed audit", identities, fixture.audit.stream() + .map(value -> new GraphIdentity(value.entityType(), ((Number)value.entityId()).longValue())).toList()); + assertEquals("six actual physical writes", 6L, + fixture.sql.stream().filter(value -> value.getOperation() == DataServiceOperation.MUTATION).count()); + var physicalIdentities = new ArrayList(); + for (var command : fixture.commands) { + Entity entity = command.getEntity(); + var expected = expected(fixture.base, entity.typeName(), entity.getId()); + assertEquals("provider command " + entity.typeName(), expected, command.getTraceChain()); + var event = fixture.audit.stream().filter(value -> value.entityType().equals(entity.typeName()) + && value.entityId().equals(entity.getId())).findFirst().orElseThrow(); + assertEquals("safe committed event", expected, event.traceChain()); + String action = command.getAction() == EntityPersistenceMutation.Action.DELETE ? "delete" + : entity.typeName().equals("CustomerOrder") || entity.typeName().equals("OrderItem") ? "update" : "insert"; + var writes = fixture.sql.stream().filter(value -> value.getOperation() == DataServiceOperation.MUTATION + && value.getTraceChain().get(1).getName().equals(entity.typeName()) + && value.getMutationLineage().equals(expected)).toList(); + assertEquals("one actual SQL write for " + entity.typeName() + "#" + entity.getId(), 1, writes.size()); + // Physical paths deliberately do not carry IDs. Bind the unique + // observed SQL fact to its real provider command, not a fabricated path ID. + physicalIdentities.add(new GraphIdentity(entity.typeName(), entity.getId())); + for (var entry : writes) { + assertEquals("success", entry.getExecutionOutcome()); + assertEquals("CustomerOrder", entry.getTraceChain().get(0).getName()); + assertEquals("sqlite", entry.getTraceChain().get(entry.getTraceChain().size() - 2).getName()); + assertEquals(action, entry.getTraceChain().get(entry.getTraceChain().size() - 1).getName()); + } + assertTrue("readback retains entity responsibility", fixture.sql.stream().anyMatch(value -> + value.getOperation() == DataServiceOperation.QUERY && value.getMutationLineage().equals(expected))); + } + assertExactGraphIdentities("command-bound physical SQL", identities, physicalIdentities); + System.out.println("GRAPH IDENTITY EVIDENCE " + "{\"expected\":" + identityJson(new ArrayList<>(identities)) + + ",\"commands\":" + identityJson(fixture.commands.stream() + .map(value -> new GraphIdentity(value.getEntity().typeName(), value.getEntity().getId())).toList()) + + ",\"physical\":" + identityJson(physicalIdentities) + + ",\"audit\":" + identityJson(fixture.audit.stream() + .map(value -> new GraphIdentity(value.entityType(), ((Number)value.entityId()).longValue())).toList()) + "}"); + assertEquals(Long.valueOf(2), E.customerOrder(graph.order).getVersion().eval()); + assertEquals(Long.valueOf(-2), E.orderItem(graph.removed).getVersion().eval()); + assertEquals(Long.valueOf(1), E.payment(graph.payment).getVersion().eval()); + var current = Q.customerOrders().withIdIs(graph.order.getId()).limit(1) + .selectOrderItemListWith(Q.orderItems().orderByIdAscending().limit(10)) + .selectPaymentListWith(Q.payments().orderByIdAscending().limit(10) + .selectPaymentAttemptListWith(Q.paymentAttempts().orderByIdAscending().limit(10))) + .comment("what: reload the committed order graph") + .purpose("why: independently verify Q and E after persistence").executeForOne(fixture.context); + assertEquals("Submitted fixture", E.customerOrder(current).getDescription().eval()); + assertEquals(Integer.valueOf(1), E.customerOrder(current).getOrderItemList().size().eval()); + assertEquals(Integer.valueOf(1), E.customerOrder(current).getPaymentList().size().eval()); + assertNull(Q.orderItems().withIdIs(graph.removed.getId()).limit(1) + .comment("what: verify the deletion mark") + .purpose("why: normal queries must hide deleted rows").executeForOne(fixture.context)); + var deleted = Q.orderItems().withIdIs(graph.removed.getId()).deletedRowsOnly().limit(1) + .comment("what: inspect the stored deletion version") + .purpose("why: prove version-aware deletion, not physical removal").executeForOne(fixture.context); + assertEquals(Long.valueOf(-2), E.orderItem(deleted).getVersion().eval()); + System.out.println("PASS Java generated normative Trace Chain graph: six physical writes and committed audits"); + } + + @Test public void generatedPreparedUpdateDeleteRecoveryCycleKeepsUnequalVersionsAndItemTraces() throws Exception { + var fixture = new Fixture(); + Graph graph = fixture.saveNormativeGraph(); + fixture.clear(); + // Discovered through current Java Delete Assist; no generated-source lookup. + graph.removed.markToRecover(); + graph.removed.comment("prepare previously removed item"); + graph.order.auditAs("prepare active cycle fixtures").save(fixture.context); + assertEquals(Long.valueOf(3), E.orderItem(graph.removed).getVersion().eval()); + + fixture.clear(); + graph.kept.updateName("Cycle entry alpha"); + graph.kept.comment("revise alpha"); + graph.removed.updateName("Cycle entry beta"); + graph.removed.comment("revise beta"); + graph.order.auditAs("revise generated entries").save(fixture.context); + assertEquals(List.of(2), fixture.itemUpdateBatchSizes); + assertCycleBoundaries(fixture, graph, "update", MutationAuditKind.UPDATED, "revise generated entries", "revise alpha", "revise beta"); + assertEquals(Long.valueOf(3), E.orderItem(graph.kept).getVersion().eval()); + assertEquals(Long.valueOf(4), E.orderItem(graph.removed).getVersion().eval()); + + fixture.clear(); + graph.kept.markForDeletion(); + graph.kept.comment("remove alpha"); + graph.removed.markForDeletion(); + graph.removed.comment("remove beta"); + graph.order.auditAs("remove generated entries").save(fixture.context); + assertEquals(List.of(2), fixture.itemDeleteBatchSizes); + assertCycleBoundaries(fixture, graph, "delete", MutationAuditKind.DELETED, "remove generated entries", "remove alpha", "remove beta"); + assertEquals(Long.valueOf(-4), E.orderItem(graph.kept).getVersion().eval()); + assertEquals(Long.valueOf(-5), E.orderItem(graph.removed).getVersion().eval()); + assertNull(Q.orderItems().withIdIs(graph.kept.getId()).limit(1) + .comment("what: inspect normal visibility after graph deletion") + .purpose("why: prove pending deletion was actually saved").executeForOne(fixture.context)); + var deleted = Q.orderItems().withIdIs(graph.removed.getId()).deletedRowsOnly().limit(1) + .comment("what: inspect retained deleted item") + .purpose("why: verify its independent negative version").executeForOne(fixture.context); + assertEquals(Long.valueOf(-5), E.orderItem(deleted).getVersion().eval()); + + fixture.clear(); + graph.kept.markToRecover(); + graph.kept.comment("restore alpha"); + graph.removed.markToRecover(); + graph.removed.comment("restore beta"); + graph.order.auditAs("restore generated entries").save(fixture.context); + assertEquals(List.of(2), fixture.itemRecoverBatchSizes); + assertCycleBoundaries(fixture, graph, "recover", MutationAuditKind.RECOVERED, "restore generated entries", "restore alpha", "restore beta"); + assertEquals(Long.valueOf(5), E.orderItem(graph.kept).getVersion().eval()); + assertEquals(Long.valueOf(6), E.orderItem(graph.removed).getVersion().eval()); + var restored = Q.customerOrders().withIdIs(graph.order.getId()).limit(1) + .selectOrderItemListWith(Q.orderItems().orderByIdAscending().limit(10)) + .comment("what: reload the restored order graph") + .purpose("why: verify two visible children through generated Q and E").executeForOne(fixture.context); + assertEquals(Integer.valueOf(2), E.customerOrder(restored).getOrderItemList().size().eval()); + assertEquals("Cycle entry alpha", E.orderItem(graph.kept).getName().eval()); + assertEquals("Cycle entry beta", E.orderItem(graph.removed).getName().eval()); + System.out.println("PASS Java generated prepared update/delete/recover: unequal versions and per-item lineage"); + } + + private static void assertCycleBoundaries(Fixture fixture, Graph graph, String operation, MutationAuditKind kind, + String rootReason, String firstReason, String secondReason) { + assertEquals(2, fixture.commands.size()); + assertEquals(2, fixture.audit.size()); + for (var item : List.of(graph.kept, graph.removed)) { + long id = E.orderItem(item).getId().eval(); + var expected = List.of(new TraceNode(TraceKind.AUDIT_REASON, "CustomerOrder", graph.order.getId(), rootReason), + new TraceNode(TraceKind.AUDIT_REASON, "OrderItem", id, item == graph.kept ? firstReason : secondReason)); + var command = fixture.commands.stream().filter(value -> value.getEntity().getId().equals(id)).findFirst().orElseThrow(); + var audit = fixture.audit.stream().filter(value -> value.entityId().equals(id)).findFirst().orElseThrow(); + assertEquals(expected, command.getTraceChain()); + assertEquals(expected, audit.traceChain()); + assertEquals(kind, audit.kind()); + var writes = fixture.sql.stream().filter(value -> value.getOperation() == DataServiceOperation.MUTATION + && value.getMutationLineage().equals(expected)).toList(); + assertEquals(1, writes.size()); + assertEquals(operation, writes.get(0).getStatementOperation()); + assertEquals(Long.valueOf(1), writes.get(0).getAffectedRows()); + assertTrue(fixture.sql.stream().anyMatch(value -> value.getOperation() == DataServiceOperation.QUERY + && value.getMutationLineage().equals(expected))); + } + } + + @Test public void generatedThreeLevelQueryProducesAllRelationFramesAndRootIntent() throws Exception { + var fixture = new Fixture(); + Graph graph = fixture.saveNormativeGraph(); + fixture.clear(); + var row = Q.paymentAttempts().withIdIs(graph.attempt.getId()).limit(1) + .selectPaymentWith(Q.payments().limit(1) + .selectCustomerOrderWith(Q.customerOrders().limit(1) + .selectPlatformWith(Q.platforms().limit(1)))) + .comment("what: load three levels of payment context") + .purpose("why: verify generated SQL trace propagation").executeForOne(fixture.context); + var payment = E.paymentAttempt(row).getPayment().eval(); + var order = E.payment(payment).getCustomerOrder().eval(); + var platform = E.customerOrder(order).getPlatform().eval(); + assertEquals("Trace Chain Verification", E.platform(platform).getName().eval()); + var statements = fixture.sql.stream().filter(value -> value.getOperation() == DataServiceOperation.QUERY).toList(); + assertEquals("one root query and three explicit relation queries", 4, statements.size()); + List names = List.of("payment", "customerOrder", "platform"); + for (int depth = 0; depth < statements.size(); depth++) { + var entry = statements.get(depth); + assertEquals("what: load three levels of payment context", entry.getComment()); + assertEquals("why: verify generated SQL trace propagation", entry.getPurpose()); + assertEquals("PaymentAttempt", entry.getTraceChain().get(0).getName()); + assertEquals("PaymentAttempt", entry.getTraceChain().get(1).getName()); + var relations = entry.getTraceChain().stream().filter(node -> node.getKind() == TraceKind.RELATION) + .map(TraceNode::getName).toList(); + assertEquals("actual relation depth " + depth, names.subList(0, depth), relations); + var details = entry.getTraceChain().stream().filter(node -> node.getKind() == TraceKind.RELATION) + .map(TraceNode::getComment).toList(); + assertEquals(List.of("PaymentAttempt.payment", "Payment.customerOrder", "CustomerOrder.platform") + .subList(0, depth), details); + var kinds = new ArrayList<>(List.of(TraceKind.OPERATION, TraceKind.REQUEST)); + for (int relation = 0; relation < depth; relation++) kinds.add(TraceKind.RELATION); + kinds.add(TraceKind.PROVIDER); kinds.add(TraceKind.SQL); + assertEquals("canonical generated path at every physical boundary", kinds, + entry.getTraceChain().stream().map(TraceNode::getKind).toList()); + assertEquals("query", entry.getTraceChain().get(0).getComment()); + assertEquals("", entry.getTraceChain().get(1).getComment()); + assertEquals("sqlite", entry.getTraceChain().get(depth + 2).getName()); + assertEquals("", entry.getTraceChain().get(depth + 2).getComment()); + assertEquals("select", entry.getTraceChain().get(depth + 3).getName()); + assertEquals("", entry.getTraceChain().get(depth + 3).getComment()); + assertTrue(entry.getTraceChain().stream().allMatch(node -> node.getEntityId() == null)); + } + System.out.println("PASS Java generated three-level SQL Trace Path and inherited request intent"); + assertFilteredForwardReference(fixture, graph); + } + + private static void assertFilteredForwardReference(Fixture fixture, Graph graph) { + var hidden = Q.payments().withIdIs(graph.payment.getId()).limit(1) + .selectCustomerOrderWith(Q.customerOrders().withIdIs(0L).limit(1)) + .comment("load filtered forward reference").purpose("preserve real FK identity") + .executeForOne(fixture.context); + var identity = E.payment(hidden).getCustomerOrder().eval(); + assertNotNull("filtered detail must not erase the FK", identity); + assertEquals(graph.order.getId(), E.customerOrder(identity).getId().eval()); + assertThrows(io.teaql.core.value.TeaQLNotLoadedException.class, + () -> E.customerOrder(identity).getDescription().eval()); + var visible = Q.payments().withIdIs(graph.payment.getId()).limit(1) + .selectCustomerOrderWith(Q.customerOrders().limit(1)) + .comment("load independent full reference").purpose("verify edge-owned detail boundaries") + .executeForOne(fixture.context); + assertEquals(graph.order.getDescription(), E.customerOrder(E.payment(visible).getCustomerOrder().eval()).getDescription().eval()); + assertThrows(io.teaql.core.value.TeaQLNotLoadedException.class, + () -> E.customerOrder(identity).getDescription().eval()); + System.out.println("PASS FORWARD_NOTLOADED: Java generated Q/E retains FK and hidden detail guard"); + } + + @Test public void generatedQueriesReturnStatementEvidenceWithoutLogging() throws Exception { + var fixture = new Fixture(false); + Graph graph = fixture.saveNormativeGraph(); + fixture.clear(); fixture.queryResults.clear(); + var comment = "what: inspect payment without SQL logging"; + var row = loadPaymentContext(fixture, graph, comment, "why: retain execution evidence independently"); + assertEquals(graph.attempt.getId(), E.paymentAttempt(row).getId().eval()); + var result = fixture.queryResults.get(fixture.queryResults.size() - 1); + assertEquals(4, result.statements().size()); + var names = List.of("payment", "customerOrder", "platform"); + for (int depth = 0; depth < 4; depth++) { + var entry = result.statements().get(depth); + assertEquals(comment, entry.getComment()); + assertEquals("success", entry.getExecutionOutcome()); + assertEquals("PaymentAttempt", entry.getTraceChain().get(0).getName()); + assertEquals(names.subList(0, depth), entry.getTraceChain().stream() + .filter(node -> node.getKind() == TraceKind.RELATION).map(TraceNode::getName).toList()); + } + assertThrows(UnsupportedOperationException.class, () -> result.statements().clear()); + assertTrue(fixture.sql.isEmpty()); + assertTrue(fixture.context.getTraceChain().isEmpty()); + System.out.println("PASS Java generated query evidence: logging disabled, three relation levels, immutable result list"); + assertFilteredForwardReference(fixture, graph); + } + + private static PaymentAttempt loadPaymentContext(Fixture fixture, Graph graph, String comment, String purpose) { + var row = Q.paymentAttempts().withIdIs(graph.attempt.getId()).limit(1) + .selectPaymentWith(Q.payments().limit(1) + .selectCustomerOrderWith(Q.customerOrders().limit(1) + .selectPlatformWith(Q.platforms().limit(1)))) + .comment(comment).purpose(purpose).executeForOne(fixture.context); + assertEquals(graph.attempt.getId(), E.paymentAttempt(row).getId().eval()); + var payment = E.paymentAttempt(row).getPayment().eval(); + var order = E.payment(payment).getCustomerOrder().eval(); + var platform = E.customerOrder(order).getPlatform().eval(); + assertEquals("Trace Chain Verification", E.platform(platform).getName().eval()); + return row; + } + + @Test public void overlappingGeneratedQueriesKeepThreeLevelRoutesOffContext() throws Exception { + var fixture = new Fixture(); + Graph graph = fixture.saveNormativeGraph(); fixture.clear(); + var firstEntered = new CountDownLatch(1); + var bothEntered = new CountDownLatch(2); + var release = new CountDownLatch(1); + fixture.queryBegin = (caller, request) -> { + assertSame(fixture.context, caller); + firstEntered.countDown(); bothEntered.countDown(); await(release); + assertTrue("root and relation queries must never write a Context trace stack", caller.getTraceChain().isEmpty()); + }; + var workers = Executors.newFixedThreadPool(2); + try { + var first = workers.submit(() -> loadPaymentContext(fixture, graph, + "what: inspect payment ownership", "why: render the first view")); + await(firstEntered); + var second = workers.submit(() -> loadPaymentContext(fixture, graph, + "what: inspect payment trace", "why: render the second view")); + await(bothEntered); + assertTrue("both real generated Q executions are live", fixture.context.getTraceChain().isEmpty()); + release.countDown(); + var left = first.get(20, TimeUnit.SECONDS); var right = second.get(20, TimeUnit.SECONDS); + assertNotSame("hydrated root objects belong to independent queries", left, right); + for (String comment : List.of("what: inspect payment ownership", "what: inspect payment trace")) { + var statements = fixture.sql.stream().filter(entry -> comment.equals(entry.getComment())).toList(); + assertEquals("each query emits its own root plus three relation statements", 4, statements.size()); + var returned = fixture.queryResults.stream() + .filter(result -> result.statements().size() == 4 + && comment.equals(result.statements().get(0).getComment())).toList(); + assertEquals("one request-owned full result per concurrent query", 1, returned.size()); + assertTrue(returned.get(0).statements().stream().allMatch(entry -> comment.equals(entry.getComment()))); + String purpose = comment.endsWith("ownership") ? "why: render the first view" : "why: render the second view"; + for (int depth = 0; depth < statements.size(); depth++) { + var entry = statements.get(depth); + assertEquals(purpose, entry.getPurpose()); + assertEquals("PaymentAttempt", entry.getTraceChain().get(0).getName()); + assertEquals(List.of("payment", "customerOrder", "platform").subList(0, depth), + entry.getTraceChain().stream().filter(node -> node.getKind() == TraceKind.RELATION) + .map(TraceNode::getName).toList()); + } + } + assertEquals(8, fixture.sql.size()); + assertTrue(fixture.context.getTraceChain().isEmpty()); + } finally { + release.countDown(); workers.shutdownNow(); + assertTrue(workers.awaitTermination(10, TimeUnit.SECONDS)); + } + System.out.println("PASS Java generated overlapping queries: request-owned three-level SQL paths, Context unchanged"); + } + + @Test public void generatedStreamKeepsItsIntentAcrossLateConsumption() throws Exception { + var fixture = new Fixture(); Graph graph = fixture.saveNormativeGraph(); fixture.clear(); + fixture.streamOpen = caller -> { + assertSame(fixture.context, caller); + assertTrue("the actual JDBC cursor open must not depend on Context frames", caller.getTraceChain().isEmpty()); + }; + try (var stream = Q.customerOrders().withIdIs(graph.order.getId()).limit(1) + .comment("what: stream the selected order").purpose("why: consume after another query") + .executeForStream(fixture.context)) { + Q.platforms().withIdIs(1L).limit(1).comment("what: inspect an unrelated platform") + .purpose("why: prove delayed cursors keep their own intent").executeForOne(fixture.context); + var rows = stream.toList(); + assertEquals(1, rows.size()); + assertEquals(graph.order.getId(), E.customerOrder(rows.get(0)).getId().eval()); + } + assertEquals(2, fixture.sql.size()); + var cursor = fixture.sql.stream().filter(entry -> "what: stream the selected order".equals(entry.getComment())) + .findFirst().orElseThrow(); + assertEquals("why: consume after another query", cursor.getPurpose()); + assertEquals("CustomerOrder", cursor.getTraceChain().get(0).getName()); + assertTrue(fixture.context.getTraceChain().isEmpty()); + System.out.println("PASS Java generated stream: request-owned SQL path and delayed consumption intent"); + } + + @Test public void generatedStreamsReturnLifecycleEvidenceWithLoggingDisabled() throws Exception { + var fixture = new Fixture(false); Graph graph = fixture.saveNormativeGraph(); fixture.clear(); + for (String mode : List.of("success", "cancelled", "failure")) { + try (var stream = Q.customerOrders().withIdIs(graph.order.getId()).limit(1) + .comment("stream " + mode).purpose("verify generated terminal evidence").executeForStream(fixture.context)) { + var cursor = fixture.cursors.get(fixture.cursors.size() - 1); + assertTrue("no terminal fact at open", cursor.statements().isEmpty()); + Q.platforms().withIdIs(1L).limit(1).comment("independent query during cursor") + .purpose("verify invocation ownership").executeForOne(fixture.context); + if (mode.equals("failure")) { + var failure = new IllegalStateException("consumer failed"); + assertSame(failure, assertThrows(IllegalStateException.class, + () -> stream.forEach(row -> { throw failure; }))); + } else { + var rows = mode.equals("cancelled") ? stream.limit(1).toList() : stream.toList(); + assertEquals(graph.order.getId(), E.customerOrder(rows.get(0)).getId().eval()); + } + } + var facts = fixture.cursors.get(fixture.cursors.size() - 1).statements(); + assertEquals(1, facts.size()); + assertEquals(mode, facts.get(0).getExecutionOutcome()); + assertEquals("stream " + mode, facts.get(0).getComment()); + assertEquals("CustomerOrder", facts.get(0).getTraceChain().get(0).getName()); + assertEquals(Integer.valueOf(1), facts.get(0).getResultCount()); + } + assertTrue(fixture.sql.isEmpty()); assertTrue(fixture.context.getTraceChain().isEmpty()); + System.out.println("PASS Java generated cursor evidence: logging disabled, completion, cancellation and failure"); + } + + private static void assertQueryPaths(Fixture fixture, String comment, String purpose, + List> expectedRelations) { + assertEquals(expectedRelations.size(), fixture.sql.size()); + for (int index = 0; index < fixture.sql.size(); index++) { + var entry = fixture.sql.get(index); + assertEquals(comment, entry.getComment()); assertEquals(purpose, entry.getPurpose()); + var kinds = new java.util.ArrayList<>(List.of(TraceKind.OPERATION, TraceKind.REQUEST)); + expectedRelations.get(index).forEach(relation -> kinds.add(TraceKind.RELATION)); + kinds.add(TraceKind.PROVIDER); kinds.add(TraceKind.SQL); + assertEquals(kinds, entry.getTraceChain().stream().map(TraceNode::getKind).toList()); + assertEquals("PaymentAttempt", entry.getTraceChain().get(0).getName()); + assertEquals("PaymentAttempt", entry.getTraceChain().get(1).getName()); + assertEquals("sqlite", entry.getTraceChain().get(entry.getTraceChain().size() - 2).getName()); + assertEquals("select", entry.getTraceChain().get(entry.getTraceChain().size() - 1).getName()); + assertEquals(expectedRelations.get(index), entry.getTraceChain().stream() + .filter(node -> node.getKind() == TraceKind.RELATION).map(TraceNode::getName).toList()); + } + assertTrue(fixture.context.getTraceChain().isEmpty()); + } + + @Test public void generatedNestedFacetsKeepTheOriginalRootAndLogicalRoute() throws Exception { + var fixture = new Fixture(); Graph graph = fixture.saveNormativeGraph(); fixture.clear(); + String comment = "what: inspect payment ownership facets"; + String purpose = "why: retain the request route through nested facet materialization"; + var rows = Q.paymentAttempts().withIdIs(graph.attempt.getId()).limit(1) + .facetByPaymentAs("payments", Q.payments().withIdIs(graph.payment.getId()).limit(1) + .facetByCustomerOrderAs("orders", Q.customerOrders().withIdIs(graph.order.getId()).limit(1))) + .comment(comment).purpose(purpose).executeForList(fixture.context); + assertEquals(1, rows.size()); + assertEquals(graph.attempt.getId(), E.paymentAttempt(rows.get(0)).getId().eval()); + var payments = rows.getFacet("payments"); assertNotNull(payments); assertEquals(1, payments.size()); + var payment = (Payment) payments.get(0); + assertEquals(graph.payment.getId(), E.payment(payment).getId().eval()); + assertEquals(1, ((Number) payment.getDynamicProperty("count")).intValue()); + assertQueryPaths(fixture, comment, purpose, List.of(List.of(), List.of(), + List.of("payment"), List.of("payment"), List.of("payment", "customerOrder"))); + System.out.println("PASS Java generated nested facets: filtered counts and original root/relation SQL paths"); + } + + @Test public void generatedFacetInsideALoadedRelationKeepsItsAncestorPath() throws Exception { + var fixture = new Fixture(); Graph graph = fixture.saveNormativeGraph(); fixture.clear(); + String comment = "what: inspect related order facets"; + String purpose = "why: retain already loaded relation ancestry"; + var row = Q.paymentAttempts().withIdIs(graph.attempt.getId()).limit(1) + .selectPaymentWith(Q.payments().limit(1) + .facetByCustomerOrderAs("orders", Q.customerOrders().withIdIs(graph.order.getId()).limit(1))) + .comment(comment).purpose(purpose).executeForOne(fixture.context); + assertEquals(graph.attempt.getId(), E.paymentAttempt(row).getId().eval()); + assertEquals(graph.payment.getId(), E.payment(E.paymentAttempt(row).getPayment().eval()).getId().eval()); + assertQueryPaths(fixture, comment, purpose, List.of(List.of(), List.of("payment"), + List.of("payment"), List.of("payment", "customerOrder"))); + System.out.println("PASS Java generated relation facet: original root and complete inherited SQL route"); + } + + @Test public void generatedCheckerRejectsInvalidBusinessStateBeforeProvider() throws Exception { + var fixture = new Fixture(); + var platform = Q.platforms().withIdIs(1L).limit(1).comment("what: reuse root") + .purpose("why: verify checker enforcement").executeForOne(fixture.context); + var invalid = Q.customerOrders().comment("what: prepare an incomplete order") + .purpose("why: test generated required-field rules").newEntity(fixture.context); + invalid.updatePlatform(platform); + invalid.updateDescription("Incomplete fixture"); + fixture.clear(); + var failure = assertThrows(io.teaql.core.checker.CheckException.class, + () -> invalid.auditAs("reject incomplete order").save(fixture.context)); + assertTrue(failure.getViolates().toString(), failure.getViolates().stream() + .anyMatch(value -> value.getLocation().modelPath().endsWith("order_number"))); + assertTrue(fixture.commands.isEmpty()); + assertTrue(fixture.sql.isEmpty()); + assertTrue(fixture.audit.isEmpty()); + System.out.println("PASS Java generated Checker rejection before provider access"); + } + + @Test public void generatedProviderFailureKeepsAttemptedLineageWithoutCommittedAudit() throws Exception { + var fixture = new Fixture(); + Graph prior = fixture.saveNormativeGraph(); + fixture.driver.execute("CREATE UNIQUE INDEX IF NOT EXISTS trace_payment_reference_unique ON payment_data(reference_code)"); + var failed = Q.customerOrders().comment("what: prepare a failing transaction") + .purpose("why: test generated graph rollback").newEntity(fixture.context); + var platform = Q.platforms().withIdIs(1L).limit(1).comment("what: reuse root") + .purpose("why: prepare the authorized fixture").executeForOne(fixture.context); + failed.updatePlatform(platform); + failed.updateOrderNumber("TRACE-FAIL-" + fixture.base); + failed.updateDescription("Will roll back"); + var duplicate = Q.payments().comment("what: prepare duplicate payment") + .purpose("why: provoke an actual SQLite uniqueness error").newEntity(fixture.context); + duplicate.updateReferenceCode(E.payment(prior.payment).getReferenceCode().eval()); + duplicate.comment("reject duplicate transfer"); + failed.addPayment(duplicate); + fixture.clear(); + assertThrows(RuntimeException.class, () -> failed.auditAs("attempt atomic submission").save(fixture.context)); + assertTrue("rollback is not a committed audit", fixture.audit.isEmpty()); + var error = fixture.sql.stream().filter(value -> "failure".equals(value.getBatchOutcome())).findFirst().orElseThrow(); + assertEquals(List.of("attempt atomic submission", "reject duplicate transfer"), + error.getMutationLineage().stream().map(TraceNode::getComment).toList()); + assertEquals(duplicate.getId(), error.getMutationLineage().get(1).getEntityId()); + assertTrue(fixture.sql.stream().anyMatch(value -> value.getOperation() == DataServiceOperation.MUTATION + && "success".equals(value.getExecutionOutcome()))); + assertNull(Q.customerOrders().withIdIs(failed.getId()).limit(1) + .comment("what: query the failed graph identity") + .purpose("why: prove the earlier root insert rolled back").executeForOne(fixture.context)); + System.out.println("PASS Java generated provider failure: attempted lineage, rollback, no committed audit"); + } + + @Test public void generatedReadbackFailurePreservesWriteTraceAndRetries() throws Exception { + var fixture = new Fixture(); + Graph graph = fixture.saveNormativeGraph(); + graph.order.updateDescription("Readback retry fixture"); + fixture.clear(); + fixture.failReadback = true; + assertThrows(RuntimeException.class, () -> graph.order.auditAs("attempt readback").save(fixture.context)); + assertTrue(fixture.audit.isEmpty()); + var write = fixture.sql.stream().filter(value -> value.getOperation() == DataServiceOperation.MUTATION).findFirst().orElseThrow(); + var readback = fixture.sql.stream().filter(value -> value.getOperation() == DataServiceOperation.QUERY + && "failure".equals(value.getExecutionOutcome())).findFirst().orElseThrow(); + assertEquals("success", write.getExecutionOutcome()); + assertEquals(write.getMutationLineage(), readback.getMutationLineage()); + assertEquals("select", readback.getStatementOperation()); + assertEquals(Long.valueOf(2), E.customerOrder(graph.order).getVersion().eval()); + fixture.failReadback = false; + fixture.clear(); + graph.order.auditAs("retry readback").save(fixture.context); + assertEquals(1, fixture.audit.size()); + assertEquals(List.of("retry readback"), fixture.audit.get(0).traceChain().stream().map(TraceNode::getComment).toList()); + assertEquals(Long.valueOf(3), E.customerOrder(graph.order).getVersion().eval()); + System.out.println("PASS Java generated readback failure: separate outcomes and successful retry"); + } + + static List expected(long base, String type, long id) { + var root = new TraceNode(TraceKind.AUDIT_REASON, "CustomerOrder", base, "submit order"); + if (type.equals("Payment") || type.equals("PaymentAttempt")) + return List.of(root, new TraceNode(TraceKind.AUDIT_REASON, "Payment", base, "authorize payment")); + if (type.equals("Shipment")) + return List.of(root, new TraceNode(TraceKind.AUDIT_REASON, "Shipment", base + 401, "dispatch shipment")); + if (type.equals("OrderItem") && id == base + 102) + return List.of(root, new TraceNode(TraceKind.AUDIT_REASON, "OrderItem", id, "remove unavailable item")); + return List.of(root); + } +} diff --git a/examples/trace-chain/src/test/java/io/teaql/examples/tracechain/IdDatabase.java b/examples/trace-chain/src/test/java/io/teaql/examples/tracechain/IdDatabase.java new file mode 100644 index 00000000..51466a05 --- /dev/null +++ b/examples/trace-chain/src/test/java/io/teaql/examples/tracechain/IdDatabase.java @@ -0,0 +1,22 @@ +package io.teaql.examples.tracechain; + +import io.teaql.core.sql.portable.TeaQLDatabase; +import io.teaql.provider.jdbc.JdbcSqlExecutor; +import java.util.List; +import java.util.Map; + +/** Infrastructure bridge only: business data uses generated Q/E/Mutation APIs. */ +final class IdDatabase implements TeaQLDatabase { + private final JdbcSqlExecutor executor; + IdDatabase(JdbcSqlExecutor executor) { this.executor = executor; } + @Override public List> query(String sql, Object[] args) { + return executor.queryForList(sql, args); + } + @Override public int executeUpdate(String sql, Object[] args) { return executor.update(sql, args); } + @Override public int[] batchUpdate(String sql, List args) { return executor.batchUpdate(sql, args); } + @Override public void execute(String sql) { executor.execute(sql); } + @Override public void executeInTransaction(Runnable action) { executor.executeInTransaction(action); } + @Override public List> getTableColumns(String table) { + throw new UnsupportedOperationException("Schema inspection belongs to the SQLite data service"); + } +} diff --git a/examples/trace-chain/verify.sh b/examples/trace-chain/verify.sh new file mode 100644 index 00000000..71eaf901 --- /dev/null +++ b/examples/trace-chain/verify.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +set -euo pipefail + +example_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_dir="$(cd "$example_dir/../.." && pwd)" +run_dir="${TEAQL_TRACE_CHAIN_VERIFY_DIR:-$(mktemp -d -t teaql-java-trace-chain.XXXXXXXX)}" +mkdir -p "$run_dir" +run_dir="$(cd "$run_dir" && pwd)" +database="$run_dir/trace-chain.db" + +library_manifest() { + (cd "$example_dir" && find lib -type f ! -path '*/target/*' -print0 | + sort -z | xargs -0 sha256sum) +} +library_manifest > "$run_dir/library-before.sha256" + +# Install only from this checkout. No candidate download, deploy, tag or publication. +mvn -B -f "$repo_dir/pom.xml" -Pruntime-examples -pl examples/trace-chain -am \ + install -DskipTests > "$run_dir/local-source-install.log" 2>&1 + +markers=( + 'TC-REQ-09 JAVA GENERATED BOOTSTRAP PASSED logging=true' + 'TC-REQ-09 JAVA GENERATED BOOTSTRAP PASSED logging=false' + 'PASS Java generated privacy retains complete raw command and safe SQL/audit root lineage' + 'PASS FORWARD_NOTLOADED: Java generated Q/E retains FK and hidden detail guard' + 'PASS Java generated cursor evidence: logging disabled, completion, cancellation and failure' + 'PASS Java generated query evidence: logging disabled, three relation levels, immutable result list' + 'PASS Java generated cross-type loaded privacy: repeated saves, rollback retry, delete and independent intent' + 'PASS Java generated normative Trace Chain graph: six physical writes and committed audits' + 'PASS Java graph identity controls: duplicate, missing and equal-ID type collapse rejected' + 'PASS Java generated three-level SQL Trace Path and inherited request intent' + 'PASS Java generated Checker rejection before provider access' + 'PASS Java generated provider failure: attempted lineage, rollback, no committed audit' + 'PASS Java generated readback failure: separate outcomes and successful retry' + 'PASS Java generated prepared batch: per-item lineage and complete ledger replacement' + 'PASS Java generated ledger override: one typed key replaces fallback; new sibling inherits only graph root at command/SQL/audit' + 'PASS Java generated prepared update/delete/recover: unequal versions and per-item lineage' + 'PASS Java generated overlapping Checker: valid commits, invalid rejected before provider' + 'PASS Java generated concurrent graphs: same Context, independent ledgers and per-item SQL/audit lineage' + 'PASS Java generated overlapping queries: request-owned three-level SQL paths, Context unchanged' + 'PASS Java generated stream: request-owned SQL path and delayed consumption intent' + 'PASS Java generated nested facets: filtered counts and original root/relation SQL paths' + 'PASS Java generated relation facet: original root and complete inherited SQL route' +) +for repetition in 1 2; do + log="$run_dir/run-$repetition.log" + if ! mvn -B -f "$example_dir/pom.xml" -Dtest=GeneratedTraceChainExampleTest,GeneratedFacetTraceExampleTest,GeneratedReverseFacetTraceExampleTest,GeneratedAggregateTraceExampleTest \ + "-Dteaql.trace.database=$database" test > "$log" 2>&1; then + tail -n 100 "$log" >&2 + exit 1 + fi + for marker in "${markers[@]}"; do + if ! grep -Fq "$marker" "$log"; then + printf 'FAIL: missing acceptance marker: %s\n' "$marker" >&2 + exit 1 + fi + done + grep -Fq 'Tests run: 24, Failures: 0, Errors: 0, Skipped: 0' "$log" + for aggregate_marker in JAVA_AGGREGATE_OBSERVED JAVA_AGGREGATE_NUMERIC JAVA_AGGREGATE_MEMBERSHIP JAVA_AGGREGATE_FORWARD; do + expected_count=4 + if [[ $aggregate_marker == JAVA_AGGREGATE_MEMBERSHIP ]]; then expected_count=8; fi + [[ $(grep -c "^$aggregate_marker " "$log") == "$expected_count" ]] + done + grep -Fq 'JAVA_NESTED_FACET_CARRIER returned nested metadata and count verified' "$log" + for logging in false true; do + sink_count=0 + if [[ $logging == true ]]; then sink_count=7; fi + grep -Fq "JAVA_LOADED_FACET logging=$logging parents=2 independentMembership=true physicalStatements=7 safeSinkStatements=$sink_count" "$log" + for all in false true; do + reverse_sink_count=0 + if [[ $logging == true ]]; then reverse_sink_count=10; fi + grep -Fq "JAVA_GENERATED_REVERSE_FACET logging=$logging includeAll=$all parents=3 fullCounts=true requestedEmpty=true physicalStatements=10 safeSinkStatements=$reverse_sink_count mutationCommands=0" "$log" + sink_count=0 + if [[ $logging == true ]]; then sink_count=7; fi + grep -Fq "JAVA_NESTED_FACET {\"logging\":$logging,\"includeAll\":$all,\"visible\":1,\"paymentCount\":2,\"orderCount\":2,\"physicalStatements\":7,\"safeSinkStatements\":$sink_count}" "$log" + for exists in false true; do + facet_rows=0 + if [[ $exists == true ]]; then facet_rows=1; fi + grep -Fq "JAVA_LOADED_EMPTY_FACET logging=$logging includeAll=$all targetExists=$exists facetRows=$facet_rows physicalStatements=4 mutationCommands=1" "$log" + done + done + done + printf 'PASS Java generated Trace Chain run %s on the same database\n' "$repetition" +done +library_manifest > "$run_dir/library-after.sha256" +cmp "$run_dir/library-before.sha256" "$run_dir/library-after.sha256" +printf 'PASS Java generated library unchanged; evidence and database retained: %s\n' "$run_dir" diff --git a/examples/verify-runtime-examples.sh b/examples/verify-runtime-examples.sh index 9a28d0d9..883e4e63 100755 --- a/examples/verify-runtime-examples.sh +++ b/examples/verify-runtime-examples.sh @@ -23,7 +23,8 @@ run_example() { local name="$1" local jar="$2" local marker="$3" - local log="$run_dir/$name.log" + local repetition="$4" + local log="$run_dir/$name-run-$repetition.log" local database="$run_dir/$name.db" java -jar "$jar" \ @@ -36,7 +37,24 @@ run_example() { kill "$active_pid" 2>/dev/null || true wait "$active_pid" 2>/dev/null || true active_pid="" - printf 'PASS %s\n' "$name" + if [[ "$name" == "school-management" ]] && ! grep -Fq \ + 'PASS Java request-owned comment/purpose gates and generated relation inheritance' "$log"; then + printf 'FAIL school-management omitted request-intent verification\n' >&2 + sed -n '1,240p' "$log" >&2 + return 1 + fi + if [[ "$name" == "school-management" ]]; then + local bootstrap_marker='PASS Java generated bootstrap request intent, committed trace and reconciliation' + local bootstrap_state='fresh=true originalVersion=1' + if [[ "$repetition" == 2 ]]; then bootstrap_state='fresh=false originalVersion=3'; fi + if ! grep -Fq "$bootstrap_marker $bootstrap_state" "$log"; then + printf 'FAIL school-management omitted fresh/warm bootstrap trace verification\n' >&2 + sed -n '1,240p' "$log" >&2 + return 1 + fi + printf '%s %s\n' "$bootstrap_marker" "$bootstrap_state" + fi + printf 'PASS %s run %s (same database)\n' "$name" "$repetition" return 0 fi if ! kill -0 "$active_pid" 2>/dev/null; then @@ -54,14 +72,17 @@ run_example() { return 1 } -run_example \ - "conformance" \ - "$repo_dir/examples/conformance/target/deploy/runtime-example-conformance-service-0.0.1-SNAPSHOT.jar" \ - "PASS Java minimum runtime conformance: 8/8" +for repetition in 1 2; do + run_example \ + "conformance" \ + "$repo_dir/examples/conformance/target/deploy/runtime-example-conformance-service-0.0.1-SNAPSHOT.jar" \ + "PASS Java minimum runtime conformance: 8/8" "$repetition" -run_example \ - "school-management" \ - "$repo_dir/examples/school-management/target/deploy/school-management-service-0.0.1-SNAPSHOT.jar" \ - "PASS Java School bootstrap, portable Query, and native SQLite Facet parity" + run_example \ + "school-management" \ + "$repo_dir/examples/school-management/target/deploy/school-management-service-0.0.1-SNAPSHOT.jar" \ + "PASS Java School bootstrap, portable Query, and native SQLite Facet parity" "$repetition" +done -printf 'PASS Java runtime examples: 2/2\n' +bash "$repo_dir/examples/trace-chain/verify.sh" +printf 'PASS Java runtime examples: 3/3\n' diff --git a/pom.xml b/pom.xml index 559e5a92..33fbe6c5 100644 --- a/pom.xml +++ b/pom.xml @@ -365,6 +365,8 @@ examples/school-management/lib examples/school-management + examples/trace-chain/lib + examples/trace-chain diff --git a/scripts/verify-examples.sh b/scripts/verify-examples.sh index 06cca2f6..7922135a 100755 --- a/scripts/verify-examples.sh +++ b/scripts/verify-examples.sh @@ -4,7 +4,7 @@ set -euo pipefail repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" verification_dir="$(mktemp -d)" trap 'rm -rf -- "$verification_dir"' EXIT -expected=(business-id-runtime conformance order-management round-trip-reference-runtime school-management security-foundations) +expected=(business-id-runtime conformance order-management round-trip-reference-runtime school-management security-foundations 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 @@ -13,6 +13,8 @@ fi cd "$repo" mvn -q -DskipTests install +mvn -q -pl teaql-sqlite \ + -Dtest=DerivedQueryTraceSqliteTest,LikeIntentPrivacySqliteTest,TypedIntentPrivacySqliteTest test mvn -q -pl examples/business-id-runtime \ -Dtest=BusinessIdRuntimeExampleTest test mvn -q -pl examples/security-foundations \ @@ -26,5 +28,8 @@ mvn -q -f examples/school-management/lib/pom.xml install -DskipTests mvn -q -f examples/school-management/pom.xml spring-boot:run \ -Dspring-boot.run.arguments="--spring.main.web-application-type=none --spring.datasource.url=jdbc:sqlite:$verification_dir/school-management.db" mvn -q -f examples/order-management/pom.xml install -DskipTests -mvn -q -f examples/order-management/pom.xml exec:java -pl java-app-console +# The console resolves .local/order.db from its process directory. Never mutate +# the developer's retained database while verifying the runtime checkout. +(cd "$verification_dir" && mvn -q -f "$repo/examples/order-management/pom.xml" exec:java -pl java-app-console) +bash examples/trace-chain/verify.sh echo "PASS: all Java examples" diff --git a/teaql-core/src/main/java/io/teaql/core/Audited.java b/teaql-core/src/main/java/io/teaql/core/Audited.java index f6e0626b..54e947a8 100644 --- a/teaql-core/src/main/java/io/teaql/core/Audited.java +++ b/teaql-core/src/main/java/io/teaql/core/Audited.java @@ -6,11 +6,10 @@ */ public class Audited { private final T inner; + private final MutationIntent intent; public Audited(T entity, String comment) { - if (comment == null || comment.trim().isEmpty()) { - throw new IllegalArgumentException("Audit comment must not be empty"); - } + this.intent = MutationIntent.of(comment); this.inner = entity; this.inner.setComment(comment); } @@ -21,6 +20,7 @@ public T entity() { @SuppressWarnings("unchecked") public R save(UserContext context) { + this.inner.setComment(intent.comment()); context.saveGraph(this.inner); return (R) this.inner; } @@ -28,6 +28,7 @@ public R save(UserContext context) { @SuppressWarnings("unchecked") public R recover(UserContext context) { this.inner.markAsRecover(); + this.inner.setComment(intent.comment()); context.saveGraph(this.inner); return (R) this.inner; } diff --git a/teaql-core/src/main/java/io/teaql/core/BaseEntity.java b/teaql-core/src/main/java/io/teaql/core/BaseEntity.java index b0a60b1d..b31a0139 100644 --- a/teaql-core/src/main/java/io/teaql/core/BaseEntity.java +++ b/teaql-core/src/main/java/io/teaql/core/BaseEntity.java @@ -42,6 +42,20 @@ protected LoadedPropertyLayout computeValue(Class type) { private DynamicFieldValues dynamicFieldValues; + // Query-only sidecar. Never a model property or mutation-ledger entry. + private transient Map> queryFacets = Map.of(); + + @Override + @com.fasterxml.jackson.annotation.JsonIgnore + public Map> getQueryFacets() { + return queryFacets; + } + + @FrameworkInternal + public void __internalSetQueryFacets(Map> facets) { + queryFacets = facets == null || facets.isEmpty() ? Map.of() : Map.copyOf(facets); + } + private Map relationCache = new HashMap<>(); private List actionList; @@ -66,16 +80,19 @@ public void setComment(String comment) { } } - private String _traceChain; + private List _traceChain = List.of(); @Override - public String getTraceChain() { + public List getTraceChain() { return _traceChain; } @Override - public void setTraceChain(String traceChain) { - this._traceChain = traceChain; + public void setTraceChain(List traceChain) { + this._traceChain = traceChain == null ? List.of() : List.copyOf(traceChain); + if (entityMutationLedger != null && id != null) { + entityMutationLedger.setTraceChain(new EntityKey(typeName(), id), this._traceChain); + } } public EntityStatus get$status() { @@ -326,6 +343,9 @@ public BaseEntity markForDeletion() { @Override public void markAsRecover() { gotoNextStatus(EntityAction.RECOVER); + if (entityMutationLedger != null && id != null) { + entityMutationLedger.markAsRecover(new EntityKey(typeName(), id)); + } } @Override @@ -547,7 +567,7 @@ public void handleUpdate(String propertyName, Object oldValue, Object newValue) if (entityMutationLedger != null && id != null) { EntityKey key = new EntityKey(typeName(), id); entityMutationLedger.set(key, propertyName, newValue); - if (_traceChain != null) { + if (!_traceChain.isEmpty()) { entityMutationLedger.setTraceChain(key, _traceChain); } } @@ -577,7 +597,7 @@ public Object getNewValue(String propertyName) { } public BaseEntity markToRecover() { - gotoNextStatus(EntityAction.RECOVER); + markAsRecover(); return this; } diff --git a/teaql-core/src/main/java/io/teaql/core/BaseRequest.java b/teaql-core/src/main/java/io/teaql/core/BaseRequest.java index dbd86ca3..aebc5d16 100644 --- a/teaql-core/src/main/java/io/teaql/core/BaseRequest.java +++ b/teaql-core/src/main/java/io/teaql/core/BaseRequest.java @@ -914,16 +914,7 @@ protected BaseRequest internalPurpose(String purpose) { * Q.tasks().filterByName("xxx").comment("Load tasks").purpose("Display board").executeForList(context); */ public ExecutableRequest purpose(String purpose) { - if (comment == null || comment.trim().isEmpty()) { - throw new TeaQLRuntimeException( - "[PURPOSE FAILED] Missing .comment() on " + getTypeName() + " query.\n" + - "Call .comment() before .purpose().\n" + - "Pattern: Q.xxx().comment(\"...\").purpose(\"...\").executeForList(context)"); - } - if (purpose == null || purpose.trim().isEmpty()) { - throw new TeaQLRuntimeException( - "[PURPOSE FAILED] purpose() must be non-empty on " + getTypeName() + " query."); - } + QueryIntent.of(comment, purpose); this.purpose = purpose; return new ExecutableRequest<>((SearchRequest) this); } diff --git a/teaql-core/src/main/java/io/teaql/core/BatchMutationExecutor.java b/teaql-core/src/main/java/io/teaql/core/BatchMutationExecutor.java new file mode 100644 index 00000000..adfa31d9 --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/BatchMutationExecutor.java @@ -0,0 +1,8 @@ +package io.teaql.core; + +import java.util.List; + +/** Optional provider capability; ordered results correspond to the request's items. */ +public interface BatchMutationExecutor extends MutationExecutor { + List mutateBatch(UserContext context, MutationBatchRequest request); +} diff --git a/teaql-core/src/main/java/io/teaql/core/DefaultMutationResult.java b/teaql-core/src/main/java/io/teaql/core/DefaultMutationResult.java index cf954a9a..f917912f 100644 --- a/teaql-core/src/main/java/io/teaql/core/DefaultMutationResult.java +++ b/teaql-core/src/main/java/io/teaql/core/DefaultMutationResult.java @@ -2,11 +2,21 @@ public final class DefaultMutationResult implements MutationResult { private final Entity persistedEntity; + private final java.util.List statements; public DefaultMutationResult(Entity persistedEntity) { + this(persistedEntity, java.util.List.of()); + } + + public DefaultMutationResult(Entity persistedEntity, java.util.List statements) { this.persistedEntity = persistedEntity; + this.statements = java.util.List.copyOf(statements); } + @Override + @com.fasterxml.jackson.annotation.JsonIgnore + public java.util.List statements() { return statements; } + @Override public Entity persistedEntity() { return persistedEntity; diff --git a/teaql-core/src/main/java/io/teaql/core/Entity.java b/teaql-core/src/main/java/io/teaql/core/Entity.java index 8976b3ec..9aca8060 100644 --- a/teaql-core/src/main/java/io/teaql/core/Entity.java +++ b/teaql-core/src/main/java/io/teaql/core/Entity.java @@ -80,6 +80,16 @@ default void addRelation(UserContext context, String relationName, Entity value) T getDynamicProperty(String propertyName); + /** Nonpersistent results of Facets requested while loading this entity. */ + @com.fasterxml.jackson.annotation.JsonIgnore + default java.util.Map> getQueryFacets() { + return java.util.Map.of(); + } + + default SmartList getQueryFacet(String name) { + return getQueryFacets().get(name); + } + /** * Returns the dynamic field values wrapper for this entity. * Dynamic fields use the '#' prefix namespace in additionalInfo. @@ -115,10 +125,10 @@ default Audited auditAs(String action) { } - default String getTraceChain() { - return null; + default java.util.List getTraceChain() { + return java.util.List.of(); } - default void setTraceChain(String traceChain) { + default void setTraceChain(java.util.List traceChain) { } } diff --git a/teaql-core/src/main/java/io/teaql/core/EntityMutationLedger.java b/teaql-core/src/main/java/io/teaql/core/EntityMutationLedger.java index 938cc3cb..335cdaca 100644 --- a/teaql-core/src/main/java/io/teaql/core/EntityMutationLedger.java +++ b/teaql-core/src/main/java/io/teaql/core/EntityMutationLedger.java @@ -4,7 +4,7 @@ /** * Central change tracking context shared across all entities in a save graph. - * Holds the change set stack, deleted keys, new keys, trace chains, and original versions. + * Holds the change set stack, deleted/recovered/new keys, trace chains, and original versions. * * This is the Java equivalent of Rust's {@code EntityMutationLedger}. */ @@ -12,8 +12,9 @@ public class EntityMutationLedger { private final ChangeSetStack changeSets = new ChangeSetStack(); private String comment; private final Set deletedKeys = new TreeSet<>(); + private final Set recoveredKeys = new TreeSet<>(); private final Set newKeys = new TreeSet<>(); - private final Map traceChains = new TreeMap<>(); + private final Map> traceChains = new TreeMap<>(); private final Map originalVersions = new TreeMap<>(); // --- Change Set Stack --- @@ -30,12 +31,14 @@ public void clearCurrentChangeSet() { changeSets.clearCurrent(); newKeys.clear(); deletedKeys.clear(); + recoveredKeys.clear(); // A successful save establishes a new persistence baseline. Keeping the // pre-save version here makes a later mutation on the same entity use a // stale optimistic-lock value (for example update -> save -> delete -> // save). The materialized entity now carries the authoritative version // returned by the provider, so the next mutation must capture that value. originalVersions.clear(); + traceChains.clear(); } public void set(EntityKey key, String field, Object value) { @@ -79,6 +82,7 @@ public Set newKeys() { public void markAsDelete(EntityKey key) { changeSets.clearEntity(key); + recoveredKeys.remove(key); deletedKeys.add(key); } @@ -90,6 +94,16 @@ public Set deletedKeys() { return Collections.unmodifiableSet(deletedKeys); } + // Recovery is a mutation even when no scalar property has changed. + public void markAsRecover(EntityKey key) { + deletedKeys.remove(key); + recoveredKeys.add(key); + } + + public Set recoveredKeys() { + return Collections.unmodifiableSet(recoveredKeys); + } + // --- Changed Fields --- public Set changedFieldNames(EntityKey key) { @@ -98,11 +112,11 @@ public Set changedFieldNames(EntityKey key) { // --- Trace Chains --- - public void setTraceChain(EntityKey key, String traceChain) { - traceChains.put(key, traceChain); + public void setTraceChain(EntityKey key, List traceChain) { + traceChains.put(key, traceChain == null ? List.of() : List.copyOf(traceChain)); } - public String getTraceChain(EntityKey key) { + public List getTraceChain(EntityKey key) { return traceChains.get(key); } @@ -116,6 +130,27 @@ public Long getOriginalVersion(EntityKey key) { return originalVersions.get(key); } + /** + * Imports only the explicitly visited entity's pending mutation. A loaded + * reference may be shared by otherwise independent graphs; importing its + * entire ledger could pull in an unrelated root's changes. Returns false + * for a read-only entity, which must retain its private ledger ownership. + */ + public boolean mergeEntityFrom(EntityMutationLedger other, EntityKey key) { + if (other == null || other == this) return false; + Map fields = other.currentChangeSet().changes().get(key); + boolean pending = (fields != null && !fields.isEmpty()) || other.newKeys.contains(key) + || other.deletedKeys.contains(key) || other.recoveredKeys.contains(key); + if (!pending) return false; + if (fields != null) fields.forEach((field, value) -> set(key, field, value)); + if (other.deletedKeys.contains(key)) markAsDelete(key); + if (other.recoveredKeys.contains(key)) markAsRecover(key); + if (other.newKeys.contains(key)) markAsNew(key); + if (other.traceChains.containsKey(key)) setTraceChain(key, other.traceChains.get(key)); + if (other.originalVersions.containsKey(key)) setOriginalVersion(key, other.originalVersions.get(key)); + return true; + } + /** * Merge another EntityMutationLedger's changes into this one. * Used when saving an entity graph (e.g., Order + OrderItems). @@ -136,6 +171,10 @@ public void mergeFrom(EntityMutationLedger other) { for (EntityKey key : other.deletedKeys()) { this.markAsDelete(key); } + + for (EntityKey key : other.recoveredKeys()) { + this.markAsRecover(key); + } // Merge new keys for (EntityKey key : other.newKeys()) { @@ -143,7 +182,7 @@ public void mergeFrom(EntityMutationLedger other) { } // Merge trace chains - for (Map.Entry entry : other.traceChains.entrySet()) { + for (Map.Entry> entry : other.traceChains.entrySet()) { this.setTraceChain(entry.getKey(), entry.getValue()); } diff --git a/teaql-core/src/main/java/io/teaql/core/ExecutionMetadata.java b/teaql-core/src/main/java/io/teaql/core/ExecutionMetadata.java index 66fb49ad..a1634559 100644 --- a/teaql-core/src/main/java/io/teaql/core/ExecutionMetadata.java +++ b/teaql-core/src/main/java/io/teaql/core/ExecutionMetadata.java @@ -17,6 +17,8 @@ public final class ExecutionMetadata { private List parameters = List.of(); private String debugQuery; private List traceChain; + private List mutationLineage = List.of(); + private String statementOperation; private String comment; private String purpose; private String auditReason; @@ -26,6 +28,7 @@ public final class ExecutionMetadata { private String logMode; private String sqlOmissionReason; private String executionOutcome; + private String batchOutcome; @com.fasterxml.jackson.annotation.JsonIgnore private transient SqlIntentRedactions intentRedactions; @@ -39,6 +42,10 @@ public final class ExecutionMetadata { public String getExecutionOutcome() { return executionOutcome; } public void setExecutionOutcome(String outcome) { executionOutcome = outcome; } + /** Batch call outcome, independent of an individual member's possibly unknown outcome. */ + public String getBatchOutcome() { return batchOutcome; } + public void setBatchOutcome(String outcome) { batchOutcome = outcome; } + public List getParameterLogPolicies() { return parameterLogPolicies; } public void setParameterLogPolicies(List policies) { parameterLogPolicies = policies == null ? List.of() : List.copyOf(policies); @@ -93,7 +100,14 @@ public void setParameters(List parameters) { public void setDebugQuery(String debugQuery) { this.debugQuery = debugQuery; } public List getTraceChain() { return traceChain; } - public void setTraceChain(List traceChain) { this.traceChain = traceChain; } + public void setTraceChain(List traceChain) { this.traceChain = traceChain == null ? List.of() : List.copyOf(traceChain); } + + public List getMutationLineage() { return mutationLineage; } + public void setMutationLineage(List lineage) { + mutationLineage = lineage == null ? List.of() : List.copyOf(lineage); + } + public String getStatementOperation() { return statementOperation; } + public void setStatementOperation(String operation) { statementOperation = operation; } public String getComment() { return comment; } public void setComment(String comment) { this.comment = comment; } diff --git a/teaql-core/src/main/java/io/teaql/core/MutationBatchRequest.java b/teaql-core/src/main/java/io/teaql/core/MutationBatchRequest.java new file mode 100644 index 00000000..839d68c8 --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/MutationBatchRequest.java @@ -0,0 +1,31 @@ +package io.teaql.core; + +import java.util.List; +import java.util.Objects; + +/** + * Immutable envelope for commands already planned under one root mutation intent. + * Local reasons belong to the individual command's lineage, not another root intent. + */ +public final class MutationBatchRequest implements MutationRequest { + private final MutationIntent intent; + private final List items; + + public MutationBatchRequest(String comment, List items) { + this(MutationIntent.of(comment), items); + } + + public MutationBatchRequest(MutationIntent intent, List items) { + this.intent = Objects.requireNonNull(intent, "intent"); + this.items = List.copyOf(items); + for (PersistenceMutation item : this.items) { + if (!intent.comment().equals(item.intent().comment())) { + throw new IllegalArgumentException("Batch member must own the batch root intent; retain local reasons in its lineage"); + } + } + } + + @Override public MutationIntent intent() { return intent; } + public List items() { return items; } + @Override public String toString() { return "MutationBatchRequest[items=" + items.size() + ", validated]"; } +} diff --git a/teaql-core/src/main/java/io/teaql/core/MutationIntent.java b/teaql-core/src/main/java/io/teaql/core/MutationIntent.java new file mode 100644 index 00000000..689b430c --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/MutationIntent.java @@ -0,0 +1,18 @@ +package io.teaql.core; + +/** Root mutation comment; exposed as auditReason in mutation SQL and committed audit events. */ +public final class MutationIntent { + private final String comment; + + private MutationIntent(String comment) { + this.comment = RequestIntentException.requireComment(comment, "mutation"); + } + + public static MutationIntent of(String comment) { return new MutationIntent(comment); } + public String comment() { return comment; } + public String auditReason() { return comment; } + public QueryIntent readbackIntent() { + return QueryIntent.of(comment, "runtime: read authoritative persisted mutation result"); + } + @Override public String toString() { return "MutationIntent[validated]"; } +} diff --git a/teaql-core/src/main/java/io/teaql/core/MutationPlan.java b/teaql-core/src/main/java/io/teaql/core/MutationPlan.java index ddca9bc5..cae07209 100644 --- a/teaql-core/src/main/java/io/teaql/core/MutationPlan.java +++ b/teaql-core/src/main/java/io/teaql/core/MutationPlan.java @@ -13,6 +13,7 @@ public record MutationPlan( Objects.requireNonNull(executionId, "executionId"); Objects.requireNonNull(requestKey, "requestKey"); Objects.requireNonNull(rootEntityType, "rootEntityType"); + MutationIntent.of(auditReason); operations = List.copyOf(operations == null ? List.of() : operations); } } diff --git a/teaql-core/src/main/java/io/teaql/core/MutationRequest.java b/teaql-core/src/main/java/io/teaql/core/MutationRequest.java new file mode 100644 index 00000000..bdfd4a8d --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/MutationRequest.java @@ -0,0 +1,7 @@ +package io.teaql.core; + +/** Every provider-bound mutation owns a validated root business reason. */ +public interface MutationRequest { + MutationIntent intent(); + default String comment() { return intent().comment(); } +} diff --git a/teaql-core/src/main/java/io/teaql/core/MutationResult.java b/teaql-core/src/main/java/io/teaql/core/MutationResult.java index a3ed2cb5..4b3946d9 100644 --- a/teaql-core/src/main/java/io/teaql/core/MutationResult.java +++ b/teaql-core/src/main/java/io/teaql/core/MutationResult.java @@ -1,6 +1,12 @@ package io.teaql.core; public interface MutationResult { + /** Actual physical statements, in execution order. Trusted diagnostics, not a wire response. */ + @com.fasterxml.jackson.annotation.JsonIgnore + default java.util.List statements() { + return java.util.List.of(); + } + default Entity persistedEntity() { return null; } diff --git a/teaql-core/src/main/java/io/teaql/core/MutationTraceScope.java b/teaql-core/src/main/java/io/teaql/core/MutationTraceScope.java new file mode 100644 index 00000000..4d41c0f8 --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/MutationTraceScope.java @@ -0,0 +1,30 @@ +package io.teaql.core; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** Graph-owned immutable parent token. Context never owns a mutation scope. */ +public final class MutationTraceScope { + private final MutationTraceScope parent; + private final TraceNode node; + + private MutationTraceScope(MutationTraceScope parent, TraceNode node) { + this.parent = parent; + this.node = node; + } + + public static MutationTraceScope append( + MutationTraceScope parent, String entityType, Long entityId, String reason) { + if (RequestIntentException.blank(reason)) return parent; + return new MutationTraceScope(parent, + new TraceNode(TraceKind.AUDIT_REASON, entityType, entityId, reason)); + } + + public List recover() { + var nodes = new ArrayList(); + for (MutationTraceScope scope = this; scope != null; scope = scope.parent) nodes.add(scope.node); + Collections.reverse(nodes); + return List.copyOf(nodes); + } +} diff --git a/teaql-core/src/main/java/io/teaql/core/PersistenceMutation.java b/teaql-core/src/main/java/io/teaql/core/PersistenceMutation.java index 07becd1d..ad1c0615 100644 --- a/teaql-core/src/main/java/io/teaql/core/PersistenceMutation.java +++ b/teaql-core/src/main/java/io/teaql/core/PersistenceMutation.java @@ -1,4 +1,4 @@ package io.teaql.core; /** One low-level provider write, distinct from a complete {@link MutationPlan}. */ -public interface PersistenceMutation {} +public interface PersistenceMutation extends MutationRequest {} diff --git a/teaql-core/src/main/java/io/teaql/core/QueryCursor.java b/teaql-core/src/main/java/io/teaql/core/QueryCursor.java new file mode 100644 index 00000000..4bf53f1c --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/QueryCursor.java @@ -0,0 +1,31 @@ +package io.teaql.core; + +import java.util.List; +import java.util.Objects; +import java.util.function.Supplier; +import java.util.stream.Stream; + +/** + * A single-use stream and its invocation-owned physical evidence. Opening a + * cursor is not completion: statements may be empty until exhaustion or close. + * Always close, including after short-circuit terminal operations. This does not + * make a Stream safe for parallel/concurrent consumption. + */ +public final class QueryCursor implements AutoCloseable { + private final Stream stream; + private final Supplier> evidence; + + public QueryCursor(Stream stream, Supplier> evidence) { + this.stream = Objects.requireNonNull(stream, "stream"); + this.evidence = Objects.requireNonNull(evidence, "evidence"); + } + + @com.fasterxml.jackson.annotation.JsonIgnore + public Stream stream() { return stream; } + + /** Immutable list snapshot; mutable metadata contains trusted raw bindings, not wire data. */ + @com.fasterxml.jackson.annotation.JsonIgnore + public List statements() { return List.copyOf(evidence.get()); } + + @Override public void close() { stream.close(); } +} diff --git a/teaql-core/src/main/java/io/teaql/core/QueryIntent.java b/teaql-core/src/main/java/io/teaql/core/QueryIntent.java new file mode 100644 index 00000000..41ac7bd5 --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/QueryIntent.java @@ -0,0 +1,20 @@ +package io.teaql.core; + +/** Immutable validated intent owned by one query request, independent of logging and context. */ +public final class QueryIntent { + private final String comment; + private final String purpose; + + private QueryIntent(String comment, String purpose) { + this.comment = RequestIntentException.requireComment(comment, "query"); + if (RequestIntentException.blank(purpose)) { + throw new RequestIntentException("QUERY_PURPOSE_REQUIRED", "purpose", "query"); + } + this.purpose = purpose; + } + + public static QueryIntent of(String comment, String purpose) { return new QueryIntent(comment, purpose); } + public String comment() { return comment; } + public String purpose() { return purpose; } + @Override public String toString() { return "QueryIntent[validated]"; } +} diff --git a/teaql-core/src/main/java/io/teaql/core/QueryRequest.java b/teaql-core/src/main/java/io/teaql/core/QueryRequest.java index b137c5eb..89c167c7 100644 --- a/teaql-core/src/main/java/io/teaql/core/QueryRequest.java +++ b/teaql-core/src/main/java/io/teaql/core/QueryRequest.java @@ -1,4 +1,7 @@ package io.teaql.core; public interface QueryRequest { + QueryIntent intent(); + default String comment() { return intent().comment(); } + default String purpose() { return intent().purpose(); } } diff --git a/teaql-core/src/main/java/io/teaql/core/QueryResult.java b/teaql-core/src/main/java/io/teaql/core/QueryResult.java index 465a4c50..06966cff 100644 --- a/teaql-core/src/main/java/io/teaql/core/QueryResult.java +++ b/teaql-core/src/main/java/io/teaql/core/QueryResult.java @@ -1,4 +1,9 @@ package io.teaql.core; public interface QueryResult { + /** Actual physical statements in execution order; trusted diagnostics, not a wire response. */ + @com.fasterxml.jackson.annotation.JsonIgnore + default java.util.List statements() { + return java.util.List.of(); + } } diff --git a/teaql-core/src/main/java/io/teaql/core/RequestIntentException.java b/teaql-core/src/main/java/io/teaql/core/RequestIntentException.java new file mode 100644 index 00000000..8699c359 --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/RequestIntentException.java @@ -0,0 +1,32 @@ +package io.teaql.core; + +/** Stable, value-free diagnostic for missing request-owned business intent. */ +public final class RequestIntentException extends TeaQLRuntimeException { + private final String code; + private final String field; + private final String requestKind; + + RequestIntentException(String code, String field, String requestKind) { + super("[" + code + "] " + requestKind + " request requires a non-blank " + field); + this.code = code; + this.field = field; + this.requestKind = requestKind; + } + + public String getCode() { return code; } + public String getField() { return field; } + public String getRequestKind() { return requestKind; } + + // Unicode White_Space, matching Rust str::trim rather than Java String.trim/isBlank. + static boolean blank(String value) { + return value == null || value.codePoints().allMatch(c -> + (c >= 0x09 && c <= 0x0d) || c == 0x20 || c == 0x85 || c == 0xa0 + || c == 0x1680 || (c >= 0x2000 && c <= 0x200a) || c == 0x2028 + || c == 0x2029 || c == 0x202f || c == 0x205f || c == 0x3000); + } + + static String requireComment(String value, String kind) { + if (blank(value)) throw new RequestIntentException("REQUEST_COMMENT_REQUIRED", "comment", kind); + return value; + } +} diff --git a/teaql-core/src/main/java/io/teaql/core/SearchRequest.java b/teaql-core/src/main/java/io/teaql/core/SearchRequest.java index 6910f222..3b8c5988 100644 --- a/teaql-core/src/main/java/io/teaql/core/SearchRequest.java +++ b/teaql-core/src/main/java/io/teaql/core/SearchRequest.java @@ -50,6 +50,14 @@ default T internalNewEntity() { String comment(); + /** Explicit originating intent carried only by framework-owned derived queries. */ + @FrameworkInternal("Nested query provenance; never inferred from UserContext trace state") + default QueryIntent inheritedQueryIntent() { return null; } + + /** Immutable execution-local provenance; not an extension or wire-input field. */ + @FrameworkInternal("SQL query provenance belongs to the request, not the Context") + default List sqlTraceSource() { return List.of(); } + /** * Returns the declared purpose of this query. * Purpose describes WHY this query is being executed (business intent). diff --git a/teaql-core/src/main/java/io/teaql/core/SqlExecutionTrace.java b/teaql-core/src/main/java/io/teaql/core/SqlExecutionTrace.java new file mode 100644 index 00000000..65304eb5 --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/SqlExecutionTrace.java @@ -0,0 +1,71 @@ +package io.teaql.core; + +import java.util.ArrayList; +import java.util.List; + +/** Immutable statement-owned source path and separate graph mutation lineage. */ +public record SqlExecutionTrace(List source, List mutationLineage, String operation, + @com.fasterxml.jackson.annotation.JsonIgnore java.util.function.Consumer statementObserver, + @com.fasterxml.jackson.annotation.JsonIgnore MutationIntent mutationIntent) { + public SqlExecutionTrace(List source, List mutationLineage, String operation) { + this(source, mutationLineage, operation, null, null); + } + public SqlExecutionTrace(List source, List mutationLineage, String operation, + java.util.function.Consumer statementObserver) { + this(source, mutationLineage, operation, statementObserver, null); + } + + /** Invocation-owned collection; never installed on Context or a cached repository. */ + public SqlExecutionTrace collecting(java.util.function.Consumer observer) { + return new SqlExecutionTrace(source, mutationLineage, operation, observer, mutationIntent); + } + + public void recordStatement(ExecutionMetadata metadata) { + var path = SqlTracePath.canonical(metadata.getTraceChain(), metadata.getBackend(), operation); + metadata.setTraceChain(path.path()); + metadata.setComment(path.comment()); + metadata.setPurpose(path.purpose()); + // Canonicalization extracts the last local reason for standalone source + // vectors. Executed mutations instead own their root intent independently + // of the graph lineage, including write readback and prepared batches. + metadata.setAuditReason(mutationIntent == null ? path.auditReason() : mutationIntent.auditReason()); + if (statementObserver != null) statementObserver.accept(metadata); + } + public SqlExecutionTrace { + source = List.copyOf(source); + mutationLineage = List.copyOf(mutationLineage); + } + + public static SqlExecutionTrace mutation(Entity entity, List lineage, String operation, + MutationIntent intent) { + java.util.Objects.requireNonNull(intent, "mutation intent"); + var source = new ArrayList<>(lineage); + source.add(new TraceNode(TraceKind.ENTITY, entity.typeName(), entity.getId(), "")); + return new SqlExecutionTrace(source, lineage, operation, null, intent); + } + + public static SqlExecutionTrace query(SearchRequest request) { + List source = request.sqlTraceSource(); + if (source.isEmpty()) { + QueryIntent intent = request.inheritedQueryIntent() == null + ? QueryIntent.of(request.comment(), request.purpose()) : request.inheritedQueryIntent(); + source = List.of(new TraceNode(TraceKind.COMMENT, request.getTypeName(), intent.comment()), + new TraceNode(TraceKind.PURPOSE, request.getTypeName(), intent.purpose())); + } + return new SqlExecutionTrace(source, List.of(), "select"); + } + + public SqlExecutionTrace readback(MutationIntent intent) { + var frames = new ArrayList<>(source); + String root = frames.isEmpty() ? "unknown" : frames.get(0).getName(); + frames.add(new TraceNode(TraceKind.COMMENT, root, intent.comment())); + frames.add(new TraceNode(TraceKind.PURPOSE, root, intent.readbackIntent().purpose())); + return new SqlExecutionTrace(frames, mutationLineage, "select", statementObserver, intent); + } + + public void applyTo(ExecutionMetadata metadata) { + metadata.setTraceChain(source); + metadata.setMutationLineage(mutationLineage); + metadata.setStatementOperation(operation); + } +} diff --git a/teaql-core/src/main/java/io/teaql/core/SqlFieldLogPolicy.java b/teaql-core/src/main/java/io/teaql/core/SqlFieldLogPolicy.java new file mode 100644 index 00000000..1e2c2dab --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/SqlFieldLogPolicy.java @@ -0,0 +1,36 @@ +package io.teaql.core; + +import io.teaql.core.meta.EntityDescriptor; +import io.teaql.core.meta.PropertyDescriptor; +import io.teaql.core.utils.SensitiveLogNames; + +/** One field-policy interpretation for graph provenance and physical bindings. */ +public final class SqlFieldLogPolicy { + private SqlFieldLogPolicy() {} + + @FrameworkInternal("Diagnostic field policy, not an application authorization policy") + public static SqlParameterLogPolicy resolve(EntityDescriptor entity, String name) { + if (SensitiveLogNames.credential(name)) return SqlParameterLogPolicy.CREDENTIAL; + for (var current = entity; current != null; current = current.getParent()) { + for (var property : current.getOwnProperties()) { + if (property.getName().equals(name)) return resolve(entity, property); + } + } + return SqlParameterLogPolicy.UNKNOWN; + } + + @FrameworkInternal("Diagnostic field policy, not an application authorization policy") + public static SqlParameterLogPolicy resolve(EntityDescriptor entity, PropertyDescriptor property) { + String name = property.getName(); + if (SensitiveLogNames.credential(name)) return SqlParameterLogPolicy.CREDENTIAL; + var owner = property.getOwner(); + var declared = property.getAdditionalInfo().get("logPolicy"); + if (entity != null && entity.getAuditMaskFields().contains(name) + || owner != null && owner.getAuditMaskFields().contains(name) + || "masked".equalsIgnoreCase(declared)) return SqlParameterLogPolicy.MASKED; + if ("credential".equalsIgnoreCase(declared)) return SqlParameterLogPolicy.CREDENTIAL; + if ("plain".equalsIgnoreCase(declared)) return SqlParameterLogPolicy.PLAIN; + var scope = owner == null ? entity : owner; + return scope != null && scope.isAuditMaskFieldsDeclared() ? SqlParameterLogPolicy.PLAIN : SqlParameterLogPolicy.UNKNOWN; + } +} diff --git a/teaql-core/src/main/java/io/teaql/core/SqlIntentRedactions.java b/teaql-core/src/main/java/io/teaql/core/SqlIntentRedactions.java index 3feeec4b..41d197a5 100644 --- a/teaql-core/src/main/java/io/teaql/core/SqlIntentRedactions.java +++ b/teaql-core/src/main/java/io/teaql/core/SqlIntentRedactions.java @@ -8,7 +8,9 @@ @JsonIgnoreType public final class SqlIntentRedactions { private record Secret(String value, boolean forced) {} - private final List secrets = new ArrayList<>(); + // Preclassification and physical binding capture may see the same operand. + // Retain first-seen order without repeated work; forced provenance remains distinct. + private final Set secrets = new LinkedHashSet<>(); @FrameworkInternal("Independent nested-query provenance snapshot") public SqlIntentRedactions copy() { @@ -17,6 +19,24 @@ public SqlIntentRedactions copy() { return result; } + @FrameworkInternal("Merge captured strings, never shared mutable entity state") + public void include(SqlIntentRedactions source) { + if (source != null) secrets.addAll(source.secrets); + } + + @FrameworkInternal("Capture loaded and changed prior scalar values before graph writes") + public void captureEntity(BaseEntity entity, io.teaql.core.meta.EntityDescriptor descriptor) { + for (var current = descriptor; current != null; current = current.getParent()) { + for (var property : current.getOwnProperties()) { + if (property instanceof io.teaql.core.meta.Relation) continue; + String name = property.getName(); + var policy = List.of(SqlFieldLogPolicy.resolve(descriptor, property)); + if (entity.isPropertyLoaded(name)) capture(policy, new Object[]{entity.getProperty(name)}); + if (entity.getUpdatedProperties().contains(name)) capture(policy, new Object[]{entity.getOldValue(name)}); + } + } + } + @FrameworkInternal("SQL diagnostic provenance only; not an application policy") public void capture(List policies, Object[] values) { boolean invalid = policies.size() != values.length; diff --git a/teaql-core/src/main/java/io/teaql/core/SqlTracePath.java b/teaql-core/src/main/java/io/teaql/core/SqlTracePath.java new file mode 100644 index 00000000..49195334 --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/SqlTracePath.java @@ -0,0 +1,50 @@ +package io.teaql.core; + +import java.util.ArrayList; +import java.util.List; + +/** Rust-compatible canonical SQL path. Intent is extracted, never repeated on path frames. */ +public final class SqlTracePath { + private SqlTracePath() {} + + public record Result(List path, String comment, String purpose, String auditReason) {} + + public static Result canonical(List source, String backend, String operation) { + source = source == null ? List.of() : source; + String comment = null, purpose = null, auditReason = null; + for (TraceNode node : source) { + if (node.getKind() == TraceKind.COMMENT) comment = node.getComment(); + if (node.getKind() == TraceKind.PURPOSE) purpose = node.getComment(); + if (node.getKind() == TraceKind.AUDIT_REASON) auditReason = node.getComment(); + } + boolean canonical = has(source, TraceKind.OPERATION) && has(source, TraceKind.PROVIDER) && has(source, TraceKind.SQL); + if (canonical) { + return new Result(source.stream().filter(node -> !intent(node.getKind())).toList(), comment, purpose, auditReason); + } + String root = source.stream().map(TraceNode::getName).filter(name -> !RequestIntentException.blank(name)) + .findFirst().orElse("unknown"); + boolean query = "select".equals(operation); + String statementEntity = root; + if (!query) { + for (TraceNode node : source) { + if (node.getKind() == TraceKind.ENTITY && !RequestIntentException.blank(node.getName())) + statementEntity = node.getName(); + } + } + var path = new ArrayList(); + path.add(new TraceNode(TraceKind.OPERATION, root, query ? "query" : "mutation")); + path.add(new TraceNode(query ? TraceKind.REQUEST : TraceKind.ENTITY, query ? root : statementEntity, "")); + source.stream().filter(node -> node.getKind() == TraceKind.RELATION).forEach(path::add); + path.add(new TraceNode(TraceKind.PROVIDER, RequestIntentException.blank(backend) ? "unknown" : backend, "")); + path.add(new TraceNode(TraceKind.SQL, operation, "")); + return new Result(List.copyOf(path), comment, purpose, auditReason); + } + + private static boolean has(List source, TraceKind kind) { + return source.stream().anyMatch(node -> node.getKind() == kind); + } + + private static boolean intent(TraceKind kind) { + return kind == TraceKind.COMMENT || kind == TraceKind.PURPOSE || kind == TraceKind.AUDIT_REASON; + } +} diff --git a/teaql-core/src/main/java/io/teaql/core/StreamingQueryExecutor.java b/teaql-core/src/main/java/io/teaql/core/StreamingQueryExecutor.java index 4d9cf4cc..15c66445 100644 --- a/teaql-core/src/main/java/io/teaql/core/StreamingQueryExecutor.java +++ b/teaql-core/src/main/java/io/teaql/core/StreamingQueryExecutor.java @@ -2,7 +2,13 @@ import java.util.stream.Stream; -/** Executes a query with resources owned by the returned closeable Stream. */ +/** Executes a validated query with resources owned by the returned closeable Stream. */ public interface StreamingQueryExecutor extends DataServiceExecutor { - Stream queryForStream(UserContext context, SearchRequest request); + /** The envelope owns the captured root intent, just as for materialized queries. */ + Stream queryForStream(UserContext context, QueryRequest request); + + /** Optional provider contract for returned lifecycle evidence, without changing the Stream API. */ + default QueryCursor queryForCursor(UserContext context, QueryRequest request) { + throw new UnsupportedOperationException("This provider does not support returned query cursor evidence"); + } } diff --git a/teaql-core/src/main/java/io/teaql/core/TraceNode.java b/teaql-core/src/main/java/io/teaql/core/TraceNode.java index d752b7b9..cce3ebf9 100644 --- a/teaql-core/src/main/java/io/teaql/core/TraceNode.java +++ b/teaql-core/src/main/java/io/teaql/core/TraceNode.java @@ -1,17 +1,23 @@ package io.teaql.core; -public class TraceNode { +public final class TraceNode { private final TraceKind kind; private final String name; private final String comment; + private final Long entityId; public TraceNode(String comment) { this(TraceKind.ENTITY, "", comment); } public TraceNode(TraceKind kind, String name, String comment) { + this(kind, name, null, comment); + } + + public TraceNode(TraceKind kind, String name, Long entityId, String comment) { this.kind = kind; this.name = name; + this.entityId = entityId; this.comment = comment; } @@ -19,12 +25,25 @@ public TraceNode(TraceKind kind, String name, String comment) { public String getName() { return name; } + public Long getEntityId() { return entityId; } + public String getComment() { return comment; } @Override public String toString() { - return kind + ":" + name + "=" + comment; + return kind + ":" + name + (entityId == null ? "" : "#" + entityId) + "=" + comment; + } + + @Override public boolean equals(Object other) { + return other instanceof TraceNode node && kind == node.kind + && java.util.Objects.equals(name, node.name) + && java.util.Objects.equals(entityId, node.entityId) + && java.util.Objects.equals(comment, node.comment); + } + + @Override public int hashCode() { + return java.util.Objects.hash(kind, name, entityId, comment); } } diff --git a/teaql-core/src/main/java/io/teaql/core/UserContext.java b/teaql-core/src/main/java/io/teaql/core/UserContext.java index 6a485b0e..5889ef04 100644 --- a/teaql-core/src/main/java/io/teaql/core/UserContext.java +++ b/teaql-core/src/main/java/io/teaql/core/UserContext.java @@ -6,6 +6,7 @@ import io.teaql.data.dynamic.DynamicFieldsFacade; import io.teaql.core.checker.CheckResult; import io.teaql.core.checker.FixEvidence; +import io.teaql.core.checker.internal.CheckerInvocation; import io.teaql.core.i18n.I18nCatalog; import io.teaql.core.i18n.Locale; import io.teaql.core.businessid.BusinessClock; @@ -29,28 +30,36 @@ public interface UserContext extends OptNullBasicTypeFromObjectGetter { String TEAQL_TRUSTED_REFERENCE_PRINCIPAL = TrustedReferencePrincipal.class.getName(); default void beginFixEvidence() { - putAttribute(TEAQL_FIX_EVIDENCE_CURRENT, new java.util.ArrayList()); + if (CheckerInvocation.current(this) == null) CheckerInvocation.forgetLastEvidence(this); + CheckerInvocation.attribute(this, TEAQL_FIX_EVIDENCE_CURRENT, new java.util.ArrayList()); } @SuppressWarnings("unchecked") default void recordFixEvidence(FixEvidence evidence) { - List current = (List) getAttribute(TEAQL_FIX_EVIDENCE_CURRENT); + List current = (List) CheckerInvocation.attribute(this, TEAQL_FIX_EVIDENCE_CURRENT); if (current == null) { current = new java.util.ArrayList<>(); - putAttribute(TEAQL_FIX_EVIDENCE_CURRENT, current); + CheckerInvocation.attribute(this, TEAQL_FIX_EVIDENCE_CURRENT, current); } current.add(evidence); } @SuppressWarnings("unchecked") default void finishFixEvidence() { - List current = (List) getAttribute(TEAQL_FIX_EVIDENCE_CURRENT); - putAttribute(TEAQL_FIX_EVIDENCE_LAST, current == null ? List.of() : List.copyOf(current)); - putAttribute(TEAQL_FIX_EVIDENCE_CURRENT, null); + List current = (List) CheckerInvocation.attribute(this, TEAQL_FIX_EVIDENCE_CURRENT); + CheckerInvocation.attribute(this, TEAQL_FIX_EVIDENCE_LAST, current == null ? List.of() : List.copyOf(current)); + CheckerInvocation.attribute(this, TEAQL_FIX_EVIDENCE_CURRENT, null); } + /** + * Diagnostic receipt of the last completed synchronous check on this + * execution thread. It is not shared across threads or asynchronous tasks. + * Explicit begin/finish sessions retain their existing Context contract. + */ @SuppressWarnings("unchecked") default List lastFixEvidence() { + List completed = CheckerInvocation.lastEvidence(this); + if (completed != null) return completed; List evidence = (List) getAttribute(TEAQL_FIX_EVIDENCE_LAST); return evidence == null ? List.of() : evidence; } diff --git a/teaql-core/src/main/java/io/teaql/core/checker/Checker.java b/teaql-core/src/main/java/io/teaql/core/checker/Checker.java index 7c6570c1..7088de74 100644 --- a/teaql-core/src/main/java/io/teaql/core/checker/Checker.java +++ b/teaql-core/src/main/java/io/teaql/core/checker/Checker.java @@ -9,6 +9,7 @@ import io.teaql.core.BaseEntity; import io.teaql.core.EntityStatus; import io.teaql.core.UserContext; +import io.teaql.core.checker.internal.CheckerInvocation; /** * check or set (default) values for the entity before persist @@ -24,10 +25,10 @@ public interface Checker { void checkAndFix(UserContext context, T entity, ObjectLocation location); default void markAsChecked(UserContext context, T entity) { - java.util.List list = (java.util.List) context.getAttribute(TEAQL_DATA_CHECKED_ITEMS); + java.util.List list = (java.util.List) CheckerInvocation.attribute(context, TEAQL_DATA_CHECKED_ITEMS); if (list == null) { list = new java.util.ArrayList(); - context.putAttribute(TEAQL_DATA_CHECKED_ITEMS, list); + CheckerInvocation.attribute(context, TEAQL_DATA_CHECKED_ITEMS, list); } list.add(entity); } @@ -37,9 +38,11 @@ default boolean needCheck(UserContext context, T entity) { return false; } - java.util.List list = (java.util.List) context.getAttribute(TEAQL_DATA_CHECKED_ITEMS); - if (list != null && list.contains(entity)) { - return false; + java.util.List list = (java.util.List) CheckerInvocation.attribute(context, TEAQL_DATA_CHECKED_ITEMS); + if (list != null) { + for (Object checked : list) { + if (checked == entity) return false; + } } if (entity.get$status() == EntityStatus.REFER) { @@ -116,10 +119,10 @@ default void maxDateTimeCheck( default void appendResult(UserContext context, CheckResult result) { context.translateCheckResult(result); - java.util.List list = (java.util.List) context.getAttribute(TEAQL_DATA_CHECK_RESULT); + java.util.List list = (java.util.List) CheckerInvocation.attribute(context, TEAQL_DATA_CHECK_RESULT); if (list == null) { list = new java.util.ArrayList(); - context.putAttribute(TEAQL_DATA_CHECK_RESULT, list); + CheckerInvocation.attribute(context, TEAQL_DATA_CHECK_RESULT, list); } list.add(result); } diff --git a/teaql-core/src/main/java/io/teaql/core/checker/internal/CheckerInvocation.java b/teaql-core/src/main/java/io/teaql/core/checker/internal/CheckerInvocation.java new file mode 100644 index 00000000..4169e200 --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/checker/internal/CheckerInvocation.java @@ -0,0 +1,113 @@ +package io.teaql.core.checker.internal; + +import io.teaql.core.UserContext; +import io.teaql.core.checker.Checker; +import io.teaql.core.checker.CheckResult; +import io.teaql.core.checker.FixEvidence; +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Framework-owned, synchronous check-and-fix invocation. This is NOT a trace + * scope, graph ledger, or asynchronous context-propagation mechanism. + * + *

The thread binding only adapts existing Checker callbacks that receive the + * original UserContext. All temporary data belongs to this invocation, never + * to that Context's shared attribute map. A nested save opens its own invocation + * and close restores the parent. No scope may cross a thread/async boundary. + */ +public final class CheckerInvocation implements AutoCloseable { + private static final ThreadLocal ACTIVE = new ThreadLocal<>(); + // One bounded, value-free diagnostic receipt per thread, not a Context-keyed + // map retaining every Context or graph ever checked by a worker. + private static final ThreadLocal LAST = new ThreadLocal<>(); + private record Completed(WeakReference context, List evidence) {} + + private final UserContext context; + private final CheckerInvocation parent; + private final Map attributes = new HashMap<>(); + private boolean closed; + + private CheckerInvocation(UserContext context) { + this.context = java.util.Objects.requireNonNull(context, "context"); + this.parent = ACTIVE.get(); + attributes.put(Checker.TEAQL_DATA_CHECK_RESULT, new ArrayList()); + attributes.put(Checker.TEAQL_DATA_CHECKED_ITEMS, new ArrayList<>()); + attributes.put(UserContext.TEAQL_FIX_EVIDENCE_CURRENT, new ArrayList()); + // Each independent graph captures its own context-provided time once. + attributes.put(Checker.TEAQL_FIX_TIME, context.businessTime()); + ACTIVE.set(this); + } + + /** Internal synchronous runtime boundary; do not open scopes in business code. */ + public static CheckerInvocation open(UserContext context) { + return new CheckerInvocation(context); + } + + public static CheckerInvocation current(UserContext context) { + for (CheckerInvocation scope = ACTIVE.get(); scope != null; scope = scope.parent) { + if (scope.context == context) return scope; + } + return null; + } + + public static boolean isScopedAttribute(String key) { + return Checker.TEAQL_DATA_CHECK_RESULT.equals(key) + || Checker.TEAQL_DATA_CHECKED_ITEMS.equals(key) + || Checker.TEAQL_FIX_TIME.equals(key) + || UserContext.TEAQL_FIX_EVIDENCE_CURRENT.equals(key) + || UserContext.TEAQL_FIX_EVIDENCE_LAST.equals(key); + } + + public Object attribute(String key) { return attributes.get(key); } + + public void attribute(String key, Object value) { + if (!isScopedAttribute(key)) throw new IllegalArgumentException("Not a Checker invocation attribute"); + if (value == null) attributes.remove(key); + else attributes.put(key, value); + } + + /** Adapts Checker helpers even when an application supplies its own UserContext implementation. */ + public static Object attribute(UserContext context, String key) { + CheckerInvocation scope = current(context); + return scope == null ? context.getAttribute(key) : scope.attribute(key); + } + + public static void attribute(UserContext context, String key, Object value) { + CheckerInvocation scope = current(context); + if (scope == null) context.putAttribute(key, value); + else scope.attribute(key, value); + } + + /** Last completed check on this synchronous execution thread; null means no receipt for this Context. */ + public static List lastEvidence(UserContext context) { + Completed completed = LAST.get(); + return completed != null && completed.context().get() == context ? completed.evidence() : null; + } + + public static void forgetLastEvidence(UserContext context) { + Completed completed = LAST.get(); + if (completed != null && completed.context().get() == context) LAST.remove(); + } + + @Override @SuppressWarnings("unchecked") + public void close() { + if (closed) return; + if (ACTIVE.get() != this) throw new IllegalStateException("Checker invocations must close on their owning thread in reverse order"); + try { + List evidence = (List) attributes.get(UserContext.TEAQL_FIX_EVIDENCE_LAST); + if (evidence == null) evidence = (List) attributes.get(UserContext.TEAQL_FIX_EVIDENCE_CURRENT); + LAST.set(new Completed(new WeakReference<>(context), evidence == null ? List.of() : List.copyOf(evidence))); + } finally { + // Diagnostic copy/cast failure must not strand this invocation or + // retain the graph after a Checker exception on a reusable worker. + if (parent == null) ACTIVE.remove(); + else ACTIVE.set(parent); + attributes.clear(); + closed = true; + } + } +} diff --git a/teaql-core/src/main/java/module-info.java b/teaql-core/src/main/java/module-info.java index e4f8640d..7fb009d6 100644 --- a/teaql-core/src/main/java/module-info.java +++ b/teaql-core/src/main/java/module-info.java @@ -6,6 +6,8 @@ // === Public API needed by generated code === exports io.teaql.core; exports io.teaql.core.checker; + // Runtime implementation only, not generated or application-facing API. + exports io.teaql.core.checker.internal to io.teaql.runtime; exports io.teaql.core.i18n; exports io.teaql.core.criteria; exports io.teaql.core.meta; diff --git a/teaql-core/src/test/java/io/teaql/core/AuditedTest.java b/teaql-core/src/test/java/io/teaql/core/AuditedTest.java index e052a58b..07dff660 100644 --- a/teaql-core/src/test/java/io/teaql/core/AuditedTest.java +++ b/teaql-core/src/test/java/io/teaql/core/AuditedTest.java @@ -67,12 +67,12 @@ public void testValidAudited() { assertEquals(entity, context.savedGraph); } - @Test(expected = IllegalArgumentException.class) + @Test(expected = RequestIntentException.class) public void testNullComment() { new Audited<>(new DummyEntity(), null); } - @Test(expected = IllegalArgumentException.class) + @Test(expected = RequestIntentException.class) public void testEmptyComment() { new Audited<>(new DummyEntity(), " "); } diff --git a/teaql-core/src/test/java/io/teaql/core/BaseEntityTest.java b/teaql-core/src/test/java/io/teaql/core/BaseEntityTest.java index baef2c97..18da6982 100644 --- a/teaql-core/src/test/java/io/teaql/core/BaseEntityTest.java +++ b/teaql-core/src/test/java/io/teaql/core/BaseEntityTest.java @@ -144,11 +144,12 @@ public void testTraceChainCopiedToRoot() { EntityMutationLedger root = new EntityMutationLedger(); entity.setEntityMutationLedger(root); - entity.setTraceChain("trace-123"); + var trace = java.util.List.of(new TraceNode(TraceKind.AUDIT_REASON, entity.typeName(), entity.getId(), "trace-123")); + entity.setTraceChain(trace); entity.updateName("Bob"); EntityKey key = new EntityKey("TestEntity", 101L); - assertEquals("trace-123", root.getTraceChain(key)); + assertEquals(trace, root.getTraceChain(key)); } @Test diff --git a/teaql-core/src/test/java/io/teaql/core/EntityMutationLedgerTest.java b/teaql-core/src/test/java/io/teaql/core/EntityMutationLedgerTest.java index e062df5d..d95112d9 100644 --- a/teaql-core/src/test/java/io/teaql/core/EntityMutationLedgerTest.java +++ b/teaql-core/src/test/java/io/teaql/core/EntityMutationLedgerTest.java @@ -116,12 +116,13 @@ public void mergeFromCopiesNewAndDeletedKeys() { public void mergeFromCopiesTraceChainsAndOriginalVersions() { EntityMutationLedger target = new EntityMutationLedger(); EntityMutationLedger source = new EntityMutationLedger(); - source.setTraceChain(ORDER, "checkout > submit"); + var trace = java.util.List.of(new TraceNode(TraceKind.AUDIT_REASON, "Order", 1L, "checkout > submit")); + source.setTraceChain(ORDER, trace); source.setOriginalVersion(ORDER, 7L); target.mergeFrom(source); - assertEquals("checkout > submit", target.getTraceChain(ORDER)); + assertEquals(trace, target.getTraceChain(ORDER)); assertEquals(Long.valueOf(7L), target.getOriginalVersion(ORDER)); } @@ -153,4 +154,88 @@ public void successfulSaveClearsThePreviousOptimisticLockBaseline() { assertNull(ledger.getOriginalVersion(ORDER)); assertTrue(ledger.currentChangeSet().changes().isEmpty()); } + + @Test + public void recoveryWithoutFieldsSurvivesMergeAndClearsAfterSave() { + EntityMutationLedger source = new EntityMutationLedger(); + source.markAsRecover(ORDER); + source.markAsRecover(ORDER); + source.setOriginalVersion(ORDER, -2L); + EntityMutationLedger target = new EntityMutationLedger(); + target.mergeFrom(source); + + assertEquals(java.util.Set.of(ORDER), target.recoveredKeys()); + assertEquals(Long.valueOf(-2), target.getOriginalVersion(ORDER)); + assertTrue(target.currentChangeSet().changes().isEmpty()); + assertThrows(UnsupportedOperationException.class, () -> target.recoveredKeys().add(OTHER_ORDER)); + target.clearCurrentChangeSet(); + assertTrue(target.recoveredKeys().isEmpty()); + assertEquals(java.util.Set.of(ORDER), source.recoveredKeys()); + } + + @Test + public void deleteAndRecoverKeysAreMutuallyExclusiveAndTypeQualified() { + EntityMutationLedger ledger = new EntityMutationLedger(); + EntityKey payment = new EntityKey("Payment", ORDER.id()); + ledger.markAsDelete(ORDER); + ledger.markAsDelete(payment); + ledger.markAsRecover(ORDER); + assertEquals(java.util.Set.of(payment), ledger.deletedKeys()); + assertEquals(java.util.Set.of(ORDER), ledger.recoveredKeys()); + ledger.markAsDelete(ORDER); + assertTrue(ledger.recoveredKeys().isEmpty()); + assertEquals(java.util.Set.of(ORDER, payment), ledger.deletedKeys()); + } + + @Test public void entityMergeDoesNotImportUnrelatedKeysWithTheSameId() { + EntityKey payment = new EntityKey("Payment", ORDER.id()); + var source = new EntityMutationLedger(); + source.set(ORDER, "status", "PAID"); + source.markAsNew(ORDER); + source.setOriginalVersion(ORDER, 7L); + var trace = java.util.List.of(new TraceNode(TraceKind.AUDIT_REASON, "Order", 1L, "submit order")); + source.setTraceChain(ORDER, trace); + source.markAsDelete(payment); + source.setOriginalVersion(payment, 90L); + var target = new EntityMutationLedger(); + assertTrue(target.mergeEntityFrom(source, ORDER)); + assertEquals("PAID", target.get(ORDER, "status")); + assertEquals(Long.valueOf(7), target.getOriginalVersion(ORDER)); + assertEquals(trace, target.getTraceChain(ORDER)); + assertTrue(target.isNew(ORDER)); + assertFalse(target.isMarkedAsDelete(payment)); + assertNull(target.getOriginalVersion(payment)); + assertTrue(source.isMarkedAsDelete(payment)); + target.set(ORDER, "status", "APPROVED"); + assertEquals("PAID", source.get(ORDER, "status")); + } + + @Test public void readOnlyEntityMergeDoesNotImportAnotherGraphsPendingChanges() { + var source = new EntityMutationLedger(); + source.setOriginalVersion(ORDER, 1L); // Loaded snapshot, no mutation. + source.set(OTHER_ORDER, "status", "SUBMITTED"); + source.markAsNew(OTHER_ORDER); + var target = new EntityMutationLedger(); + assertFalse(target.mergeEntityFrom(source, ORDER)); + assertTrue(target.currentChangeSet().changes().isEmpty()); + assertTrue(target.newKeys().isEmpty()); + assertNull(target.getOriginalVersion(ORDER)); + assertFalse(target.mergeEntityFrom(null, ORDER)); + } + + @Test public void entityMergePreservesFieldlessDeletionAndRecovery() { + var source = new EntityMutationLedger(); + source.markAsDelete(ORDER); + source.setOriginalVersion(ORDER, 4L); + source.markAsRecover(OTHER_ORDER); + source.setOriginalVersion(OTHER_ORDER, -5L); + var target = new EntityMutationLedger(); + assertTrue(target.mergeEntityFrom(source, ORDER)); + assertTrue(target.isMarkedAsDelete(ORDER)); + assertEquals(Long.valueOf(4), target.getOriginalVersion(ORDER)); + assertTrue(target.mergeEntityFrom(source, OTHER_ORDER)); + assertTrue(target.recoveredKeys().contains(OTHER_ORDER)); + assertEquals(Long.valueOf(-5), target.getOriginalVersion(OTHER_ORDER)); + assertTrue(target.currentChangeSet().changes().isEmpty()); + } } diff --git a/teaql-core/src/test/java/io/teaql/core/EntityQueryFacetsTest.java b/teaql-core/src/test/java/io/teaql/core/EntityQueryFacetsTest.java new file mode 100644 index 00000000..9e725fa9 --- /dev/null +++ b/teaql-core/src/test/java/io/teaql/core/EntityQueryFacetsTest.java @@ -0,0 +1,46 @@ +package io.teaql.core; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.HashMap; +import java.util.Map; +import org.junit.Test; +import static org.junit.Assert.*; + +public class EntityQueryFacetsTest { + @Test public void absentAndRequestedEmptyAreDifferentWithoutImplicitLoading() { + var entity = new BaseEntity(); + assertNull(entity.getQueryFacet("orders")); + entity.__internalSetQueryFacets(Map.of("orders", new SmartList<>())); + assertNotNull(entity.getQueryFacet("orders")); + assertTrue(entity.getQueryFacet("orders").isEmpty()); + } + + @Test public void sidecarCopiesTheMapAndDoesNotShareAnotherEntitysMetadata() { + var source = new HashMap>(); + source.put("orders", new SmartList<>()); + var first = new BaseEntity(); first.__internalSetQueryFacets(source); + source.clear(); + var second = new BaseEntity(); + assertNotNull(first.getQueryFacet("orders")); + assertNull(second.getQueryFacet("orders")); + assertThrows(UnsupportedOperationException.class, () -> first.getQueryFacets().clear()); + } + + @Test public void queryMetadataDoesNotBecomeAModelMutationOrJsonField() throws Exception { + var entity = new BaseEntity(); + var before = entity.getUpdatedProperties(); + var ledger = entity.getEntityMutationLedger(); + entity.__internalSetQueryFacets(Map.of("orders", new SmartList<>())); + assertEquals(before, entity.getUpdatedProperties()); + assertSame(ledger, entity.getEntityMutationLedger()); + assertNull(entity.getDynamicProperty("queryFacets")); + assertFalse(new ObjectMapper().valueToTree(entity).has("queryFacets")); + } + + @Test public void replacementClearsPreviouslyLoadedQueryMetadata() { + var entity = new BaseEntity(); + entity.__internalSetQueryFacets(Map.of("orders", new SmartList<>())); + entity.__internalSetQueryFacets(Map.of()); + assertNull(entity.getQueryFacet("orders")); + } +} diff --git a/teaql-core/src/test/java/io/teaql/core/MutationBatchRequestTest.java b/teaql-core/src/test/java/io/teaql/core/MutationBatchRequestTest.java new file mode 100644 index 00000000..5b9fe824 --- /dev/null +++ b/teaql-core/src/test/java/io/teaql/core/MutationBatchRequestTest.java @@ -0,0 +1,43 @@ +package io.teaql.core; + +import java.util.ArrayList; +import java.util.List; +import org.junit.Test; +import static org.junit.Assert.*; + +public class MutationBatchRequestTest { + private static PersistenceMutation item(String comment) { + MutationIntent intent = MutationIntent.of(comment); + return () -> intent; + } + + @Test public void annotatedChildrenCannotFillMissingRootIntent() { + for (String blank : new String[]{null, "", " ", "\t\n", "\u2003\u00a0"}) { + var error = assertThrows(RequestIntentException.class, + () -> new MutationBatchRequest(blank, List.of(item("annotated child")))); + assertEquals("REQUEST_COMMENT_REQUIRED", error.getCode()); + assertEquals("comment", error.getField()); + } + } + + @Test public void rootValidationPrecedesEvenMalformedMemberInput() { + assertThrows(RequestIntentException.class, () -> new MutationBatchRequest("\u2003", null)); + } + + @Test public void ownsRootIntentAndImmutableOrderedMemberSnapshot() { + var first = item("root reason"); + var second = item("root reason"); + var input = new ArrayList(List.of(first, second)); + var request = new MutationBatchRequest("root reason", input); + input.clear(); + assertEquals(List.of(first, second), request.items()); + assertEquals("root reason", request.comment()); + assertThrows(UnsupportedOperationException.class, () -> request.items().clear()); + assertFalse(request.toString().contains("root reason")); + } + + @Test public void differentlyOwnedRootIntentsAreRejectedInsteadOfSilentlyReplaced() { + assertThrows(IllegalArgumentException.class, + () -> new MutationBatchRequest("batch root", List.of(item("another request root")))); + } +} diff --git a/teaql-core/src/test/java/io/teaql/core/RequestIntentVectorsTest.java b/teaql-core/src/test/java/io/teaql/core/RequestIntentVectorsTest.java new file mode 100644 index 00000000..e40e3df5 --- /dev/null +++ b/teaql-core/src/test/java/io/teaql/core/RequestIntentVectorsTest.java @@ -0,0 +1,62 @@ +package io.teaql.core; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HexFormat; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import static org.junit.Assert.*; + +/** Frozen shared conformance vectors. Trace/logging/children never fill a missing root slot. */ +@RunWith(Parameterized.class) +public class RequestIntentVectorsTest { + @Parameterized.Parameters(name = "{0}") + public static Collection cases() throws Exception { + byte[] bytes; + try (var input = RequestIntentVectorsTest.class.getResourceAsStream("/request-intent-v1.json")) { + assertNotNull(input); bytes = input.readAllBytes(); + } + assertEquals("3b911b0edb1b6634204a41c199f67f709d6408405b87d4f2f72a02110cb0b38b", + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes))); + var data = new ObjectMapper().readTree(new String(bytes, StandardCharsets.UTF_8)); + assertEquals("teaql.request-intent.v1", data.path("contract").asText()); + var result = new ArrayList(); + for (var item : data.path("cases")) result.add(new Object[]{item.path("id").asText(), item}); + assertEquals(20, result.size()); return result; + } + + private final JsonNode vector; + public RequestIntentVectorsTest(String id, JsonNode vector) { this.vector = vector; } + private static String field(JsonNode input, String name) { + var value = input.get(name); return value == null || value.isNull() ? null : value.asText(); + } + + @Test public void sharedRequestIntentContract() { + var input = vector.path("input"); + String kind = vector.path("kind").asText(); + try { + if (kind.equals("query")) { + var intent = QueryIntent.of(field(input, "comment"), field(input, "purpose")); + assertFalse("Expected rejection", vector.has("error")); + assertEquals(vector.path("expected").path("comment").asText(), intent.comment()); + assertEquals(vector.path("expected").path("purpose").asText(), intent.purpose()); + } else { + var intent = MutationIntent.of(field(input, "comment")); + assertFalse("Expected rejection", vector.has("error")); + assertEquals(vector.path("expected").path("comment").asText(), intent.comment()); + assertEquals(intent.comment(), intent.auditReason()); + assertEquals(intent.comment(), intent.readbackIntent().comment()); + } + } catch (RequestIntentException error) { + assertTrue(vector.has("error")); + assertEquals(vector.path("error").path("code").asText(), error.getCode()); + assertEquals(vector.path("error").path("field").asText(), error.getField()); + assertEquals(kind, error.getRequestKind()); + } + } +} diff --git a/teaql-core/src/test/java/io/teaql/core/SqlExecutionTraceTest.java b/teaql-core/src/test/java/io/teaql/core/SqlExecutionTraceTest.java new file mode 100644 index 00000000..a54f9694 --- /dev/null +++ b/teaql-core/src/test/java/io/teaql/core/SqlExecutionTraceTest.java @@ -0,0 +1,94 @@ +package io.teaql.core; + +import java.util.ArrayList; +import java.util.List; +import org.junit.Test; +import static org.junit.Assert.*; + +public class SqlExecutionTraceTest { + static final class Request extends BaseRequest { + List source = List.of(); + Request() { super(BaseEntity.class); } + @Override public String getTypeName() { return "CustomerOrder"; } + @Override public List sqlTraceSource() { return source; } + } + + @Test public void rootQueryCapturesItsOwnIntentNotLaterRequestChanges() { + var request = new Request(); + request.comment = "first request"; + request.purpose = "verify ownership"; + var first = SqlExecutionTrace.query(request); + request.comment = "second request"; + var second = SqlExecutionTrace.query(request); + assertEquals("first request", first.source().get(0).getComment()); + assertEquals("second request", second.source().get(0).getComment()); + assertEquals("CustomerOrder", first.source().get(0).getName()); + assertEquals("select", first.operation()); + assertTrue(first.mutationLineage().isEmpty()); + } + + @Test public void derivedStatementFreezesCompleteOrderedSource() { + var request = new Request(); + request.source = new ArrayList<>(List.of( + new TraceNode(TraceKind.COMMENT, "PaymentAttempt", "load payment context"), + new TraceNode(TraceKind.PURPOSE, "PaymentAttempt", "render details"), + new TraceNode(TraceKind.RELATION, "payment", "PaymentAttempt.payment"), + new TraceNode(TraceKind.RELATION, "customerOrder", "Payment.customerOrder"))); + var captured = SqlExecutionTrace.query(request); + request.source.clear(); + assertEquals(4, captured.source().size()); + assertThrows(UnsupportedOperationException.class, () -> captured.source().clear()); + var canonical = SqlTracePath.canonical(captured.source(), "sqlite", captured.operation()); + assertEquals("PaymentAttempt", canonical.path().get(0).getName()); + assertEquals("payment", canonical.path().get(2).getName()); + assertEquals("customerOrder", canonical.path().get(3).getName()); + } + + @Test public void mutationStatementOwnsRootIntentWithAndWithoutAnObserver() { + var entity = new BaseEntity(); + entity.__internalSet("id", 201L); + var lineage = List.of(new TraceNode(TraceKind.AUDIT_REASON, "CustomerOrder", 100L, "submit order"), + new TraceNode(TraceKind.AUDIT_REASON, "Payment", 201L, "authorize payment")); + var intent = MutationIntent.of("submit order"); + // Standalone canonical vectors remain last-intent-wins. An executed + // mutation gets its root reason from its validated request, not this fold. + assertEquals("authorize payment", SqlTracePath.canonical(lineage, "sqlite", "insert").auditReason()); + for (String operation : List.of("insert", "update", "delete", "recover")) { + for (boolean observing : List.of(false, true)) { + var captured = new ArrayList(); + var trace = SqlExecutionTrace.mutation(entity, lineage, operation, intent); + if (observing) trace = trace.collecting(captured::add); + assertSame(intent, trace.mutationIntent()); + var metadata = new ExecutionMetadata(); + metadata.setBackend("sqlite"); + trace.applyTo(metadata); + trace.recordStatement(metadata); + assertEquals("submit order", metadata.getAuditReason()); + assertNull(metadata.getComment()); + assertEquals(lineage, metadata.getMutationLineage()); + assertEquals(observing ? 1 : 0, captured.size()); + assertTrue(metadata.getTraceChain().stream().noneMatch(node -> node.getKind() == TraceKind.AUDIT_REASON)); + } + } + assertThrows(NullPointerException.class, () -> SqlExecutionTrace.mutation(entity, lineage, "insert", null)); + } + + @Test public void mutationReadbackRetainsIndependentRootReasonAndQueryIntent() { + var entity = new BaseEntity(); + var intent = MutationIntent.of("submit order"); + var lineage = List.of(new TraceNode(TraceKind.AUDIT_REASON, "CustomerOrder", 100L, "submit order"), + new TraceNode(TraceKind.AUDIT_REASON, "Shipment", 301L, "prepare shipment")); + var collected = new ArrayList(); + var trace = SqlExecutionTrace.mutation(entity, lineage, "insert", intent).collecting(collected::add).readback(intent); + var metadata = new ExecutionMetadata(); + metadata.setBackend("sqlite"); + trace.applyTo(metadata); + trace.recordStatement(metadata); + assertSame(intent, trace.mutationIntent()); + assertEquals("submit order", metadata.getAuditReason()); + assertEquals("submit order", metadata.getComment()); + assertEquals(intent.readbackIntent().purpose(), metadata.getPurpose()); + assertEquals(lineage, metadata.getMutationLineage()); + assertEquals(List.of(metadata), collected); + } +} diff --git a/teaql-core/src/test/java/io/teaql/core/SqlIntentRedactionsTest.java b/teaql-core/src/test/java/io/teaql/core/SqlIntentRedactionsTest.java index b244c491..32694546 100644 --- a/teaql-core/src/test/java/io/teaql/core/SqlIntentRedactionsTest.java +++ b/teaql-core/src/test/java/io/teaql/core/SqlIntentRedactionsTest.java @@ -5,6 +5,53 @@ import static org.junit.Assert.*; public class SqlIntentRedactionsTest { + @Test public void repeatedTypedAndPhysicalCaptureIsIdempotentWithoutLosingForcedSecrets() { + var source = new SqlIntentRedactions(); + for (int i = 0; i < 3; i++) source.capture(List.of(SqlParameterLogPolicy.MASKED), new Object[]{"SECRET"}); + var safe = new java.util.ArrayList(); source.appendTo(safe, false); + assertEquals(List.of("SECRET"), safe); + var child = source.copy(); child.include(source); + child.capture(List.of(SqlParameterLogPolicy.CREDENTIAL), new Object[]{"SECRET"}); + child.capture(List.of(SqlParameterLogPolicy.MASKED), new Object[]{"NEXT"}); + var debug = new java.util.ArrayList(); child.appendTo(debug, true); + assertEquals("forced classification must survive masked duplicates", List.of("SECRET"), debug); + safe.clear(); source.appendTo(safe, false); + assertEquals("descendant capture cannot mutate parent provenance", List.of("SECRET"), safe); + } + @Test public void inheritedPolicyAndCredentialsMatchBindingPolicy() { + var parent = new io.teaql.core.meta.EntityDescriptor(); + var field = new io.teaql.core.meta.PropertyDescriptor(); + field.setName("name"); field.setOwner(parent); + parent.setProperties(List.of(field)); + var child = new io.teaql.core.meta.EntityDescriptor(); child.setParent(parent); + assertEquals(SqlParameterLogPolicy.UNKNOWN, SqlFieldLogPolicy.resolve(child, "name")); + parent.setAuditMaskFields(List.of()); + assertEquals(SqlParameterLogPolicy.PLAIN, SqlFieldLogPolicy.resolve(child, "name")); + child.setAuditMaskFields(List.of("name")); + assertEquals(SqlParameterLogPolicy.MASKED, SqlFieldLogPolicy.resolve(child, "name")); + field.with("logPolicy", "plain"); + assertEquals(SqlParameterLogPolicy.MASKED, SqlFieldLogPolicy.resolve(child, field)); + assertEquals(SqlParameterLogPolicy.CREDENTIAL, SqlFieldLogPolicy.resolve(child, "access_token")); + assertEquals(SqlParameterLogPolicy.UNKNOWN, SqlFieldLogPolicy.resolve(child, "missing")); + } + + @Test public void capturedEntityOldAndNewValuesSurviveLaterMutationWithoutSharingState() { + var descriptor = new io.teaql.core.meta.EntityDescriptor(); + var field = new io.teaql.core.meta.PropertyDescriptor(); + field.setName("name"); field.setOwner(descriptor); + descriptor.setProperties(List.of(field)); descriptor.setAuditMaskFields(List.of("name")); + var entity = new BaseEntityTest.TestEntity(); + entity.setProperty("name", "PRIVATE-OLD"); entity.updateName("PRIVATE-NEW"); + var captured = new SqlIntentRedactions(); captured.captureEntity(entity, descriptor); + var merged = new SqlIntentRedactions(); merged.include(captured); + entity.updateName("PRIVATE-LATER"); entity.clearUpdatedProperties(); + captured.capture(List.of(SqlParameterLogPolicy.MASKED), new Object[]{"SOURCE-LATER"}); + var values = new java.util.ArrayList(); merged.appendTo(values, false); + assertEquals(List.of("PRIVATE-NEW", "PRIVATE-OLD"), values); + var debugValues = new java.util.ArrayList(); merged.appendTo(debugValues, true); + assertTrue(debugValues.isEmpty()); + } + @Test public void snapshotDoesNotAccumulateDescendantOrSiblingValues() { var root = new SqlIntentRedactions(); root.capture(List.of(SqlParameterLogPolicy.MASKED), new Object[]{"Riverside"}); diff --git a/teaql-core/src/test/java/io/teaql/core/SqlTracePathVectorsTest.java b/teaql-core/src/test/java/io/teaql/core/SqlTracePathVectorsTest.java new file mode 100644 index 00000000..ce4f955e --- /dev/null +++ b/teaql-core/src/test/java/io/teaql/core/SqlTracePathVectorsTest.java @@ -0,0 +1,61 @@ +package io.teaql.core; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import static org.junit.Assert.*; + +/** #202: exact shared pure-algorithm vectors; not generated relation-loading evidence. */ +@RunWith(Parameterized.class) +public class SqlTracePathVectorsTest { + @Parameterized.Parameters(name = "{0}") + public static Collection cases() throws Exception { + try (var input = SqlTracePathVectorsTest.class.getResourceAsStream("/sql-trace-path-v1.json")) { + assertNotNull(input); + byte[] bytes = input.readAllBytes(); + assertEquals("7cb67eb1fd08a723611e9f8fad121e060a17546c3b01640052346498d5a6b0b7", + java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(bytes))); + var document = new ObjectMapper().readTree(bytes); + assertEquals("teaql.sql-trace-path.v1", document.path("contract").asText()); + var result = new ArrayList(); + for (var item : document.path("cases")) result.add(new Object[]{item.path("id").asText(), item}); + assertEquals(12, result.size()); + return result; + } + } + + private final JsonNode vector; + public SqlTracePathVectorsTest(String id, JsonNode vector) { this.vector = vector; } + + private static String text(JsonNode input, String name) { + var value = input.get(name); + return value == null || value.isNull() ? null : value.asText(); + } + + private static List nodes(JsonNode values) { + var result = new ArrayList(); + for (var value : values) { + String kind = text(value, "kind").replaceAll("([a-z])([A-Z])", "$1_$2").toUpperCase(java.util.Locale.ROOT); + Long id = value.path("entityId").isNull() ? null : value.path("entityId").asLong(); + result.add(new TraceNode(TraceKind.valueOf(kind), text(value, "name"), id, text(value, "detail"))); + } + return result; + } + + @Test public void sharedCanonicalPathAndLastIntentWins() { + String backend = text(vector, "backend"), operation = text(vector, "operation"); + var result = SqlTracePath.canonical(nodes(vector.path("source")), backend, operation); + assertEquals(nodes(vector.path("expectedPath")), result.path()); + var intent = vector.path("expectedIntent"); + assertEquals(text(intent, "comment"), result.comment()); + assertEquals(text(intent, "purpose"), result.purpose()); + assertEquals(text(intent, "auditReason"), result.auditReason()); + assertEquals(result.path(), SqlTracePath.canonical(result.path(), "ignored-new-backend", operation).path()); + assertThrows(UnsupportedOperationException.class, () -> result.path().clear()); + } +} diff --git a/teaql-core/src/test/resources/request-intent-v1.json b/teaql-core/src/test/resources/request-intent-v1.json new file mode 100644 index 00000000..b348abaa --- /dev/null +++ b/teaql-core/src/test/resources/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/teaql-core/src/test/resources/sql-trace-path-v1.json b/teaql-core/src/test/resources/sql-trace-path-v1.json new file mode 100644 index 00000000..90354189 --- /dev/null +++ b/teaql-core/src/test/resources/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/teaql-data-service-sql/src/main/java/io/teaql/dataservice/sql/SqlDataServiceExecutor.java b/teaql-data-service-sql/src/main/java/io/teaql/dataservice/sql/SqlDataServiceExecutor.java index 54301ab1..9e45d598 100644 --- a/teaql-data-service-sql/src/main/java/io/teaql/dataservice/sql/SqlDataServiceExecutor.java +++ b/teaql-data-service-sql/src/main/java/io/teaql/dataservice/sql/SqlDataServiceExecutor.java @@ -12,7 +12,7 @@ import io.teaql.core.TransactionCallback; import io.teaql.core.TransactionExecutor; -public class SqlDataServiceExecutor implements QueryExecutor, io.teaql.core.StreamingQueryExecutor, MutationExecutor, TransactionExecutor, SchemaExecutor { +public class SqlDataServiceExecutor implements QueryExecutor, io.teaql.core.StreamingQueryExecutor, io.teaql.core.BatchMutationExecutor, TransactionExecutor, SchemaExecutor { private final String name; private final SqlExecutionAdapter executionAdapter; private final DataServiceCapabilities capabilities; @@ -52,22 +52,13 @@ public QueryResult query(UserContext context, QueryRequest request) { } @Override - public java.util.stream.Stream queryForStream(UserContext context, io.teaql.core.SearchRequest request) { - // Unlike list execution, this path does not enter TeaQLRuntime.executeForList's trace scope. - int pushed = 0; - try { - context.pushTrace(io.teaql.core.TraceKind.OPERATION, request.getTypeName(), "query"); pushed++; - context.pushTrace(io.teaql.core.TraceKind.REQUEST, request.getTypeName(), request.getTypeName()); pushed++; - if (request.comment() != null) { - context.pushTrace(io.teaql.core.TraceKind.COMMENT, request.getTypeName(), request.comment()); pushed++; - } - if (request.purpose() != null) { - context.pushTrace(io.teaql.core.TraceKind.PURPOSE, request.getTypeName(), request.purpose()); pushed++; - } - return getPortableService(context).queryForStream(context, request); - } finally { - for (int i = 0; i < pushed; i++) context.popTrace(); - } + public java.util.stream.Stream queryForStream(UserContext context, QueryRequest request) { + return this.queryForCursor(context, request).stream(); + } + + @Override + public io.teaql.core.QueryCursor queryForCursor(UserContext context, QueryRequest request) { + return getPortableService(context).queryForCursor(context, request); } @Override @@ -75,6 +66,11 @@ public MutationResult mutate(UserContext context, PersistenceMutation request) { return getPortableService(context).mutate(context, request); } + @Override + public java.util.List mutateBatch(UserContext context, io.teaql.core.MutationBatchRequest request) { + return getPortableService(context).mutateBatch(context, request); + } + @Override public T executeInTransaction(UserContext context, TransactionCallback action) { return getPortableService(context).executeInTransaction(context, action); @@ -160,19 +156,22 @@ public java.util.stream.Stream> queryForStream(io. public java.util.stream.Stream> queryForStream(io.teaql.core.UserContext context, String sql, Object[] args, io.teaql.core.sql.portable.SqlLogBindings bindings) { boolean logging = context.isQueryExecutionLoggingEnabled(); - long start = logging ? System.nanoTime() : 0L; + boolean collecting = logging || bindings.collectsStatements(); + long start = collecting ? System.nanoTime() : 0L; io.teaql.core.ExecutionMetadata meta = null; - if (logging) { + if (collecting) { meta = statementMetadata(context, sql, args, bindings, io.teaql.core.DataServiceOperation.QUERY); // Snapshot before request trace scopes are popped; lazy consumption may happen later. - var trace = context.getTraceChain(); - meta.setTraceChain(trace == null || trace.isEmpty() - ? java.util.List.of(new io.teaql.core.TraceNode(io.teaql.core.TraceKind.OPERATION, "stream", "query")) - : java.util.List.copyOf(trace)); + if (bindings.executionTrace() == null) { + var trace = context.getTraceChain(); + meta.setTraceChain(trace == null || trace.isEmpty() + ? java.util.List.of(new io.teaql.core.TraceNode(io.teaql.core.TraceKind.OPERATION, "stream", "query")) + : java.util.List.copyOf(trace)); + } } var stream = diagnosed(context, sql, args, bindings, io.teaql.core.DataServiceOperation.QUERY, logging, start, () -> executionAdapter.queryForStream(sql, args)); - return SqlDiagnosticStream.wrap(context, stream, meta, start); + return SqlDiagnosticStream.wrap(context, stream, meta, start, bindings, logging); } @Override public int executeUpdate(String sql, Object[] args) { @@ -204,11 +203,12 @@ public java.util.List> query(io.teaql.core.UserCon public java.util.List> query(io.teaql.core.UserContext context, String sql, Object[] args, io.teaql.core.sql.portable.SqlLogBindings bindings) { boolean logging = context.isQueryExecutionLoggingEnabled(); - long start = logging ? System.nanoTime() : 0L; + boolean collecting = logging || bindings.collectsStatements(); + long start = collecting ? System.nanoTime() : 0L; java.util.List> res = diagnosed(context, sql, args, bindings, io.teaql.core.DataServiceOperation.QUERY, logging, start, () -> executionAdapter.queryForList(sql, args)); - if (!logging) return res; + if (!collecting) return res; long elapsed = (System.nanoTime() - start) / 1000; io.teaql.core.ExecutionMetadata meta = new io.teaql.core.ExecutionMetadata(); meta.setBackend(debugDatabaseKind.toLowerCase(java.util.Locale.ROOT)); @@ -220,7 +220,7 @@ public java.util.List> query(io.teaql.core.UserCon meta.setParameterizedQuery(sql); meta.setParameters(parameters(args)); bindings.applyTo(meta); - context.recordExecutionMetadata(meta); + recordStatement(context, bindings, meta, logging); return res; } @@ -236,11 +236,12 @@ public java.util.List query( io.teaql.core.UserContext context, String sql, Object[] args, io.teaql.core.CompiledRowMapper rowMapper, io.teaql.core.sql.portable.SqlLogBindings bindings) { boolean logging = context.isQueryExecutionLoggingEnabled(); - long start = logging ? System.nanoTime() : 0L; + boolean collecting = logging || bindings.collectsStatements(); + long start = collecting ? System.nanoTime() : 0L; java.util.List res = diagnosed(context, sql, args, bindings, io.teaql.core.DataServiceOperation.QUERY, logging, start, () -> executionAdapter.query(sql, args, rowMapper)); - if (!logging) return res; + if (!collecting) return res; long elapsed = (System.nanoTime() - start) / 1000; io.teaql.core.ExecutionMetadata meta = new io.teaql.core.ExecutionMetadata(); meta.setBackend(debugDatabaseKind.toLowerCase(java.util.Locale.ROOT)); @@ -252,7 +253,7 @@ public java.util.List query( meta.setParameterizedQuery(sql); meta.setParameters(parameters(args)); bindings.applyTo(meta); - context.recordExecutionMetadata(meta); + recordStatement(context, bindings, meta, logging); return res; } @@ -265,11 +266,12 @@ public int executeUpdate(io.teaql.core.UserContext context, String sql, Object[] public int executeUpdate(io.teaql.core.UserContext context, String sql, Object[] args, io.teaql.core.sql.portable.SqlLogBindings bindings) { boolean logging = context.isMutationExecutionLoggingEnabled(); - long start = logging ? System.nanoTime() : 0L; + boolean collecting = logging || bindings.collectsStatements(); + long start = collecting ? System.nanoTime() : 0L; int res = diagnosed(context, sql, args, bindings, io.teaql.core.DataServiceOperation.MUTATION, logging, start, () -> executionAdapter.update(sql, args)); - if (!logging) return res; + if (!collecting) return res; long elapsed = (System.nanoTime() - start) / 1000; io.teaql.core.ExecutionMetadata meta = new io.teaql.core.ExecutionMetadata(); meta.setBackend(debugDatabaseKind.toLowerCase(java.util.Locale.ROOT)); @@ -281,7 +283,7 @@ public int executeUpdate(io.teaql.core.UserContext context, String sql, Object[] meta.setParameterizedQuery(sql); meta.setParameters(parameters(args)); bindings.applyTo(meta); - context.recordExecutionMetadata(meta); + recordStatement(context, bindings, meta, logging); return res; } @@ -293,17 +295,19 @@ public int[] batchUpdate(io.teaql.core.UserContext context, String sql, java.uti @Override public int[] batchUpdate(io.teaql.core.UserContext context, String sql, java.util.List batchArgs, io.teaql.core.sql.portable.SqlLogBindings bindings) { + bindings.validateBatchSize(batchArgs == null ? 0 : batchArgs.size()); boolean logging = context.isMutationExecutionLoggingEnabled(); - long start = logging ? System.nanoTime() : 0L; + boolean collecting = logging || bindings.collectsStatements(); + long start = collecting ? System.nanoTime() : 0L; int[] res; try { res = executionAdapter.batchUpdate(sql, batchArgs); } catch (RuntimeException failure) { - if (logging) recordBatch(context, sql, batchArgs, bindings, start, batchCounts(failure), failure); + if (collecting) recordBatch(context, sql, batchArgs, bindings, start, batchCounts(failure), failure, logging); throw failure; } - if (logging) recordBatch(context, sql, batchArgs, bindings, start, - res == null ? null : java.util.Arrays.stream(res).asLongStream().toArray(), null); + if (collecting) recordBatch(context, sql, batchArgs, bindings, start, + res == null ? null : java.util.Arrays.stream(res).asLongStream().toArray(), null, logging); return res; } @@ -357,13 +361,16 @@ private static long[] batchCounts(Throwable failure) { } private void recordBatch(UserContext context, String sql, java.util.List batchArgs, - io.teaql.core.sql.portable.SqlLogBindings bindings, long start, long[] counts, RuntimeException failure) { + io.teaql.core.sql.portable.SqlLogBindings bindings, long start, long[] counts, RuntimeException failure, + boolean logging) { long elapsed = (System.nanoTime() - start) / 1000; int size = batchArgs == null ? 0 : batchArgs.size(); for (int row = 0; row < Math.max(size, failure == null ? 0 : 1); row++) { var meta = statementMetadata(context, sql, row < size ? batchArgs.get(row) : null, - bindings, io.teaql.core.DataServiceOperation.MUTATION); + row < size ? bindings.forBatchRow(row) : bindings, io.teaql.core.DataServiceOperation.MUTATION); meta.setElapsedUs(row == 0 ? elapsed : 0); + meta.setBatchOutcome(failure == null ? "success" + : failure instanceof java.util.concurrent.CancellationException ? "cancelled" : "failure"); long count = counts != null && row < counts.length ? counts[row] : Long.MIN_VALUE; meta.setExecutionOutcome(count >= 0 || count == java.sql.Statement.SUCCESS_NO_INFO ? "success" : count == java.sql.Statement.EXECUTE_FAILED ? "failure" : "unknown"); @@ -372,7 +379,7 @@ private void recordBatch(UserContext context, String sql, java.util.List T diagnosed(UserContext context, String sql, Object[] args, try { return execute.get(); } catch (RuntimeException failure) { - if (logging) { + if (logging || bindings.collectsStatements()) { io.teaql.core.ExecutionMetadata meta = new io.teaql.core.ExecutionMetadata(); meta.setBackend(debugDatabaseKind.toLowerCase(java.util.Locale.ROOT)); meta.setOperation(operation); @@ -400,7 +407,7 @@ private T diagnosed(UserContext context, String sql, Object[] args, meta.setParameters(parameters(args)); bindings.applyTo(meta); try { - context.recordExecutionMetadata(meta); + recordStatement(context, bindings, meta, logging); } catch (RuntimeException diagnosticFailure) { // Preserve the original driver error; no unsafe fallback logger. } @@ -409,6 +416,13 @@ private T diagnosed(UserContext context, String sql, Object[] args, } } + private static void recordStatement(UserContext context, + io.teaql.core.sql.portable.SqlLogBindings bindings, io.teaql.core.ExecutionMetadata metadata, + boolean logging) { + bindings.recordStatement(metadata); + if (logging) context.recordExecutionMetadata(metadata); + } + public static String debugSql(String sql, Object[] args) { return debugSql(sql, args, "sqlite"); } diff --git a/teaql-data-service-sql/src/main/java/io/teaql/dataservice/sql/SqlDiagnosticStream.java b/teaql-data-service-sql/src/main/java/io/teaql/dataservice/sql/SqlDiagnosticStream.java index ee2c47a9..308db5c0 100644 --- a/teaql-data-service-sql/src/main/java/io/teaql/dataservice/sql/SqlDiagnosticStream.java +++ b/teaql-data-service-sql/src/main/java/io/teaql/dataservice/sql/SqlDiagnosticStream.java @@ -14,19 +14,25 @@ final class SqlDiagnosticStream implements Spliterator { private final Stream source; private final ExecutionMetadata metadata; private final long start; + private final io.teaql.core.sql.portable.SqlLogBindings bindings; + private final boolean logging; private Spliterator rows; private boolean done; private long delivered; - private SqlDiagnosticStream(UserContext context, Stream source, ExecutionMetadata metadata, long start) { + private SqlDiagnosticStream(UserContext context, Stream source, ExecutionMetadata metadata, long start, + io.teaql.core.sql.portable.SqlLogBindings bindings, boolean logging) { this.context = context; this.source = source; this.metadata = metadata; this.start = start; + this.bindings = bindings; + this.logging = logging; } - static Stream wrap(UserContext context, Stream source, ExecutionMetadata metadata, long start) { - var cursor = new SqlDiagnosticStream<>(context, source, metadata, start); + static Stream wrap(UserContext context, Stream source, ExecutionMetadata metadata, long start, + io.teaql.core.sql.portable.SqlLogBindings bindings, boolean logging) { + var cursor = new SqlDiagnosticStream<>(context, source, metadata, start, bindings, logging); try { cursor.rows = source.spliterator(); } catch (RuntimeException | Error failure) { @@ -68,7 +74,8 @@ private void finish(String outcome, Throwable original) { metadata.setResultCount(delivered <= Integer.MAX_VALUE ? (int) delivered : null); metadata.setResultSummary("Cursor " + outcome + "; delivered " + delivered + " rows"); try { - context.recordExecutionMetadata(metadata); + bindings.recordStatement(metadata); + if (logging) context.recordExecutionMetadata(metadata); } catch (RuntimeException | Error sinkFailure) { if (failure == null) failure = sinkFailure; } diff --git a/teaql-data-service-sql/src/test/java/io/teaql/dataservice/sql/SqlDataServiceExecutorTest.java b/teaql-data-service-sql/src/test/java/io/teaql/dataservice/sql/SqlDataServiceExecutorTest.java index 27c55f07..59380def 100644 --- a/teaql-data-service-sql/src/test/java/io/teaql/dataservice/sql/SqlDataServiceExecutorTest.java +++ b/teaql-data-service-sql/src/test/java/io/teaql/dataservice/sql/SqlDataServiceExecutorTest.java @@ -50,7 +50,11 @@ public void testBasicCapabilities() { public void testQueryPlaceholder() { SqlDataServiceExecutor executor = new SqlDataServiceExecutor("sql", new MockSqlExecutionAdapter()); Assert.assertThrows(io.teaql.core.TeaQLRuntimeException.class, () -> { - executor.query(null, new QueryRequest() {}); + executor.query(null, new QueryRequest() { + @Override public io.teaql.core.QueryIntent intent() { + return io.teaql.core.QueryIntent.of("test unsupported request", "verify provider rejects unknown envelope"); + } + }); }); } @@ -58,7 +62,11 @@ public void testQueryPlaceholder() { public void testMutatePlaceholder() { SqlDataServiceExecutor executor = new SqlDataServiceExecutor("sql", new MockSqlExecutionAdapter()); Assert.assertThrows(io.teaql.core.TeaQLRuntimeException.class, () -> { - executor.mutate(null, new PersistenceMutation() {}); + executor.mutate(null, new PersistenceMutation() { + @Override public io.teaql.core.MutationIntent intent() { + return io.teaql.core.MutationIntent.of("test unsupported mutation"); + } + }); }); } @@ -113,7 +121,11 @@ public static class ScopedTask extends BaseEntity { } public static class ScopedTaskRequest extends BaseRequest { - public ScopedTaskRequest() { super(ScopedTask.class); } + public ScopedTaskRequest() { + super(ScopedTask.class); + internalComment("load scoped metadata fixture"); + internalPurpose("verify each runtime uses its own table metadata"); + } @Override public String getTypeName() { return "ScopedTask"; } } diff --git a/teaql-data-service-sql/src/test/java/io/teaql/dataservice/sql/SqlStreamBatchMaskingTest.java b/teaql-data-service-sql/src/test/java/io/teaql/dataservice/sql/SqlStreamBatchMaskingTest.java index c5bba591..cd76dfca 100644 --- a/teaql-data-service-sql/src/test/java/io/teaql/dataservice/sql/SqlStreamBatchMaskingTest.java +++ b/teaql-data-service-sql/src/test/java/io/teaql/dataservice/sql/SqlStreamBatchMaskingTest.java @@ -22,6 +22,7 @@ public class SqlStreamBatchMaskingTest { private final AtomicInteger closed = new AtomicInteger(); private final RuntimeException error = new IllegalStateException("PASSWORD-CANARY Riverside"); private boolean enabled = true; + private SqlLogBindings streamBindings = BINDINGS; private boolean brokenSink; private final DefaultUserContext context = new DefaultUserContext(TeaQLRuntime.builder() .metadata(new SimpleEntityMetaFactory()).logSink((caller, metadata) -> { @@ -60,7 +61,7 @@ private Stream> stream(String mode) throws Exception { }); }); try { - return db.queryForStream(context, SQL, ARGS, BINDINGS); + return db.queryForStream(context, SQL, ARGS, streamBindings); } finally { context.popTrace(); context.popTrace(); } @@ -162,6 +163,50 @@ private void terminal(String outcome, Integer delivered) { assertEquals(0, logs.size()); assertEquals(1, closed.get()); } + @Test public void cursorCollectsTerminalEvidenceIndependentlyOfLogging() throws Exception { + for (boolean logging : List.of(false, true)) { + for (String mode : List.of("ok", "empty", "early", "unused", "read", "cancel", "close", "consumer")) { + var fixture = new SqlStreamBatchMaskingTest(); + fixture.enabled = logging; + var facts = new ArrayList(); + var request = new BaseRequest<>(BaseEntity.class, BaseEntity::new) { + { internalComment("read Riverside"); internalPurpose("verify cursor lifecycle"); } + @Override public String getTypeName() { return "Customer"; } + }; + fixture.streamBindings = BINDINGS.withTrace(SqlExecutionTrace.query(request).collecting(facts::add)); + var rows = fixture.stream(mode); + assertTrue("open is not completion", facts.isEmpty()); + try (rows) { + switch (mode) { + case "early" -> assertEquals(1, rows.limit(1).toList().size()); + case "unused" -> { } + case "read", "close" -> assertSame(fixture.error, assertThrows(RuntimeException.class, rows::toList)); + case "cancel" -> assertThrows(CancellationException.class, rows::toList); + case "consumer" -> assertSame(fixture.error, assertThrows(RuntimeException.class, + () -> rows.forEach(row -> { throw fixture.error; }))); + default -> rows.toList(); + } + } + rows.close(); + assertEquals(mode + " logging=" + logging, 1, facts.size()); + assertEquals(1, fixture.closed.get()); + var fact = facts.get(0); + String outcome = switch (mode) { + case "early", "unused", "cancel" -> "cancelled"; + case "read", "close", "consumer" -> "failure"; + default -> "success"; + }; + int count = switch (mode) { case "empty", "unused" -> 0; case "ok", "close" -> 3; default -> 1; }; + assertEquals(outcome, fact.getExecutionOutcome()); + assertEquals(Integer.valueOf(count), fact.getResultCount()); + assertEquals("read Riverside", fact.getComment()); + assertEquals("select", fact.getTraceChain().get(fact.getTraceChain().size() - 1).getName()); + assertEquals(logging ? 1 : 0, fixture.logs.size()); + fixture.safe(LogPrivacy.sql(fact, false)); + } + } + } + private void batch(int[] result, RuntimeException failure) throws Exception { var db = database((proxy, method, args) -> { if (failure != null) throw failure; @@ -176,6 +221,7 @@ private void batch(int[] result, RuntimeException failure) throws Exception { @Test public void batchSuccessAndSuccessNoInfo() throws Exception { batch(new int[]{1, 0, -2}, null); assertEquals(List.of("success", "success", "success"), logs.stream().map(ExecutionMetadata::getExecutionOutcome).toList()); + assertTrue(logs.stream().allMatch(log -> "success".equals(log.getBatchOutcome()))); assertEquals(Long.valueOf(1), logs.get(0).getAffectedRows()); assertEquals(Long.valueOf(0), logs.get(1).getAffectedRows()); assertNull(logs.get(2).getAffectedRows()); @@ -183,6 +229,7 @@ private void batch(int[] result, RuntimeException failure) throws Exception { @Test public void partialBatchDoesNotInventUnexecutedOutcomes() throws Exception { batch(null, new RuntimeException(new BatchUpdateException("PASSWORD-CANARY", new int[]{1, -3}))); assertEquals(List.of("success", "failure", "unknown"), logs.stream().map(ExecutionMetadata::getExecutionOutcome).toList()); + assertTrue(logs.stream().allMatch(log -> "failure".equals(log.getBatchOutcome()))); assertEquals(Long.valueOf(1), logs.get(0).getAffectedRows()); assertNull(logs.get(1).getAffectedRows()); assertNull(logs.get(2).getAffectedRows()); } @@ -190,6 +237,7 @@ private void batch(int[] result, RuntimeException failure) throws Exception { batch(null, error); assertTrue(logs.stream().allMatch(log -> "unknown".equals(log.getExecutionOutcome()) && log.getAffectedRows() == null)); assertTrue(logs.stream().allMatch(log -> log.getResultSummary().contains("Batch failure"))); + assertTrue(logs.stream().allMatch(log -> "failure".equals(log.getBatchOutcome()))); } @Test public void brokenSinkDoesNotReplaceBatchFailure() throws Exception { brokenSink = true; batch(null, error); @@ -197,4 +245,39 @@ private void batch(int[] result, RuntimeException failure) throws Exception { @Test public void disabledBatchLogsPreserveOriginalError() throws Exception { enabled = false; batch(null, error); } + + private SqlExecutionTrace itemTrace(long id, String reason) { + var entity = new BaseEntity(); + entity.__internalSet("id", id); + return SqlExecutionTrace.mutation(entity, + List.of(new TraceNode(TraceKind.AUDIT_REASON, "OrderItem", id, reason)), "insert", + io.teaql.core.MutationIntent.of(reason)); + } + + @Test public void partialPreparedBatchKeepsPerRowTraceForSuccessFailureAndUnknown() throws Exception { + var traces = List.of(itemTrace(201, "entry alpha"), itemTrace(202, "entry beta"), itemTrace(203, "entry gamma")); + var supplied = new ArrayList<>(traces); + var bindings = BINDINGS.withBatchTraces(supplied); + supplied.clear(); + var failure = new RuntimeException(new BatchUpdateException("PASSWORD-CANARY", new int[]{1, -3})); + var db = database((proxy, method, args) -> { throw failure; }); + assertSame(failure, assertThrows(RuntimeException.class, + () -> db.batchUpdate(context, SQL, List.of(ARGS, ARGS.clone(), ARGS.clone()), bindings))); + assertEquals(List.of("success", "failure", "unknown"), logs.stream().map(ExecutionMetadata::getExecutionOutcome).toList()); + for (int index = 0; index < logs.size(); index++) { + assertEquals(traces.get(index).mutationLineage(), logs.get(index).getMutationLineage()); + safe(logs.get(index)); + } + assertThrows(UnsupportedOperationException.class, () -> bindings.batchTraces().clear()); + } + + @Test public void malformedTraceRowCountRejectsBeforeDriverEvenWithLoggingDisabled() throws Exception { + enabled = false; + var driverCalls = new AtomicInteger(); + var db = database((proxy, method, args) -> { driverCalls.incrementAndGet(); return new int[]{1, 1}; }); + assertThrows(IllegalArgumentException.class, () -> db.batchUpdate(context, SQL, List.of(ARGS, ARGS.clone()), + BINDINGS.withBatchTraces(List.of(itemTrace(201, "only one trace"))))); + assertEquals(0, driverCalls.get()); + assertTrue(logs.isEmpty()); + } } diff --git a/teaql-hana/pom.xml b/teaql-hana/pom.xml index 9b12a763..588c94a4 100644 --- a/teaql-hana/pom.xml +++ b/teaql-hana/pom.xml @@ -36,7 +36,7 @@ org.apache.maven.plugins maven-surefire-plugin - --add-reads io.teaql.hana=io.teaql.runtime + --add-modules io.teaql.runtime --add-reads io.teaql.hana=io.teaql.runtime diff --git a/teaql-jackson/src/test/java/io/teaql/jackson/BaseEntitySerializationTest.java b/teaql-jackson/src/test/java/io/teaql/jackson/BaseEntitySerializationTest.java index 9156829a..3e7a79b6 100644 --- a/teaql-jackson/src/test/java/io/teaql/jackson/BaseEntitySerializationTest.java +++ b/teaql-jackson/src/test/java/io/teaql/jackson/BaseEntitySerializationTest.java @@ -29,7 +29,8 @@ public void serializesDynamicPropertiesWithTeaQLModule() throws Exception { entity.updateId(1001L); entity.updateVersion(7L); entity.setComment("internal comment"); - entity.setTraceChain("internal trace"); + entity.setTraceChain(java.util.List.of(new io.teaql.core.TraceNode( + io.teaql.core.TraceKind.AUDIT_REASON, entity.typeName(), 1001L, "internal trace"))); entity.putAdditional("#customer_asset_no", "A-10086"); ObjectMapper mapper = new ObjectMapper().registerModule(TeaQLModule.INSTANCE); @@ -41,6 +42,7 @@ public void serializesDynamicPropertiesWithTeaQLModule() throws Exception { assertFalse(json.has("$status")); assertFalse(json.has("comment")); assertFalse(json.has("traceChain")); + assertFalse(json.toString().contains("internal trace")); assertFalse(json.has("additionalInfo")); } diff --git a/teaql-runtime-log/src/main/java/io/teaql/runtime/log/HumanReaderFormatter.java b/teaql-runtime-log/src/main/java/io/teaql/runtime/log/HumanReaderFormatter.java index 3f597241..bb8b11f1 100644 --- a/teaql-runtime-log/src/main/java/io/teaql/runtime/log/HumanReaderFormatter.java +++ b/teaql-runtime-log/src/main/java/io/teaql/runtime/log/HumanReaderFormatter.java @@ -16,7 +16,7 @@ private String formatTraceChain(List traceChain) { return java.util.stream.IntStream.range(0, traceChain.size()) .mapToObj(i -> { TraceNode t = traceChain.get(i); - return i + ":" + t.getKind() + ":" + t.getName() + "=" + t.getComment(); + return i + ":" + t; }) .collect(Collectors.joining(" -> ")); } @@ -28,10 +28,12 @@ public String formatExecutionLog(io.teaql.core.ExecutionMetadata metadata) { String traceStr = formatTraceChain(metadata.getTraceChain()); String traceDisplay = traceStr.isEmpty() ? "" : " - [" + traceStr + "]"; - return String.format("[%s]-[%5dµs]-[SQL]-ExecutionLog%s - [%s] outcome=%s comment=%s purpose=%s auditReason=%s\n SQL: %s", + return String.format("[%s]-[%5dµs]-[SQL]-ExecutionLog%s - [%s] outcome=%s batchOutcome=%s comment=%s purpose=%s auditReason=%s mutationLineage=[%s]\n SQL: %s", ts, metadata.getElapsedUs(), traceDisplay, metadata.getResultSummary(), metadata.getExecutionOutcome() == null ? "unknown" : metadata.getExecutionOutcome(), + metadata.getBatchOutcome() == null ? "not_applicable" : metadata.getBatchOutcome(), metadata.getComment(), metadata.getPurpose(), metadata.getAuditReason(), + formatTraceChain(metadata.getMutationLineage()), metadata.getDebugQuery() == null ? "" : metadata.getDebugQuery()); } diff --git a/teaql-runtime-log/src/main/java/io/teaql/runtime/log/JsonReaderFormatter.java b/teaql-runtime-log/src/main/java/io/teaql/runtime/log/JsonReaderFormatter.java index d7ef7a54..17839946 100644 --- a/teaql-runtime-log/src/main/java/io/teaql/runtime/log/JsonReaderFormatter.java +++ b/teaql-runtime-log/src/main/java/io/teaql/runtime/log/JsonReaderFormatter.java @@ -12,7 +12,8 @@ private String formatTraceChain(List traceChain) { return "[" + traceChain.stream() .map(t -> (CharSequence)("{\"kind\":\"" + t.getKind() + "\",\"name\":\"" + escapeJson(t.getName()) - + "\",\"value\":\"" + escapeJson(t.getComment()) + "\"}")) + + "\",\"entityId\":" + t.getEntityId() + + ",\"value\":\"" + escapeJson(t.getComment()) + "\"}")) .collect(Collectors.joining(",")) + "]"; } @@ -31,8 +32,9 @@ private String escapeJson(String text) { @Override public String formatExecutionLog(io.teaql.core.ExecutionMetadata metadata) { metadata = io.teaql.runtime.LogPrivacy.sql(metadata, io.teaql.runtime.LogPrivacy.plaintextEnabled()); - return String.format("{\"type\":\"EXEC_LOG\",\"tracePath\":%s,\"backend\":\"%s\",\"operation\":\"%s\",\"comment\":\"%s\",\"purpose\":\"%s\",\"auditReason\":\"%s\",\"elapsedUs\":%d,\"resultCount\":%s,\"affectedRows\":%s,\"summary\":\"%s\",\"sql\":\"%s\",\"logMode\":\"%s\",\"maskedParameters\":%s,\"sqlOmissionReason\":\"%s\",\"executionOutcome\":\"%s\"}", + return String.format("{\"type\":\"EXEC_LOG\",\"tracePath\":%s,\"mutationLineage\":%s,\"backend\":\"%s\",\"operation\":\"%s\",\"comment\":\"%s\",\"purpose\":\"%s\",\"auditReason\":\"%s\",\"elapsedUs\":%d,\"resultCount\":%s,\"affectedRows\":%s,\"summary\":\"%s\",\"sql\":\"%s\",\"logMode\":\"%s\",\"maskedParameters\":%s,\"sqlOmissionReason\":\"%s\",\"executionOutcome\":\"%s\",\"batchOutcome\":\"%s\"}", formatTraceChain(metadata.getTraceChain()), + formatTraceChain(metadata.getMutationLineage()), escapeJson(metadata.getBackend()), metadata.getOperation(), escapeJson(metadata.getComment()), escapeJson(metadata.getPurpose()), escapeJson(metadata.getAuditReason()), metadata.getElapsedUs(), @@ -40,7 +42,8 @@ public String formatExecutionLog(io.teaql.core.ExecutionMetadata metadata) { escapeJson(metadata.getResultSummary()), escapeJson(metadata.getDebugQuery()), escapeJson(metadata.getLogMode()), metadata.getParameterMasked(), escapeJson(metadata.getSqlOmissionReason()), - escapeJson(metadata.getExecutionOutcome() == null ? "unknown" : metadata.getExecutionOutcome())); + escapeJson(metadata.getExecutionOutcome() == null ? "unknown" : metadata.getExecutionOutcome()), + escapeJson(metadata.getBatchOutcome() == null ? "not_applicable" : metadata.getBatchOutcome())); } @Override diff --git a/teaql-runtime-log/src/test/java/io/teaql/runtime/log/ExecutionLogPrivacyTest.java b/teaql-runtime-log/src/test/java/io/teaql/runtime/log/ExecutionLogPrivacyTest.java index 48c8dee4..3df31ce1 100644 --- a/teaql-runtime-log/src/test/java/io/teaql/runtime/log/ExecutionLogPrivacyTest.java +++ b/teaql-runtime-log/src/test/java/io/teaql/runtime/log/ExecutionLogPrivacyTest.java @@ -67,6 +67,24 @@ private ExecutionMetadata metadata() { return metadata; } + @Test public void bothFormattersPreserveTypedMutationIdentityAndSeparateBatchFailure() { + var metadata = metadata(); + metadata.setParameters(List.of(SECRET)); + metadata.setMutationLineage(List.of(new io.teaql.core.TraceNode( + io.teaql.core.TraceKind.AUDIT_REASON, "CustomerOrder", 1001L, "persist " + SECRET))); + metadata.setExecutionOutcome("unknown"); + metadata.setBatchOutcome("failure"); + String human = new HumanReaderFormatter().formatExecutionLog(metadata); + String json = new JsonReaderFormatter().formatExecutionLog(metadata); + assertTrue(human, human.contains("CustomerOrder#1001")); + assertTrue(human, human.contains("outcome=unknown batchOutcome=failure")); + assertTrue(json, json.contains("\"mutationLineage\":[")); + assertTrue(json, json.contains("\"entityId\":1001")); + assertTrue(json, json.contains("\"executionOutcome\":\"unknown\",\"batchOutcome\":\"failure\"")); + assertFalse(human, human.contains(SECRET)); + assertFalse(json, json.contains(SECRET)); + } + @Test public void failureOutcomeAndUnknownCountsSurviveBothFormatters() { var metadata = metadata(); diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/DefaultQueryRequest.java b/teaql-runtime/src/main/java/io/teaql/runtime/DefaultQueryRequest.java index 19895493..ef238ede 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/DefaultQueryRequest.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/DefaultQueryRequest.java @@ -2,14 +2,27 @@ import io.teaql.core.QueryRequest; import io.teaql.core.SearchRequest; +import io.teaql.core.QueryIntent; +import java.util.Objects; -public class DefaultQueryRequest implements QueryRequest { +public final class DefaultQueryRequest implements QueryRequest { private final SearchRequest searchRequest; + private final QueryIntent intent; public DefaultQueryRequest(SearchRequest searchRequest) { - this.searchRequest = searchRequest; + this(searchRequest, searchRequest.inheritedQueryIntent() == null + ? QueryIntent.of(searchRequest.comment(), searchRequest.purpose()) + : searchRequest.inheritedQueryIntent()); } + /** Framework-derived requests inherit an already validated root intent, never ambient trace. */ + public DefaultQueryRequest(SearchRequest searchRequest, QueryIntent intent) { + this.intent = Objects.requireNonNull(intent, "intent"); + this.searchRequest = Objects.requireNonNull(searchRequest, "searchRequest"); + } + + @Override public QueryIntent intent() { return intent; } + public SearchRequest getSearchRequest() { return searchRequest; } diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/DefaultQueryResult.java b/teaql-runtime/src/main/java/io/teaql/runtime/DefaultQueryResult.java index 548ca07b..82698685 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/DefaultQueryResult.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/DefaultQueryResult.java @@ -7,16 +7,25 @@ public class DefaultQueryResult implements QueryResult { private final SmartList result; private final AggregationResult aggregationResult; + private final java.util.List statements; public DefaultQueryResult(SmartList result) { this(result, null); } public DefaultQueryResult(SmartList result, AggregationResult aggregationResult) { + this(result, aggregationResult, java.util.List.of()); + } + + public DefaultQueryResult(SmartList result, AggregationResult aggregationResult, + java.util.List statements) { this.result = result; this.aggregationResult = aggregationResult; + this.statements = java.util.List.copyOf(statements); } + @Override public java.util.List statements() { return statements; } + public SmartList getResult() { return result; } diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/DefaultTextRuntimeLogSink.java b/teaql-runtime/src/main/java/io/teaql/runtime/DefaultTextRuntimeLogSink.java index 6eada02d..de270397 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/DefaultTextRuntimeLogSink.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/DefaultTextRuntimeLogSink.java @@ -28,13 +28,15 @@ public boolean requiresSensitiveSqlData() { public void writeExecutionLog(UserContext context, ExecutionMetadata metadata) { metadata = LogPrivacy.sql(metadata, false); output.printf( - "[TeaQL SQL][%s][%dus] %s outcome=%s comment=%s purpose=%s auditReason=%s tracePath=%s%n" + "[TeaQL SQL][%s][%dus] %s outcome=%s batchOutcome=%s comment=%s purpose=%s auditReason=%s tracePath=%s mutationLineage=%s%n" + "SQL: %s%n", metadata.getOperation() == null ? "unknown" : metadata.getOperation().name().toLowerCase(), metadata.getElapsedUs(), resultSummary(metadata), metadata.getExecutionOutcome() == null ? "unknown" : metadata.getExecutionOutcome(), + metadata.getBatchOutcome() == null ? "not_applicable" : metadata.getBatchOutcome(), nullToEmpty(metadata.getComment()), nullToEmpty(metadata.getPurpose()), nullToEmpty(metadata.getAuditReason()), formatTrace(metadata.getTraceChain()), + formatTrace(metadata.getMutationLineage()), nullToEmpty(metadata.getDebugQuery())); } diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/DefaultUserContext.java b/teaql-runtime/src/main/java/io/teaql/runtime/DefaultUserContext.java index 73e906f4..4c7413a0 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/DefaultUserContext.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/DefaultUserContext.java @@ -108,6 +108,11 @@ public void saveGraph(Entity entity) { @Override public void putAttribute(String key, Object value) { + var checker = io.teaql.core.checker.internal.CheckerInvocation.current(this); + if (checker != null && io.teaql.core.checker.internal.CheckerInvocation.isScopedAttribute(key)) { + checker.attribute(key, value); + return; + } if (value == null) { storage.remove(key); } else { @@ -117,13 +122,17 @@ public void putAttribute(String key, Object value) { @Override public Object getAttribute(String key) { + var checker = io.teaql.core.checker.internal.CheckerInvocation.current(this); + if (checker != null && io.teaql.core.checker.internal.CheckerInvocation.isScopedAttribute(key)) { + return checker.attribute(key); + } return storage.get(key); } @Override @SuppressWarnings("unchecked") public T getAttribute(String key, Class clazz) { - Object val = storage.get(key); + Object val = getAttribute(key); if (clazz != null && clazz.isInstance(val)) { return (T) val; } @@ -292,34 +301,19 @@ public void recordExecutionMetadata(io.teaql.core.ExecutionMetadata metadata) { if (metadata.getTraceChain() == null || metadata.getTraceChain().isEmpty()) { metadata.setTraceChain(getTraceChain()); } - if (metadata.getTraceChain() != null) { - for (TraceNode node : metadata.getTraceChain()) { - if (node.getKind() == TraceKind.COMMENT) metadata.setComment(node.getComment()); - if (node.getKind() == TraceKind.PURPOSE) metadata.setPurpose(node.getComment()); - if (node.getKind() == TraceKind.AUDIT_REASON) metadata.setAuditReason(node.getComment()); - } - } - java.util.List canonical = new java.util.ArrayList<>(); - if (metadata.getTraceChain() != null) { - metadata.getTraceChain().stream() - .filter(node -> node.getKind() != TraceKind.COMMENT - && node.getKind() != TraceKind.PURPOSE - && node.getKind() != TraceKind.AUDIT_REASON - && node.getKind() != TraceKind.PROVIDER - && node.getKind() != TraceKind.SQL) - .forEach(canonical::add); - } - String backend = metadata.getBackend() == null ? "unknown" : metadata.getBackend(); - canonical.add(new TraceNode(TraceKind.PROVIDER, backend, backend)); - String sqlOperation = sqlOperation(metadata); - canonical.add(new TraceNode(TraceKind.SQL, sqlOperation, sqlOperation)); - metadata.setTraceChain(canonical); + var canonical = io.teaql.core.SqlTracePath.canonical( + metadata.getTraceChain(), metadata.getBackend(), sqlOperation(metadata)); + if (canonical.comment() != null) metadata.setComment(canonical.comment()); + if (canonical.purpose() != null) metadata.setPurpose(canonical.purpose()); + if (canonical.auditReason() != null) metadata.setAuditReason(canonical.auditReason()); + metadata.setTraceChain(canonical.path()); if (runtime != null) { runtime.recordExecutionMetadata(this, metadata); } } private static String sqlOperation(io.teaql.core.ExecutionMetadata metadata) { + if (metadata.getStatementOperation() != null) return metadata.getStatementOperation(); String sql = metadata.getParameterizedQuery(); if (sql != null) { String normalized = sql.stripLeading(); @@ -343,7 +337,8 @@ private static String sqlOperation(io.teaql.core.ExecutionMetadata metadata) { public final T evaluate(String expression, Object... args) { // Built-in: "now" comes from the context-owned business clock. if ("now".equalsIgnoreCase(expression)) { - Object captured = getAttribute(io.teaql.core.checker.Checker.TEAQL_FIX_TIME); + Object captured = io.teaql.core.checker.internal.CheckerInvocation.attribute( + this, io.teaql.core.checker.Checker.TEAQL_FIX_TIME); return (T) (captured != null ? captured : businessTime()); } // Delegate to subclass or extension for application-defined expressions. diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/EntityPersistenceMutation.java b/teaql-runtime/src/main/java/io/teaql/runtime/EntityPersistenceMutation.java index ecd7ca81..46d685e5 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/EntityPersistenceMutation.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/EntityPersistenceMutation.java @@ -2,18 +2,61 @@ import io.teaql.core.Entity; import io.teaql.core.PersistenceMutation; +import io.teaql.core.MutationIntent; +import io.teaql.core.MutationTraceScope; +import io.teaql.core.TraceNode; +import java.util.List; +import java.util.Objects; -public class EntityPersistenceMutation implements PersistenceMutation { +public final class EntityPersistenceMutation implements PersistenceMutation { public enum Action { SAVE, DELETE } private final Entity entity; private final Action action; + private final MutationIntent intent; + private final List traceChain; + private final transient Entity diagnosticSource; + private final transient io.teaql.core.SqlIntentRedactions graphRedactions; public EntityPersistenceMutation(Entity entity, Action action) { - this.entity = entity; - this.action = action; + this(entity, action, MutationIntent.of(entity.getComment())); } + public EntityPersistenceMutation(Entity entity, Action action, MutationIntent intent) { + this(entity, action, intent, + MutationTraceScope.append(null, entity.typeName(), entity.getId(), intent.comment()).recover()); + } + + public EntityPersistenceMutation(Entity entity, Action action, MutationIntent intent, List traceChain) { + this(entity, action, intent, traceChain, entity); + } + + public EntityPersistenceMutation(Entity entity, Action action, MutationIntent intent, List traceChain, + Entity diagnosticSource) { + this(entity, action, intent, traceChain, diagnosticSource, null); + } + + public EntityPersistenceMutation(Entity entity, Action action, MutationIntent intent, List traceChain, + Entity diagnosticSource, io.teaql.core.SqlIntentRedactions graphRedactions) { + this.entity = Objects.requireNonNull(entity, "entity"); + this.action = Objects.requireNonNull(action, "action"); + this.intent = Objects.requireNonNull(intent, "intent"); + this.traceChain = List.copyOf(Objects.requireNonNull(traceChain, "traceChain")); + this.diagnosticSource = diagnosticSource == null ? entity : diagnosticSource; + this.graphRedactions = graphRedactions == null ? new io.teaql.core.SqlIntentRedactions() : graphRedactions.copy(); + } + + @io.teaql.core.FrameworkInternal("Invocation-local graph privacy source; never a write payload") + public io.teaql.core.SqlIntentRedactions diagnosticRedactions() { return graphRedactions.copy(); } + + /** Original loaded values for invocation-local redaction; never used as the write payload. */ + @io.teaql.core.FrameworkInternal("Mutation diagnostic provenance only") + public Entity diagnosticSource() { return diagnosticSource; } + + @Override public MutationIntent intent() { return intent; } + + public List getTraceChain() { return traceChain; } + public Entity getEntity() { return entity; } diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/LogPrivacy.java b/teaql-runtime/src/main/java/io/teaql/runtime/LogPrivacy.java index 6b91966d..62c36cef 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/LogPrivacy.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/LogPrivacy.java @@ -62,7 +62,7 @@ public static String scrub(String text, Collection values) { public static List trace(List nodes, Collection values) { if (nodes == null) return List.of(); return nodes.stream().map(node -> new TraceNode(node.getKind(), - scrub(node.getName(), values), scrub(node.getComment(), values))).toList(); + scrub(node.getName(), values), node.getEntityId(), scrub(node.getComment(), values))).toList(); } public static ExecutionMetadata sql(ExecutionMetadata source, boolean allow) { @@ -106,6 +106,7 @@ public static ExecutionMetadata sql(ExecutionMetadata source, boolean allow) { ExecutionMetadata safe = new ExecutionMetadata(); safe.setBackend(source.getBackend()); safe.setOperation(source.getOperation()); safe.setExecutionOutcome(source.getExecutionOutcome()); + safe.setBatchOutcome(source.getBatchOutcome()); safe.setStartedAt(source.getStartedAt()); safe.setEndedAt(source.getEndedAt()); safe.setElapsedUs(source.getElapsedUs()); safe.setAffectedRows(source.getAffectedRows()); safe.setResultCount(source.getResultCount()); @@ -115,6 +116,8 @@ public static ExecutionMetadata sql(ExecutionMetadata source, boolean allow) { safe.setBackendRequestId(scrub(source.getBackendRequestId(), secrets)); safe.setComment(scrub(source.getComment(), secrets)); safe.setPurpose(scrub(source.getPurpose(), secrets)); safe.setAuditReason(scrub(source.getAuditReason(), secrets)); safe.setTraceChain(trace(source.getTraceChain(), secrets)); + safe.setMutationLineage(trace(source.getMutationLineage(), secrets)); + safe.setStatementOperation(source.getStatementOperation()); if (orphanedDebug) { safe.setComment(hideIntent(source.getComment())); safe.setPurpose(hideIntent(source.getPurpose())); safe.setAuditReason(hideIntent(source.getAuditReason())); @@ -122,7 +125,9 @@ public static ExecutionMetadata sql(ExecutionMetadata source, boolean allow) { if (source.getResultCount() == null && source.getAffectedRows() == null) safe.setResultSummary(hideIntent(source.getResultSummary())); safe.setTraceChain(source.getTraceChain() == null ? List.of() : source.getTraceChain().stream() - .map(node -> new TraceNode(node.getKind(), hideIntent(node.getName()), hideIntent(node.getComment()))).toList()); + .map(node -> new TraceNode(node.getKind(), hideIntent(node.getName()), node.getEntityId(), hideIntent(node.getComment()))).toList()); + safe.setMutationLineage(source.getMutationLineage().stream() + .map(node -> new TraceNode(node.getKind(), hideIntent(node.getName()), node.getEntityId(), hideIntent(node.getComment()))).toList()); } String sql = source.getParameterizedQuery(); safe.setParameterizedQuery(sql); @@ -190,7 +195,12 @@ static Object copyValue(Object value) { } public static RawAuditEvent audit(RawAuditEvent source, boolean allow) { + return audit(source, allow, null); + } + + public static RawAuditEvent audit(RawAuditEvent source, boolean allow, io.teaql.core.SqlIntentRedactions redactions) { List secrets = new ArrayList<>(); + if (redactions != null) redactions.appendTo(secrets, allow); List changes = source.changes().stream().map(change -> { boolean mask = !allow || credential(change.field()) || hasCredentials(change.oldValue()) || hasCredentials(change.newValue()); if (!mask) return change; diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/SensitiveDiagnosticTextRuntimeLogSink.java b/teaql-runtime/src/main/java/io/teaql/runtime/SensitiveDiagnosticTextRuntimeLogSink.java index 51eabb48..cb8155bd 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/SensitiveDiagnosticTextRuntimeLogSink.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/SensitiveDiagnosticTextRuntimeLogSink.java @@ -29,13 +29,15 @@ public boolean requiresSensitiveSqlData() { public void writeExecutionLog(UserContext context, ExecutionMetadata metadata) { metadata = LogPrivacy.sql(metadata, LogPrivacy.plaintextEnabled()); output.printf( - "[TeaQL SQL][%s][%dus] %s outcome=%s comment=%s purpose=%s auditReason=%s tracePath=%s%n" + "[TeaQL SQL][%s][%dus] %s outcome=%s batchOutcome=%s comment=%s purpose=%s auditReason=%s tracePath=%s mutationLineage=%s%n" + "SQL: %s%n", metadata.getOperation() == null ? "unknown" : metadata.getOperation().name().toLowerCase(), metadata.getElapsedUs(), resultSummary(metadata), metadata.getExecutionOutcome() == null ? "unknown" : metadata.getExecutionOutcome(), + metadata.getBatchOutcome() == null ? "not_applicable" : metadata.getBatchOutcome(), nullToEmpty(metadata.getComment()), nullToEmpty(metadata.getPurpose()), nullToEmpty(metadata.getAuditReason()), formatTrace(metadata.getTraceChain()), + formatTrace(metadata.getMutationLineage()), nullToEmpty(metadata.getDebugQuery())); } } diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/SqlLogProjectionCache.java b/teaql-runtime/src/main/java/io/teaql/runtime/SqlLogProjectionCache.java index 08ed28c7..10d60663 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/SqlLogProjectionCache.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/SqlLogProjectionCache.java @@ -33,14 +33,20 @@ private static String fingerprint(ExecutionMetadata m) { add(digest, m.getAffectedRows()); add(digest, m.getResultCount()); add(digest, m.getResultSummary()); add(digest, m.getBackendRequestId()); add(digest, m.getParameterizedQuery()); add(digest, m.getDebugQuery()); add(digest, m.getComment()); add(digest, m.getPurpose()); add(digest, m.getAuditReason()); + add(digest, m.getStatementOperation()); + add(digest, m.getMutationLineage().size()); + for (var node : m.getMutationLineage()) { + add(digest, node.getKind()); add(digest, node.getName()); add(digest, node.getEntityId()); add(digest, node.getComment()); + } add(digest, m.getParameterLogPolicies()); add(digest, m.getParameterMasked()); add(digest, m.isGeneratedSql()); add(digest, m.getLogMode()); add(digest, m.getSqlOmissionReason()); add(digest, m.getExecutionOutcome()); + add(digest, m.getBatchOutcome()); add(digest, m.getParameterCount()); for (Object value : m.getParameters()) add(digest, SqlLogRenderer.literal(value, m.getBackend())); add(digest, m.getTraceChain() == null ? -1 : m.getTraceChain().size()); if (m.getTraceChain() != null) for (var node : m.getTraceChain()) { - add(digest, node.getKind()); add(digest, node.getName()); add(digest, node.getComment()); + add(digest, node.getKind()); add(digest, node.getName()); add(digest, node.getEntityId()); add(digest, node.getComment()); } return HexFormat.of().formatHex(digest.digest()); } catch (IllegalArgumentException unsupported) { @@ -72,8 +78,10 @@ private static ExecutionMetadata copy(ExecutionMetadata m) { c.setParameterLogPolicies(m.getParameterLogPolicies()); c.setParameterMasked(m.getParameterMasked()); c.setGeneratedSql(m.isGeneratedSql()); c.setLogMode(m.getLogMode()); c.setSqlOmissionReason(m.getSqlOmissionReason()); c.setExecutionOutcome(m.getExecutionOutcome()); + c.setBatchOutcome(m.getBatchOutcome()); c.setComment(m.getComment()); c.setPurpose(m.getPurpose()); c.setAuditReason(m.getAuditReason()); c.setTraceChain(m.getTraceChain() == null ? null : java.util.List.copyOf(m.getTraceChain())); + c.setMutationLineage(m.getMutationLineage()); c.setStatementOperation(m.getStatementOperation()); return c; } } diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java b/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java index 81293562..e855c33b 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java @@ -166,35 +166,14 @@ public SmartList executeForList(UserContext context, Searc new RuntimeTelemetry.Operation("query", request.getTypeName() + ".list", Map.of("teaql.entity.type", request.getTypeName()))); try { - if (request.purpose() == null || request.purpose().trim().isEmpty()) { - throw new TeaQLRuntimeException("[PURPOSE REQUIRED] Missing .purpose() on query execution."); - } + QueryIntent intent = QueryIntent.of(request.comment(), request.purpose()); enforceMaterializedLimit(request, request.hardLimit()); if (queryPolicy != null) { queryPolicy.enforceSelect(context, request); } - boolean pushedComment = false; - boolean pushedPurpose = false; - context.pushTrace(TraceKind.OPERATION, request.getTypeName(), "query"); - context.pushTrace(TraceKind.REQUEST, request.getTypeName(), request.getTypeName()); - if (request.comment() != null && !request.comment().trim().isEmpty()) { - context.pushTrace(TraceKind.COMMENT, request.getTypeName(), request.comment()); - pushedComment = true; - } - if (request.purpose() != null && !request.purpose().trim().isEmpty()) { - context.pushTrace(TraceKind.PURPOSE, request.getTypeName(), request.purpose()); - pushedPurpose = true; - } - try { - SmartList result = executeForListResolved(context, request); - telemetryScope.success(Map.of("teaql.result.cardinality", result.size())); - return result; - } finally { - if (pushedPurpose) context.popTrace(); - if (pushedComment) context.popTrace(); - context.popTrace(); - context.popTrace(); - } + SmartList result = executeForListResolved(context, request, intent); + telemetryScope.success(Map.of("teaql.result.cardinality", result.size())); + return result; } catch (RuntimeException | Error error) { telemetryScope.failure(error); throw error; @@ -204,6 +183,7 @@ public SmartList executeForList(UserContext context, Searc @SuppressWarnings("unchecked") public SmartList executeForPage( UserContext context, SearchRequest request, int offset, int limit) { + QueryIntent intent = QueryIntent.of(request.comment(), request.purpose()); if (!(request instanceof BaseRequest baseRequest)) { throw new TeaQLRuntimeException("Paged execution requires a generated BaseRequest"); } @@ -227,7 +207,7 @@ public SmartList executeForPage( String route = descriptor.getDataService(); if (route == null || route.isEmpty()) route = "default"; QueryExecutor executor = registry.resolveQueryExecutor(route); - QueryResult countResult = executor.query(context, new DefaultQueryRequest(countRequest)); + QueryResult countResult = executor.query(context, new DefaultQueryRequest(countRequest, intent)); if (!(countResult instanceof DefaultQueryResult result) || result.getAggregationResult() == null) { throw new TeaQLRuntimeException("Exact page count is not supported for route: " + route); @@ -242,14 +222,11 @@ public SmartList executeForPage( /** Executes a business-facing streaming query after the same policy gate as list queries. */ public Stream executeForStream( UserContext context, SearchRequest request) { - if (request.purpose() == null || request.purpose().trim().isEmpty()) { - throw new TeaQLRuntimeException( - "[PURPOSE REQUIRED] Missing .purpose() on streaming query execution."); - } + QueryIntent intent = QueryIntent.of(request.comment(), request.purpose()); if (queryPolicy != null) { queryPolicy.enforceSelect(context, request); } - return executeForStreamResolved(context, request); + return executeForStreamResolved(context, request, intent); } /** @@ -258,19 +235,16 @@ public Stream executeForStream( */ public Stream internalExecuteForStream( UserContext context, SearchRequest request) { - if (context.getTraceChain() == null || context.getTraceChain().isEmpty()) { - throw new TeaQLRuntimeException( - "[INTERNAL QUERY CONTEXT REQUIRED] Nested streaming query has no authorized root trace."); - } + QueryIntent intent = requireInheritedQueryIntent(request); if (queryPolicy != null) { queryPolicy.enforceSelect(context, request); } - return executeForStreamResolved(context, request); + return executeForStreamResolved(context, request, intent); } @SuppressWarnings("unchecked") private Stream executeForStreamResolved( - UserContext context, SearchRequest request) { + UserContext context, SearchRequest request, QueryIntent intent) { EntityDescriptor descriptor = metadata.resolveEntityDescriptor(request.getTypeName()); String route = descriptor != null ? descriptor.getDataService() : null; if (route == null || route.isEmpty()) { @@ -278,15 +252,15 @@ private Stream executeForStreamResolved( } DataServiceExecutor executor = registry.resolve(route); if (executor instanceof StreamingQueryExecutor streamingQueryExecutor) { - return streamingQueryExecutor.queryForStream(context, request); + return streamingQueryExecutor.queryForStream(context, new DefaultQueryRequest(request, intent)); } throw new TeaQLRuntimeException("Streaming query is not supported for route: " + route); } /** - * Executes a framework-owned nested query under the trace established by its - * already-authorized root request. Nested relation requests are generated as - * query expressions and deliberately do not carry a second business purpose. + * Executes a framework-owned nested query with the explicit provenance of its + * already-authorized root request, never a Context trace stack. Nested relation + * requests do not require a second caller-supplied business purpose. */ public SmartList internalExecuteForList( UserContext context, SearchRequest request) { @@ -306,22 +280,14 @@ public SmartList internalExecuteForList( new RuntimeTelemetry.Operation("relation_load", request.getTypeName() + ".relation", relationAttributes)); try { - if (context.getTraceChain() == null || context.getTraceChain().isEmpty()) { - throw new TeaQLRuntimeException( - "[INTERNAL QUERY CONTEXT REQUIRED] Nested query has no authorized root trace."); - } + QueryIntent intent = requireInheritedQueryIntent(request); enforceMaterializedLimit(request, SearchRequest.DEFAULT_HARD_LIMIT); if (queryPolicy != null) { queryPolicy.enforceSelect(context, request); } - context.pushTrace(TraceKind.RELATION, request.getTypeName(), request.getTypeName()); - try { - SmartList result = executeForListResolved(context, request); - relationScope.success(Map.of("teaql.result.cardinality", result.size())); - return result; - } finally { - context.popTrace(); - } + SmartList result = executeForListResolved(context, request, intent); + relationScope.success(Map.of("teaql.result.cardinality", result.size())); + return result; } catch (RuntimeException | Error error) { relationScope.failure(error); throw error; @@ -334,6 +300,15 @@ private static void copyRelationPlanAttribute( if (value != null) attributes.put(attribute, value); } + private static QueryIntent requireInheritedQueryIntent(SearchRequest request) { + QueryIntent intent = request.inheritedQueryIntent(); + if (intent == null) { + throw new TeaQLRuntimeException( + "[INTERNAL QUERY CONTEXT REQUIRED] Nested query requires an explicit validated root request intent."); + } + return intent; + } + private static void enforceMaterializedLimit(SearchRequest request, int hardLimit) { Slice slice = request.getSlice(); if (slice == null) { @@ -350,7 +325,7 @@ private static void enforceMaterializedLimit(SearchRequest request, int hardL @SuppressWarnings("unchecked") private SmartList executeForListResolved( - UserContext context, SearchRequest request) { + UserContext context, SearchRequest request, QueryIntent intent) { EntityDescriptor descriptor = metadata.resolveEntityDescriptor(request.getTypeName()); String route = descriptor.getDataService(); if (route == null || route.isEmpty()) { @@ -360,7 +335,7 @@ private SmartList executeForListResolved( if (queryExecutor == null) { throw new TeaQLRuntimeException("No QueryExecutor registered for route: " + route); } - QueryRequest queryRequest = new DefaultQueryRequest(request); + QueryRequest queryRequest = new DefaultQueryRequest(request, intent); RuntimeTelemetry.Scope providerScope = RuntimeTelemetry.startSafely(telemetry, new RuntimeTelemetry.Operation("provider", route + ".query", Map.of( "teaql.provider.kind", route, @@ -381,42 +356,25 @@ private SmartList executeForListResolved( } public AggregationResult aggregation(UserContext context, SearchRequest request) { - if (request.purpose() == null || request.purpose().trim().isEmpty()) { - throw new TeaQLRuntimeException("[PURPOSE REQUIRED] Missing .purpose() on aggregation."); - } + QueryIntent intent = QueryIntent.of(request.comment(), request.purpose()); if (queryPolicy != null) { queryPolicy.enforceSelect(context, request); } - boolean pushedComment = false; - boolean pushedPurpose = false; - if (request.comment() != null && !request.comment().trim().isEmpty()) { - context.pushTrace(TraceKind.COMMENT, request.getTypeName(), request.comment()); - pushedComment = true; + EntityDescriptor descriptor = metadata.resolveEntityDescriptor(request.getTypeName()); + String route = descriptor.getDataService(); + if (route == null || route.isEmpty()) { + route = "default"; } - if (request.purpose() != null && !request.purpose().trim().isEmpty()) { - context.pushTrace(TraceKind.PURPOSE, request.getTypeName(), request.purpose()); - pushedPurpose = true; + QueryExecutor queryExecutor = registry.resolveQueryExecutor(route); + if (queryExecutor == null) { + throw new TeaQLRuntimeException("No QueryExecutor registered for route: " + route); } - try { - EntityDescriptor descriptor = metadata.resolveEntityDescriptor(request.getTypeName()); - String route = descriptor.getDataService(); - if (route == null || route.isEmpty()) { - route = "default"; - } - QueryExecutor queryExecutor = registry.resolveQueryExecutor(route); - if (queryExecutor == null) { - throw new TeaQLRuntimeException("No QueryExecutor registered for route: " + route); - } - QueryRequest queryRequest = new DefaultQueryRequest(request); - QueryResult queryResult = queryExecutor.query(context, queryRequest); - if (queryResult instanceof DefaultQueryResult) { - return ((DefaultQueryResult) queryResult).getAggregationResult(); - } - throw new TeaQLRuntimeException("Unsupported QueryResult type: " + queryResult.getClass().getName()); - } finally { - if (pushedPurpose) context.popTrace(); - if (pushedComment) context.popTrace(); + QueryRequest queryRequest = new DefaultQueryRequest(request, intent); + QueryResult queryResult = queryExecutor.query(context, queryRequest); + if (queryResult instanceof DefaultQueryResult) { + return ((DefaultQueryResult) queryResult).getAggregationResult(); } + throw new TeaQLRuntimeException("Unsupported QueryResult type: " + queryResult.getClass().getName()); } public void saveGraph(UserContext context, Object items) { @@ -429,25 +387,13 @@ public void saveGraph(UserContext context, Object items) { } } - private static final String SAVE_GRAPH_ACTIVE_ROUTE_KEY = "__teaql_save_graph_route__"; - public void saveGraph(UserContext context, Entity entity) { RuntimeTelemetry.Scope telemetryScope = RuntimeTelemetry.startSafely(telemetry, new RuntimeTelemetry.Operation("mutation", entity.typeName() + ".save", Map.of( "teaql.entity.type", entity.typeName(), "teaql.mutation.kind", "save"))); try { - if (entity.getComment() == null || entity.getComment().trim().isEmpty()) { - throw new TeaQLRuntimeException("[AUDIT REQUIRED] Missing .auditAs() or .setComment() before saveGraph()."); - } - boolean pushed = false; - context.pushTrace(TraceKind.OPERATION, entity.typeName(), "mutation"); - context.pushTrace(TraceKind.ENTITY, entity.typeName(), entity.typeName()); - if (entity.getComment() != null && !entity.getComment().trim().isEmpty()) { - context.pushTrace(TraceKind.AUDIT_REASON, entity.typeName(), entity.getComment()); - pushed = true; - } - try { + MutationIntent intent = MutationIntent.of(entity.getComment()); checkAndFix(context, entity); // Get entity's own EntityMutationLedger EntityMutationLedger entityMutationLedger = ((BaseEntity) entity).getEntityMutationLedger(); @@ -468,23 +414,20 @@ public void saveGraph(UserContext context, Entity entity) { entityMutationLedger, Collections.newSetFromMap(new IdentityHashMap<>())); + Map traceScopes = new HashMap<>(); + MutationTraceScope graphScope = MutationTraceScope.append( + null, entity.typeName(), entity.getId(), intent.comment()); + traceScopes.put(new EntityKey(entity.typeName(), entity.getId()), graphScope); + Set traceVisited = Collections.newSetFromMap(new IdentityHashMap<>()); + traceVisited.add(entity); + visitRelatedEntities(entity, child -> collectMutationTraceScopes(child, graphScope, traceScopes, traceVisited)); + EntityDescriptor descriptor = metadata.resolveEntityDescriptor(entity.typeName()); String route = descriptor.getDataService(); if (route == null || route.isEmpty()) { route = "default"; } - Object activeRoute = context.extension(SAVE_GRAPH_ACTIVE_ROUTE_KEY); - if (activeRoute == null) { - context.putAttribute(SAVE_GRAPH_ACTIVE_ROUTE_KEY, route); - } else if (!activeRoute.equals(route)) { - throw new TeaQLRuntimeException( - "[CROSS-PROVIDER MUTATION] saveGraph attempted to write entity '" - + entity.typeName() + "' to route '" + route - + "' while the current saveGraph chain is already writing to route '" - + activeRoute + "'."); - } - MutationExecutor mutationExecutor = registry.resolveMutationExecutor(route); if (mutationExecutor == null) { throw new TeaQLRuntimeException("No MutationExecutor registered for route: " + route); @@ -501,17 +444,18 @@ public void saveGraph(UserContext context, Entity entity) { new PersistenceState( value.getVersion(), value.get$status(), value.isPropertyLoaded(BaseEntity.VERSION_PROPERTY)))); - MutationPlan mutationPlan = buildMutationPlan(entity, entityMutationLedger, realEntities); + MutationPlan mutationPlan = buildMutationPlan(entity, entityMutationLedger, realEntities, intent); + requireSingleMutationRoute(mutationPlan, route); MutationGovernanceSnapshot governance = reviewMutationPlan(context, mutationPlan); List completed; try { if (mutationExecutor instanceof TransactionExecutor transactionExecutor) { completed = transactionExecutor.executeInTransaction(context, () -> executeLedgerPlan(context, entityMutationLedger, mutationExecutor, - realEntities, governance)); + realEntities, governance, intent, traceScopes, graphScope)); } else { completed = executeLedgerPlan( - context, entityMutationLedger, mutationExecutor, realEntities, governance); + context, entityMutationLedger, mutationExecutor, realEntities, governance, intent, traceScopes, graphScope); } } catch (RuntimeException | Error failure) { restoreGraphPersistenceState( @@ -524,17 +468,48 @@ public void saveGraph(UserContext context, Entity entity) { completeLedgerPlan(context, completed); entityMutationLedger.clearCurrentChangeSet(); telemetryScope.success(); - } finally { - if (pushed) context.popTrace(); - context.popTrace(); - context.popTrace(); - } } catch (RuntimeException | Error error) { telemetryScope.failure(error); throw error; } } + /** One atomic plan cannot borrow a root provider for a different entity route. */ + private void requireSingleMutationRoute(MutationPlan plan, String rootRoute) { + for (MutationOperation operation : plan.operations()) { + String type = operation.entity().entity(); + EntityDescriptor descriptor = metadata.resolveEntityDescriptor(type); + String route = descriptor.getDataService(); + if (route == null || route.isEmpty()) route = "default"; + if (!rootRoute.equals(route)) { + throw new TeaQLRuntimeException( + "[CROSS-PROVIDER MUTATION] Atomic mutation plan contains entity '" + + type + "' on route '" + route + + "' outside its root route '" + rootRoute + + "'. Use independently audited saves or explicit orchestration."); + } + } + } + + private void collectMutationTraceScopes(Entity entity, MutationTraceScope parent, + Map scopes, Set visited) { + if (!(entity instanceof BaseEntity baseEntity) || !visited.add(entity)) return; + MutationTraceScope active = MutationTraceScope.append( + parent, entity.typeName(), entity.getId(), entity.getComment()); + EntityKey key = new EntityKey(entity.typeName(), entity.getId()); + // A reference cannot replace the lineage of its materialized counterpart. + if (baseEntity.get$status() != io.teaql.core.EntityStatus.REFER) scopes.put(key, active); + visitRelatedEntities(entity, child -> collectMutationTraceScopes(child, active, scopes, visited)); + } + + private List mutationTrace(EntityMutationLedger ledger, EntityKey key, + Map scopes, MutationTraceScope graphScope) { + List specific = ledger.getTraceChain(key); + if (specific != null && !specific.isEmpty()) return specific; + MutationTraceScope scope = scopes.getOrDefault(key, graphScope); + return scope.recover(); + } + @SuppressWarnings({"rawtypes", "unchecked"}) private void checkAndFix(UserContext context, Entity entity) { Checker checker = checkers.get(entity.runtimeType()); @@ -544,26 +519,16 @@ private void checkAndFix(UserContext context, Entity entity) { if (checker == null) { return; } - context.putAttribute(Checker.TEAQL_DATA_CHECK_RESULT, new ArrayList()); - context.putAttribute(Checker.TEAQL_DATA_CHECKED_ITEMS, new ArrayList<>()); - context.beginFixEvidence(); - boolean ownsFixTime = context.getAttribute(Checker.TEAQL_FIX_TIME) == null; - if (ownsFixTime) { - context.putAttribute(Checker.TEAQL_FIX_TIME, context.businessTime()); - } - try { - checker.checkAndFix(context, (BaseEntity) entity); - List violations = (List) - context.getAttribute(Checker.TEAQL_DATA_CHECK_RESULT); - if (violations != null && !violations.isEmpty()) { - throw new CheckException(new ArrayList<>(violations)); - } - } finally { - context.finishFixEvidence(); - context.putAttribute(Checker.TEAQL_DATA_CHECK_RESULT, null); - context.putAttribute(Checker.TEAQL_DATA_CHECKED_ITEMS, null); - if (ownsFixTime) { - context.putAttribute(Checker.TEAQL_FIX_TIME, null); + try (var invocation = io.teaql.core.checker.internal.CheckerInvocation.open(context)) { + context.beginFixEvidence(); + try { + checker.checkAndFix(context, (BaseEntity) entity); + List violations = (List) invocation.attribute(Checker.TEAQL_DATA_CHECK_RESULT); + if (violations != null && !violations.isEmpty()) { + throw new CheckException(new ArrayList<>(violations)); + } + } finally { + context.finishFixEvidence(); } } } @@ -595,10 +560,12 @@ private void mergeRelatedEntityMutationLedgers( } visitRelatedEntities(entity, related -> { + if (visited.contains(related)) return; BaseEntity relatedBase = (BaseEntity) related; EntityMutationLedger relatedRoot = relatedBase.getEntityMutationLedger(); - if (relatedRoot != null && relatedRoot != targetRoot) { - targetRoot.mergeFrom(relatedRoot); + EntityKey relatedKey = new EntityKey(related.typeName(), related.getId()); + if (relatedBase.get$status() != EntityStatus.REFER + && targetRoot.mergeEntityFrom(relatedRoot, relatedKey)) { relatedBase.setEntityMutationLedger(targetRoot); } mergeRelatedEntityMutationLedgers(related, targetRoot, visited); @@ -617,6 +584,7 @@ private void recordGraphChanges( if (baseEntity.getId() != null && baseEntity.get$status() != io.teaql.core.EntityStatus.REFER) { EntityKey key = new EntityKey(baseEntity.typeName(), baseEntity.getId()); + if (baseEntity.recoverItem()) targetRoot.markAsRecover(key); for (String property : baseEntity.getUpdatedProperties()) { targetRoot.set(key, property, baseEntity.__internalGet(property)); } @@ -697,13 +665,14 @@ private static BaseEntity preferMaterializedEntity( private MutationPlan buildMutationPlan( Entity rootEntity, EntityMutationLedger ledger, - Map realEntities) { + Map realEntities, MutationIntent intent) { EntityChangeSet changeSet = ledger.currentChangeSet(); Set deleted = ledger.deletedKeys(); Set created = ledger.newKeys(); Set keys = new TreeSet<>(); keys.addAll(changeSet.changes().keySet()); keys.addAll(deleted); + keys.addAll(ledger.recoveredKeys()); List operations = new ArrayList<>(); for (EntityKey key : keys) { @@ -717,7 +686,7 @@ private MutationPlan buildMutationPlan( changes = changeSet.changes().getOrDefault(key, Map.of()); if (created.contains(key) || key.id() == null) { kind = MutationOperationKind.CREATE; - } else if (target != null && target.recoverItem()) { + } else if (ledger.recoveredKeys().contains(key)) { kind = MutationOperationKind.RECOVER; } else { kind = MutationOperationKind.UPDATE; @@ -731,7 +700,7 @@ private MutationPlan buildMutationPlan( UUID.randomUUID().toString(), rootEntity.typeName() + ".saveGraph", rootEntity.typeName(), - rootEntity.getComment(), + intent.auditReason(), operations); } @@ -808,45 +777,64 @@ private List executeLedgerPlan( EntityMutationLedger root, MutationExecutor mutationExecutor, Map realEntities, - MutationGovernanceSnapshot governance) { + MutationGovernanceSnapshot governance, + MutationIntent intent, Map traceScopes, MutationTraceScope graphScope) { List completed = new ArrayList<>(); EntityChangeSet changeSet = root.currentChangeSet(); Set deletedKeys = root.deletedKeys(); Set newKeys = root.newKeys(); + var graphRedactions = new SqlIntentRedactions(); + realEntities.values().forEach(value -> graphRedactions.captureEntity( + value, metadata.resolveEntityDescriptor(value.typeName()))); + changeSet.changes().forEach((key, values) -> { + var descriptor = metadata.resolveEntityDescriptor(key.entity()); + values.forEach((field, value) -> graphRedactions.capture( + List.of(SqlFieldLogPolicy.resolve(descriptor, field)), new Object[]{value})); + }); // 1. Execute Deletes List sortedDeletedKeys = new ArrayList<>(deletedKeys); Collections.sort(sortedDeletedKeys); + Map> deleteBatches = new TreeMap<>(); for (EntityKey key : sortedDeletedKeys) { - EntityDescriptor descriptor = metadata.resolveEntityDescriptor(key.entity()); - if (descriptor == null) { - throw new TeaQLRuntimeException("No entity descriptor for: " + key.entity()); - } - BaseEntity target = realEntities.get(key); - BaseEntity deleteEntity = mutationEntity(descriptor, target); - deleteEntity.__internalSet("id", key.id()); - Long originalVersion = root.getOriginalVersion(key); - if (originalVersion == null && target != null) originalVersion = target.getVersion(); - if (originalVersion != null) deleteEntity.__internalSet("version", originalVersion); - deleteEntity.set$status(io.teaql.core.EntityStatus.PERSISTED); - deleteEntity.markForDeletion(); - if (root.getComment() != null) deleteEntity.setComment(root.getComment()); + deleteBatches.computeIfAbsent(key.entity(), ignored -> new ArrayList<>()).add(key); + } + for (var batch : deleteBatches.entrySet()) { + List requests = new ArrayList<>(); + List targets = new ArrayList<>(); + EntityDescriptor descriptor = metadata.resolveEntityDescriptor(batch.getKey()); + if (descriptor == null) throw new TeaQLRuntimeException("No entity descriptor for: " + batch.getKey()); + for (EntityKey key : batch.getValue()) { + BaseEntity target = realEntities.get(key); + BaseEntity deleteEntity = mutationEntity(descriptor, target); + deleteEntity.__internalSet("id", key.id()); + Long originalVersion = root.getOriginalVersion(key); + if (originalVersion == null && target != null) originalVersion = target.getVersion(); + if (originalVersion != null) deleteEntity.__internalSet("version", originalVersion); + deleteEntity.set$status(io.teaql.core.EntityStatus.PERSISTED); + deleteEntity.markForDeletion(); + if (root.getComment() != null) deleteEntity.setComment(root.getComment()); - EntityPersistenceMutation mutationRequest = new EntityPersistenceMutation( - deleteEntity, EntityPersistenceMutation.Action.DELETE); - MutationResult result = mutateWithTelemetry(context, mutationExecutor, mutationRequest, - key.entity(), "delete"); - completed.add(new PendingMutation( - descriptor, target == null ? deleteEntity : target, result, - MutationAuditKind.DELETED, Collections.emptyMap(), governance)); + EntityPersistenceMutation mutationRequest = new EntityPersistenceMutation( + deleteEntity, EntityPersistenceMutation.Action.DELETE, intent, mutationTrace(root, key, traceScopes, graphScope), target, graphRedactions); + requests.add(mutationRequest); + targets.add(target == null ? deleteEntity : target); + } + List results = mutateBatchWithTelemetry( + context, mutationExecutor, intent, requests, batch.getKey(), "delete"); + for (int index = 0; index < requests.size(); index++) { + completed.add(new PendingMutation(descriptor, targets.get(index), results.get(index), + MutationAuditKind.DELETED, Collections.emptyMap(), governance, intent, requests.get(index).getTraceChain(), graphRedactions)); + } } // 2. Group changes Map> insertBatches = new TreeMap<>(); Map> updateBatches = new TreeMap<>(); - for (Map.Entry> entry : changeSet.changes().entrySet()) { - EntityKey key = entry.getKey(); + Set changedKeys = new TreeSet<>(changeSet.changes().keySet()); + changedKeys.addAll(root.recoveredKeys()); + for (EntityKey key : changedKeys) { if (deletedKeys.contains(key)) continue; boolean isNew = newKeys.contains(key) || key.id() == null; @@ -865,6 +853,10 @@ private List executeLedgerPlan( if (descriptor == null) { throw new TeaQLRuntimeException("No entity descriptor for: " + entityName); } + List requests = new ArrayList<>(); + List targets = new ArrayList<>(); + List> snapshots = new ArrayList<>(); + Collections.sort(keys); for (EntityKey key : keys) { Map changes = changeSet.changes().get(key); if (changes == null) continue; @@ -881,16 +873,21 @@ private List executeLedgerPlan( if (root.getComment() != null) entity.setComment(root.getComment()); EntityPersistenceMutation mutationRequest = new EntityPersistenceMutation( - entity, EntityPersistenceMutation.Action.SAVE); - MutationResult result = mutateWithTelemetry(context, mutationExecutor, mutationRequest, - entityName, "save"); + entity, EntityPersistenceMutation.Action.SAVE, intent, mutationTrace(root, key, traceScopes, graphScope), target, graphRedactions); + requests.add(mutationRequest); + targets.add(target == null ? entity : target); + snapshots.add(snapshotChanges(changes)); + } + List results = mutateBatchWithTelemetry( + context, mutationExecutor, intent, requests, entityName, "save"); + for (int index = 0; index < requests.size(); index++) { completed.add(new PendingMutation( - descriptor, target == null ? entity : target, result, - MutationAuditKind.CREATED, snapshotChanges(changes), governance)); + descriptor, targets.get(index), results.get(index), + MutationAuditKind.CREATED, snapshots.get(index), governance, intent, requests.get(index).getTraceChain(), graphRedactions)); } } - // 4. Execute Updates + // 4. Execute Updates and Recoveries for (Map.Entry> entry : updateBatches.entrySet()) { String entityName = entry.getKey(); List keys = entry.getValue(); @@ -898,33 +895,42 @@ private List executeLedgerPlan( if (descriptor == null) { throw new TeaQLRuntimeException("No entity descriptor for: " + entityName); } - for (EntityKey key : keys) { - Map changes = changeSet.changes().get(key); - if (changes == null) continue; - BaseEntity target = realEntities.get(key); - BaseEntity entity = mutationEntity(descriptor, target); - entity.__internalSet("id", key.id()); - Long version = root.getOriginalVersion(key); - if (version == null && target != null) version = target.getVersion(); - if (version != null) { - entity.__internalSet("version", version); + // Separate recover from update: they use different version transitions. + Collections.sort(keys); + for (boolean recovering : List.of(false, true)) { + List requests = new ArrayList<>(); + List targets = new ArrayList<>(); + List> snapshots = new ArrayList<>(); + for (EntityKey key : keys) { + if (root.recoveredKeys().contains(key) != recovering) continue; + BaseEntity target = realEntities.get(key); + Map changes = changeSet.changes().getOrDefault(key, Map.of()); + BaseEntity entity = mutationEntity(descriptor, target); + entity.__internalSet("id", key.id()); + Long version = root.getOriginalVersion(key); + if (version == null && target != null) version = target.getVersion(); + if (version != null) { + entity.__internalSet("version", version); + } + for (Map.Entry change : changes.entrySet()) { + entity.updateProperty(change.getKey(), change.getValue()); + } + entity.set$status(recovering ? io.teaql.core.EntityStatus.UPDATED_RECOVER : io.teaql.core.EntityStatus.UPDATED); + if (root.getComment() != null) entity.setComment(root.getComment()); + + EntityPersistenceMutation mutationRequest = new EntityPersistenceMutation( + entity, EntityPersistenceMutation.Action.SAVE, intent, mutationTrace(root, key, traceScopes, graphScope), target, graphRedactions); + requests.add(mutationRequest); + targets.add(target == null ? entity : target); + snapshots.add(snapshotChanges(changes)); } - for (Map.Entry change : changes.entrySet()) { - entity.updateProperty(change.getKey(), change.getValue()); + MutationAuditKind auditKind = recovering ? MutationAuditKind.RECOVERED : MutationAuditKind.UPDATED; + List results = mutateBatchWithTelemetry( + context, mutationExecutor, intent, requests, entityName, auditKind.name().toLowerCase(Locale.ROOT)); + for (int index = 0; index < requests.size(); index++) { + completed.add(new PendingMutation(descriptor, targets.get(index), results.get(index), + auditKind, snapshots.get(index), governance, intent, requests.get(index).getTraceChain(), graphRedactions)); } - entity.set$status(io.teaql.core.EntityStatus.UPDATED); - if (root.getComment() != null) entity.setComment(root.getComment()); - - EntityPersistenceMutation mutationRequest = new EntityPersistenceMutation( - entity, EntityPersistenceMutation.Action.SAVE); - MutationAuditKind auditKind = target != null && target.recoverItem() - ? MutationAuditKind.RECOVERED - : MutationAuditKind.UPDATED; - MutationResult result = mutateWithTelemetry(context, mutationExecutor, mutationRequest, - entityName, auditKind.name().toLowerCase(Locale.ROOT)); - completed.add(new PendingMutation( - descriptor, target == null ? entity : target, result, - auditKind, snapshotChanges(changes), governance)); } } return completed; @@ -935,7 +941,7 @@ private void completeLedgerPlan(UserContext context, List compl applyPersistedEntity(mutation.descriptor(), mutation.target(), mutation.result()); emitAuditEvent( context, mutation.target(), mutation.auditKind(), mutation.changedValues(), - mutation.governance()); + mutation.governance(), mutation.intent(), mutation.traceChain(), mutation.redactions()); mutation.target().clearUpdatedProperties(); } } @@ -959,7 +965,8 @@ private record PendingMutation( MutationResult result, MutationAuditKind auditKind, Map changedValues, - MutationGovernanceSnapshot governance) {} + MutationGovernanceSnapshot governance, MutationIntent intent, List traceChain, + SqlIntentRedactions redactions) {} private record PersistenceState( Long version, io.teaql.core.EntityStatus status, boolean versionLoaded) {} @@ -1021,12 +1028,56 @@ private MutationResult mutateWithTelemetry( } } + private List mutateBatchWithTelemetry( + UserContext context, MutationExecutor executor, MutationIntent intent, + List requests, String entityType, String operation) { + if (requests.size() < 2 || !(executor instanceof io.teaql.core.BatchMutationExecutor batchExecutor)) { + List results = new ArrayList<>(); + for (EntityPersistenceMutation request : requests) { + results.add(mutateWithTelemetry(context, executor, request, entityType, operation)); + } + return results; + } + var request = new io.teaql.core.MutationBatchRequest(intent, requests); + String provider = executor.getClass().getSimpleName(); + RuntimeTelemetry.Scope scope = RuntimeTelemetry.startSafely(telemetry, + new RuntimeTelemetry.Operation("provider", provider + ".mutation.batch", Map.of( + "teaql.provider.kind", provider, + "teaql.provider.operation", operation, + "teaql.entity.type", entityType, + "teaql.batch.size", requests.size()))); + try { + List results = batchExecutor.mutateBatch(context, request); + if (results == null || results.size() != requests.size()) { + throw new TeaQLRuntimeException("Batch mutation must return one ordered result per item"); + } + for (int index = 0; index < results.size(); index++) { + MutationResult result = results.get(index); + if (result == null || result.persistedEntity() == null) { + throw new TeaQLRuntimeException("Batch mutation did not return an authoritative persisted entity"); + } + Entity expected = requests.get(index).getEntity(); + Entity persisted = result.persistedEntity(); + if (!expected.typeName().equals(persisted.typeName()) + || !Objects.equals(expected.getId(), persisted.getId())) { + throw new TeaQLRuntimeException("Batch mutation result identity does not match its ordered command"); + } + } + scope.success(); + return results; + } catch (RuntimeException | Error error) { + scope.failure(error); + throw error; + } + } + private void emitAuditEvent( UserContext context, Entity entity, MutationAuditKind kind, Map changedValues, - MutationGovernanceSnapshot governance) { + MutationGovernanceSnapshot governance, MutationIntent intent, List traceChain, + SqlIntentRedactions redactions) { List changes = new ArrayList<>(); if (changedValues != null) { for (Map.Entry entry : changedValues.entrySet()) { @@ -1040,10 +1091,10 @@ private void emitAuditEvent( entity.typeName(), entity.getId(), changes, - context.getTraceChain(), + traceChain, context.getAttribute(GeneratedSchemaBootstrap.AUDIT_ACTOR_ATTRIBUTE, String.class), context.getAttribute(GeneratedSchemaBootstrap.AUDIT_CATEGORY_ATTRIBUTE, String.class), - entity.getComment(), + intent.auditReason(), entity.getVersion(), java.time.Instant.now(), governance); @@ -1058,12 +1109,12 @@ private void emitAuditEvent( // The standard sink is server-owned by TeaQLRuntime and cannot be replaced by // dynamic input or an application capability registered on UserContext. if (logSink != null) { - logSink.writeAuditEvent(context, LogPrivacy.audit(rawEvent, LogPrivacy.plaintextEnabled())); + logSink.writeAuditEvent(context, LogPrivacy.audit(rawEvent, LogPrivacy.plaintextEnabled(), redactions)); } AppAuditEventSink appSink = context.capability(AppAuditEventSink.class); if (appSink != null) { - appSink.onAuditEvent(context, buildSafeAuditEvent(rawEvent)); + appSink.onAuditEvent(context, buildSafeAuditEvent(rawEvent, redactions)); } telemetryScope.success(); } catch (RuntimeException | Error error) { @@ -1072,7 +1123,7 @@ private void emitAuditEvent( } } - private SafeAuditEvent buildSafeAuditEvent(RawAuditEvent event) { + private SafeAuditEvent buildSafeAuditEvent(RawAuditEvent event, SqlIntentRedactions redactions) { EntityDescriptor descriptor = metadata.resolveEntityDescriptor(event.entityType()); Set maskFields = descriptor == null ? Collections.emptySet() @@ -1081,6 +1132,7 @@ private SafeAuditEvent buildSafeAuditEvent(RawAuditEvent event) { List fields = new ArrayList<>(); List sensitiveValues = new ArrayList<>(); boolean allowPlaintext = LogPrivacy.plaintextEnabled(); + redactions.appendTo(sensitiveValues, allowPlaintext); for (AuditFieldChange change : event.changes()) { if ((!allowPlaintext && maskFields.contains(change.field())) || LogPrivacy.credential(change.field()) || LogPrivacy.hasCredentials(change.oldValue()) || LogPrivacy.hasCredentials(change.newValue())) { diff --git a/teaql-runtime/src/test/java/io/teaql/runtime/CheckerInvocationTest.java b/teaql-runtime/src/test/java/io/teaql/runtime/CheckerInvocationTest.java new file mode 100644 index 00000000..55e07924 --- /dev/null +++ b/teaql-runtime/src/test/java/io/teaql/runtime/CheckerInvocationTest.java @@ -0,0 +1,151 @@ +package io.teaql.runtime; + +import io.teaql.core.UserContext; +import io.teaql.core.checker.Checker; +import io.teaql.core.checker.internal.CheckerInvocation; +import io.teaql.core.checker.FixEvidence; +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Test; +import static org.junit.Assert.*; + +/** Lifecycle tests, complementary to the actual generated Checker/SQLite acceptance. */ +public class CheckerInvocationTest { + private static FixEvidence evidence(String path) { + return new FixEvidence("CustomerOrder", path, FixEvidence.Source.CLOCK, "graphClock"); + } + + @Test public void reservedAttributesAndClockRestoreAfterNestedInvocations() { + var clocks = new AtomicInteger(); + var context = new DefaultUserContext(null) { + @Override public LocalDateTime businessTime() { + return LocalDateTime.of(2026, 10, 2, 0, 0).plusDays(clocks.getAndIncrement()); + } + }; + context.putAttribute("application.config", "preserved"); + try (var outer = CheckerInvocation.open(context)) { + assertSame(outer, CheckerInvocation.current(context)); + Object outerResults = context.getAttribute(Checker.TEAQL_DATA_CHECK_RESULT); + assertEquals(LocalDateTime.of(2026, 10, 2, 0, 0), context.evaluate("now")); + context.recordFixEvidence(evidence("outer_clock")); + try (var inner = CheckerInvocation.open(context)) { + assertSame(inner, CheckerInvocation.current(context)); + assertNotSame(outerResults, context.getAttribute(Checker.TEAQL_DATA_CHECK_RESULT)); + assertEquals(LocalDateTime.of(2026, 10, 3, 0, 0), context.evaluate("now")); + context.recordFixEvidence(evidence("inner_clock")); + context.finishFixEvidence(); + } + assertEquals(List.of(evidence("inner_clock")), context.lastFixEvidence()); + assertSame(outerResults, context.getAttribute(Checker.TEAQL_DATA_CHECK_RESULT)); + assertEquals(LocalDateTime.of(2026, 10, 2, 0, 0), context.evaluate("now")); + context.finishFixEvidence(); + } + assertEquals(2, clocks.get()); + assertNull(CheckerInvocation.current(context)); + assertNull(context.getAttribute(Checker.TEAQL_DATA_CHECK_RESULT)); + assertNull(context.getAttribute(Checker.TEAQL_DATA_CHECKED_ITEMS)); + assertNull(context.getAttribute(Checker.TEAQL_FIX_TIME)); + assertNull(context.getAttribute(UserContext.TEAQL_FIX_EVIDENCE_CURRENT)); + assertNull("completed receipt is not a shared Context attribute", context.getAttribute(UserContext.TEAQL_FIX_EVIDENCE_LAST)); + assertEquals(List.of(evidence("outer_clock")), context.lastFixEvidence()); + assertEquals("preserved", context.getAttribute("application.config")); + } + + @Test public void closeOnWrongThreadFailsWithoutDestroyingTheOwningInvocation() throws Exception { + var context = new DefaultUserContext(null) { + @Override public LocalDateTime businessTime() { return LocalDateTime.of(2026, 10, 2, 0, 0); } + }; + var workers = Executors.newSingleThreadExecutor(); + try (var invocation = CheckerInvocation.open(context)) { + var error = workers.submit(() -> { + assertNull(CheckerInvocation.current(context)); + assertTrue(context.lastFixEvidence().isEmpty()); + return assertThrows(IllegalStateException.class, invocation::close); + }).get(10, TimeUnit.SECONDS); + assertTrue(error.getMessage().contains("owning thread")); + assertSame(invocation, CheckerInvocation.current(context)); + } finally { + workers.shutdownNow(); + assertTrue(workers.awaitTermination(10, TimeUnit.SECONDS)); + } + assertNull(CheckerInvocation.current(context)); + } + + @Test public void failedClockCaptureCannotReplaceTheOuterInvocation() { + var calls = new AtomicInteger(); + var context = new DefaultUserContext(null) { + @Override public LocalDateTime businessTime() { + if (calls.incrementAndGet() == 2) throw new IllegalStateException("clock unavailable"); + return LocalDateTime.of(2026, 10, 2, 0, 0); + } + }; + try (var outer = CheckerInvocation.open(context)) { + assertThrows(IllegalStateException.class, () -> CheckerInvocation.open(context)); + assertSame(outer, CheckerInvocation.current(context)); + assertEquals(LocalDateTime.of(2026, 10, 2, 0, 0), context.evaluate("now")); + } + assertNull(CheckerInvocation.current(context)); + } + + @Test public void explicitEvidenceSessionSupersedesThePreviousCompletedReceipt() { + var context = new DefaultUserContext(null) { + @Override public LocalDateTime businessTime() { return LocalDateTime.of(2026, 10, 2, 0, 0); } + }; + try (var ignored = CheckerInvocation.open(context)) { + context.recordFixEvidence(evidence("checked_clock")); + context.finishFixEvidence(); + } + assertEquals(List.of(evidence("checked_clock")), context.lastFixEvidence()); + context.beginFixEvidence(); + context.recordFixEvidence(evidence("explicit_clock")); + context.finishFixEvidence(); + assertEquals(List.of(evidence("explicit_clock")), context.lastFixEvidence()); + } + + @Test public void contextsAreMatchedByIdentityAndClosingMustBeLifo() { + var first = new DefaultUserContext(null) { + @Override public LocalDateTime businessTime() { return LocalDateTime.of(2026, 10, 2, 0, 0); } + }; + var second = new DefaultUserContext(null) { + @Override public LocalDateTime businessTime() { return LocalDateTime.of(2026, 10, 3, 0, 0); } + }; + try (var outer = CheckerInvocation.open(first)) { + try (var inner = CheckerInvocation.open(second)) { + assertSame(outer, CheckerInvocation.current(first)); + assertSame(inner, CheckerInvocation.current(second)); + assertNotSame(CheckerInvocation.attribute(first, Checker.TEAQL_DATA_CHECK_RESULT), + CheckerInvocation.attribute(second, Checker.TEAQL_DATA_CHECK_RESULT)); + assertThrows(IllegalStateException.class, outer::close); + assertSame(inner, CheckerInvocation.current(second)); + } + assertNull(CheckerInvocation.current(second)); + assertSame(outer, CheckerInvocation.current(first)); + } + assertNull(CheckerInvocation.current(first)); + } + + @Test public void evidenceFailureCannotLeaveTheInnerInvocationBound() { + var context = new DefaultUserContext(null) { + @Override public LocalDateTime businessTime() { return LocalDateTime.of(2026, 10, 2, 0, 0); } + }; + try (var outer = CheckerInvocation.open(context)) { + var inner = CheckerInvocation.open(context); + // A broken custom diagnostic hook must not poison subsequent saves. + inner.attribute(UserContext.TEAQL_FIX_EVIDENCE_LAST, "invalid evidence receipt"); + try { + assertThrows(ClassCastException.class, inner::close); + assertSame("failure still restores the outer invocation", outer, CheckerInvocation.current(context)); + } finally { + // Ensure the intentional red run does not contaminate other tests. + if (CheckerInvocation.current(context) == inner) { + inner.attribute(UserContext.TEAQL_FIX_EVIDENCE_LAST, List.of()); + inner.close(); + } + } + } + assertNull(CheckerInvocation.current(context)); + } +} diff --git a/teaql-runtime/src/test/java/io/teaql/runtime/GraphTraceChainTest.java b/teaql-runtime/src/test/java/io/teaql/runtime/GraphTraceChainTest.java new file mode 100644 index 00000000..41eb3a10 --- /dev/null +++ b/teaql-runtime/src/test/java/io/teaql/runtime/GraphTraceChainTest.java @@ -0,0 +1,261 @@ +package io.teaql.runtime; + +import io.teaql.core.*; +import io.teaql.core.meta.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.Test; +import static org.junit.Assert.*; + +/** #202: real runtime planning and safe audit, not a SQL integration substitute. */ +public class GraphTraceChainTest { + static final class GraphEntity extends BaseEntity { + private final String type; + private final Map values = new HashMap<>(); + GraphEntity(String type) { this.type = type; } + @Override public String typeName() { return type; } + @Override public Object __internalGet(String field) { + return field.equals("name") || field.equals("children") ? values.get(field) : super.__internalGet(field); + } + @Override public void __internalSet(String field, Object value) { + if (field.equals("name") || field.equals("children")) values.put(field, value); + else super.__internalSet(field, value); + } + } + + static class Provider implements MutationExecutor { + final List requests = new CopyOnWriteArrayList<>(); + @Override public MutationResult mutate(UserContext context, PersistenceMutation request) { + EntityPersistenceMutation item = (EntityPersistenceMutation) request; + requests.add(item); + return new DefaultMutationResult(item.getEntity()); + } + @Override public String name() { return "fixture"; } + @Override public DataServiceCapabilities capabilities() { return new DataServiceCapabilities(); } + } + + static class BatchProvider extends Provider implements BatchMutationExecutor { + int batchCalls; + boolean reverseResults; + @Override public List mutateBatch(UserContext context, MutationBatchRequest request) { + batchCalls++; + assertEquals("runtime owns the batch root intent", "save grouped graph", request.comment()); + var results = new ArrayList(); + for (var item : request.items()) results.add(super.mutate(context, item)); + if (reverseResults) Collections.reverse(results); + return results; + } + } + + @Test public void sameTypeGraphUsesOptionalBatchCapabilityAndKeepsItemCommands() { + var provider = new BatchProvider(); + var events = new ArrayList(); + var runtime = TeaQLRuntime.builder().metadata(metadata()).dataService("fixture", provider).build(); + var context = new DefaultUserContext(runtime); + context.putAttribute(AppAuditEventSink.class.getName(), (AppAuditEventSink) (caller, event) -> events.add(event)); + var root = existing("CustomerOrder", 100L); + var first = new GraphEntity("OrderItem"); + first.__internalInitializeNewEntityId(201L); + first.updateProperty("name", "alpha item"); + first.setComment("alpha branch"); + var second = new GraphEntity("OrderItem"); + second.__internalInitializeNewEntityId(202L); + second.updateProperty("name", "beta item"); + second.setComment("beta branch"); + root.updateProperty("children", List.of(second, first)); + root.auditAs("save grouped graph").save(context); + assertEquals(1, provider.batchCalls); + assertReasons(events, "OrderItem", 201L, List.of("save grouped graph", "alpha branch")); + assertReasons(events, "OrderItem", 202L, List.of("save grouped graph", "beta branch")); + assertEquals("keys are planned in deterministic ID order", Long.valueOf(201), provider.requests.get(0).getEntity().getId()); + } + + @Test public void reversedProviderBatchResultsCannotBeAppliedToDifferentEntities() { + var provider = new BatchProvider(); + provider.reverseResults = true; + var events = new ArrayList(); + var runtime = TeaQLRuntime.builder().metadata(metadata()).dataService("fixture", provider).build(); + var context = new DefaultUserContext(runtime); + context.putAttribute(AppAuditEventSink.class.getName(), (AppAuditEventSink) (caller, event) -> events.add(event)); + var root = existing("CustomerOrder", 100L); + var first = new GraphEntity("OrderItem"); + first.__internalInitializeNewEntityId(201L); + first.updateProperty("name", "alpha item"); + var second = new GraphEntity("OrderItem"); + second.__internalInitializeNewEntityId(202L); + second.updateProperty("name", "beta item"); + root.updateProperty("children", List.of(first, second)); + var error = assertThrows(TeaQLRuntimeException.class, () -> root.auditAs("save grouped graph").save(context)); + assertEquals("Batch mutation result identity does not match its ordered command", error.getMessage()); + assertTrue(events.isEmpty()); + assertEquals("alpha item", first.getProperty("name")); + assertEquals("beta item", second.getProperty("name")); + } + + static SimpleEntityMetaFactory metadata() { + var metadata = new SimpleEntityMetaFactory(); + for (String type : List.of("CustomerOrder", "OrderItem", "Payment", "PaymentAttempt", "Shipment")) { + var descriptor = new EntityDescriptor(); + descriptor.setType(type); + descriptor.setDataService("fixture"); + descriptor.setTargetType(GraphEntity.class); + descriptor.setEntitySupplier(() -> new GraphEntity(type)); + var children = new Relation(); + children.setName("children"); + children.setOwner(descriptor); + descriptor.setProperties(List.of(children)); + metadata.register(descriptor); + } + return metadata; + } + + static GraphEntity persisted(String type, long id) { + var entity = new GraphEntity(type); + entity.__internalSet("id", id); + entity.__internalSet("version", 1L); + entity.set$status(EntityStatus.PERSISTED); + return entity; + } + + static GraphEntity existing(String type, long id) { + var entity = persisted(type, id); + entity.updateProperty("name", type + " changed"); + return entity; + } + + @Test public void normativeGraphKeepsOwnAndInheritedReasonsAtCommittedAudit() { + var provider = new Provider(); + var events = new ArrayList(); + var runtime = TeaQLRuntime.builder().metadata(metadata()).dataService("fixture", provider).build(); + var context = new DefaultUserContext(runtime); + context.putAttribute(AppAuditEventSink.class.getName(), + (AppAuditEventSink) (caller, event) -> events.add(event)); + var root = existing("CustomerOrder", 100L); + root.setComment("submit order"); + var item = existing("OrderItem", 201L); + var removed = persisted("OrderItem", 202L); + removed.setComment("remove unavailable item"); + removed.markForDeletion(); + var payment = existing("Payment", 100L); // Same numeric ID, different type. + payment.setComment("authorize payment"); + var attempt = existing("PaymentAttempt", 401L); + payment.updateProperty("children", List.of(attempt)); + var shipment = existing("Shipment", 501L); + shipment.setComment("dispatch shipment"); + root.updateProperty("children", List.of(item, payment, shipment, removed)); + + root.auditAs("submit order").save(context); + + assertEquals(6, provider.requests.size()); + assertEquals(6, events.size()); + assertReasons(events, "CustomerOrder", List.of("submit order")); + assertReasons(events, "Payment", List.of("submit order", "authorize payment")); + assertReasons(events, "PaymentAttempt", List.of("submit order", "authorize payment")); + assertReasons(events, "Shipment", List.of("submit order", "dispatch shipment")); + assertReasons(events, "OrderItem", 201L, List.of("submit order")); + assertReasons(events, "OrderItem", 202L, List.of("submit order", "remove unavailable item")); + for (var request : provider.requests) { + var event = events.stream().filter(value -> value.entityType().equals(request.getEntity().typeName()) + && Objects.equals(value.entityId(), request.getEntity().getId())).findFirst().orElseThrow(); + assertEquals("command and committed audit must retain the same typed identity", request.getTraceChain(), event.traceChain()); + assertEquals(Long.valueOf(100), request.getTraceChain().get(0).getEntityId()); + assertThrows(UnsupportedOperationException.class, () -> request.getTraceChain().clear()); + } + assertTrue("save must not mutate ambient Context trace", context.getTraceChain().isEmpty()); + } + + @Test public void completeLedgerChainReplacesFallbackOnlyForItsTypedKey() { + var provider = new Provider(); + var runtime = TeaQLRuntime.builder().metadata(metadata()).dataService("fixture", provider).build(); + var context = new DefaultUserContext(runtime); + var root = existing("CustomerOrder", 100L); + root.setComment("root fallback"); + var payment = existing("Payment", 100L); + payment.setComment("local fallback must not be appended"); + var complete = List.of(new TraceNode(TraceKind.AUDIT_REASON, "CustomerOrder", 100L, "delegated root"), + new TraceNode(TraceKind.AUDIT_REASON, "Payment", 100L, "delegated payment")); + payment.setTraceChain(complete); + root.updateProperty("children", List.of(payment)); + root.auditAs("root fallback").save(context); + var paymentRequest = provider.requests.stream().filter(item -> item.getEntity().typeName().equals("Payment")).findFirst().orElseThrow(); + assertEquals(complete, paymentRequest.getTraceChain()); + var rootRequest = provider.requests.stream().filter(item -> item.getEntity().typeName().equals("CustomerOrder")).findFirst().orElseThrow(); + assertEquals(List.of(new TraceNode(TraceKind.AUDIT_REASON, "CustomerOrder", 100L, "root fallback")), rootRequest.getTraceChain()); + assertNull("completed ledger releases its override", + root.getEntityMutationLedger().getTraceChain(new EntityKey("Payment", 100L))); + } + + @Test public void newDescendantReasonCarriesAllocatedIdAndBlankReasonInherits() { + var provider = new Provider(); + var ids = new AtomicLong(700); + var runtime = TeaQLRuntime.builder().metadata(metadata()).dataService("fixture", provider) + .idGenerationService((caller, entity) -> ids.getAndIncrement()).build(); + var context = new DefaultUserContext(runtime); + var root = existing("CustomerOrder", 100L); + var payment = new GraphEntity("Payment"); + payment.updateProperty("name", "new payment"); + payment.setComment("new local payment reason"); + var attempt = new GraphEntity("PaymentAttempt"); + attempt.updateProperty("name", "new attempt"); + attempt.setComment("\u2003 \t"); + payment.updateProperty("children", List.of(attempt)); + root.updateProperty("children", List.of(payment)); + root.auditAs("allocated root reason").save(context); + var paymentRequest = provider.requests.stream().filter(item -> item.getEntity().typeName().equals("Payment")).findFirst().orElseThrow(); + assertEquals(Long.valueOf(700), paymentRequest.getTraceChain().get(1).getEntityId()); + var attemptRequest = provider.requests.stream().filter(item -> item.getEntity().typeName().equals("PaymentAttempt")).findFirst().orElseThrow(); + assertEquals(paymentRequest.getTraceChain(), attemptRequest.getTraceChain()); + } + + @Test public void concurrentGraphsDoNotShareContextReasons() throws Exception { + var entered = new CountDownLatch(2); + var proceed = new CountDownLatch(1); + var provider = new Provider() { + @Override public MutationResult mutate(UserContext context, PersistenceMutation request) { + entered.countDown(); + try { + if (!proceed.await(10, TimeUnit.SECONDS)) throw new AssertionError("provider overlap timeout"); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); throw new AssertionError(interrupted); + } + return super.mutate(context, request); + } + }; + var events = new CopyOnWriteArrayList(); + var runtime = TeaQLRuntime.builder().metadata(metadata()).dataService("fixture", provider).build(); + var context = new DefaultUserContext(runtime); + context.putAttribute(AppAuditEventSink.class.getName(), (AppAuditEventSink) (caller, event) -> events.add(event)); + var executor = Executors.newFixedThreadPool(2); + try { + var a = executor.submit(() -> existing("CustomerOrder", 100L).auditAs("graph A").save(context)); + var b = executor.submit(() -> existing("CustomerOrder", 200L).auditAs("graph B").save(context)); + assertTrue("test must prove live overlap", entered.await(10, TimeUnit.SECONDS)); + assertTrue("no request may push a Context trace while paused inside provider", context.getTraceChain().isEmpty()); + proceed.countDown(); + a.get(10, TimeUnit.SECONDS); b.get(10, TimeUnit.SECONDS); + } finally { + proceed.countDown(); executor.shutdownNow(); + } + assertEquals(2, events.size()); + assertReasons(events, "CustomerOrder", 100L, List.of("graph A")); + assertReasons(events, "CustomerOrder", 200L, List.of("graph B")); + } + + static void assertReasons(List events, String type, List reasons) { + var event = events.stream().filter(value -> value.entityType().equals(type)).findFirst().orElseThrow(); + assertReasons(event, reasons); + } + + static void assertReasons(List events, String type, long id, List reasons) { + var event = events.stream().filter(value -> value.entityType().equals(type) + && Objects.equals(value.entityId(), id)).findFirst().orElseThrow(); + assertReasons(event, reasons); + } + + static void assertReasons(SafeAuditEvent event, List reasons) { + assertEquals("committed lineage of " + event.entityType() + "#" + event.entityId(), reasons, + event.traceChain().stream().filter(node -> node.getKind() == TraceKind.AUDIT_REASON) + .map(TraceNode::getComment).toList()); + } +} diff --git a/teaql-runtime/src/test/java/io/teaql/runtime/LogPrivacyTest.java b/teaql-runtime/src/test/java/io/teaql/runtime/LogPrivacyTest.java index d443534c..0e4c615f 100644 --- a/teaql-runtime/src/test/java/io/teaql/runtime/LogPrivacyTest.java +++ b/teaql-runtime/src/test/java/io/teaql/runtime/LogPrivacyTest.java @@ -9,6 +9,36 @@ import static org.junit.Assert.*; public class LogPrivacyTest { + @Test public void graphSnapshotProtectsSiblingAuditAndKeepsDebugPolicy() { + var privacy = new io.teaql.core.SqlIntentRedactions(); + privacy.capture(List.of(io.teaql.core.SqlParameterLogPolicy.MASKED, + io.teaql.core.SqlParameterLogPolicy.CREDENTIAL), new Object[]{"SIBLING-OLD", "SECRET-TOKEN"}); + var raw = new RawAuditEvent(MutationAuditKind.UPDATED, "Customer", 17L, + List.of(new AuditFieldChange("description", "before", "after")), + List.of(new TraceNode("page 1 replace SIBLING-OLD SECRET-TOKEN")), + "operator", "mutation", "page 1 replace SIBLING-OLD SECRET-TOKEN", 2L, null); + var safe = LogPrivacy.audit(raw, false, privacy); + assertFalse(safe.toString().contains("SIBLING-OLD")); + assertFalse(safe.toString().contains("SECRET-TOKEN")); + assertTrue(safe.toString().contains("page 1")); + var debug = LogPrivacy.audit(raw, true, privacy); + assertTrue(debug.toString().contains("SIBLING-OLD")); + assertFalse(debug.toString().contains("SECRET-TOKEN")); + assertTrue(raw.toString().contains("SECRET-TOKEN")); + } + + @Test public void mutationPrivacySnapshotCannotBeChangedBySourceOrConsumer() { + var privacy = new io.teaql.core.SqlIntentRedactions(); + privacy.capture(List.of(io.teaql.core.SqlParameterLogPolicy.MASKED), new Object[]{"PRIVATE-OLD"}); + var entity = new io.teaql.core.BaseEntity(); + var request = new EntityPersistenceMutation(entity, EntityPersistenceMutation.Action.SAVE, + io.teaql.core.MutationIntent.of("save graph"), List.of(), entity, privacy); + privacy.capture(List.of(io.teaql.core.SqlParameterLogPolicy.MASKED), new Object[]{"LATER-SOURCE"}); + request.diagnosticRedactions().capture(List.of(io.teaql.core.SqlParameterLogPolicy.MASKED), new Object[]{"LATER-CONSUMER"}); + var values = new java.util.ArrayList(); request.diagnosticRedactions().appendTo(values, false); + assertEquals(List.of("PRIVATE-OLD"), values); + } + @Test public void realProcessEnvironmentControlsFileOutput() throws Exception { for (String setting : new String[] { "", "true", LogPrivacy.ACKNOWLEDGEMENT + " ", LogPrivacy.ACKNOWLEDGEMENT }) { var output = Files.createTempFile("teaql-log-process-", ".log"); diff --git a/teaql-runtime/src/test/java/io/teaql/runtime/MutationRouteIsolationTest.java b/teaql-runtime/src/test/java/io/teaql/runtime/MutationRouteIsolationTest.java new file mode 100644 index 00000000..61cc60e1 --- /dev/null +++ b/teaql-runtime/src/test/java/io/teaql/runtime/MutationRouteIsolationTest.java @@ -0,0 +1,130 @@ +package io.teaql.runtime; + +import io.teaql.core.*; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import org.junit.Test; +import static org.junit.Assert.*; +import static io.teaql.runtime.GraphTraceChainTest.*; + +/** #202: the atomic route boundary belongs to one mutation plan, not Context. */ +public class MutationRouteIsolationTest { + private static DefaultUserContext context(Provider orders, Provider payments, List events) { + var metadata = metadata(); + metadata.resolveEntityDescriptor("Payment").setDataService("payments"); + var runtime = TeaQLRuntime.builder().metadata(metadata) + .dataService("fixture", orders).dataService("payments", payments).build(); + var context = new DefaultUserContext(runtime); + context.putAttribute(AppAuditEventSink.class.getName(), + (AppAuditEventSink) (caller, event) -> events.add(event)); + return context; + } + + @Test public void independentSequentialSavesCanUseDifferentRoutesOnOneContext() { + var orders = new Provider(); var payments = new Provider(); + var events = new CopyOnWriteArrayList(); + var context = context(orders, payments, events); + existing("CustomerOrder", 100L).auditAs("independent order").save(context); + existing("Payment", 100L).auditAs("independent payment").save(context); + assertEquals(1, orders.requests.size()); assertEquals(1, payments.requests.size()); + assertReasons(events, "CustomerOrder", List.of("independent order")); + assertReasons(events, "Payment", List.of("independent payment")); + assertNull(context.getAttribute("__teaql_save_graph_route__")); + } + + @Test public void independentOverlappingSavesDoNotBorrowAnotherGraphsRoute() throws Exception { + var entered = new CountDownLatch(2); var proceed = new CountDownLatch(1); + var orders = pausingProvider(entered, proceed); var payments = pausingProvider(entered, proceed); + var events = new CopyOnWriteArrayList(); + var context = context(orders, payments, events); + var workers = Executors.newFixedThreadPool(2); + try { + var order = workers.submit(() -> existing("CustomerOrder", 100L).auditAs("overlap order").save(context)); + var payment = workers.submit(() -> existing("Payment", 100L).auditAs("overlap payment").save(context)); + assertTrue("both independent provider routes must be live", entered.await(3, TimeUnit.SECONDS)); + assertNull(context.getAttribute("__teaql_save_graph_route__")); + proceed.countDown(); order.get(10, TimeUnit.SECONDS); payment.get(10, TimeUnit.SECONDS); + } finally { + proceed.countDown(); workers.shutdownNow(); + assertTrue(workers.awaitTermination(10, TimeUnit.SECONDS)); + } + assertEquals(1, orders.requests.size()); assertEquals(1, payments.requests.size()); + assertReasons(events, "CustomerOrder", List.of("overlap order")); + assertReasons(events, "Payment", List.of("overlap payment")); + } + + private static Provider pausingProvider(CountDownLatch entered, CountDownLatch proceed) { + return new Provider() { + @Override public MutationResult mutate(UserContext context, PersistenceMutation request) { + entered.countDown(); + try { + if (!proceed.await(10, TimeUnit.SECONDS)) throw new AssertionError("route overlap timeout"); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); throw new AssertionError(error); + } + return super.mutate(context, request); + } + }; + } + + @Test public void oneMixedProviderGraphIsRejectedBeforeAnyProviderMutationOrAudit() { + var orders = new Provider(); var payments = new Provider(); + var events = new CopyOnWriteArrayList(); + var context = context(orders, payments, events); + var order = existing("CustomerOrder", 100L); var payment = existing("Payment", 100L); + order.updateProperty("children", List.of(payment)); + var error = assertThrows(TeaQLRuntimeException.class, + () -> order.auditAs("must remain atomic").save(context)); + assertTrue(error.getMessage().contains("CROSS-PROVIDER MUTATION")); + assertTrue(error.getMessage().contains("Payment")); + assertTrue(orders.requests.isEmpty()); assertTrue(payments.requests.isEmpty()); + assertTrue(events.isEmpty()); + assertEquals("Payment changed", order.getEntityMutationLedger().get(new EntityKey("Payment", 100L), "name")); + assertNull(context.getAttribute("__teaql_save_graph_route__")); + } + + @Test public void detachedDeletionCannotBypassThePlanRouteCheck() { + var orders = new Provider(); var payments = new Provider(); + var events = new CopyOnWriteArrayList(); + var context = context(orders, payments, events); + var order = existing("CustomerOrder", 100L); + var key = new EntityKey("Payment", 301L); + order.getEntityMutationLedger().markAsDelete(key); + order.getEntityMutationLedger().setOriginalVersion(key, 4L); + assertThrows(TeaQLRuntimeException.class, () -> order.auditAs("detached cross-route deletion").save(context)); + assertTrue(orders.requests.isEmpty()); assertTrue(payments.requests.isEmpty()); assertTrue(events.isEmpty()); + assertTrue(order.getEntityMutationLedger().isMarkedAsDelete(key)); + } + + @Test public void readOnlyForeignProviderRelationDoesNotCountAsAWrite() { + var orders = new Provider(); var payments = new Provider(); + var events = new CopyOnWriteArrayList(); + var context = context(orders, payments, events); + var order = existing("CustomerOrder", 100L); var payment = persisted("Payment", 301L); + var paymentLedger = payment.getEntityMutationLedger(); + order.updateProperty("children", List.of(payment)); + order.auditAs("read-only payment reference").save(context); + assertEquals(1, orders.requests.size()); assertTrue(payments.requests.isEmpty()); + assertSame(paymentLedger, payment.getEntityMutationLedger()); + assertEquals(1, events.size()); + } + + @Test public void failedSaveDoesNotPoisonTheNextIndependentRoute() { + var orders = new Provider() { + @Override public MutationResult mutate(UserContext caller, PersistenceMutation request) { + throw new IllegalStateException("fixture provider failure"); + } + }; + var payments = new Provider(); var events = new CopyOnWriteArrayList(); + var context = context(orders, payments, events); + assertThrows(IllegalStateException.class, + () -> existing("CustomerOrder", 100L).auditAs("failed order").save(context)); + existing("Payment", 100L).auditAs("payment after failed order").save(context); + assertEquals(1, payments.requests.size()); assertEquals(1, events.size()); + assertReasons(events, "Payment", List.of("payment after failed order")); + assertNull(context.getAttribute("__teaql_save_graph_route__")); + } +} diff --git a/teaql-runtime/src/test/java/io/teaql/runtime/QueryTraceIsolationTest.java b/teaql-runtime/src/test/java/io/teaql/runtime/QueryTraceIsolationTest.java new file mode 100644 index 00000000..42761d67 --- /dev/null +++ b/teaql-runtime/src/test/java/io/teaql/runtime/QueryTraceIsolationTest.java @@ -0,0 +1,155 @@ +package io.teaql.runtime; + +import io.teaql.core.*; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; +import org.junit.Test; +import static org.junit.Assert.*; + +/** #202: query provenance belongs to its request, including while execution overlaps. */ +public class QueryTraceIsolationTest { + private static class Request extends BaseRequest { + Request(String comment, String purpose) { + super(TeaQLRuntimeTest.DummyEntity.class); + changeIntent(comment, purpose); + } + void changeIntent(String comment, String purpose) { internalComment(comment); internalPurpose(purpose); } + @Override public String getTypeName() { return "Dummy"; } + } + + private static void await(CountDownLatch latch) { + try { assertTrue("query must reach the controlled checkpoint", latch.await(10, TimeUnit.SECONDS)); } + catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new AssertionError(interrupted); + } + } + + @Test public void overlappingListAndAggregateNeverWriteTheContextTraceStack() throws Exception { + var firstEntered = new CountDownLatch(1); + var bothEntered = new CountDownLatch(2); + var release = new CountDownLatch(1); + var captured = new CopyOnWriteArrayList(); + var provider = new TeaQLRuntimeTest.DummyQueryExecutor() { + @Override public QueryResult query(UserContext caller, QueryRequest request) { + captured.add(request); + firstEntered.countDown(); bothEntered.countDown(); await(release); + return new DefaultQueryResult(new SmartList<>(), new AggregationResult()); + } + }; + var runtime = TeaQLRuntime.builder().metadata(new TeaQLRuntimeTest.DummyMetaFactory()) + .dataService("dummy", provider).build(); + var context = new DefaultUserContext(runtime); + context.pushTrace("unrelated application diagnostic"); + var baseline = context.getTraceChain(); + var workers = Executors.newFixedThreadPool(2); + try { + var first = workers.submit(() -> runtime.executeForList(context, new Request("read first", "render first"))); + await(firstEntered); + var second = workers.submit(() -> runtime.aggregation(context, new Request("count second", "render second"))); + await(bothEntered); + assertEquals("two live queries must not append operation, intent or relation frames to Context", + baseline, context.getTraceChain()); + assertEquals(List.of("read first", "count second"), captured.stream().map(QueryRequest::comment).toList()); + assertEquals(List.of("render first", "render second"), captured.stream().map(QueryRequest::purpose).toList()); + release.countDown(); + first.get(10, TimeUnit.SECONDS); second.get(10, TimeUnit.SECONDS); + assertEquals(baseline, context.getTraceChain()); + } finally { + release.countDown(); workers.shutdownNow(); + assertTrue(workers.awaitTermination(10, TimeUnit.SECONDS)); + } + } + + @Test public void reentrantInternalQueryUsesExplicitIntentWithoutAmbientFrames() { + var captured = new CopyOnWriteArrayList(); + var provider = new TeaQLRuntimeTest.DummyQueryExecutor() { + @Override public QueryResult query(UserContext caller, QueryRequest request) { + assertTrue("root and nested execution must leave Context untouched", caller.getTraceChain().isEmpty()); + captured.add(request); + if (captured.size() == 1) { + var child = new Request(null, null) { + @Override public QueryIntent inheritedQueryIntent() { return request.intent(); } + }; + caller.internalExecuteForList(child); + } + return super.query(caller, request); + } + }; + var runtime = TeaQLRuntime.builder().metadata(new TeaQLRuntimeTest.DummyMetaFactory()) + .dataService("dummy", provider).build(); + var context = new DefaultUserContext(runtime); + runtime.executeForList(context, new Request("load graph", "render graph")); + assertEquals(2, captured.size()); + assertSame(captured.get(0).intent(), captured.get(1).intent()); + assertTrue(context.getTraceChain().isEmpty()); + } + + @Test public void failedProviderCannotAddOrPopAnApplicationDiagnostic() { + var contextRef = new AtomicReference(); + var baseline = new AtomicReference>(); + var failure = new TeaQLRuntimeException("intentional query failure"); + var provider = new TeaQLRuntimeTest.DummyQueryExecutor() { + @Override public QueryResult query(UserContext caller, QueryRequest request) { + assertSame(contextRef.get(), caller); + assertEquals(baseline.get(), caller.getTraceChain()); + throw failure; + } + }; + var runtime = TeaQLRuntime.builder().metadata(new TeaQLRuntimeTest.DummyMetaFactory()) + .dataService("dummy", provider).build(); + var context = new DefaultUserContext(runtime); contextRef.set(context); + context.pushTrace("application diagnostic"); baseline.set(context.getTraceChain()); + assertSame(failure, assertThrows(TeaQLRuntimeException.class, + () -> runtime.executeForList(context, new Request("fail read", "verify failure cleanup")))); + assertEquals(baseline.get(), context.getTraceChain()); + } + + private static final class StreamProvider implements StreamingQueryExecutor { + String comment; + String purpose; + @Override public String name() { return "dummy"; } + @Override public DataServiceCapabilities capabilities() { return new DataServiceCapabilities(); } + @Override public Stream queryForStream(UserContext context, QueryRequest request) { + comment = request.comment(); purpose = request.purpose(); + assertTrue(context.getTraceChain().isEmpty()); + return Stream.empty(); + } + } + + @Test public void streamingProviderReceivesTheIntentCapturedBeforePolicy() { + var provider = new StreamProvider(); + var runtime = TeaQLRuntime.builder().metadata(new TeaQLRuntimeTest.DummyMetaFactory()) + .dataService("dummy", provider).queryPolicy(new QueryPolicy() { + @Override public void enforceSelect(UserContext context, SearchRequest request) { + ((Request) request).changeIntent("later builder comment", "later builder purpose"); + } + }).build(); + try (var stream = runtime.executeForStream(new DefaultUserContext(runtime), + new Request("original stream comment", "original stream purpose"))) { + assertEquals(0, stream.count()); + } + assertEquals("original stream comment", provider.comment); + assertEquals("original stream purpose", provider.purpose); + } + + @Test public void internalStreamDoesNotRequireRepeatedRootIntentOnTheChildBuilder() { + var provider = new StreamProvider(); + var runtime = TeaQLRuntime.builder().metadata(new TeaQLRuntimeTest.DummyMetaFactory()) + .dataService("dummy", provider).build(); + var intent = QueryIntent.of("root stream comment", "root stream purpose"); + var request = new Request(null, null) { + @Override public QueryIntent inheritedQueryIntent() { return intent; } + }; + try (var stream = runtime.internalExecuteForStream(new DefaultUserContext(runtime), request)) { + assertEquals(0, stream.count()); + } + assertEquals(intent.comment(), provider.comment); + assertEquals(intent.purpose(), provider.purpose); + } +} diff --git a/teaql-runtime/src/test/java/io/teaql/runtime/RequestIntentGateTest.java b/teaql-runtime/src/test/java/io/teaql/runtime/RequestIntentGateTest.java new file mode 100644 index 00000000..9ecc234b --- /dev/null +++ b/teaql-runtime/src/test/java/io/teaql/runtime/RequestIntentGateTest.java @@ -0,0 +1,373 @@ +package io.teaql.runtime; + +import io.teaql.core.*; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Test; +import static org.junit.Assert.*; + +/** Request intent is a business gate, not an optional SQL logging annotation. */ +public class RequestIntentGateTest { + private static final class CountingProvider implements QueryExecutor, StreamingQueryExecutor, MutationExecutor { + private final AtomicInteger calls = new AtomicInteger(); + @Override public QueryResult query(UserContext context, QueryRequest request) { + calls.incrementAndGet(); return new DefaultQueryResult(new SmartList<>()); + } + @Override public java.util.stream.Stream queryForStream( + UserContext context, QueryRequest request) { + calls.incrementAndGet(); return java.util.stream.Stream.empty(); + } + @Override public MutationResult mutate(UserContext context, PersistenceMutation request) { + calls.incrementAndGet(); return null; + } + @Override public String name() { return "dummy"; } + @Override public DataServiceCapabilities capabilities() { return new DataServiceCapabilities(); } + } + private static BaseRequest request(String declaredComment, String declaredPurpose) { + return new BaseRequest<>(TeaQLRuntimeTest.DummyEntity.class) { + { internalComment(declaredComment); internalPurpose(declaredPurpose); } + @Override public String getTypeName() { return "Dummy"; } + }; + } + + private static void required(String code, Runnable action) { + try { + action.run(); + fail("Expected " + code + " before execution"); + } catch (TeaQLRuntimeException error) { + assertTrue(error.getMessage(), error.getMessage().contains(code)); + if (code.equals("REQUEST_COMMENT_REQUIRED") || code.equals("QUERY_PURPOSE_REQUIRED")) { + assertTrue("Intent failures must expose structured diagnostics", error instanceof RequestIntentException); + var intentError = (RequestIntentException) error; + assertEquals(code, intentError.getCode()); + assertEquals(code.equals("QUERY_PURPOSE_REQUIRED") ? "purpose" : "comment", intentError.getField()); + } + assertFalse(error.getMessage().contains("SECRET-CANARY")); + } + } + + private static void requiredIntent(String code, String kind, Runnable action) { + required(code, () -> { + try { + action.run(); + } catch (RequestIntentException error) { + assertEquals(kind, error.getRequestKind()); + throw error; + } + }); + } + + @Test public void rootIntentMatrixRejectsBeforeAnyPolicyProviderOrSink() { + for (boolean logging : new boolean[]{false, true}) { + var provider = new CountingProvider(); + var sink = new TeaQLRuntimeTest.RecordingRuntimeLogSink(); + AtomicInteger queryPolicies = new AtomicInteger(); + AtomicInteger mutationRegistrations = new AtomicInteger(); + var runtime = TeaQLRuntime.builder().metadata(new TeaQLRuntimeTest.DummyMetaFactory()) + .dataService("dummy", provider).logSink(sink) + .queryExecutionLogging(logging).mutationExecutionLogging(logging) + .queryPolicy(new QueryPolicy() { + @Override public void enforceSelect(UserContext context, SearchRequest request) { + queryPolicies.incrementAndGet(); + } + }) + .mutationPolicyRegistry(plan -> { + mutationRegistrations.incrementAndGet(); + return java.util.Optional.empty(); + }).build(); + var context = new DefaultUserContext(runtime); + // Ambient intent must never fill a missing request-owned slot. + context.pushTrace(TraceKind.COMMENT, "Dummy", "unrelated old comment"); + context.pushTrace(TraceKind.PURPOSE, "Dummy", "unrelated old purpose"); + context.pushTrace(TraceKind.AUDIT_REASON, "Dummy", "unrelated old reason"); + for (String blank : new String[]{null, "", " \t\r\n", "\u0085", "\u00a0", "\u2003"}) { + for (boolean missingComment : new boolean[]{true, false}) { + String code = missingComment ? "REQUEST_COMMENT_REQUIRED" : "QUERY_PURPOSE_REQUIRED"; + var query = request(missingComment ? blank : "load SECRET-CANARY", + missingComment ? "render SECRET-CANARY" : blank); + requiredIntent(code, "query", () -> runtime.executeForList(context, query)); + requiredIntent(code, "query", () -> runtime.executeForStream(context, query)); + requiredIntent(code, "query", () -> runtime.aggregation(context, query)); + requiredIntent(code, "query", () -> runtime.executeForPage(context, query, 0, 10)); + } + var entity = new TeaQLRuntimeTest.DummyEntity(); + entity.setComment(blank); + requiredIntent("REQUEST_COMMENT_REQUIRED", "mutation", () -> runtime.saveGraph(context, entity)); + } + assertEquals(0, queryPolicies.get()); + assertEquals(0, mutationRegistrations.get()); + assertEquals(0, provider.calls.get()); + assertTrue(sink.executions.isEmpty()); + assertTrue(sink.auditEvents.isEmpty()); + assertTrue(sink.governanceEvents.isEmpty()); + } + } + + @Test public void directQueryEnvelopeRequiresCommentEvenWithPurpose() { + for (String comment : new String[]{null, "", " \t\r\n", "\u2003", "\u00a0"}) { + required("REQUEST_COMMENT_REQUIRED", () -> + new DefaultQueryRequest(request(comment, "render orders SECRET-CANARY"))); + } + } + + @Test public void requestOwnedTraceCannotFillMissingCommentBeforePolicyOrProvider() { + for (boolean logging : new boolean[]{false, true}) { + var provider = new CountingProvider(); + var sink = new TeaQLRuntimeTest.RecordingRuntimeLogSink(); + var queryPolicies = new AtomicInteger(); + var mutationRegistrations = new AtomicInteger(); + var runtime = TeaQLRuntime.builder().metadata(new TeaQLRuntimeTest.DummyMetaFactory()) + .dataService("dummy", provider).logSink(sink) + .queryExecutionLogging(logging).mutationExecutionLogging(logging) + .queryPolicy(new QueryPolicy() { + @Override public void enforceSelect(UserContext context, SearchRequest request) { + queryPolicies.incrementAndGet(); + } + }) + .mutationPolicyRegistry(plan -> { + mutationRegistrations.incrementAndGet(); + return java.util.Optional.empty(); + }).build(); + var context = new DefaultUserContext(runtime); + var querySource = List.of( + new TraceNode(TraceKind.COMMENT, "Dummy", null, "SECRET-CANARY trace-only comment"), + new TraceNode(TraceKind.PURPOSE, "Dummy", null, "SECRET-CANARY trace-only purpose")); + var query = new BaseRequest(TeaQLRuntimeTest.DummyEntity.class) { + { internalPurpose("declared query purpose"); } + @Override public String getTypeName() { return "Dummy"; } + @Override public List sqlTraceSource() { return querySource; } + }; + assertNull("the required property is actually omitted", query.comment()); + assertEquals(querySource, query.sqlTraceSource()); + requiredIntent("REQUEST_COMMENT_REQUIRED", "query", () -> new DefaultQueryRequest(query)); + requiredIntent("REQUEST_COMMENT_REQUIRED", "query", () -> runtime.executeForList(context, query)); + requiredIntent("REQUEST_COMMENT_REQUIRED", "query", () -> runtime.executeForStream(context, query)); + requiredIntent("REQUEST_COMMENT_REQUIRED", "query", () -> runtime.aggregation(context, query)); + requiredIntent("REQUEST_COMMENT_REQUIRED", "query", () -> runtime.executeForPage(context, query, 0, 10)); + + var entity = new TeaQLRuntimeTest.DummyEntity(); + entity.__internalSet("id", 801L); entity.__internalSet("version", 1L); + entity.set$status(EntityStatus.PERSISTED); + entity.updateProperty("name", "pending mutation payload"); + var mutationSource = List.of(new TraceNode( + TraceKind.AUDIT_REASON, "Dummy", 801L, "SECRET-CANARY trace-only audit reason")); + entity.setTraceChain(mutationSource); + assertNull(entity.getComment()); + assertEquals(mutationSource, entity.getTraceChain()); + for (var action : EntityPersistenceMutation.Action.values()) + requiredIntent("REQUEST_COMMENT_REQUIRED", "mutation", () -> new EntityPersistenceMutation(entity, action)); + requiredIntent("REQUEST_COMMENT_REQUIRED", "mutation", () -> runtime.saveGraph(context, entity)); + + assertEquals(0, queryPolicies.get()); + assertEquals(0, mutationRegistrations.get()); + assertEquals(0, provider.calls.get()); + assertTrue(sink.executions.isEmpty()); + assertTrue(sink.auditEvents.isEmpty()); + assertTrue(sink.governanceEvents.isEmpty()); + assertTrue("no ambient trace was supplied or created", context.getTraceChain().isEmpty()); + } + } + + @Test public void explicitMutationCommentSurvivesEachBlankTypedRouteTail() { + for (boolean logging : new boolean[]{false, true}) { + for (var kind : List.of(TraceKind.ENTITY, TraceKind.PROVIDER, TraceKind.SQL)) { + var provider = new TeaQLRuntimeTest.RecordingMutationExecutor(); + var sink = new TeaQLRuntimeTest.RecordingRuntimeLogSink(); + var policies = new AtomicInteger(); + var entity = new TeaQLRuntimeTest.DummyEntity(); + entity.__internalSet("id", 802L); entity.__internalSet("version", 1L); + entity.set$status(EntityStatus.PERSISTED); + entity.updateProperty("name", "changed field"); + String comment = " explicit mutation request reason "; + entity.setComment(comment); + var tail = new TraceNode(kind, kind == TraceKind.PROVIDER ? "dummy" + : kind == TraceKind.SQL ? "update" : "Dummy", null, ""); + // Deliberately supplied diagnostic input for TC-REQ-13, not + // evidence that a generated graph constructs these route nodes. + var source = List.of(new TraceNode(TraceKind.AUDIT_REASON, "Dummy", 802L, comment), tail); + entity.setTraceChain(source); + var request = new EntityPersistenceMutation(entity, EntityPersistenceMutation.Action.SAVE, + MutationIntent.of(comment), source); + assertEquals(tail, request.getTraceChain().get(request.getTraceChain().size() - 1)); + assertEquals("", tail.getComment()); + assertEquals(comment, request.comment()); + assertEquals(comment, request.intent().readbackIntent().comment()); + + var runtime = TeaQLRuntime.builder().metadata(new TeaQLRuntimeTest.DummyMetaFactory()) + .dataService("dummy", provider).logSink(sink) + .queryExecutionLogging(logging).mutationExecutionLogging(logging) + .mutationPolicyRegistry(plan -> java.util.Optional.of(new MutationPolicy() { + @Override public MutationPolicyIdentity identity() { + return new MutationPolicyIdentity("route-tail", "1", "test"); + } + @Override public MutationDecision review(UserContext context, MutationPlan plan) { + policies.incrementAndGet(); + assertEquals(comment, plan.auditReason()); + return MutationDecision.allow(); + } + })).build(); + var context = new DefaultUserContext(runtime); + runtime.saveGraph(context, entity); + assertEquals(1, policies.get()); + assertEquals(1, provider.requests.size()); + var emitted = provider.requests.get(0); + assertEquals(source, emitted.getTraceChain()); + assertEquals(comment, emitted.comment()); + assertEquals(comment, emitted.intent().readbackIntent().comment()); + assertEquals(1, sink.auditEvents.size()); + assertEquals(comment, sink.auditEvents.get(0).reason()); + assertTrue(context.getTraceChain().isEmpty()); + } + } + } + + @Test public void directQueryEnvelopeRequiresPurpose() { + for (String purpose : new String[]{null, "", "\u2003"}) { + required("QUERY_PURPOSE_REQUIRED", () -> + new DefaultQueryRequest(request("load orders SECRET-CANARY", purpose))); + } + } + + @Test public void everyRootQueryGateRunsBeforePolicyAndWithLoggingDisabled() { + AtomicInteger policyCalls = new AtomicInteger(); + var provider = new CountingProvider(); + var runtime = TeaQLRuntime.builder().metadata(new TeaQLRuntimeTest.DummyMetaFactory()) + .dataService("dummy", provider) + .queryExecutionLogging(false).mutationExecutionLogging(false) + .queryPolicy(new QueryPolicy() { + @Override public void enforceSelect(UserContext context, SearchRequest request) { + policyCalls.incrementAndGet(); + } + }).build(); + var context = new DefaultUserContext(runtime); + context.pushTrace(TraceKind.COMMENT, "Dummy", "unrelated old comment"); + context.pushTrace(TraceKind.PURPOSE, "Dummy", "unrelated old purpose"); + required("REQUEST_COMMENT_REQUIRED", () -> runtime.executeForList(context, request(null, "render orders"))); + required("REQUEST_COMMENT_REQUIRED", () -> runtime.executeForStream(context, request(null, "render orders"))); + required("REQUEST_COMMENT_REQUIRED", () -> runtime.aggregation(context, request(null, "render orders"))); + required("REQUEST_COMMENT_REQUIRED", () -> runtime.executeForPage(context, request(null, "render orders"), 0, 10)); + assertEquals(0, policyCalls.get()); + assertEquals(0, provider.calls.get()); + } + + @Test public void mutationEnvelopeRequiresExplicitAuditComment() { + var entity = new TeaQLRuntimeTest.DummyEntity(); + required("REQUEST_COMMENT_REQUIRED", () -> + new EntityPersistenceMutation(entity, EntityPersistenceMutation.Action.SAVE)); + entity.setComment("\u2003"); + required("REQUEST_COMMENT_REQUIRED", () -> + new EntityPersistenceMutation(entity, EntityPersistenceMutation.Action.DELETE)); + } + + @Test public void graphMutationGateRunsBeforePolicyAndProvider() { + AtomicInteger policyCalls = new AtomicInteger(); + var provider = new CountingProvider(); + var runtime = TeaQLRuntime.builder().metadata(new TeaQLRuntimeTest.DummyMetaFactory()) + .dataService("dummy", provider) + .mutationPolicyRegistry(plan -> java.util.Optional.of(new MutationPolicy() { + @Override public MutationPolicyIdentity identity() { + return new MutationPolicyIdentity("test", "1", "test"); + } + @Override public MutationDecision review(UserContext context, MutationPlan plan) { + policyCalls.incrementAndGet(); return MutationDecision.allow(); + } + })).queryExecutionLogging(false).mutationExecutionLogging(false).build(); + var entity = new TeaQLRuntimeTest.DummyEntity(); + entity.setComment("\u2003"); + var context = new DefaultUserContext(runtime); + context.pushTrace(TraceKind.AUDIT_REASON, "Dummy", "old unrelated reason"); + required("REQUEST_COMMENT_REQUIRED", () -> runtime.saveGraph(context, entity)); + assertEquals(0, policyCalls.get()); + assertEquals(0, provider.calls.get()); + } + + @Test public void ambientTraceCannotAuthorizeAnUnscopedNestedQuery() { + AtomicInteger policyCalls = new AtomicInteger(); + var runtime = TeaQLRuntime.builder().metadata(new TeaQLRuntimeTest.DummyMetaFactory()) + .queryPolicy(new QueryPolicy() { + @Override public void enforceSelect(UserContext context, SearchRequest request) { + policyCalls.incrementAndGet(); + } + }).build(); + var context = new DefaultUserContext(runtime); + context.pushTrace("forged root trace"); + required("INTERNAL QUERY CONTEXT REQUIRED", () -> context.internalExecuteForList(request(null, null))); + required("INTERNAL QUERY CONTEXT REQUIRED", () -> context.internalExecuteForStream(request(null, null))); + assertEquals(0, policyCalls.get()); + } + + @Test public void mutationPlanCannotExposeAnUnvalidatedReasonToPolicy() { + required("REQUEST_COMMENT_REQUIRED", () -> + new MutationPlan("exec", "request", "Dummy", null, List.of())); + } + + @Test public void queryAndMutationEnvelopesKeepTheirValidatedSnapshot() { + class MutableRequest extends BaseRequest { + MutableRequest() { super(TeaQLRuntimeTest.DummyEntity.class); } + void changeIntent(String comment, String purpose) { internalComment(comment); internalPurpose(purpose); } + } + var builder = new MutableRequest(); + builder.changeIntent(" load original ", "render original"); + var query = new DefaultQueryRequest(builder); + builder.changeIntent("another operation", "another purpose"); + assertEquals(" load original ", query.comment()); + assertEquals("render original", query.purpose()); + var entity = new TeaQLRuntimeTest.DummyEntity(); entity.setComment("submit original"); + var mutation = new EntityPersistenceMutation(entity, EntityPersistenceMutation.Action.SAVE); + entity.setComment("another operation"); + assertEquals("submit original", mutation.comment()); + assertEquals("submit original", mutation.intent().readbackIntent().comment()); + assertFalse(query.intent().toString().contains("original")); + assertFalse(mutation.intent().toString().contains("original")); + } + + @Test public void nestedQueryCarriesExplicitIntentWithoutAmbientContextOrLogging() { + var captured = new java.util.ArrayList(); + var runtime = TeaQLRuntime.builder().metadata(new TeaQLRuntimeTest.DummyMetaFactory()) + .queryExecutionLogging(false).mutationExecutionLogging(false) + .dataService("dummy", new TeaQLRuntimeTest.DummyQueryExecutor() { + @Override public QueryResult query(UserContext context, QueryRequest request) { + captured.add(request); return super.query(context, request); + } + }).build(); + var root = QueryIntent.of("load order graph", "render order details"); + var child = new BaseRequest(TeaQLRuntimeTest.DummyEntity.class) { + @Override public String getTypeName() { return "Dummy"; } + @Override public QueryIntent inheritedQueryIntent() { return root; } + }; + var context = new DefaultUserContext(runtime); + assertTrue(context.getTraceChain().isEmpty()); + assertNull(child.comment()); assertNull(child.purpose()); + assertEquals(1, context.internalExecuteForList(child).size()); + assertSame(root, captured.get(0).intent()); + assertEquals(root.comment(), captured.get(0).comment()); + assertEquals(root.purpose(), captured.get(0).purpose()); + assertTrue(context.getTraceChain().isEmpty()); + } + + @Test public void policyAndCommittedAuditUseTheCapturedMutationRequestReason() { + var provider = new TeaQLRuntimeTest.RecordingMutationExecutor(); + var sink = new TeaQLRuntimeTest.RecordingRuntimeLogSink(); + var entity = new TeaQLRuntimeTest.DummyEntity(); + entity.__internalSet("id", 701L); entity.__internalSet("version", 1L); + entity.set$status(EntityStatus.PERSISTED); + entity.updateProperty("name", "changed field"); entity.setComment("root operation reason"); + var runtime = TeaQLRuntime.builder().metadata(new TeaQLRuntimeTest.DummyMetaFactory()) + .dataService("dummy", provider).logSink(sink) + .mutationPolicyRegistry(key -> java.util.Optional.of(new MutationPolicy() { + @Override public MutationPolicyIdentity identity() { + return new MutationPolicyIdentity("test", "1", "test"); + } + @Override public MutationDecision review(UserContext context, MutationPlan plan) { + assertEquals("root operation reason", plan.auditReason()); + entity.setComment("a different later comment"); + return MutationDecision.allow(); + } + })).build(); + runtime.saveGraph(new DefaultUserContext(runtime), entity); + assertEquals(1, provider.requests.size()); + assertEquals("root operation reason", provider.requests.get(0).comment()); + assertEquals(1, sink.auditEvents.size()); + assertEquals("root operation reason", sink.auditEvents.get(0).reason()); + } +} diff --git a/teaql-runtime/src/test/java/io/teaql/runtime/SqlInheritedIntentTest.java b/teaql-runtime/src/test/java/io/teaql/runtime/SqlInheritedIntentTest.java index 1118ab3d..60a810f7 100644 --- a/teaql-runtime/src/test/java/io/teaql/runtime/SqlInheritedIntentTest.java +++ b/teaql-runtime/src/test/java/io/teaql/runtime/SqlInheritedIntentTest.java @@ -18,7 +18,8 @@ private ExecutionMetadata readback() { m.setIntentRedactions(source); m.setComment("what: Riverside PublicAddress PASSWORD-CANARY UNKNOWN-CANARY"); m.setPurpose(m.getComment()); m.setAuditReason(m.getComment()); - m.setTraceChain(List.of(new TraceNode(TraceKind.AUDIT_REASON, "Customer", m.getComment()))); + m.setTraceChain(List.of(new TraceNode(TraceKind.AUDIT_REASON, "Customer", 1L, m.getComment()))); + m.setMutationLineage(m.getTraceChain()); m.setResultCount(1); return m; } @@ -31,6 +32,9 @@ private ExecutionMetadata readback() { assertEquals(debug, safe.getAuditReason().contains("Riverside")); assertTrue(safe.getAuditReason().contains("PublicAddress")); assertFalse(safe.getTraceChain().toString().contains("CANARY")); + assertFalse(safe.getMutationLineage().toString().contains("CANARY")); + assertEquals(debug, safe.getMutationLineage().get(0).getComment().contains("Riverside")); + assertEquals(Long.valueOf(1), safe.getMutationLineage().get(0).getEntityId()); assertTrue(safe.getDebugQuery().contains("id = 1 LIMIT 10000")); assertEquals(Integer.valueOf(1), safe.getResultCount()); } @@ -43,9 +47,20 @@ private ExecutionMetadata readback() { var safe = LogPrivacy.sql(debug, false); assertEquals("what: [REDACTED] PublicAddress [REDACTED] [REDACTED]", safe.getAuditReason()); assertNull(safe.getIntentRedactions()); + assertEquals(safe.getAuditReason(), safe.getMutationLineage().get(0).getComment()); assertEquals(safe.getAuditReason(), LogPrivacy.sql(LogPrivacy.sql(debug, true), false).getAuditReason()); } + @Test public void changingTypedIdentityInvalidatesTheRememberedDebugProjection() { + var debug = LogPrivacy.sql(readback(), true); + var old = debug.getMutationLineage().get(0); + debug.setMutationLineage(List.of(new TraceNode(old.getKind(), old.getName(), 2L, old.getComment()))); + var safe = LogPrivacy.sql(debug, false); + assertEquals(Long.valueOf(2), safe.getMutationLineage().get(0).getEntityId()); + assertFalse(safe.getMutationLineage().toString().contains("Riverside")); + assertFalse(safe.getMutationLineage().toString().contains("CANARY")); + } + @Test public void nestedCredentialsAndMismatchedPoliciesStayHiddenInDebug() { var m = readback(); var intent = new SqlIntentRedactions(); diff --git a/teaql-runtime/src/test/java/io/teaql/runtime/TeaQLRuntimeTest.java b/teaql-runtime/src/test/java/io/teaql/runtime/TeaQLRuntimeTest.java index b3fa2a09..6edee167 100644 --- a/teaql-runtime/src/test/java/io/teaql/runtime/TeaQLRuntimeTest.java +++ b/teaql-runtime/src/test/java/io/teaql/runtime/TeaQLRuntimeTest.java @@ -130,8 +130,8 @@ public static class RecordingStreamingQueryExecutor implements StreamingQueryExe @Override public Stream queryForStream( - UserContext context, SearchRequest request) { - this.request = request; + UserContext context, QueryRequest request) { + this.request = ((DefaultQueryRequest) request).getSearchRequest(); return Stream.empty(); } @@ -655,13 +655,14 @@ public void internalStreamingRequiresAuthorizedRootAndAppliesRequestPolicy() { Assert.assertEquals(0, policyCalls.get()); context.pushTrace("authorized root query"); - try (Stream ignored = context.internalExecuteForStream(request)) { + SearchRequest scoped = inheritedDummyRequest(); + try (Stream ignored = context.internalExecuteForStream(scoped)) { Assert.assertEquals(0, ignored.count()); } finally { context.popTrace(); } Assert.assertEquals(1, policyCalls.get()); - Assert.assertSame(request, executor.request); + Assert.assertSame(scoped, executor.request); } @Test @@ -700,7 +701,7 @@ public void testNestedQueryInheritsAuthorizedRootTrace() { }) .build(); DefaultUserContext context = new DefaultUserContext(runtime); - SearchRequest nested = bareDummyRequest(); + SearchRequest nested = inheritedDummyRequest(); context.pushTrace("authorized root query"); try { @@ -723,7 +724,7 @@ public void TOPN_010_nestedRelationTelemetryCarriesPlanDimensions() { }) .build(); DefaultUserContext context = new DefaultUserContext(runtime); - BaseRequest nested = (BaseRequest) bareDummyRequest(); + BaseRequest nested = (BaseRequest) inheritedDummyRequest(); nested.putExtension("teaql.internal.top_n.parent_count", 3); nested.putExtension("teaql.internal.top_n.per_parent_limit", 2); nested.putExtension("teaql.internal.top_n.probe_threshold", 3); @@ -768,6 +769,15 @@ public String getTypeName() { }; } + private static SearchRequest inheritedDummyRequest() { + return new BaseRequest(DummyEntity.class) { + private final QueryIntent rootIntent = QueryIntent.of("load dummy graph", "verify relation loading"); + { internalComment(rootIntent.comment()); internalPurpose(rootIntent.purpose()); } + @Override public String getTypeName() { return "Dummy"; } + @Override public QueryIntent inheritedQueryIntent() { return rootIntent; } + }; + } + public static class ContainerEntity extends BaseEntity { private DummyEntity rel1; private DummyEntity rel2; @@ -882,9 +892,12 @@ public void testSaveGraphLedgerClassificationAndExecutionOrder() throws Exceptio java.lang.reflect.Method method = TeaQLRuntime.class.getDeclaredMethod( "executeLedgerPlan", UserContext.class, EntityMutationLedger.class, - MutationExecutor.class, java.util.Map.class, MutationGovernanceSnapshot.class); + MutationExecutor.class, java.util.Map.class, MutationGovernanceSnapshot.class, MutationIntent.class, + java.util.Map.class, MutationTraceScope.class); method.setAccessible(true); - method.invoke(runtime, new DefaultUserContext(runtime), root, executor, realEntities, null); + method.invoke(runtime, new DefaultUserContext(runtime), root, executor, realEntities, null, + MutationIntent.of("root comment"), java.util.Map.of(), + MutationTraceScope.append(null, "Dummy", null, "root comment")); List requests = executor.requests; diff --git a/teaql-sql-portable/ANDROID_GUIDE.md b/teaql-sql-portable/ANDROID_GUIDE.md index a52240ea..928aa6b6 100644 --- a/teaql-sql-portable/ANDROID_GUIDE.md +++ b/teaql-sql-portable/ANDROID_GUIDE.md @@ -266,5 +266,5 @@ Android's `SQLiteDatabase` handles multi-threaded accesses internally using lock * Ensure you reuse a single, shared `SQLiteOpenHelper` instance across the entire application to avoid thread conflict and database locking exceptions. ### Schema Generation and Upgrades -* Use `dataService.ensureSchema(userContext, "Task")` during application startup or within `SQLiteOpenHelper.onCreate()` to automatically compile and create tables for all registered repositories if they do not exist. -* For schema migrations, either leverage `dataService.ensureSchema(userContext, "Task")` (which automatically detects missing tables/columns) or manage migrations using Android's native `onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion)` override. +* Install the generated Runtime Module and an `AndroidSqliteDataServiceExecutor` in the runtime registry, then explicitly call `userContext.ensureSchema()`. The selected provider reconciles physical tables/columns; the generated bootstrap creates roots and constants using validated, audited Mutation APIs. +* For schema migrations, use the same context-owned lifecycle or manage physical migrations using Android's native `onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion)`. Do not recreate the removed Portable `ensureSchema(context, type)` / `ensureInitData(context)` data-seeding shortcuts. diff --git a/teaql-sql-portable/src/main/java/io/teaql/core/internal/TempRequest.java b/teaql-sql-portable/src/main/java/io/teaql/core/internal/TempRequest.java index cd8aa646..51ba8ba3 100644 --- a/teaql-sql-portable/src/main/java/io/teaql/core/internal/TempRequest.java +++ b/teaql-sql-portable/src/main/java/io/teaql/core/internal/TempRequest.java @@ -4,14 +4,33 @@ import io.teaql.core.OrderBys; import io.teaql.core.SearchCriteria; import io.teaql.core.SearchRequest; +import io.teaql.core.QueryIntent; public class TempRequest extends BaseRequest { String type; + private QueryIntent rootIntent; + private SearchRequest original; public TempRequest(SearchRequest request) { + this(request, request.inheritedQueryIntent()); + } + + public TempRequest(SearchRequest request, QueryIntent rootIntent) { super(request.returnType()); + this.original = request; + this.rootIntent = rootIntent; type = request.getTypeName(); copy(request); + this.comment = rootIntent == null ? request.comment() : rootIntent.comment(); + this.purpose = rootIntent == null ? request.purpose() : rootIntent.purpose(); + } + + @Override public QueryIntent inheritedQueryIntent() { return rootIntent; } + @Override public java.util.List sqlTraceSource() { + return original == null ? java.util.List.of() : original.sqlTraceSource(); + } + @Override public io.teaql.core.Entity internalNewEntity() { + return original == null ? super.internalNewEntity() : original.internalNewEntity(); } public TempRequest(Class returnType, String typeName) { diff --git a/teaql-sql-portable/src/main/java/io/teaql/core/sql/SqlAstCompiler.java b/teaql-sql-portable/src/main/java/io/teaql/core/sql/SqlAstCompiler.java index 29b13e13..52c9fac3 100644 --- a/teaql-sql-portable/src/main/java/io/teaql/core/sql/SqlAstCompiler.java +++ b/teaql-sql-portable/src/main/java/io/teaql/core/sql/SqlAstCompiler.java @@ -89,7 +89,12 @@ public String buildAggregationSQL( SearchRequest request, Map parameters, List tables) { - + // A facet/count compilation can materialize a relation predicate just + // like a data SELECT. Carry the captured intent in this compilation's + // bindings, never in the shared Context. + if (parameters instanceof SqlParameters tracked) { + tracked.captureQueryContext(request); + } String idTable = tables.get(0); String whereSql = prepareCondition(metadata, repository, userContext, idTable, request.getSearchCriteria(), parameters); diff --git a/teaql-sql-portable/src/main/java/io/teaql/core/sql/SqlParameters.java b/teaql-sql-portable/src/main/java/io/teaql/core/sql/SqlParameters.java index a0920ecd..c27731bf 100644 --- a/teaql-sql-portable/src/main/java/io/teaql/core/sql/SqlParameters.java +++ b/teaql-sql-portable/src/main/java/io/teaql/core/sql/SqlParameters.java @@ -9,6 +9,26 @@ public final class SqlParameters extends HashMap { private final Map policies = new HashMap<>(); private SqlParameterLogPolicy currentPolicy = SqlParameterLogPolicy.UNKNOWN; private boolean generated = true; + private io.teaql.core.QueryIntent queryIntent; + private io.teaql.core.SearchRequest originatingQuery; + + /** Immutable originating intent for cross-provider relation predicates during this compilation. */ + public io.teaql.core.QueryIntent queryIntent() { return queryIntent; } + public void captureQueryIntent(io.teaql.core.QueryIntent intent) { + if (queryIntent == null) queryIntent = java.util.Objects.requireNonNull(intent, "intent"); + } + /** Provider-owned scoped request, retained only for this compilation. */ + public io.teaql.core.SearchRequest originatingQuery() { return originatingQuery; } + public void captureQueryContext(io.teaql.core.SearchRequest request) { + var intent = request.inheritedQueryIntent(); + if (intent == null && request.comment() != null && request.purpose() != null) { + intent = io.teaql.core.QueryIntent.of(request.comment(), request.purpose()); + } + if (intent != null) { + captureQueryIntent(intent); + if (originatingQuery == null) originatingQuery = request; + } + } public SqlParameterLogPolicy policy(String name) { return policies.getOrDefault(name, SqlParameterLogPolicy.UNKNOWN); diff --git a/teaql-sql-portable/src/main/java/io/teaql/core/sql/expression/ExpressionHelper.java b/teaql-sql-portable/src/main/java/io/teaql/core/sql/expression/ExpressionHelper.java index 625fc19a..6b79d563 100644 --- a/teaql-sql-portable/src/main/java/io/teaql/core/sql/expression/ExpressionHelper.java +++ b/teaql-sql-portable/src/main/java/io/teaql/core/sql/expression/ExpressionHelper.java @@ -16,6 +16,29 @@ public class ExpressionHelper { OrderBysParser.class, ParameterParser.class, PropertyParser.class, SubQueryParser.class, TwoOperatorExpressionParser.class, TypeCriteriaParser.class, VersionSearchCriteriaParser.class); + /** Diagnostic inspection may trust only the same built-in parsers as compilation. */ + public static boolean hasBuiltinParser(Expression expression, SQLColumnResolver resolver) { + if (expression == null || expression instanceof SQLExpressionParser) return false; + for (Class type = expression.getClass(); type != null; type = type.getSuperclass()) { + var parser = resolver.getExpressionParsers().get(type); + if (parser != null) return BUILTIN.contains(parser.getClass()); + } + return false; + } + + /** Resolved expression scope, not the caller-supplied parameter name. */ + public static io.teaql.core.SqlParameterLogPolicy parameterPolicy( + UserContext context, Expression expression, SQLColumnResolver resolver) { + var properties = expression.properties(context); + var policy = io.teaql.core.SqlParameterLogPolicy.PLAIN; + if (properties == null || properties.isEmpty()) return io.teaql.core.SqlParameterLogPolicy.UNKNOWN; + for (String property : properties) { + var candidate = resolver.parameterLogPolicy(property); + if (rank(candidate) > rank(policy)) policy = candidate; + } + return policy; + } + public static String toSql( UserContext userContext, Expression expression, @@ -72,14 +95,7 @@ private static String toSqlInternal( try { if (expression instanceof io.teaql.core.criteria.TwoOperatorCriteria || expression instanceof io.teaql.core.criteria.Between) { - var properties = expression.properties(userContext); - var policy = io.teaql.core.SqlParameterLogPolicy.PLAIN; - if (properties == null || properties.isEmpty()) policy = io.teaql.core.SqlParameterLogPolicy.UNKNOWN; - else for (String property : properties) { - var candidate = columnResolver.parameterLogPolicy(property); - if (rank(candidate) > rank(policy)) policy = candidate; - } - tracked.currentPolicy(policy); + tracked.currentPolicy(parameterPolicy(userContext, expression, columnResolver)); } return parser.toSql(userContext, expression, idTable, parameters, columnResolver); } finally { tracked.currentPolicy(previous); } diff --git a/teaql-sql-portable/src/main/java/io/teaql/core/sql/expression/SubQueryParser.java b/teaql-sql-portable/src/main/java/io/teaql/core/sql/expression/SubQueryParser.java index 51aedf2a..a52ca922 100644 --- a/teaql-sql-portable/src/main/java/io/teaql/core/sql/expression/SubQueryParser.java +++ b/teaql-sql-portable/src/main/java/io/teaql/core/sql/expression/SubQueryParser.java @@ -83,7 +83,17 @@ public String toSql( } // fall back - SmartList referred = userContext.internalExecuteForList(dependsOn); + var rootIntent = parameters instanceof io.teaql.core.sql.SqlParameters tracked + ? tracked.queryIntent() : null; + if (rootIntent == null) { + throw new io.teaql.core.TeaQLRuntimeException( + "[INTERNAL QUERY CONTEXT REQUIRED] Materialized relation predicate requires originating query intent."); + } + var origin = parameters instanceof io.teaql.core.sql.SqlParameters tracked + ? tracked.originatingQuery() : null; + SmartList referred = origin != null && sqlColumnResolver instanceof PortableSQLRepository owner + ? owner.materializeRelationPredicate(userContext, dependsOn, origin, propertyName) + : userContext.internalExecuteForList(new TempRequest(dependsOn, rootIntent)); Set dependsOnValues = new HashSet<>(); for (Entity entity : referred) { Object propertyValue = entity.getProperty(dependsOnPropertyName); diff --git a/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/PortableSQLDataService.java b/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/PortableSQLDataService.java index 38b5da53..2228ea3a 100644 --- a/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/PortableSQLDataService.java +++ b/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/PortableSQLDataService.java @@ -6,7 +6,7 @@ import java.util.*; import java.util.concurrent.ConcurrentHashMap; -public class PortableSQLDataService implements DataServiceExecutor, QueryExecutor, StreamingQueryExecutor, MutationExecutor, TransactionExecutor { +public class PortableSQLDataService implements DataServiceExecutor, QueryExecutor, StreamingQueryExecutor, BatchMutationExecutor, TransactionExecutor { private final String name; private final DataServiceCapabilities capabilities; @@ -30,6 +30,7 @@ public PortableSQLDataService(String name, TeaQLDatabase database, EntityMetaFac this.capabilities = new DataServiceCapabilities(); this.capabilities.setQuery(true); this.capabilities.setMutation(true); + this.capabilities.setBatchMutation(true); this.capabilities.setTransaction(true); this.capabilities.setStreamingQuery(true); } @@ -75,13 +76,16 @@ public QueryResult query(UserContext context, QueryRequest request) { throw new TeaQLRuntimeException("Unsupported QueryRequest in PortableSQLDataService"); } SearchRequest searchRequest = ((DefaultQueryRequest) request).getSearchRequest(); + SqlIntentRedactions intent = SqlDiagnosticRequest.source(context, searchRequest); + if (intent == null) intent = new SqlIntentRedactions(); + var statements = new ArrayList(); + searchRequest = SqlDiagnosticRequest.collecting(searchRequest, intent, request.intent(), statements::add); String typeName = searchRequest.getTypeName(); PortableSQLRepository repository = getRepository(typeName); - SqlIntentRedactions intent = SqlDiagnosticRequest.source(context, searchRequest); if (searchRequest.hasSimpleAgg()) { AggregationResult aggregation = repository.doAggregateInternal(context, (SearchRequest) searchRequest, intent); - return new DefaultQueryResult(new SmartList<>(), aggregation); + return new DefaultQueryResult(new SmartList<>(), aggregation, statements); } SmartList result = repository.loadInternal(context, (SearchRequest) searchRequest, intent); @@ -90,15 +94,31 @@ public QueryResult query(UserContext context, QueryRequest request) { } attachDynamicAggregations(context, (SmartList) result, searchRequest, intent); - return new DefaultQueryResult((SmartList) result); + return new DefaultQueryResult((SmartList) result, null, statements); } @Override - public java.util.stream.Stream queryForStream(UserContext context, SearchRequest request) { - if (request.hasSimpleAgg() || !request.enhanceRelations().isEmpty() || !request.enhanceChildren().isEmpty()) { + @SuppressWarnings("unchecked") + public java.util.stream.Stream queryForStream(UserContext context, QueryRequest request) { + return this.queryForCursor(context, request).stream(); + } + + @Override + @SuppressWarnings("unchecked") + public QueryCursor queryForCursor(UserContext context, QueryRequest request) { + if (!(request instanceof DefaultQueryRequest query)) { + throw new TeaQLRuntimeException("Unsupported QueryRequest in PortableSQLDataService"); + } + SearchRequest searchRequest = (SearchRequest) query.getSearchRequest(); + SqlIntentRedactions source = SqlDiagnosticRequest.source(context, searchRequest); + if (source == null) source = new SqlIntentRedactions(); + var statements = new java.util.concurrent.CopyOnWriteArrayList(); + SearchRequest scoped = SqlDiagnosticRequest.collecting(searchRequest, source, request.intent(), statements::add); + if (scoped.hasSimpleAgg() || !scoped.enhanceRelations().isEmpty() || !scoped.enhanceChildren().isEmpty()) { throw new TeaQLRuntimeException("Streaming aggregation/relation enhancement is not supported; stream root rows only"); } - return this.getRepository(request.getTypeName()).streamInternal(context, request); + return new QueryCursor<>(this.getRepository(scoped.getTypeName()).streamInternal(context, scoped), + () -> statements); } private void attachDynamicAggregations( @@ -124,7 +144,7 @@ private void attachDynamicAggregations( } io.teaql.core.internal.TempRequest request = - new io.teaql.core.internal.TempRequest(aggregateRequest); + dynamicAggregateRequest(aggregateRequest, partitionProperty, intent, parentRequest); request.groupBy(partitionProperty); request.appendSearchCriteria( request.createBasicSearchCriteria( @@ -160,6 +180,25 @@ private void attachDynamicAggregations( } } + private SqlDiagnosticRequest dynamicAggregateRequest(SearchRequest aggregateRequest, + String partitionProperty, SqlIntentRedactions intent, SearchRequest parentRequest) { + EntityDescriptor aggregateDescriptor = metadata.resolveEntityDescriptor(aggregateRequest.getTypeName()); + PropertyDescriptor partition = findProperty(aggregateDescriptor, partitionProperty); + if (partition instanceof Relation relation && shouldHandle(aggregateDescriptor, relation)) { + PropertyDescriptor reverse = relation.getReverseProperty(); + EntityDescriptor parentDescriptor = metadata.resolveEntityDescriptor(parentRequest.getTypeName()); + while (reverse != null && parentDescriptor != null) { + if (reverse.getOwner() == parentDescriptor) { + return SqlDiagnosticRequest.forRelation( + aggregateRequest, intent, parentRequest, reverse.getName()); + } + parentDescriptor = parentDescriptor.getParent(); + } + } + // Arbitrary partitions still inherit their request origin, but cannot claim a model edge. + return SqlDiagnosticRequest.forDerived(aggregateRequest, intent, parentRequest); + } + private Entity parentByAggregationKey(Map parentsById, Object parentId) { if (parentId instanceof Number number) { return parentsById.get(number.longValue()); @@ -189,10 +228,10 @@ private void enhanceRelations( if (!(property instanceof Relation)) return; if (shouldHandle(entityDescriptor, (Relation) property)) { - enhanceParent(userContext, dataSet, (Relation) property, r, intent); + enhanceParent(userContext, dataSet, (Relation) property, r, intent, request); return; } - collectChildren(userContext, dataSet, (Relation) property, r, intent); + collectChildren(userContext, dataSet, (Relation) property, r, intent, request); }); } @@ -224,7 +263,7 @@ private void enhanceParent( UserContext userContext, SmartList results, Relation relation, - SearchRequest parentRequest, SqlIntentRedactions intent) { + SearchRequest parentRequest, SqlIntentRedactions intent, SearchRequest origin) { List parents = results.stream() .map(e -> e.getProperty(relation.getName())) @@ -234,7 +273,37 @@ private void enhanceParent( .toList(); if (io.teaql.core.utils.ObjectUtil.isEmpty(parents)) return; - io.teaql.core.internal.TempRequest parentTemp = new SqlDiagnosticRequest(parentRequest, intent); + // Facets belong to the referenced entity's query, not the union of + // every parent's membership. Keep them in a nonpersistent sidecar. + // Ordinary relation hydration continues to use the bulk lookup below. + if (parentRequest.getFacetRequests() != null && !parentRequest.getFacetRequests().isEmpty()) { + Map loadedById = new HashMap<>(); + for (Entity parent : parents) { + var scoped = SqlDiagnosticRequest.forRelation(parentRequest, intent, origin, relation.getName()); + scoped.appendSearchCriteria(scoped.createBasicSearchCriteria( + BaseEntity.ID_PROPERTY, io.teaql.core.criteria.Operator.EQUAL, parent.getId())); + if (scoped.getSlice() == null) scoped.setSize(1); + SmartList loaded = userContext.internalExecuteForList(scoped); + Map> facets = new HashMap<>(); + loaded.getFacets().forEach(facets::put); + for (Entity entity : loaded) { + if (entity instanceof BaseEntity base) base.__internalSetQueryFacets(facets); + loadedById.put(entity.getId(), entity); + } + } + for (Entity result : results) { + Object old = result.getProperty(relation.getName()); + if (old instanceof Entity reference) { + Entity loaded = loadedById.get(reference.getId()); + // A target filter cannot erase the source's known FK. + if (loaded != null) attachRelation(result, relation, loaded); + } + } + return; + } + + io.teaql.core.internal.TempRequest parentTemp = SqlDiagnosticRequest.forRelation( + parentRequest, intent, origin, relation.getName()); parentTemp.appendSearchCriteria(parentTemp.createBasicSearchCriteria(BaseEntity.ID_PROPERTY, io.teaql.core.criteria.Operator.IN, parents)); // This is a framework-owned lookup over the already materialized child page. // A caller may project the parent without specifying a separate page size, but @@ -259,15 +328,38 @@ private void collectChildren( UserContext userContext, SmartList dataSet, Relation relation, - SearchRequest childRequest, SqlIntentRedactions intent) { - io.teaql.core.internal.TempRequest childTempRequest = new SqlDiagnosticRequest(childRequest, intent); + SearchRequest childRequest, SqlIntentRedactions intent, SearchRequest origin) { + io.teaql.core.internal.TempRequest childTempRequest = SqlDiagnosticRequest.forRelation( + childRequest, intent, origin, relation.getName()); PropertyDescriptor reverseProperty = relation.getReverseProperty(); - childTempRequest.selectProperty(reverseProperty.getName()); + selectRelationAttachmentKey(childTempRequest, reverseProperty.getName()); Slice slice = childTempRequest.getSlice(); boolean boundedTopN = slice != null && slice.getSize() > 0; if (boundedTopN) ensureStableEntityIdOrder(childTempRequest); Integer configuredThreshold = childTempRequest.topNProbeParentThreshold(); boolean probe = boundedTopN && shouldProbe(dataSet.size(), configuredThreshold); + + // A collection Facet is scoped to one parent's entire filtered child + // set, even when the visible child page is smaller. A union query's + // Facets cannot be copied to every parent. Keep each returned SmartList + // intact, including its explicitly loaded empty Facets. + if (childRequest.getFacetRequests() != null && !childRequest.getFacetRequests().isEmpty()) { + for (Entity parent : dataSet) { + var scoped = SqlDiagnosticRequest.forRelation(childRequest, intent, origin, relation.getName()); + selectRelationAttachmentKey(scoped, reverseProperty.getName()); + scoped.setPartitionProperty(null); + if (boundedTopN) { + ensureStableEntityIdOrder(scoped); + addTopNTelemetry(scoped, dataSet.size(), slice.getSize(), configuredThreshold, + "facet-scope", dataSet.size()); + } + scoped.appendSearchCriteria(scoped.createBasicSearchCriteria( + reverseProperty.getName(), io.teaql.core.criteria.Operator.EQUAL, parent)); + SmartList loaded = userContext.internalExecuteForList(scoped); + parent.setProperty(relation.getName(), loaded); + } + return; + } SmartList children = new SmartList<>(); if (probe) { @@ -275,8 +367,8 @@ private void collectChildren( "probe", dataSet.size()); for (Entity parent : dataSet) { io.teaql.core.internal.TempRequest probeRequest = - new SqlDiagnosticRequest(childRequest, intent); - probeRequest.selectProperty(reverseProperty.getName()); + SqlDiagnosticRequest.forRelation(childRequest, intent, origin, relation.getName()); + selectRelationAttachmentKey(probeRequest, reverseProperty.getName()); probeRequest.setPartitionProperty(null); ensureStableEntityIdOrder(probeRequest); probeRequest.appendSearchCriteria( @@ -328,6 +420,14 @@ private void attachRelation(Entity target, PropertyDescriptor relation, Entity v } } + private void selectRelationAttachmentKey(BaseRequest request, String property) { + // Public selectProperty intentionally unselects a same-named relation. + // Internal key projection must preserve that requested hydration (and + // must not mutate TempRequest's shared source relation map). + request.getProjections().removeIf(projection -> projection.name().equals(property)); + request.getProjections().add(new SimpleNamedExpression(property)); + } + private void ensureStableEntityIdOrder(BaseRequest request) { boolean hasId = request.getOrderBy().properties(null).stream() .anyMatch(BaseEntity.ID_PROPERTY::equals); @@ -367,8 +467,16 @@ public MutationResult mutate(UserContext context, PersistenceMutation request) { String typeName = entity.typeName(); PortableSQLRepository repository = getRepository(typeName); // Local to this mutation, never stored on context or a shared repository. - var readbackIntent = context.isQueryExecutionLoggingEnabled() || context.isMutationExecutionLoggingEnabled() - ? new io.teaql.core.SqlIntentRedactions() : null; + var readbackIntent = mutation.diagnosticRedactions(); + repository.captureMutationIntent(entity, readbackIntent); + if (mutation.diagnosticSource() != entity) + repository.captureMutationIntent(mutation.diagnosticSource(), readbackIntent); + var statements = new ArrayList(); + + String operation = mutation.getAction() == EntityPersistenceMutation.Action.DELETE ? "delete" + : entity.newItem() ? "insert" : entity.recoverItem() ? "recover" : "update"; + var trace = io.teaql.core.SqlExecutionTrace.mutation(entity, mutation.getTraceChain(), operation, mutation.intent()) + .collecting(statements::add); if (mutation.getAction() == EntityPersistenceMutation.Action.SAVE) { if (entity.getId() == null) { @@ -377,19 +485,19 @@ public MutationResult mutate(UserContext context, PersistenceMutation request) { } if (entity.newItem()) { ((BaseEntity) entity).__internalSet("version", 1L); - repository.createInternal(context, Collections.singletonList(entity), readbackIntent); + repository.createInternal(context, Collections.singletonList(entity), readbackIntent, trace); } else if (entity.updateItem()) { - repository.updateInternal(context, Collections.singletonList(entity), readbackIntent); + repository.updateInternal(context, Collections.singletonList(entity), readbackIntent, trace); ((BaseEntity) entity).__internalSet("version", entity.getVersion() + 1); } else if (entity.recoverItem()) { - repository.recoverInternal(context, Collections.singletonList(entity), readbackIntent); + repository.recoverInternal(context, Collections.singletonList(entity), readbackIntent, trace); ((BaseEntity) entity).__internalSet("version", -entity.getVersion() + 1); } if (entity instanceof BaseEntity) { ((BaseEntity) entity).gotoNextStatus(EntityAction.PERSIST); } } else if (mutation.getAction() == EntityPersistenceMutation.Action.DELETE) { - repository.deleteInternal(context, Collections.singletonList(entity), readbackIntent); + repository.deleteInternal(context, Collections.singletonList(entity), readbackIntent, trace); ((BaseEntity) entity).__internalSet("version", -(entity.getVersion() + 1)); if (entity instanceof BaseEntity) { ((BaseEntity) entity).gotoNextStatus(EntityAction.PERSIST); @@ -400,9 +508,90 @@ public MutationResult mutate(UserContext context, PersistenceMutation request) { if (entity.getId() != null && (mutation.getAction() == EntityPersistenceMutation.Action.SAVE || mutation.getAction() == EntityPersistenceMutation.Action.DELETE)) { - persisted = repository.loadPersistedById(context, entity.getId(), readbackIntent); + persisted = repository.loadPersistedById(context, entity.getId(), readbackIntent, trace.readback(mutation.intent())); + } + return new io.teaql.core.DefaultMutationResult(persisted, statements); + } + + @Override + @SuppressWarnings("unchecked") + public List mutateBatch(UserContext context, MutationBatchRequest request) { + Objects.requireNonNull(request, "request"); + List items = new ArrayList<>(); + for (PersistenceMutation item : request.items()) { + if (!(item instanceof EntityPersistenceMutation mutation)) { + throw new TeaQLRuntimeException("Unsupported batch member in PortableSQLDataService"); + } + items.add(mutation); + } + if (items.isEmpty()) return List.of(); + String type = items.get(0).getEntity().typeName(); + String operation = batchOperation(items.get(0)); + if (items.stream().anyMatch(item -> !item.getEntity().typeName().equals(type) + || !batchOperation(item).equals(operation))) { + throw new TeaQLRuntimeException("Portable SQL prepared batch requires one entity type and mutation operation"); } - return new io.teaql.core.DefaultMutationResult(persisted); + return executeInTransaction(context, () -> { + PortableSQLRepository repository = getRepository(type); + var redactions = new SqlIntentRedactions(); + for (var item : items) { + redactions.include(item.diagnosticRedactions()); + repository.captureMutationIntent(item.getEntity(), redactions); + if (item.diagnosticSource() != item.getEntity()) + repository.captureMutationIntent(item.diagnosticSource(), redactions); + } + List entities = new ArrayList<>(); + List traces = new ArrayList<>(); + List> statements = new ArrayList<>(); + for (EntityPersistenceMutation item : items) { + var entity = (BaseEntity) item.getEntity(); + if (operation.equals("insert")) { + if (entity.getId() == null) entity.__internalSet("id", repository.prepareId(context, entity)); + entity.__internalSet("version", 1L); + } else if (entity.getId() == null || entity.getVersion() == null) { + throw new TeaQLRuntimeException("Prepared persisted mutation requires identity and optimistic version"); + } + entities.add(entity); + var memberStatements = new ArrayList(); + statements.add(memberStatements); + traces.add(SqlExecutionTrace.mutation(entity, item.getTraceChain(), operation, item.intent()) + .collecting(memberStatements::add)); + } + switch (operation) { + case "insert" -> repository.createBatchInternal(context, entities, redactions, traces); + case "update" -> repository.updateBatchInternal(context, entities, redactions, traces); + case "delete" -> repository.deleteBatchInternal(context, entities, redactions, traces); + case "recover" -> repository.recoverBatchInternal(context, entities, redactions, traces); + default -> throw new TeaQLRuntimeException("Unsupported prepared mutation operation"); + } + List results = new ArrayList<>(); + for (int index = 0; index < items.size(); index++) { + var item = items.get(index); + var entity = (BaseEntity) item.getEntity(); + if (!operation.equals("insert")) { + long version = entity.getVersion(); + entity.__internalSet("version", operation.equals("delete") ? -(version + 1) + : operation.equals("recover") ? -version + 1 : version + 1); + } + entity.gotoNextStatus(EntityAction.PERSIST); + Entity persisted = repository.loadPersistedById(context, entity.getId(), redactions, + traces.get(index).readback(item.intent())); + if (persisted == null) throw new TeaQLRuntimeException("Batch mutation readback returned no entity"); + results.add(new DefaultMutationResult(persisted, statements.get(index))); + } + return List.copyOf(results); + }); + } + + private String batchOperation(EntityPersistenceMutation item) { + Entity entity = item.getEntity(); + if (item.getAction() == EntityPersistenceMutation.Action.DELETE && entity.deleteItem()) return "delete"; + if (item.getAction() == EntityPersistenceMutation.Action.SAVE) { + if (entity.newItem()) return "insert"; + if (entity.updateItem()) return "update"; + if (entity.recoverItem()) return "recover"; + } + throw new TeaQLRuntimeException("Prepared mutation member has no executable persistence state"); } @Override @@ -427,8 +616,4 @@ public T executeInTransaction(UserContext context, TransactionCallback ac return (T) resultHolder[0]; } - public void ensureSchema(UserContext context, String typeName) { - PortableSQLRepository repository = getRepository(typeName); - repository.ensureSchema(context); - } } diff --git a/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/PortableSQLRepository.java b/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/PortableSQLRepository.java index 635f05ee..ec2e608d 100644 --- a/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/PortableSQLRepository.java +++ b/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/PortableSQLRepository.java @@ -61,7 +61,6 @@ import io.teaql.core.meta.EntityDescriptor; import io.teaql.core.meta.EntityMetaFactory; import io.teaql.core.meta.PropertyDescriptor; -import io.teaql.core.meta.PropertyType; import io.teaql.core.meta.Relation; import io.teaql.core.sql.SQLColumn; @@ -223,6 +222,9 @@ public Map getExpressionParsers() { // ========================================== public String buildDataSQL(UserContext userContext, SearchRequest request, Map parameters) { + if (parameters instanceof io.teaql.core.sql.SqlParameters tracked) { + tracked.captureQueryContext(request); + } String partitionProperty = request.getPartitionProperty(); if (ObjectUtil.isNotEmpty(partitionProperty) && request.getSlice() != null) { ensureOrderByForPartition(request); @@ -232,6 +234,17 @@ public String buildDataSQL(UserContext userContext, SearchRequest request, Map materializeRelationPredicate(UserContext context, SearchRequest child, + SearchRequest origin, String propertyName) { + var source = SqlDiagnosticRequest.source(context, origin); + var property = findProperty(propertyName); + var lookup = property instanceof Relation + ? SqlDiagnosticRequest.forRelation(child, source, origin, propertyName) + : SqlDiagnosticRequest.forDerived(child, source, origin); + return context.internalExecuteForList(lookup); + } + // ========================================== // Named parameter → positional parameter conversion // ========================================== @@ -262,16 +275,18 @@ private record ColumnBinding( private record QueryShape(String key, Object[] arguments) {} - private PositionalSQL withQueryIntent(PositionalSQL sql, io.teaql.core.SqlIntentRedactions intent) { + private PositionalSQL withQueryIntent(PositionalSQL sql, io.teaql.core.SqlIntentRedactions intent, + SearchRequest request) { if (intent == null) return sql; intent.capture(sql.logBindings.policies(), sql.args); return new PositionalSQL(sql.sql, sql.args, new SqlLogBindings(sql.logBindings.policies(), - sql.logBindings.generated(), sql.logBindings.diagnosticSql(), intent.copy())); + sql.logBindings.generated(), sql.logBindings.diagnosticSql(), intent.copy(), + SqlDiagnosticRequest.statementTrace(request))); } private SqlLogBindings withMutationIntent(SqlLogBindings bindings, io.teaql.core.SqlIntentRedactions intent) { if (intent == null) return bindings; - return new SqlLogBindings(bindings.policies(), bindings.generated(), bindings.diagnosticSql(), intent.copy()); + return new SqlLogBindings(bindings.policies(), bindings.generated(), bindings.diagnosticSql(), intent.copy(), bindings.executionTrace(), bindings.batchTraces()); } private PositionalSQL toPositional(String namedSql, Map params) { @@ -589,7 +604,7 @@ private IdSetExecution prepareIdSetPage(UserContext context, SearchRequest } String key = idSetQueryKey(context, working, options, idSql, idParams); // Capture current bindings even when retained IDs avoid executing the discovery query. - PositionalSQL idStatement = withQueryIntent(toPositional(idSql, idParams), intent); + PositionalSQL idStatement = withQueryIntent(toPositional(idSql, idParams), intent, idRequest); IdSetStore store = idSetStore(context); RetainedIdSet retained; try { @@ -700,6 +715,9 @@ SmartList loadInternal(UserContext userContext, SearchRequest request, private SmartList loadWithIntent(UserContext userContext, SearchRequest request, io.teaql.core.SqlIntentRedactions intent) { + // Inspect the current typed request, including future child predicates, before + // any SQL. Cached plans never own these values and binds are never rewritten. + SqlLikeIntent.capture(userContext, request, this, intent); IdSetExecution idSetExecution = prepareIdSetPage(userContext, request, intent); if (idSetExecution.optimized() && idSetExecution.pageIds().length == 0) { return SmartList.empty(request.returnType()); @@ -744,7 +762,7 @@ private SmartList loadWithIntent(UserContext userContext, SearchRequest re } } // Attach only after inserting the reusable plan: no original values enter the plan cache. - psql = withQueryIntent(psql, intent); + psql = withQueryIntent(psql, intent, request); SmartList smartList; Object mapperExtension = request.getExtension(COMPILED_ROW_MAPPER); io.teaql.core.CompiledRowMapper selectedMapper = @@ -787,7 +805,8 @@ private SmartList loadWithIntent(UserContext userContext, SearchRequest re if (facetRequests != null && !facetRequests.isEmpty()) { io.teaql.core.sql.SqlAstCompiler compiler = new io.teaql.core.sql.SqlAstCompiler(); for (io.teaql.core.FacetRequest facetRequest : facetRequests) { - io.teaql.core.internal.TempRequest tr = new io.teaql.core.internal.TempRequest(request); + io.teaql.core.internal.TempRequest tr = + SqlDiagnosticRequest.forDerived(request, intent, request); tr.setAggregations(new io.teaql.core.Aggregations()); tr.groupBy(facetRequest.getRelationName()); tr.count("count"); @@ -797,7 +816,7 @@ private SmartList loadWithIntent(UserContext userContext, SearchRequest re String facetSql = compiler.buildAggregationSQL(this.sqlMetadata, this, userContext, tr, facetParams, facetTables); if (!io.teaql.core.utils.ObjectUtil.isEmpty(facetSql)) { var facetIntent = intent == null ? null : intent.copy(); - PositionalSQL psqlFacet = withQueryIntent(toPositional(facetSql, facetParams), facetIntent); + PositionalSQL psqlFacet = withQueryIntent(toPositional(facetSql, facetParams), facetIntent, request); List> facetRows = database.query(userContext, psqlFacet.sql, psqlFacet.args, psqlFacet.logBindings); SmartList facetEntities = new SmartList<>(); @@ -817,9 +836,16 @@ private SmartList loadWithIntent(UserContext userContext, SearchRequest re idToCount.put(io.teaql.core.utils.Convert.convert(Long.class, relId), countVal); } } - io.teaql.core.internal.TempRequest fetchRelReq = new io.teaql.core.internal.TempRequest(relationReq); + io.teaql.core.internal.TempRequest fetchRelReq = + SqlDiagnosticRequest.forRelation( + relationReq, facetIntent, request, facetRequest.getRelationName()); if (facetRequest.isMergeCriteria()) { - fetchRelReq.appendSearchCriteria(request.getSearchCriteria()); + // The count query already applied the source's + // filters. Its FK membership is the only valid + // restriction on the target: source predicates + // belong to another table/type (even "id"). + fetchRelReq.appendSearchCriteria(fetchRelReq.createBasicSearchCriteria( + BaseEntity.ID_PROPERTY, io.teaql.core.criteria.Operator.IN, relIds)); } SmartList loadedRels = relationRepo.loadInternal(userContext, fetchRelReq, facetIntent); java.util.List countAliases = relationReq.getAggregations().getAggregates() @@ -836,6 +862,10 @@ private SmartList loadWithIntent(UserContext userContext, SearchRequest re } facetEntities.add(rel); } + // Materialization changes the row carrier, not the + // nested facet result. Preserve its collection-owned + // metadata without sharing the mutable map itself. + loadedRels.getFacets().forEach(facetEntities::addFacet); } } smartList.addFacet(facetRequest.getFacetName(), facetEntities); @@ -1029,6 +1059,19 @@ private boolean isActiveVersionPredicate(SearchCriteria criteria) { return number.longValue() == 0L && number.doubleValue() == 0D; } + /** Loaded private values may appear in intent even when absent from this write's bindings. */ + void captureMutationIntent(Entity entity, io.teaql.core.SqlIntentRedactions intent) { + if (!(entity instanceof BaseEntity base)) return; + for (PropertyDescriptor property : allProperties) { + if (property instanceof Relation || !shouldHandle(property)) continue; + String name = property.getName(); + var policy = List.of(parameterLogPolicy(name)); + if (base.isPropertyLoaded(name)) intent.capture(policy, new Object[] {base.getProperty(name)}); + if (base.getUpdatedProperties().contains(name)) + intent.capture(policy, new Object[] {base.getOldValue(name)}); + } + } + @SuppressWarnings("unchecked") public T loadPersistedById(UserContext userContext, Long id) { return loadPersistedById(userContext, id, null); @@ -1036,12 +1079,18 @@ public T loadPersistedById(UserContext userContext, Long id) { @SuppressWarnings("unchecked") T loadPersistedById(UserContext userContext, Long id, io.teaql.core.SqlIntentRedactions intent) { + return loadPersistedById(userContext, id, intent, null); + } + + @SuppressWarnings("unchecked") + T loadPersistedById(UserContext userContext, Long id, io.teaql.core.SqlIntentRedactions intent, + io.teaql.core.SqlExecutionTrace trace) { String primaryTable = thisPrimaryTableName != null ? thisPrimaryTableName : tableName(entityDescriptor.getType()); String sql = "SELECT * FROM " + escapeIdentifier(primaryTable) + " WHERE " + escapeIdentifier("id") + " = ?"; List> rows = database.query(userContext, sql, new Object[] {id}, - new SqlLogBindings(List.of(parameterLogPolicy("id")), true, null, intent)); + new SqlLogBindings(List.of(parameterLogPolicy("id")), true, null, intent, trace)); if (rows.size() != 1) { throw new TeaQLRuntimeException( "Persisted " + entityDescriptor.getType() + "(" + id + ") could not be read back"); @@ -1075,10 +1124,13 @@ T loadPersistedById(UserContext userContext, Long id, io.teaql.core.SqlIntentRed } public Stream streamInternal(UserContext userContext, SearchRequest request) { + var intent = SqlDiagnosticRequest.source(userContext, request); + SqlLikeIntent.capture(userContext, request, this, intent); Map params = new io.teaql.core.sql.SqlParameters(); String sql = buildDataSQL(userContext, request, params); if (ObjectUtil.isEmpty(sql)) return Stream.empty(); - PositionalSQL psql = toPositional(sql, params); + PositionalSQL psql = withQueryIntent(toPositional(sql, params), + intent, request); return database.queryForStream(userContext, psql.sql, psql.args, psql.logBindings) .map(row -> mapRowToEntity(userContext, request, row)); } @@ -1218,48 +1270,197 @@ public void createInternal(UserContext userContext, Collection createItems) { } void createInternal(UserContext userContext, Collection createItems, io.teaql.core.SqlIntentRedactions intent) { - if (intent != null) createItems.forEach(item -> intent.captureTargetId(item.getId())); - List sqlEntities = CollectionUtil.map(createItems, - i -> convertToSQLEntityForInsert(userContext, i), true); - if (ObjectUtil.isEmpty(sqlEntities)) return; + createInternal(userContext, createItems, intent, null); + } - SQLEntity sqlEntity = sqlEntities.get(0); - Map> tableColumns = sqlEntity.getTableColumnNames(); + void createInternal(UserContext userContext, Collection createItems, io.teaql.core.SqlIntentRedactions intent, + io.teaql.core.SqlExecutionTrace trace) { + createRows(userContext, new ArrayList<>(createItems), intent, trace, List.of()); + } - Map> rows = new HashMap<>(); - for (SQLEntity entity : sqlEntities) { + void createBatchInternal(UserContext userContext, List createItems, io.teaql.core.SqlIntentRedactions intent, + List traces) { + if (createItems.size() != traces.size()) { + throw new IllegalArgumentException("Insert batch requires one trace per entity"); + } + createRows(userContext, createItems, intent, null, List.copyOf(traces)); + } + + private record InsertShape(String table, List columns) { + private InsertShape { columns = List.copyOf(columns); } + } + + private record InsertRow(Object[] values, SQLEntity entity, io.teaql.core.SqlExecutionTrace trace) {} + + private void createRows(UserContext userContext, List createItems, io.teaql.core.SqlIntentRedactions intent, + io.teaql.core.SqlExecutionTrace fallback, List traces) { + if (intent != null) createItems.forEach(item -> intent.captureTargetId(item.getId())); + Map> rows = new java.util.LinkedHashMap<>(); + for (int index = 0; index < createItems.size(); index++) { + SQLEntity entity = convertToSQLEntityForInsert(userContext, createItems.get(index)); Map tableColumnValues = entity.getTableColumnValues(); for (Map.Entry entry : tableColumnValues.entrySet()) { String k = entry.getKey(); List v = entry.getValue(); - List values = rows.computeIfAbsent(k, key -> new ArrayList<>()); if (auxiliaryTableNames.contains(k) && entity.allNullExceptID(v)) continue; - values.add(v.toArray()); + var shape = new InsertShape(k, entity.getTableColumnNames().get(k)); + rows.computeIfAbsent(shape, key -> new ArrayList<>()).add( + new InsertRow(v.toArray(), entity, traces.isEmpty() ? fallback : traces.get(index))); } } - - TreeMap> sorted = MapUtil.sort(rows, (t1, t2) -> { - if (t1.equals(versionTableName)) return -1; - if (t2.equals(versionTableName)) return 1; - return 0; + // Capture every sibling/table before any statement is emitted. A root reason + // can mention a masked value belonging to a later member of this batch. + if (intent != null) rows.forEach((shape, members) -> { + var policies = logBindings(shape.table(), shape.columns()).policies(); + members.forEach(member -> intent.capture(policies, member.values())); }); - - sorted.forEach((k, v) -> { - if (v.isEmpty()) return; - List columns = tableColumns.get(k); + var shapes = new ArrayList<>(rows.keySet()); + shapes.sort(java.util.Comparator.comparingInt((InsertShape shape) -> shape.table().equals(versionTableName) ? 0 : 1) + .thenComparing(InsertShape::table).thenComparing(shape -> String.join(",", shape.columns()))); + for (InsertShape shape : shapes) { + List members = rows.get(shape); + SQLEntity first = members.get(0).entity(); io.teaql.core.sql.SqlAstCompiler compiler = new io.teaql.core.sql.SqlAstCompiler(); - String sql = compiler.buildInsertSQL(this, k, columns, sqlEntity.getTraceChain()); - var bindings = logBindings(k, columns, sql, sqlEntity.getTraceChain()); - if (intent != null) for (Object[] args : v) intent.capture(bindings.policies(), args); - database.batchUpdate(userContext, sql, v, withMutationIntent(bindings, intent)); + String sql = compiler.buildInsertSQL(this, shape.table(), shape.columns(), first.getTraceChain()); + var bindings = logBindings(shape.table(), shape.columns(), sql, first.getTraceChain()); + bindings = traces.isEmpty() ? bindings.withTrace(fallback) + : bindings.withBatchTraces(members.stream().map(InsertRow::trace).toList()); + database.batchUpdate(userContext, sql, members.stream().map(InsertRow::values).toList(), + withMutationIntent(bindings, intent)); + } + } + + private record PreparedWriteShape(String table, String sql, List bindingColumns, + boolean optimistic, boolean exactlyOne) { + private PreparedWriteShape { bindingColumns = List.copyOf(bindingColumns); } + } + + private record PreparedWriteRow(Object[] values, io.teaql.core.SqlExecutionTrace trace) {} + + void updateBatchInternal(UserContext context, List entities, io.teaql.core.SqlIntentRedactions intent, + List traces) { + requireMemberTraces(entities, traces); + Map> rows = new java.util.LinkedHashMap<>(); + var compiler = new io.teaql.core.sql.SqlAstCompiler(); + for (int index = 0; index < entities.size(); index++) { + T entity = entities.get(index); + if (intent != null) intent.captureTargetId(entity.getId()); + SQLEntity converted = convertToSQLEntityForUpdate(context, entity); + boolean versionUpdated = false; + if (converted != null) { + for (var entry : converted.getTableColumnValues().entrySet()) { + String table = entry.getKey(); + List columns = new ArrayList<>(converted.getTableColumnNames().get(table)); + List values = new ArrayList<>(entry.getValue()); + List bindings = new ArrayList<>(columns); + String sql; + boolean optimistic = table.equals(versionTableName); + boolean primary = primaryTableNames.contains(table); + if (optimistic) { + versionUpdated = true; + columns.add("version"); + bindings.add("version"); bindings.add("id"); bindings.add("version"); + values.add(entity.getVersion() + 1); values.add(entity.getId()); values.add(entity.getVersion()); + sql = compiler.buildUpdateVersionSQL(this, table, columns, null); + } else if (primary) { + bindings.add("id"); values.add(entity.getId()); + sql = compiler.buildUpdatePrimarySQL(this, table, columns, null); + } else { + sql = dialect.buildSubsidiaryInsertSql(table, columns); + } + var shape = new PreparedWriteShape(table, sql, bindings, optimistic, optimistic || primary); + rows.computeIfAbsent(shape, ignored -> new ArrayList<>()).add( + new PreparedWriteRow(values.toArray(), traces.get(index))); + } + } + if (!versionUpdated) { + var shape = new PreparedWriteShape(versionTableName, + compiler.buildUpdateVersionTableVersionSQL(this, versionTableName), + List.of("version", "id", "version"), true, true); + rows.computeIfAbsent(shape, ignored -> new ArrayList<>()).add(new PreparedWriteRow( + new Object[]{entity.getVersion() + 1, entity.getId(), entity.getVersion()}, traces.get(index))); + } + } + executePreparedWrites(context, rows, intent); + } + + void deleteBatchInternal(UserContext context, List entities, io.teaql.core.SqlIntentRedactions intent, + List traces) { + versionBatchInternal(context, entities, intent, traces, false); + } + + void recoverBatchInternal(UserContext context, List entities, io.teaql.core.SqlIntentRedactions intent, + List traces) { + versionBatchInternal(context, entities, intent, traces, true); + } + + private void versionBatchInternal(UserContext context, List entities, io.teaql.core.SqlIntentRedactions intent, + List traces, boolean recover) { + requireMemberTraces(entities, traces); + var compiler = new io.teaql.core.sql.SqlAstCompiler(); + var shape = new PreparedWriteShape(versionTableName, + recover ? compiler.buildRecoverSQL(this, versionTableName) : compiler.buildDeleteSQL(this, versionTableName), + List.of("version", "id", "version"), true, true); + List members = new ArrayList<>(); + for (int index = 0; index < entities.size(); index++) { + T entity = entities.get(index); + Long version = entity.getVersion(); + if (version == null || (recover ? version >= 0 : version <= 0)) { + throw new IllegalArgumentException("Delete/recover batch requires the matching persisted version sign"); + } + if (intent != null) intent.captureTargetId(entity.getId()); + members.add(new PreparedWriteRow(new Object[]{recover ? -version + 1 : -(version + 1), + entity.getId(), version}, traces.get(index))); + } + executePreparedWrites(context, Map.of(shape, members), intent); + } + + private void requireMemberTraces(List entities, List traces) { + if (entities.size() != traces.size()) throw new IllegalArgumentException("Write batch requires one trace per entity"); + if (traces.stream().anyMatch(java.util.Objects::isNull)) throw new IllegalArgumentException("Write batch trace must not be null"); + } + + private void executePreparedWrites(UserContext context, + Map> rows, io.teaql.core.SqlIntentRedactions intent) { + // Capture the complete planned batch before emitting any table's statement. + if (intent != null) rows.forEach((shape, members) -> { + var policies = logBindings(shape.table(), shape.bindingColumns()).policies(); + members.forEach(member -> intent.capture(policies, member.values())); }); + var shapes = new ArrayList<>(rows.keySet()); + shapes.sort(java.util.Comparator.comparingInt((PreparedWriteShape shape) -> shape.optimistic() ? 0 : 1) + .thenComparing(PreparedWriteShape::table).thenComparing(PreparedWriteShape::sql)); + for (var shape : shapes) { + var members = rows.get(shape); + if (members.isEmpty()) continue; + var bindings = logBindings(shape.table(), shape.bindingColumns()) + .withBatchTraces(members.stream().map(PreparedWriteRow::trace).toList()); + int[] counts = database.batchUpdate(context, shape.sql(), members.stream().map(PreparedWriteRow::values).toList(), + withMutationIntent(bindings, intent)); + if (counts.length != members.size()) throw new TeaQLRuntimeException("Prepared mutation returned an incomplete row-count array"); + if (shape.exactlyOne()) { + for (int count : counts) { + if (count == 1) continue; + if (count == java.sql.Statement.SUCCESS_NO_INFO) { + throw new TeaQLRuntimeException("Prepared mutation requires an exact per-item affected-row count"); + } + if (shape.optimistic()) throw new ConcurrentModifyException(); + throw new TeaQLRuntimeException("primary table update failed"); + } + } + } } - public void updateInternal(UserContext userContext, Collection updateItems) { + public void updateInternal(UserContext userContext, Collection updateItems) { updateInternal(userContext, updateItems, null); } void updateInternal(UserContext userContext, Collection updateItems, io.teaql.core.SqlIntentRedactions intent) { + updateInternal(userContext, updateItems, intent, null); + } + + void updateInternal(UserContext userContext, Collection updateItems, io.teaql.core.SqlIntentRedactions intent, + io.teaql.core.SqlExecutionTrace trace) { if (intent != null) updateItems.forEach(item -> intent.captureTargetId(item.getId())); if (ObjectUtil.isEmpty(updateItems)) return; List sqlEntities = CollectionUtil.map(updateItems, @@ -1279,42 +1480,43 @@ void updateInternal(UserContext userContext, Collection updateItems, io.teaql boolean primaryTable = this.primaryTableNames.contains(k); if (versionTable) { - updateVersionTable(userContext, sqlEntity, versionTableUpdated, k, columns, l, intent); + updateVersionTable(userContext, sqlEntity, versionTableUpdated, k, columns, l, intent, trace); return; } if (primaryTable) { - updatePrimaryTable(userContext, sqlEntity, k, columns, l, intent); + updatePrimaryTable(userContext, sqlEntity, k, columns, l, intent, trace); return; } String updateSql = dialect.buildSubsidiaryInsertSql(k, columns); - var bindings = logBindings(k, columns); + var bindings = logBindings(k, columns).withTrace(trace); if (intent != null) intent.capture(bindings.policies(), l.toArray()); database.executeUpdate(userContext, updateSql, l.toArray(), withMutationIntent(bindings, intent)); }); if (!versionTableUpdated.get()) { - updateVersionTableVersion(userContext, sqlEntity, intent); + updateVersionTableVersion(userContext, sqlEntity, intent, trace); } } } - private void updateVersionTableVersion(UserContext userContext, SQLEntity sqlEntity, io.teaql.core.SqlIntentRedactions intent) { + private void updateVersionTableVersion(UserContext userContext, SQLEntity sqlEntity, io.teaql.core.SqlIntentRedactions intent, + io.teaql.core.SqlExecutionTrace trace) { io.teaql.core.sql.SqlAstCompiler compiler = new io.teaql.core.sql.SqlAstCompiler(); String updateSql = compiler.buildUpdateVersionTableVersionSQL(this, this.versionTableName); Object[] parameters = {sqlEntity.getVersion() + 1, sqlEntity.getId(), sqlEntity.getVersion()}; - var bindings = logBindings(this.versionTableName, List.of("version", "id", "version")); + var bindings = logBindings(this.versionTableName, List.of("version", "id", "version")).withTrace(trace); if (intent != null) intent.capture(bindings.policies(), parameters); int update = database.executeUpdate(userContext, updateSql, parameters, withMutationIntent(bindings, intent)); if (update != 1) throw new ConcurrentModifyException(); } private void updatePrimaryTable(UserContext userContext, SQLEntity sqlEntity, String k, List columns, List l, - io.teaql.core.SqlIntentRedactions intent) { + io.teaql.core.SqlIntentRedactions intent, io.teaql.core.SqlExecutionTrace trace) { l.add(sqlEntity.getId()); io.teaql.core.sql.SqlAstCompiler compiler = new io.teaql.core.sql.SqlAstCompiler(); String updateSql = compiler.buildUpdatePrimarySQL(this, k, columns, sqlEntity.getTraceChain()); List bindings = new ArrayList<>(columns); bindings.add("id"); - var policies = logBindings(k, bindings, updateSql, sqlEntity.getTraceChain()); + var policies = logBindings(k, bindings, updateSql, sqlEntity.getTraceChain()).withTrace(trace); if (intent != null) intent.capture(policies.policies(), l.toArray()); int update = database.executeUpdate(userContext, updateSql, l.toArray(), withMutationIntent(policies, intent)); if (update != 1) throw new TeaQLRuntimeException("primary table update failed"); @@ -1322,7 +1524,7 @@ private void updatePrimaryTable(UserContext userContext, SQLEntity sqlEntity, St private void updateVersionTable(UserContext userContext, SQLEntity sqlEntity, AtomicBoolean versionTableUpdated, String k, List columns, List l, - io.teaql.core.SqlIntentRedactions intent) { + io.teaql.core.SqlIntentRedactions intent, io.teaql.core.SqlExecutionTrace trace) { versionTableUpdated.set(true); columns.add("version"); l.add(sqlEntity.getVersion() + 1); @@ -1331,7 +1533,7 @@ private void updateVersionTable(UserContext userContext, SQLEntity sqlEntity, io.teaql.core.sql.SqlAstCompiler compiler = new io.teaql.core.sql.SqlAstCompiler(); String updateSql = compiler.buildUpdateVersionSQL(this, k, columns, sqlEntity.getTraceChain()); List bindings = new ArrayList<>(columns); bindings.add("id"); bindings.add("version"); - var policies = logBindings(k, bindings, updateSql, sqlEntity.getTraceChain()); + var policies = logBindings(k, bindings, updateSql, sqlEntity.getTraceChain()).withTrace(trace); if (intent != null) intent.capture(policies.policies(), l.toArray()); int update = database.executeUpdate(userContext, updateSql, l.toArray(), withMutationIntent(policies, intent)); if (update != 1) throw new ConcurrentModifyException(); @@ -1342,6 +1544,11 @@ public void deleteInternal(UserContext userContext, Collection entities) { } void deleteInternal(UserContext userContext, Collection entities, io.teaql.core.SqlIntentRedactions intent) { + deleteInternal(userContext, entities, intent, null); + } + + void deleteInternal(UserContext userContext, Collection entities, io.teaql.core.SqlIntentRedactions intent, + io.teaql.core.SqlExecutionTrace trace) { if (intent != null) entities.forEach(item -> intent.captureTargetId(item.getId())); if (ObjectUtil.isEmpty(entities)) return; io.teaql.core.sql.SqlAstCompiler compiler = new io.teaql.core.sql.SqlAstCompiler(); @@ -1350,7 +1557,7 @@ void deleteInternal(UserContext userContext, Collection entities, io.teaql.co .filter(e -> e.getVersion() > 0) .map(e -> new Object[]{-(e.getVersion() + 1), e.getId(), e.getVersion()}) .collect(Collectors.toList()); - var bindings = logBindings(this.versionTableName, List.of("version", "id", "version")); + var bindings = logBindings(this.versionTableName, List.of("version", "id", "version")).withTrace(trace); if (intent != null) for (Object[] row : args) intent.capture(bindings.policies(), row); int[] rets = database.batchUpdate(userContext, updateSql, args, withMutationIntent(bindings, intent)); for (int ret : rets) { @@ -1363,6 +1570,11 @@ public void recoverInternal(UserContext userContext, Collection entities) { } void recoverInternal(UserContext userContext, Collection entities, io.teaql.core.SqlIntentRedactions intent) { + recoverInternal(userContext, entities, intent, null); + } + + void recoverInternal(UserContext userContext, Collection entities, io.teaql.core.SqlIntentRedactions intent, + io.teaql.core.SqlExecutionTrace trace) { if (intent != null) entities.forEach(item -> intent.captureTargetId(item.getId())); if (ObjectUtil.isEmpty(entities)) return; io.teaql.core.sql.SqlAstCompiler compiler = new io.teaql.core.sql.SqlAstCompiler(); @@ -1371,7 +1583,7 @@ void recoverInternal(UserContext userContext, Collection entities, io.teaql.c .filter(e -> e.getVersion() < 0) .map(e -> new Object[]{(-e.getVersion() + 1), e.getId(), e.getVersion()}) .collect(Collectors.toList()); - var bindings = logBindings(this.versionTableName, List.of("version", "id", "version")); + var bindings = logBindings(this.versionTableName, List.of("version", "id", "version")).withTrace(trace); if (intent != null) for (Object[] row : args) intent.capture(bindings.policies(), row); int[] rets = database.batchUpdate(userContext, updateSql, args, withMutationIntent(bindings, intent)); for (int ret : rets) { @@ -1402,11 +1614,6 @@ public Long prepareId(UserContext userContext, T entity) { // Schema management // ========================================== - public void ensureSchema(UserContext context) { - ensurePhysicalSchema(context); - ensureInitData(context); - } - /** * Reconciles physical database objects only. Generated runtime modules use * this boundary before creating roots and constants through audited typed @@ -1756,226 +1963,6 @@ private IllegalStateException schemaFailure(String operation, String table, Exce cause); } - public void ensureInitData(UserContext context) { - if (entityDescriptor.isRoot()) ensureRoot(context); - if (entityDescriptor.isConstant()) ensureConstant(context); - } - - private void ensureRoot(UserContext context) { - List> dbRow = queryBootstrap(context, - StrUtil.format("SELECT * FROM {} WHERE id = '1'", tableName(entityDescriptor.getType())), - "inspect root"); - - if (!dbRow.isEmpty()) { - ensureBootstrapIdFloor(context, 1L); - long version = bootstrapVersion(dbRow.get(0), "inspect root version"); - if (version > 0) return; - String sql = StrUtil.format("UPDATE {} SET version = {} where id = '1'", - tableName(entityDescriptor.getType()), Math.max(1L, -version)); - logInfo(sql + ";"); - if (ensureTableEnabled(context)) { - updateBootstrap(context, sql, "restore root"); - } - return; - } - - List columns = new ArrayList<>(); - List rootRow = new ArrayList<>(); - for (PropertyDescriptor ownProperty : entityDescriptor.getOwnProperties()) { - columns.add(getSqlColumn(ownProperty).getColumnName()); - rootRow.add(getRootPropertyValue(context, ownProperty)); - } - String sql = StrUtil.format("INSERT INTO {} ({}) VALUES ({})", - tableName(entityDescriptor.getType()), - CollectionUtil.join(columns, ","), - CollectionUtil.join(rootRow, ",", value -> getSqlValue(value))); - logInfo(sql + ";"); - if (ensureTableEnabled(context)) { - try { - executeBootstrap(context, sql, "create root"); - } catch (IllegalStateException insertFailure) { - List> observed = queryBootstrap(context, - StrUtil.format("SELECT * FROM {} WHERE id = '1'", tableName(entityDescriptor.getType())), - "verify root after failed insert"); - if (observed.isEmpty()) throw insertFailure; - long version = bootstrapVersion(observed.get(0), "verify root version"); - if (version <= 0) { - updateBootstrap(context, - StrUtil.format("UPDATE {} SET version = {} where id = '1'", - tableName(entityDescriptor.getType()), Math.max(1L, -version)), - "restore root after failed insert"); - } - } - } - ensureBootstrapIdFloor(context, 1L); - } - - private void ensureConstant(UserContext context) { - PropertyDescriptor identifier = entityDescriptor.getIdentifier(); - List candidates = identifier.getCandidates(); - List ownProperties = entityDescriptor.getOwnProperties(); - List columns = ownProperties.stream() - .map(p -> getSqlColumn(p).getColumnName()) - .collect(Collectors.toList()); - - for (int idx = 0; idx < candidates.size(); idx++) { - final int i = idx; - String code = candidates.get(i); - List oneConstant = ownProperties.stream() - .map(p -> getConstantPropertyValue(context, p, i, code)) - .collect(Collectors.toList()); - Object constantId = getConstantPropertyValue( - context, entityDescriptor.findIdProperty(), i, code); - - List> existing = queryBootstrap(context, - StrUtil.format("SELECT * FROM {} WHERE id = '{}'", - tableName(entityDescriptor.getType()), constantId), - "inspect constant"); - if (!existing.isEmpty()) { - long version = bootstrapVersion(existing.get(0), "inspect constant version"); - if (version > 0) { - reconcileConstant(context, ownProperties, oneConstant, existing.get(0), version); - ensureBootstrapIdFloor(context, constantId); - continue; - } - restoreConstant(context, constantId, version, ownProperties, oneConstant); - ensureBootstrapIdFloor(context, constantId); - continue; - } - - String sql = StrUtil.format("INSERT INTO {} ({}) VALUES ({})", - tableName(entityDescriptor.getType()), - CollectionUtil.join(columns, ","), - CollectionUtil.join(oneConstant, ",", value -> getSqlValue(value))); - logInfo(sql + ";"); - if (ensureTableEnabled(context)) { - try { - executeBootstrap(context, sql, "create constant"); - } catch (IllegalStateException insertFailure) { - List> observed = queryBootstrap(context, - StrUtil.format("SELECT * FROM {} WHERE id = '{}'", - tableName(entityDescriptor.getType()), constantId), - "verify constant after failed insert"); - if (observed.isEmpty()) throw insertFailure; - long version = bootstrapVersion(observed.get(0), "verify constant version"); - if (version > 0) { - reconcileConstant(context, ownProperties, oneConstant, observed.get(0), version); - } else { - restoreConstant(context, constantId, version, ownProperties, oneConstant); - } - } - } - ensureBootstrapIdFloor(context, constantId); - } - } - - private List> queryBootstrap( - UserContext context, String sql, String operation) { - try { - return database.query(context, sql, new Object[0]); - } catch (Exception failure) { - throw bootstrapFailure(operation, failure); - } - } - - private void executeBootstrap(UserContext context, String sql, String operation) { - try { - database.execute(context, sql); - } catch (Exception failure) { - throw bootstrapFailure(operation, failure); - } - } - - private void updateBootstrap(UserContext context, String sql, String operation) { - try { - int affected = database.executeUpdate(context, sql, new Object[0]); - if (affected != 1) { - throw new IllegalStateException("Expected one bootstrap row, updated " + affected); - } - } catch (Exception failure) { - throw bootstrapFailure(operation, failure); - } - } - - private IllegalStateException bootstrapFailure(String operation, Exception cause) { - return new IllegalStateException( - "Cannot " + operation + " for " + entityDescriptor.getType() - + " on table " + tableName(entityDescriptor.getType()), - cause); - } - - private long bootstrapVersion(Map row, String operation) { - try { - return Long.parseLong(String.valueOf(findColumnValue(row, "version"))); - } catch (RuntimeException failure) { - throw bootstrapFailure(operation, failure); - } - } - - private void restoreConstant( - UserContext context, - Object constantId, - long version, - List properties, - List desiredValues) { - String table = tableName(entityDescriptor.getType()); - String sql = StrUtil.format("UPDATE {} SET version = {} WHERE id = '{}'", - table, Math.max(1L, -version), constantId); - logInfo(sql + ";"); - if (!ensureTableEnabled(context)) return; - updateBootstrap(context, sql, "restore constant"); - List> restored = queryBootstrap(context, - StrUtil.format("SELECT * FROM {} WHERE id = '{}'", table, constantId), - "inspect restored constant"); - if (restored.size() != 1) { - throw bootstrapFailure("inspect restored constant", - new IllegalStateException("Expected one restored row, found " + restored.size())); - } - long restoredVersion = bootstrapVersion(restored.get(0), "inspect restored constant version"); - reconcileConstant(context, properties, desiredValues, restored.get(0), restoredVersion); - } - - private void ensureBootstrapIdFloor(UserContext context, Object id) { - if (!ensureTableEnabled(context) || id == null) return; - long floor = id instanceof Number - ? ((Number) id).longValue() - : Long.parseLong(String.valueOf(id)); - new IdSpaceIdGenerator(database, getTqlIdSpaceTable()) - .ensureFloor(entityDescriptor.getType(), floor); - } - - private void reconcileConstant( - UserContext context, - List properties, - List desiredValues, - Map existing, - long version) { - List assignments = new ArrayList<>(); - Object id = null; - for (int i = 0; i < properties.size(); i++) { - PropertyDescriptor property = properties.get(i); - Object desired = desiredValues.get(i); - String column = getSqlColumn(property).getColumnName(); - if (property.isId()) { - id = desired; - continue; - } - if (property.isVersion()) continue; - if (!bootstrapValuesEqual(findColumnValue(existing, column), desired)) { - assignments.add(dialect.escapeIdentifier(column) + " = " + getSqlValue(desired)); - } - } - if (assignments.isEmpty()) return; - assignments.add("version = version + 1"); - String sql = StrUtil.format( - "UPDATE {} SET {} WHERE id = {} AND version = {}", - tableName(entityDescriptor.getType()), - CollectionUtil.join(assignments, ","), - getSqlValue(id), - version); - logInfo(sql + ";"); - if (ensureTableEnabled(context)) updateBootstrap(context, sql, "reconcile constant"); - } private Object findColumnValue(Map row, String column) { for (Map.Entry entry : row.entrySet()) { @@ -1993,16 +1980,6 @@ private Object findFacetRelationValue(Map row, String relationNa return column == null ? null : findColumnValue(row, column.getColumnName()); } - private boolean bootstrapValuesEqual(Object existing, Object desired) { - if (java.util.Objects.equals(existing, desired)) return true; - if (existing == null || desired == null) return false; - try { - return new BigDecimal(String.valueOf(existing)) - .compareTo(new BigDecimal(String.valueOf(desired))) == 0; - } catch (NumberFormatException ignored) { - return String.valueOf(existing).equals(String.valueOf(desired)); - } - } // ========================================== // Helper methods @@ -2107,37 +2084,6 @@ private String tableAlias(String table) { return NamingCase.toCamelCase(table); } - protected String getSqlValue(Object value) { - if (value == null) return "NULL"; - if (value instanceof Number) return String.valueOf(value); - if (value instanceof Boolean) return boolToSqlString(value); - return StrUtil.wrapIfMissing(String.valueOf(value), "'", "'"); - } - - private Object getRootPropertyValue(UserContext context, PropertyDescriptor property) { - if (property.isId()) return 1L; - if (property.isVersion()) return 1L; - String createFunction = property.getAdditionalInfo().get("createFunction"); - if (!ObjectUtil.isEmpty(createFunction)) return context.evaluate(createFunction); - return property.getAdditionalInfo().get("candidates"); - } - - private Object getConstantPropertyValue(UserContext context, PropertyDescriptor property, int index, String identifier) { - if (property.isVersion()) return 1L; - PropertyType type = property.getType(); - if (BaseEntity.class.isAssignableFrom(type.javaType())) return "1"; - String createFunction = property.getAdditionalInfo().get("createFunction"); - if (!ObjectUtil.isEmpty(createFunction)) return context.evaluate(createFunction); - List candidates = property.getCandidates(); - if (property.isIdentifier()) return identifier; - if (ObjectUtil.isNotEmpty(candidates)) return CollectionUtil.get(candidates, index); - if (property.isId()) return Math.abs((long) identifier.toUpperCase().hashCode()); - return null; - } - - private long genIdForCandidateCode(String code) { - return Math.abs((long) code.toUpperCase().hashCode()); - } // ========================================== // SQL building helpers @@ -2178,18 +2124,7 @@ public io.teaql.core.SqlParameterLogPolicy parameterLogPolicy(String name) { return io.teaql.core.SqlParameterLogPolicy.CREDENTIAL; for (PropertyDescriptor property : allProperties) { if (!property.getName().equals(name)) continue; - if (entityDescriptor.getAuditMaskFields().contains(name) - || property.getOwner() != null && property.getOwner().getAuditMaskFields().contains(name) - || "masked".equalsIgnoreCase(property.getAdditionalInfo().get("logPolicy"))) - return io.teaql.core.SqlParameterLogPolicy.MASKED; - if ("credential".equalsIgnoreCase(property.getAdditionalInfo().get("logPolicy"))) - return io.teaql.core.SqlParameterLogPolicy.CREDENTIAL; - if ("plain".equalsIgnoreCase(property.getAdditionalInfo().get("logPolicy"))) - return io.teaql.core.SqlParameterLogPolicy.PLAIN; - EntityDescriptor owner = property.getOwner(); - return (owner == null ? entityDescriptor : owner).isAuditMaskFieldsDeclared() - ? io.teaql.core.SqlParameterLogPolicy.PLAIN - : io.teaql.core.SqlParameterLogPolicy.UNKNOWN; + return io.teaql.core.SqlFieldLogPolicy.resolve(entityDescriptor, property); } return io.teaql.core.SqlParameterLogPolicy.UNKNOWN; } @@ -2274,6 +2209,7 @@ AggregationResult doAggregateInternal(UserContext userContext, SearchRequest private AggregationResult aggregateWithIntent(UserContext userContext, SearchRequest request, io.teaql.core.SqlIntentRedactions intent) { if (!request.hasSimpleAgg()) return null; + SqlLikeIntent.capture(userContext, request, this, intent); io.teaql.core.sql.SqlAstCompiler compiler = new io.teaql.core.sql.SqlAstCompiler(); List tables = compiler.collectAggregationTables(sqlMetadata, this, userContext, request); @@ -2285,7 +2221,7 @@ private AggregationResult aggregateWithIntent(UserContext userContext, SearchReq String sql = compiler.buildAggregationSQL(sqlMetadata, this, userContext, request, parameters, tables); if (sql == null) return null; - PositionalSQL psql = withQueryIntent(toPositional(sql, parameters), intent); + PositionalSQL psql = withQueryIntent(toPositional(sql, parameters), intent, request); List> rows = database.query(userContext, psql.sql, psql.args, psql.logBindings); AggregationResult result = new AggregationResult(); @@ -2349,7 +2285,4 @@ protected io.teaql.core.EntityStatus resolvePersistedStatus(Long version) { : io.teaql.core.EntityStatus.PERSISTED; } - protected String boolToSqlString(Object value) { - return ((Boolean) value) ? "1" : "0"; - } } diff --git a/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/SqlDiagnosticRequest.java b/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/SqlDiagnosticRequest.java index cf28fba0..630e1398 100644 --- a/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/SqlDiagnosticRequest.java +++ b/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/SqlDiagnosticRequest.java @@ -1,6 +1,7 @@ package io.teaql.core.sql.portable; import io.teaql.core.SearchRequest; +import io.teaql.core.QueryIntent; import io.teaql.core.SqlIntentRedactions; import io.teaql.core.UserContext; import io.teaql.core.internal.TempRequest; @@ -10,33 +11,113 @@ final class SqlDiagnosticRequest extends TempRequest { private final transient SqlIntentRedactions source; private final transient boolean executionScope; private final transient SearchRequest original; + private final transient QueryIntent rootIntent; + private final transient java.util.List traceSource; + private final transient java.util.function.Consumer statementObserver; SqlDiagnosticRequest(SearchRequest request, SqlIntentRedactions source) { - this(request, source, false); + this(request, source, request.inheritedQueryIntent() == null + ? QueryIntent.of(request.comment(), request.purpose()) : request.inheritedQueryIntent(), false); } - private SqlDiagnosticRequest(SearchRequest request, SqlIntentRedactions source, boolean executionScope) { + private SqlDiagnosticRequest(SearchRequest request, SqlIntentRedactions source, + QueryIntent rootIntent, boolean executionScope) { + this(request, source, rootIntent, executionScope, request.sqlTraceSource()); + } + + private SqlDiagnosticRequest(SearchRequest request, SqlIntentRedactions source, + QueryIntent rootIntent, boolean executionScope, + java.util.List traceSource) { + this(request, source, rootIntent, executionScope, traceSource, observer(request)); + } + + private SqlDiagnosticRequest(SearchRequest request, SqlIntentRedactions source, + QueryIntent rootIntent, boolean executionScope, + java.util.List traceSource, + java.util.function.Consumer observer) { super(request); + this.statementObserver = observer; this.original = request; // TempRequest's relation-oriented copy omits these root-query semantics. - this.comment = request.comment(); - this.purpose = request.purpose(); + this.rootIntent = java.util.Objects.requireNonNull(rootIntent, "rootIntent"); + this.comment = rootIntent.comment(); + this.purpose = rootIntent.purpose(); this.searchForText = request.getSearchForText(); this.dynamicFieldSelection = request.getDynamicFieldSelection(); this.hardLimit = request.hardLimit(); this.source = source == null || executionScope ? source : source.copy(); this.executionScope = executionScope; + this.traceSource = traceSource.isEmpty() + ? java.util.List.of( + new io.teaql.core.TraceNode(io.teaql.core.TraceKind.COMMENT, request.getTypeName(), rootIntent.comment()), + new io.teaql.core.TraceNode(io.teaql.core.TraceKind.PURPOSE, request.getTypeName(), rootIntent.purpose())) + : java.util.List.copyOf(traceSource); } static SqlDiagnosticRequest forExecution(SearchRequest request, SqlIntentRedactions source) { - return new SqlDiagnosticRequest(request, source, true); + QueryIntent intent = request.inheritedQueryIntent() == null + ? QueryIntent.of(request.comment(), request.purpose()) : request.inheritedQueryIntent(); + return forExecution(request, source, intent); + } + + static SqlDiagnosticRequest forExecution(SearchRequest request, SqlIntentRedactions source, QueryIntent intent) { + return new SqlDiagnosticRequest(request, source, intent, true); + } + + static SqlDiagnosticRequest collecting(SearchRequest request, SqlIntentRedactions source, + QueryIntent intent, java.util.function.Consumer observer) { + // A relation lookup can re-enter the provider. Its result has its own + // collection while the root invocation still owns all descendant facts. + var parentObserver = observer(request); + var combined = parentObserver == null ? observer : parentObserver.andThen(observer); + return new SqlDiagnosticRequest(request, source, intent, true, request.sqlTraceSource(), combined); + } + + private static java.util.function.Consumer observer(SearchRequest request) { + return request instanceof SqlDiagnosticRequest scoped ? scoped.statementObserver : null; + } + + static io.teaql.core.SqlExecutionTrace statementTrace(SearchRequest request) { + return io.teaql.core.SqlExecutionTrace.query(request).collecting(observer(request)); + } + + @Override public QueryIntent inheritedQueryIntent() { return rootIntent; } + @Override public java.util.List sqlTraceSource() { return traceSource; } + + /** Derived work keeps its parent's origin even when it does not traverse a model relation. */ + static SqlDiagnosticRequest forDerived(SearchRequest child, SqlIntentRedactions source, + SearchRequest parent) { + QueryIntent intent = parentIntent(parent); + return new SqlDiagnosticRequest(child, source, intent, false, parentTrace(parent, intent), observer(parent)); + } + + static SqlDiagnosticRequest forRelation(SearchRequest child, SqlIntentRedactions source, + SearchRequest parent, String relationName) { + QueryIntent intent = parentIntent(parent); + var trace = new java.util.ArrayList<>(parentTrace(parent, intent)); + trace.add(new io.teaql.core.TraceNode(io.teaql.core.TraceKind.RELATION, relationName, + parent.getTypeName() + "." + relationName)); + return new SqlDiagnosticRequest(child, source, intent, false, trace, observer(parent)); + } + + private static QueryIntent parentIntent(SearchRequest parent) { + QueryIntent inherited = parent.inheritedQueryIntent(); + return inherited == null ? QueryIntent.of(parent.comment(), parent.purpose()) : inherited; + } + + private static java.util.List parentTrace(SearchRequest parent, QueryIntent intent) { + var trace = parent.sqlTraceSource(); + return trace.isEmpty() ? java.util.List.of( + new io.teaql.core.TraceNode(io.teaql.core.TraceKind.COMMENT, parent.getTypeName(), intent.comment()), + new io.teaql.core.TraceNode(io.teaql.core.TraceKind.PURPOSE, parent.getTypeName(), intent.purpose())) + : trace; } @Override public io.teaql.core.Entity internalNewEntity() { return original.internalNewEntity(); } @Override public boolean tryUseSubQuery() { return original.tryUseSubQuery(); } static SqlIntentRedactions source(UserContext context, SearchRequest request) { - if (!context.isQueryExecutionLoggingEnabled()) return null; + if (!context.isQueryExecutionLoggingEnabled() && observer(request) == null) return null; if (request instanceof SqlDiagnosticRequest scoped && scoped.source != null) return scoped.executionScope ? scoped.source : scoped.source.copy(); return new SqlIntentRedactions(); diff --git a/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/SqlLikeIntent.java b/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/SqlLikeIntent.java new file mode 100644 index 00000000..3043aeac --- /dev/null +++ b/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/SqlLikeIntent.java @@ -0,0 +1,95 @@ +package io.teaql.core.sql.portable; + +import io.teaql.core.*; +import io.teaql.core.criteria.*; +import io.teaql.core.sql.expression.ExpressionHelper; +import java.util.*; + +/** Read-only provenance from typed operands before any root or derived SQL executes. */ +final class SqlLikeIntent { + private final UserContext context; + private final SqlIntentRedactions output; + private final Set> visited = Collections.newSetFromMap(new IdentityHashMap<>()); + + private SqlLikeIntent(UserContext context, SqlIntentRedactions output) { + this.context = context; + this.output = output; + } + + static void capture(UserContext context, SearchRequest request, + PortableSQLRepository repository, SqlIntentRedactions output) { + if (output != null) new SqlLikeIntent(context, output).request(request, repository); + } + + private void request(SearchRequest request, PortableSQLRepository repository) { + if (request == null || repository == null || !visited.add(request)) return; + expression(request.getSearchCriteria(), repository); + if (request.getProjections() != null) + for (var projection : request.getProjections()) expression(projection.getExpression(), repository); + if (request.getOrderBy() != null) + for (var order : request.getOrderBy().getOrderBys()) expression(order.getExpression(), repository); + if (request.getAggregations() != null) + for (var selected : request.getAggregations().getSelectedExpressions()) expression(selected.getExpression(), repository); + if (request.enhanceRelations() != null) + for (var child : request.enhanceRelations().values()) child(child, repository); + if (request.enhanceChildren() != null) + for (var child : request.enhanceChildren().values()) child(child, repository); + if (request.getFacetRequests() != null) + for (var facet : request.getFacetRequests()) child(facet.getRequest(), repository); + if (request.getDynamicAggregateAttributes() != null) + for (var aggregate : request.getDynamicAggregateAttributes()) child(aggregate.getAggregateRequest(), repository); + } + + private void child(SearchRequest child, PortableSQLRepository owner) { + if (child == null || owner.getResolver() == null) return; + request(child, owner.getResolver().resolve(child.getTypeName())); + } + + private void expression(Expression expression, PortableSQLRepository repository) { + if (!ExpressionHelper.hasBuiltinParser(expression, repository)) return; + if (expression instanceof VersionSearchCriteria version) { + expression(version.getSearchCriteria(), repository); + } else if (expression instanceof SubQuerySearchCriteria subquery) { + child(subquery.getDependsOn(), repository); + } else if (expression instanceof FunctionApply function) { + if (function instanceof Between && function.getOperator() == Operator.BETWEEN + && function.getExpressions().size() == 3 + && ExpressionHelper.hasBuiltinParser(function.first(), repository)) { + // Each bound may have been rewritten independently; trust only matching typed operands. + capture(function, function.second(), Operator.BETWEEN, repository); + capture(function, function.third(), Operator.BETWEEN, repository); + } else if (function instanceof TwoOperatorCriteria && function.getExpressions().size() == 2 + && function.getOperator() instanceof Operator operator) { + capture(function, function.second(), operator, repository); + if (operator == Operator.EQUAL + && phonetic(function.first(), repository) instanceof PropertyReference + && phonetic(function.second(), repository) instanceof Parameter parameter) { + // BaseRequest emits EQ(SOUNDEX(property), SOUNDEX(parameter)). Inherit + // the enclosing field's policy, not the parameter's caller-supplied name. + capture(function, parameter, Operator.SOUNDS_LIKE, repository); + } + } + for (var child : function.getExpressions()) expression(child, repository); + } + } + + private Expression phonetic(Expression expression, PortableSQLRepository repository) { + if (expression instanceof FunctionApply function + && ExpressionHelper.hasBuiltinParser(function, repository) + && function.getOperator() == Operator.SOUNDS_LIKE && function.getExpressions().size() == 1 + && ExpressionHelper.hasBuiltinParser(function.first(), repository)) return function.first(); + return null; + } + + private void capture(Expression scope, Expression operand, Operator operator, + PortableSQLRepository repository) { + if (!(operand instanceof Parameter parameter) + || !ExpressionHelper.hasBuiltinParser(parameter, repository) + || parameter.getOperator() != operator) return; + var policy = ExpressionHelper.parameterPolicy(context, scope, repository); + if (io.teaql.core.utils.SensitiveLogNames.credential(parameter.getName())) + policy = SqlParameterLogPolicy.CREDENTIAL; + output.capture(List.of(policy), new Object[]{parameter.getValue()}); + } + +} diff --git a/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/SqlLogBindings.java b/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/SqlLogBindings.java index c5ceb7a2..b64833dc 100644 --- a/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/SqlLogBindings.java +++ b/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/SqlLogBindings.java @@ -6,19 +6,56 @@ /** Immutable compiler-owned parameter provenance, separate from execution values. */ public record SqlLogBindings(List policies, boolean generated, String diagnosticSql, - io.teaql.core.SqlIntentRedactions intentRedactions) { + io.teaql.core.SqlIntentRedactions intentRedactions, io.teaql.core.SqlExecutionTrace executionTrace, + List batchTraces) { public static final SqlLogBindings UNKNOWN = new SqlLogBindings(List.of(), false); - public SqlLogBindings { policies = List.copyOf(policies); } + public SqlLogBindings { policies = List.copyOf(policies); batchTraces = List.copyOf(batchTraces); } public SqlLogBindings(List policies, boolean generated) { this(policies, generated, null); } public SqlLogBindings(List policies, boolean generated, String diagnosticSql) { this(policies, generated, diagnosticSql, null); } + public SqlLogBindings(List policies, boolean generated, String diagnosticSql, + io.teaql.core.SqlIntentRedactions intentRedactions) { + this(policies, generated, diagnosticSql, intentRedactions, null); + } + public SqlLogBindings(List policies, boolean generated, String diagnosticSql, + io.teaql.core.SqlIntentRedactions intentRedactions, io.teaql.core.SqlExecutionTrace executionTrace) { + this(policies, generated, diagnosticSql, intentRedactions, executionTrace, List.of()); + } + public SqlLogBindings withTrace(io.teaql.core.SqlExecutionTrace trace) { + return new SqlLogBindings(policies, generated, diagnosticSql, intentRedactions, trace); + } + + public SqlLogBindings withBatchTraces(List traces) { + return new SqlLogBindings(policies, generated, diagnosticSql, intentRedactions, null, traces); + } + + public void validateBatchSize(int rowCount) { + if (!batchTraces.isEmpty() && batchTraces.size() != rowCount) { + throw new IllegalArgumentException("SQL batch trace count must match physical row count"); + } + } + + public SqlLogBindings forBatchRow(int index) { + return batchTraces.isEmpty() ? this + : new SqlLogBindings(policies, generated, diagnosticSql, intentRedactions, batchTraces.get(index)); + } + + public boolean collectsStatements() { + return executionTrace != null && executionTrace.statementObserver() != null + || batchTraces.stream().anyMatch(trace -> trace.statementObserver() != null); + } + + public void recordStatement(ExecutionMetadata metadata) { + if (executionTrace != null) executionTrace.recordStatement(metadata); + } public void applyTo(ExecutionMetadata metadata) { metadata.setParameterLogPolicies(policies); metadata.setGeneratedSql(generated); metadata.setIntentRedactions(intentRedactions); if (diagnosticSql != null) metadata.setParameterizedQuery(diagnosticSql); + if (executionTrace != null) executionTrace.applyTo(metadata); } } diff --git a/teaql-sql-portable/src/test/java/io/teaql/core/sql/portable/PortableSQLBootstrapBoundaryTest.java b/teaql-sql-portable/src/test/java/io/teaql/core/sql/portable/PortableSQLBootstrapBoundaryTest.java new file mode 100644 index 00000000..54373b9a --- /dev/null +++ b/teaql-sql-portable/src/test/java/io/teaql/core/sql/portable/PortableSQLBootstrapBoundaryTest.java @@ -0,0 +1,71 @@ +package io.teaql.core.sql.portable; + +import io.teaql.core.UserContext; +import io.teaql.core.meta.EntityDescriptor; +import io.teaql.core.meta.PropertyDescriptor; +import io.teaql.core.meta.SimpleEntityMetaFactory; +import io.teaql.core.meta.SimplePropertyType; +import io.teaql.core.sql.GenericSQLProperty; +import io.teaql.runtime.DefaultUserContext; +import io.teaql.runtime.TeaQLRuntime; +import java.util.ArrayList; +import java.util.List; +import org.junit.Test; +import static org.junit.Assert.*; + +/** Native provider boundary; generated typed bootstrap is verified by the School example. */ +public class PortableSQLBootstrapBoundaryTest { + @Test + public void portableRepositoryDoesNotExposeDataBootstrap() { + assertThrows(NoSuchMethodException.class, + () -> PortableSQLRepository.class.getMethod("ensureInitData", UserContext.class)); + assertThrows(NoSuchMethodException.class, + () -> PortableSQLRepository.class.getMethod("ensureSchema", UserContext.class)); + } + + @Test + public void portableServiceCannotReintroduceDataBootstrap() { + assertThrows(NoSuchMethodException.class, + () -> PortableSQLDataService.class.getMethod("ensureSchema", UserContext.class, String.class)); + } + + @Test + public void physicalSchemaDoesNotInterpretRootOrConstantCandidates() throws Exception { + for (boolean constant : List.of(false, true)) { + var database = new PortableSQLDatabaseTest.SQLiteTeaQLDatabase(); + var descriptor = new EntityDescriptor(); + descriptor.setType("BootstrapBoundary"); + descriptor.setTargetType(PortableSQLDatabaseTest.Task.class); + descriptor.setEntitySupplier(PortableSQLDatabaseTest.Task::new); + if (constant) { + descriptor.setParent(new EntityDescriptor()); + descriptor.with("constant", "true"); + } + List properties = new ArrayList<>(); + for (String name : List.of("id", "version", "code")) { + boolean numeric = !"code".equals(name); + var property = new GenericSQLProperty("bootstrap_boundary_data", name, + numeric ? "INTEGER" : "VARCHAR(100)"); + property.setName(name); + property.setOwner(descriptor); + property.setType(new SimplePropertyType(numeric ? Long.class : String.class)); + if ("id".equals(name)) property.with("candidates", "1001"); + if ("code".equals(name)) property.with("identifier", "true").with("candidates", "PRIMARY"); + properties.add(property); + } + descriptor.setProperties(properties); + var context = new DefaultUserContext(TeaQLRuntime.builder() + .metadata(new SimpleEntityMetaFactory()).build()); + var repository = new PortableSQLRepository<>(descriptor, database, null); + repository.ensurePhysicalSchema(context); + repository.ensurePhysicalSchema(context); + assertTrue(database.query("SELECT * FROM bootstrap_boundary_data", new Object[0]).isEmpty()); + // Repeated DDL must neither reconcile values nor revive tombstones. + database.execute("INSERT INTO bootstrap_boundary_data VALUES (1001,-2,'KEEP')"); + repository.ensurePhysicalSchema(context); + var row = database.query("SELECT * FROM bootstrap_boundary_data", new Object[0]).get(0); + assertEquals(-2L, ((Number) row.get("version")).longValue()); + assertEquals("KEEP", row.get("code")); + } + } +} diff --git a/teaql-sql-portable/src/test/java/io/teaql/core/sql/portable/PortableSQLBootstrapFailureTest.java b/teaql-sql-portable/src/test/java/io/teaql/core/sql/portable/PortableSQLBootstrapFailureTest.java deleted file mode 100644 index 170de832..00000000 --- a/teaql-sql-portable/src/test/java/io/teaql/core/sql/portable/PortableSQLBootstrapFailureTest.java +++ /dev/null @@ -1,372 +0,0 @@ -package io.teaql.core.sql.portable; - -import io.teaql.core.UserContext; -import io.teaql.core.meta.EntityDescriptor; -import io.teaql.core.meta.PropertyDescriptor; -import io.teaql.core.meta.SimpleEntityMetaFactory; -import io.teaql.core.meta.SimplePropertyType; -import io.teaql.core.sql.GenericSQLProperty; -import io.teaql.runtime.DefaultUserContext; -import io.teaql.runtime.TeaQLRuntime; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import org.junit.Assert; -import org.junit.Test; - -public class PortableSQLBootstrapFailureTest { - private static final String TABLE = "bootstrap_failure_data"; - - @Test - public void rootSelectFailureIsNotTreatedAsMissingRoot() throws Exception { - FailingDatabase database = new FailingDatabase(); - PortableSQLRepository repository = repository(database, false); - UserContext context = context(); - repository.ensurePhysicalSchema(context); - database.failQuery = true; - - IllegalStateException failure = Assert.assertThrows( - IllegalStateException.class, () -> repository.ensureInitData(context)); - Assert.assertTrue(failure.getMessage().contains("inspect root")); - Assert.assertTrue(failure.getMessage().contains("BootstrapFailure")); - Assert.assertTrue(failure.getMessage().contains(TABLE)); - Assert.assertTrue(failure.getCause().getMessage().contains("simulated bootstrap SELECT")); - Assert.assertEquals(0, database.insertCount); - } - - @Test - public void rootInsertFailureIsNotReportedAsSuccess() throws Exception { - FailingDatabase database = new FailingDatabase(); - PortableSQLRepository repository = repository(database, false); - UserContext context = context(); - repository.ensurePhysicalSchema(context); - database.failInsert = true; - - IllegalStateException failure = Assert.assertThrows( - IllegalStateException.class, () -> repository.ensureInitData(context)); - Assert.assertTrue(failure.getMessage().contains("create root")); - Assert.assertTrue(failure.getMessage().contains("BootstrapFailure")); - Assert.assertTrue(failure.getMessage().contains(TABLE)); - Assert.assertTrue(failure.getCause().getMessage().contains("simulated bootstrap INSERT")); - Assert.assertTrue(database.query("SELECT * FROM " + TABLE, new Object[0]).isEmpty()); - Assert.assertTrue(database.query("SELECT * FROM teaql_id_space", new Object[0]).isEmpty()); - } - - @Test - public void rootInsertFailureIsAcceptedOnlyWhenTheRowCanBeObserved() throws Exception { - FailingDatabase database = new FailingDatabase(); - PortableSQLRepository repository = repository(database, false); - UserContext context = context(); - repository.ensurePhysicalSchema(context); - database.failInsertAfterCommit = true; - - repository.ensureInitData(context); - - Assert.assertEquals(1, database.query("SELECT * FROM " + TABLE, new Object[0]).size()); - Assert.assertEquals(1L, ((Number) database.query( - "SELECT current_level FROM teaql_id_space WHERE type_name = 'BootstrapFailure'", - new Object[0]).get(0).get("current_level")).longValue()); - } - - @Test - public void malformedRootVersionIncludesBootstrapContext() throws Exception { - FailingDatabase database = new FailingDatabase(); - PortableSQLRepository repository = repository(database, false); - UserContext context = context(); - repository.ensurePhysicalSchema(context); - database.execute("INSERT INTO " + TABLE + " (id,version) VALUES (1,'BROKEN')"); - - IllegalStateException failure = Assert.assertThrows( - IllegalStateException.class, () -> repository.ensureInitData(context)); - Assert.assertTrue(failure.getMessage().contains("inspect root version")); - Assert.assertTrue(failure.getMessage().contains(TABLE)); - Assert.assertTrue(failure.getCause() instanceof NumberFormatException); - } - - @Test - public void rootRestoreFailureIsNotReportedAsSuccess() throws Exception { - FailingDatabase database = new FailingDatabase(); - PortableSQLRepository repository = repository(database, false); - UserContext context = context(); - repository.ensurePhysicalSchema(context); - database.execute("INSERT INTO " + TABLE + " (id,version) VALUES (1,-1)"); - database.failUpdate = true; - - IllegalStateException failure = Assert.assertThrows( - IllegalStateException.class, () -> repository.ensureInitData(context)); - Assert.assertTrue(failure.getMessage().contains("restore root")); - Assert.assertTrue(failure.getMessage().contains("BootstrapFailure")); - Assert.assertTrue(failure.getMessage().contains(TABLE)); - Assert.assertTrue(failure.getCause().getMessage().contains("simulated bootstrap UPDATE")); - Assert.assertEquals(-1L, ((Number) database.query( - "SELECT version FROM " + TABLE + " WHERE id = 1", new Object[0]) - .get(0).get("version")).longValue()); - } - - @Test - public void zeroRowRootRestoreIsNotReportedAsSuccess() throws Exception { - FailingDatabase database = new FailingDatabase(); - PortableSQLRepository repository = repository(database, false); - UserContext context = context(); - repository.ensurePhysicalSchema(context); - database.execute("INSERT INTO " + TABLE + " (id,version) VALUES (1,-1)"); - database.zeroRowUpdate = true; - - IllegalStateException failure = Assert.assertThrows( - IllegalStateException.class, () -> repository.ensureInitData(context)); - Assert.assertTrue(failure.getMessage().contains("restore root")); - Assert.assertTrue(failure.getCause().getMessage().contains("updated 0")); - } - - @Test - public void constantSelectFailureIsNotTreatedAsMissingConstant() throws Exception { - FailingDatabase database = new FailingDatabase(); - PortableSQLRepository repository = repository(database, true); - UserContext context = context(); - repository.ensurePhysicalSchema(context); - database.failQuery = true; - - IllegalStateException failure = Assert.assertThrows( - IllegalStateException.class, () -> repository.ensureInitData(context)); - Assert.assertTrue(failure.getMessage().contains("inspect constant")); - Assert.assertTrue(failure.getMessage().contains(TABLE)); - Assert.assertEquals(0, database.insertCount); - } - - @Test - public void constantInsertFailureIsNotReportedAsSuccess() throws Exception { - FailingDatabase database = new FailingDatabase(); - PortableSQLRepository repository = repository(database, true); - UserContext context = context(); - repository.ensurePhysicalSchema(context); - database.failInsert = true; - - IllegalStateException failure = Assert.assertThrows( - IllegalStateException.class, () -> repository.ensureInitData(context)); - Assert.assertTrue(failure.getMessage().contains("create constant")); - Assert.assertTrue(failure.getMessage().contains("BootstrapFailure")); - Assert.assertTrue(failure.getMessage().contains(TABLE)); - Assert.assertTrue(failure.getCause().getMessage().contains("simulated bootstrap INSERT")); - Assert.assertTrue(database.query("SELECT * FROM " + TABLE, new Object[0]).isEmpty()); - Assert.assertTrue(database.query("SELECT * FROM teaql_id_space", new Object[0]).isEmpty()); - } - - @Test - public void constantInsertFailureIsAcceptedOnlyWhenTheRowCanBeObserved() throws Exception { - FailingDatabase database = new FailingDatabase(); - PortableSQLRepository repository = repository(database, true); - UserContext context = context(); - repository.ensurePhysicalSchema(context); - database.failInsertAfterCommit = true; - - repository.ensureInitData(context); - - Assert.assertEquals(1, database.query("SELECT * FROM " + TABLE, new Object[0]).size()); - Assert.assertEquals(1, database.insertCount); - Assert.assertEquals(Math.abs((long) "PRIMARY".hashCode()), - ((Number) database.query( - "SELECT current_level FROM teaql_id_space WHERE type_name = 'BootstrapFailure'", - new Object[0]).get(0).get("current_level")).longValue()); - } - - @Test - public void constantReconcileFailureIsNotReportedAsSuccess() throws Exception { - FailingDatabase database = new FailingDatabase(); - PortableSQLRepository repository = repository(database, true); - UserContext context = context(); - repository.ensurePhysicalSchema(context); - long constantId = Math.abs((long) "PRIMARY".hashCode()); - database.execute("INSERT INTO " + TABLE - + " (id,version,code) VALUES (" + constantId + ",1,'STALE')"); - database.failUpdate = true; - - IllegalStateException failure = Assert.assertThrows( - IllegalStateException.class, () -> repository.ensureInitData(context)); - Assert.assertTrue(failure.getMessage().contains("reconcile constant")); - Assert.assertTrue(failure.getMessage().contains("BootstrapFailure")); - Assert.assertTrue(failure.getMessage().contains(TABLE)); - Assert.assertTrue(failure.getCause().getMessage().contains("simulated bootstrap UPDATE")); - Assert.assertEquals("STALE", database.query( - "SELECT code FROM " + TABLE + " WHERE id = " + constantId, new Object[0]) - .get(0).get("code")); - } - - @Test - public void constantRestoreFailureIsNotReportedAsSuccess() throws Exception { - FailingDatabase database = new FailingDatabase(); - PortableSQLRepository repository = repository(database, true); - UserContext context = context(); - repository.ensurePhysicalSchema(context); - long constantId = Math.abs((long) "PRIMARY".hashCode()); - database.execute("INSERT INTO " + TABLE - + " (id,version,code) VALUES (" + constantId + ",-1,'PRIMARY')"); - database.failUpdate = true; - - IllegalStateException failure = Assert.assertThrows( - IllegalStateException.class, () -> repository.ensureInitData(context)); - Assert.assertTrue(failure.getMessage().contains("restore constant")); - Assert.assertTrue(failure.getMessage().contains("BootstrapFailure")); - Assert.assertTrue(failure.getMessage().contains(TABLE)); - Assert.assertTrue(failure.getCause().getMessage().contains("simulated bootstrap UPDATE")); - Assert.assertEquals(-1L, ((Number) database.query( - "SELECT version FROM " + TABLE + " WHERE id = " + constantId, - new Object[0]).get(0).get("version")).longValue()); - } - - @Test - public void restoredConstantReconcilesChangedAttributes() throws Exception { - FailingDatabase database = new FailingDatabase(); - PortableSQLRepository repository = repository(database, true); - UserContext context = context(); - repository.ensurePhysicalSchema(context); - long constantId = Math.abs((long) "PRIMARY".hashCode()); - database.execute("INSERT INTO " + TABLE - + " (id,version,code) VALUES (" + constantId + ",-1,'STALE')"); - - repository.ensureInitData(context); - repository.ensureInitData(context); - - Map restored = database.query( - "SELECT * FROM " + TABLE + " WHERE id = " + constantId, - new Object[0]).get(0); - Assert.assertEquals("PRIMARY", restored.get("code")); - Assert.assertEquals(2L, ((Number) restored.get("version")).longValue()); - } - - @Test - public void zeroVersionRootAndConstantBecomeActive() throws Exception { - for (boolean constant : List.of(false, true)) { - FailingDatabase database = new FailingDatabase(); - PortableSQLRepository repository = repository(database, constant); - UserContext context = context(); - repository.ensurePhysicalSchema(context); - long id = constant ? Math.abs((long) "PRIMARY".hashCode()) : 1L; - database.execute("INSERT INTO " + TABLE + " (id,version,code) VALUES (" - + id + ",0,'STALE')"); - - repository.ensureInitData(context); - - Map row = database.query( - "SELECT * FROM " + TABLE + " WHERE id = " + id, new Object[0]).get(0); - Assert.assertTrue(((Number) row.get("version")).longValue() > 0); - if (constant) Assert.assertEquals("PRIMARY", row.get("code")); - } - } - - @Test - public void zeroRowConstantReconcileIsNotReportedAsSuccess() throws Exception { - FailingDatabase database = new FailingDatabase(); - PortableSQLRepository repository = repository(database, true); - UserContext context = context(); - repository.ensurePhysicalSchema(context); - long constantId = Math.abs((long) "PRIMARY".hashCode()); - database.execute("INSERT INTO " + TABLE - + " (id,version,code) VALUES (" + constantId + ",1,'STALE')"); - database.zeroRowUpdate = true; - - IllegalStateException failure = Assert.assertThrows( - IllegalStateException.class, () -> repository.ensureInitData(context)); - Assert.assertTrue(failure.getMessage().contains("reconcile constant")); - Assert.assertTrue(failure.getCause().getMessage().contains("updated 0")); - } - - @Test - public void repeatedRootAndConstantBootstrapIsIdempotent() throws Exception { - for (boolean constant : List.of(false, true)) { - FailingDatabase database = new FailingDatabase(); - PortableSQLRepository repository = repository(database, constant); - UserContext context = context(); - repository.ensurePhysicalSchema(context); - - repository.ensureInitData(context); - repository.ensureInitData(context); - - List> rows = database.query( - "SELECT * FROM " + TABLE, new Object[0]); - Assert.assertEquals(1, rows.size()); - Assert.assertEquals(1L, ((Number) rows.get(0).get("version")).longValue()); - Assert.assertEquals(1, database.insertCount); - } - } - - private static PortableSQLRepository repository( - FailingDatabase database, boolean constant) { - EntityDescriptor descriptor = new EntityDescriptor(); - descriptor.setType("BootstrapFailure"); - descriptor.setTargetType(PortableSQLDatabaseTest.Task.class); - descriptor.setEntitySupplier(PortableSQLDatabaseTest.Task::new); - if (constant) { - EntityDescriptor parent = new EntityDescriptor(); - parent.setType("BootstrapParent"); - descriptor.setParent(parent); - descriptor.with("constant", "true"); - } - List properties = new ArrayList<>(); - for (String name : List.of("id", "version", "code")) { - boolean numeric = !"code".equals(name); - GenericSQLProperty property = new GenericSQLProperty( - TABLE, name, numeric ? "INTEGER" : "VARCHAR(100)"); - property.setName(name); - property.setOwner(descriptor); - property.setType(new SimplePropertyType(numeric ? Long.class : String.class)); - if (constant && "code".equals(name)) { - property.with("identifier", "true"); - property.with("candidates", "PRIMARY"); - } - properties.add(property); - } - descriptor.setProperties(properties); - return new PortableSQLRepository<>(descriptor, database, null); - } - - private static UserContext context() { - return new DefaultUserContext(TeaQLRuntime.builder() - .metadata(new SimpleEntityMetaFactory()) - .build()); - } - - private static final class FailingDatabase extends PortableSQLDatabaseTest.SQLiteTeaQLDatabase { - private boolean failQuery; - private boolean failInsert; - private boolean failInsertAfterCommit; - private boolean failUpdate; - private boolean zeroRowUpdate; - private int insertCount; - - private FailingDatabase() throws Exception {} - - @Override - public List> query(String sql, Object[] args) { - if (failQuery && sql.startsWith("SELECT * FROM " + TABLE + " WHERE")) { - throw new IllegalStateException("simulated bootstrap SELECT failure"); - } - return super.query(sql, args); - } - - @Override - public void execute(String sql) { - if (sql.startsWith("INSERT INTO " + TABLE)) { - insertCount++; - if (failInsert) { - throw new IllegalStateException("simulated bootstrap INSERT failure"); - } - if (failInsertAfterCommit) { - super.execute(sql); - throw new IllegalStateException("simulated concurrent bootstrap INSERT"); - } - } - super.execute(sql); - } - - @Override - public int executeUpdate(String sql, Object[] args) { - if (sql.startsWith("UPDATE " + TABLE)) { - if (failUpdate) { - throw new IllegalStateException("simulated bootstrap UPDATE failure"); - } - if (zeroRowUpdate) return 0; - } - return super.executeUpdate(sql, args); - } - } -} diff --git a/teaql-sql-portable/src/test/java/io/teaql/core/sql/portable/PortableSQLDatabaseTest.java b/teaql-sql-portable/src/test/java/io/teaql/core/sql/portable/PortableSQLDatabaseTest.java index 4bbba68f..97300aaf 100644 --- a/teaql-sql-portable/src/test/java/io/teaql/core/sql/portable/PortableSQLDatabaseTest.java +++ b/teaql-sql-portable/src/test/java/io/teaql/core/sql/portable/PortableSQLDatabaseTest.java @@ -304,8 +304,8 @@ private static RelationRuntime relationRuntime(String foreignKeyColumn, long chi .build(); UserContext runtimeContext = new DefaultUserContext(runtime); runtimeContext.putAttribute("ensureTable", true); - service.ensureSchema(runtimeContext, "TopNParent"); - service.ensureSchema(runtimeContext, "TopNChild"); + service.getRepository("TopNParent").ensurePhysicalSchema(runtimeContext); + service.getRepository("TopNChild").ensurePhysicalSchema(runtimeContext); database.execute("DELETE FROM top_n_parent_data"); database.execute("INSERT INTO top_n_parent_data (id, version, name)" + " VALUES (1, 1, 'parent')"); @@ -341,8 +341,8 @@ public void TOPN_012_canonicalRelationIndexEnsureIsIdempotentOnSQLite() { registerTopNFixture(); sqliteDb.execute("DROP INDEX idx_top_n_child_data_parent_id"); sqliteDb.clearExecuteTrace(); - sqlDataService.ensureSchema(context, "TopNChild"); - sqlDataService.ensureSchema(context, "TopNChild"); + sqlDataService.getRepository("TopNChild").ensurePhysicalSchema(context); + sqlDataService.getRepository("TopNChild").ensurePhysicalSchema(context); List> indexes = sqliteDb.query( "SELECT name, sql FROM sqlite_master WHERE type='index' " @@ -472,8 +472,8 @@ private void registerTopNFixture() { relation.setColumnName("parent"); relation.setColumnType("INTEGER"); metaFactory.register(child); - sqlDataService.ensureSchema(context, "TopNParent"); - sqlDataService.ensureSchema(context, "TopNChild"); + sqlDataService.getRepository("TopNParent").ensurePhysicalSchema(context); + sqlDataService.getRepository("TopNChild").ensurePhysicalSchema(context); sqliteDb.execute("DELETE FROM top_n_child_data"); sqliteDb.execute("DELETE FROM top_n_parent_data"); seedTopNFixture(); @@ -544,47 +544,6 @@ public void schemaOnlyRepositoryMustNotHydrateRelationFromGlobalMetadata() { } } - @Test - public void testConstantBootstrapIsIdempotentAndReconcilesModelChanges() throws Exception { - SQLiteTeaQLDatabase database = new SQLiteTeaQLDatabase(); - EntityDescriptor descriptor = new EntityDescriptor(); - descriptor.setType("SchoolType"); - descriptor.setTargetType(Task.class); - descriptor.setEntitySupplier(Task::new); - descriptor.setParent(new EntityDescriptor()); - descriptor.with("constant", "true"); - - GenericSQLProperty id = bootstrapProperty(descriptor, "id", "INTEGER", Long.class); - id.with("candidates", "1001,1002"); - GenericSQLProperty version = bootstrapProperty(descriptor, "version", "INTEGER", Long.class); - GenericSQLProperty code = bootstrapProperty(descriptor, "code", "VARCHAR(100)", String.class); - code.with("identifier", "true").with("candidates", "PRIMARY,SECONDARY"); - GenericSQLProperty name = bootstrapProperty(descriptor, "name", "VARCHAR(100)", String.class); - name.with("candidates", "Primary,Secondary"); - descriptor.setProperties(List.of(id, version, code, name)); - - PortableSQLRepository repository = new PortableSQLRepository<>(descriptor, database, null); - repository.ensurePhysicalSchema(context); - List> physicalOnly = database.query( - "SELECT id FROM school_type_data", new Object[0]); - assertTrue("Physical schema reconciliation must not write bootstrap data", physicalOnly.isEmpty()); - repository.ensureSchema(context); - repository.ensureSchema(context); - List> unchanged = database.query( - "SELECT id, version, name FROM school_type_data ORDER BY id", new Object[0]); - assertEquals(2, unchanged.size()); - assertEquals(1L, ((Number) unchanged.get(0).get("version")).longValue()); - assertEquals(1L, ((Number) unchanged.get(1).get("version")).longValue()); - assertEquals(1003L, new IdSpaceIdGenerator(database).nextId("SchoolType")); - - name.with("candidates", "Primary School,Secondary"); - repository.ensureSchema(context); - List> reconciled = database.query( - "SELECT id, version, name FROM school_type_data ORDER BY id", new Object[0]); - assertEquals("Primary School", reconciled.get(0).get("name")); - assertEquals(2L, ((Number) reconciled.get(0).get("version")).longValue()); - assertEquals(1L, ((Number) reconciled.get(1).get("version")).longValue()); - } @Test public void postSaveReloadUsesTheMappedPrimaryTable() throws Exception { @@ -636,15 +595,6 @@ public void postSaveReloadUsesTheMappedPrimaryTable() throws Exception { repository.loadPersistedById(isolatedContext, 78L).getTitle()); } - private static GenericSQLProperty bootstrapProperty( - EntityDescriptor owner, String name, String sqlType, Class javaType) { - GenericSQLProperty property = - new GenericSQLProperty("school_type_data", name, sqlType); - property.setName(name); - property.setOwner(owner); - property.setType(new SimplePropertyType(javaType)); - return property; - } @Test public void testSingleDynamicAggregateIsAttachedToEachReturnedParent() { @@ -878,8 +828,8 @@ public void testCompleteForwardAndReverseRelationFixtureIncludingOrphanNullOnSQL new Object[] {"version", "INTEGER", Long.class})); metaFactory.register(group); metaFactory.register(record); - sqlDataService.ensureSchema(context, "QueryGroup"); - sqlDataService.ensureSchema(context, "QueryRecord"); + sqlDataService.getRepository("QueryGroup").ensurePhysicalSchema(context); + sqlDataService.getRepository("QueryRecord").ensurePhysicalSchema(context); sqliteDb.execute("DELETE FROM query_group_data"); sqliteDb.execute("DELETE FROM query_record_data"); sqliteDb.execute("INSERT INTO query_group_data VALUES " @@ -985,7 +935,7 @@ public void testCompleteScalarFixtureIncludingNullableBooleanExecutesOnSQLite() properties.add(scalarProperty(descriptor, "version", "INTEGER", Long.class)); descriptor.setProperties(properties); metaFactory.register(descriptor); - sqlDataService.ensureSchema(context, "QueryScalar"); + sqlDataService.getRepository("QueryScalar").ensurePhysicalSchema(context); sqliteDb.execute("DELETE FROM query_scalar_data"); sqliteDb.execute( "INSERT INTO query_scalar_data VALUES " @@ -1254,7 +1204,7 @@ public static void setup() throws Exception { context.putAttribute("ensureTable", true); // enable schema generation // Generate schema - sqlDataService.ensureSchema(context, "Task"); + sqlDataService.getRepository("Task").ensurePhysicalSchema(context); } @Test diff --git a/teaql-sql-portable/src/test/java/io/teaql/core/sql/portable/SqlDiagnosticRequestTest.java b/teaql-sql-portable/src/test/java/io/teaql/core/sql/portable/SqlDiagnosticRequestTest.java new file mode 100644 index 00000000..53f76011 --- /dev/null +++ b/teaql-sql-portable/src/test/java/io/teaql/core/sql/portable/SqlDiagnosticRequestTest.java @@ -0,0 +1,84 @@ +package io.teaql.core.sql.portable; + +import io.teaql.core.*; +import java.util.*; +import org.junit.Test; +import static org.junit.Assert.*; + +/** #202: derived request factories inherit their explicit parent, never ambient Context. */ +public class SqlDiagnosticRequestTest { + private static class Request extends BaseRequest { + private final String type; + Request(String type, String comment, String purpose) { + super(BaseEntity.class, BaseEntity::new); + this.type = type; internalComment(comment); internalPurpose(purpose); + } + @Override public String getTypeName() { return type; } + void replaceComment(String comment) { internalComment(comment); } + } + + @Test public void unscopedParentOwnsTheRootEvenWhenTheChildHasDifferentIntent() { + var parent = new Request("SourceDocument", "inspect the document", "render details"); + var child = new Request("SourceLine", "unrelated child comment", "unrelated purpose"); + var derived = SqlDiagnosticRequest.forRelation(child, null, parent, "lines"); + assertEquals(parent.comment(), derived.comment()); assertEquals(parent.purpose(), derived.purpose()); + assertEquals(List.of( + new TraceNode(TraceKind.COMMENT, "SourceDocument", parent.comment()), + new TraceNode(TraceKind.PURPOSE, "SourceDocument", parent.purpose()), + new TraceNode(TraceKind.RELATION, "lines", "SourceDocument.lines")), derived.sqlTraceSource()); + assertTrue(parent.sqlTraceSource().isEmpty()); assertTrue(child.sqlTraceSource().isEmpty()); + } + + @Test public void nonRelationWorkKeepsAnImmutableParentSnapshotWithoutAFabricatedEdge() { + var trace = new ArrayList<>(List.of( + new TraceNode(TraceKind.COMMENT, "SourceDocument", "inspect the document"), + new TraceNode(TraceKind.PURPOSE, "SourceDocument", "render details"), + new TraceNode(TraceKind.RELATION, "lines", "SourceDocument.lines"))); + var original = List.copyOf(trace); + var intent = QueryIntent.of("inspect the document", "render details"); + var parent = new Request("SourceLine", "unused local comment", "unused local purpose") { + @Override public List sqlTraceSource() { return trace; } + @Override public QueryIntent inheritedQueryIntent() { return intent; } + }; + var child = new Request("Statistic", null, null); + var derived = SqlDiagnosticRequest.forDerived(child, null, parent); + trace.clear(); parent.replaceComment("later unrelated comment"); + assertEquals(original, derived.sqlTraceSource()); + assertSame(intent, derived.inheritedQueryIntent()); + assertEquals("inspect the document", derived.comment()); + assertThrows(UnsupportedOperationException.class, () -> derived.sqlTraceSource().clear()); + } + + @Test public void validChildIntentCannotReplaceMissingParentIntent() { + var child = new Request("SourceLine", "valid child comment", "valid child purpose"); + for (var parent : List.of(new Request("SourceDocument", " ", "render details"), + new Request("SourceDocument", "inspect the document", " "))) { + assertThrows(RequestIntentException.class, () -> SqlDiagnosticRequest.forDerived(child, null, parent)); + assertThrows(RequestIntentException.class, () -> SqlDiagnosticRequest.forRelation(child, null, parent, "lines")); + } + } + + @Test public void compilationKeepsItsFirstScopedOriginWithoutSharingAnotherInvocationsContext() { + var parent = new Request("SourceDocument", "inspect document A", "render A"); + var scoped = SqlDiagnosticRequest.collecting(parent, new SqlIntentRedactions(), + QueryIntent.of(parent.comment(), parent.purpose()), statement -> {}); + var child = SqlDiagnosticRequest.forRelation(new Request("SourceLine", "local", "local"), + null, scoped, "lines"); + var first = new io.teaql.core.sql.SqlParameters(); + first.captureQueryContext(scoped); + first.captureQueryContext(child); + parent.replaceComment("later changed builder"); + assertSame(scoped, first.originatingQuery()); + assertEquals("inspect document A", first.queryIntent().comment()); + assertEquals("render A", first.queryIntent().purpose()); + assertEquals(scoped.sqlTraceSource(), first.originatingQuery().sqlTraceSource()); + var second = new io.teaql.core.sql.SqlParameters(); + assertNull(second.originatingQuery()); assertNull(second.queryIntent()); + var independent = new Request("Independent", "inspect B", "render B"); + second.captureQueryContext(independent); + assertSame(independent, second.originatingQuery()); + assertEquals("inspect B", second.queryIntent().comment()); + assertEquals("inspect document A", first.queryIntent().comment()); + assertTrue("compilation scope is not a SQL bind or wire extension", first.isEmpty()); + } +} diff --git a/teaql-sql-portable/src/test/java/io/teaql/core/sql/portable/SqlParameterPropagationTest.java b/teaql-sql-portable/src/test/java/io/teaql/core/sql/portable/SqlParameterPropagationTest.java index 8aee3759..f767bb60 100644 --- a/teaql-sql-portable/src/test/java/io/teaql/core/sql/portable/SqlParameterPropagationTest.java +++ b/teaql-sql-portable/src/test/java/io/teaql/core/sql/portable/SqlParameterPropagationTest.java @@ -70,7 +70,10 @@ private static class Fixture { } DefaultUserContext context() { return new DefaultUserContext(runtime); } Request request(String field, Operator op, Object... values) { - Request request = new Request(); request.selectSelf(); request.offset(0, 10); + Request request = new Request() { + { internalComment("verify SQL parameter provenance"); internalPurpose("test compiled plan privacy"); } + }; + request.selectSelf(); request.offset(0, 10); request.appendSearchCriteria(request.createBasicSearchCriteria(field, op, values)); return request; } @@ -106,6 +109,12 @@ Request request(String field, Operator op, Object... values) { assertEquals(SqlParameterLogPolicy.MASKED, c.bindings.policies().get(title)); assertEquals(SqlParameterLogPolicy.PLAIN, c.bindings.policies().get(Arrays.asList(c.args).indexOf("ACTIVE"))); assertEquals(SqlParameterLogPolicy.PLAIN, c.bindings.policies().get(c.args.length - 1)); + var secrets = new ArrayList(); + c.bindings.intentRedactions().appendTo(secrets, false); + assertTrue("cached typed LIKE must retain the current original operand", + secrets.contains(i == 0 ? "Riverside" : "Lakeside")); + assertFalse("the cached plan must not retain another invocation's operand", + secrets.contains(i == 0 ? "Lakeside" : "Riverside")); } } diff --git a/teaql-sqlite/src/test/java/io/teaql/sqlite/BlankLocalReasonSqliteTest.java b/teaql-sqlite/src/test/java/io/teaql/sqlite/BlankLocalReasonSqliteTest.java new file mode 100644 index 00000000..08f02807 --- /dev/null +++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/BlankLocalReasonSqliteTest.java @@ -0,0 +1,146 @@ +package io.teaql.sqlite; + +import io.teaql.core.*; +import io.teaql.runtime.AppAuditEventSink; +import io.teaql.runtime.EntityPersistenceMutation; +import java.util.*; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import static org.junit.Assert.*; + +/** TC-MUT-11 through actual runtime graph planning, SQLite and committed safe audit. + * Native fixture metadata is not evidence for generated relation traversal. */ +@RunWith(Parameterized.class) +public class BlankLocalReasonSqliteTest { + @Parameterized.Parameters(name = "logging={0}") + public static Collection modes() { return List.of(new Object[]{false}, new Object[]{true}); } + + private final boolean logging; + public BlankLocalReasonSqliteTest(boolean logging) { this.logging = logging; } + + @Test public void blankLocalReasonsInheritAtCommandSqlAndCommittedAudit() throws Exception { + String[] blanks = {null, "", " \t\r\n", "\u0085", "\u00a0", "\u2003"}; + runGraph(blanks, true); + } + + @Test public void nonWhiteSpaceControlReasonsSurviveAtActualSinks() throws Exception { + String[] controls = {"\u001c", "\u001d", "\u001e", "\u001f"}; + runGraph(controls, false); + } + + private void runGraph(String[] localReasons, boolean inherit) throws Exception { + var fixture = new GraphTraceSqliteTest.Fixture(); + fixture.queryLogging = logging; + fixture.mutationLogging = logging; + if (inherit) { + for (String blank : localReasons) { + var invalid = fixture.create("CustomerOrder", 9999, "invalid public root"); + var error = assertThrows(RequestIntentException.class, () -> invalid.auditAs(blank).save(fixture.context)); + assertRequestError(error); + invalid.setComment(blank); + assertRequestError(assertThrows(RequestIntentException.class, () -> fixture.context.saveGraph(invalid))); + assertTrue(fixture.commands.isEmpty()); + assertTrue(fixture.results.isEmpty()); + assertTrue(fixture.sql.isEmpty()); + assertTrue(fixture.audit.isEmpty()); + } + } + + // Direct DataSource connections are independent of Spring's thread-bound + // transaction. The safe sink must see committed rows, not pending writes. + Map tables = Map.of("CustomerOrder", "customer_order_data", "Payment", "payment_data", + "PaymentAttempt", "payment_attempt_data", "Shipment", "shipment_data"); + fixture.context.putAttribute(AppAuditEventSink.class.getName(), (AppAuditEventSink) (caller, event) -> { + assertTrue(event.entityId() instanceof Number); + long targetId = ((Number) event.entityId()).longValue(); + try (var connection = fixture.dataSource.getConnection(); + var select = connection.prepareStatement("SELECT id, version FROM " + tables.get(event.entityType()) + " WHERE id = ?")) { + assertTrue("the audit probe must not read through the graph's transaction", connection.getAutoCommit()); + select.setLong(1, targetId); + try (var row = select.executeQuery()) { + assertTrue("safe audit arrived before real database commit", row.next()); + assertEquals(targetId, row.getLong(1)); + assertEquals(1, row.getLong(2)); + assertFalse(row.next()); + } + } catch (java.sql.SQLException failure) { + throw new AssertionError("independent committed-row audit probe failed", failure); + } + fixture.audit.add(event); + }); + var root = fixture.create("CustomerOrder", 100, "native order"); + var payment = fixture.create("Payment", 201, "native payment"); + payment.setComment("authorize payment"); + var shipment = fixture.create("Shipment", 301, "native shipment"); + shipment.setComment("prepare shipment"); + var attempts = new ArrayList(); + for (int index = 0; index < localReasons.length; index++) { + var attempt = fixture.create("PaymentAttempt", 400L + index, "native attempt"); + // Feed the original local value to planning, never replace blanks + // with a parent reason or inject an expected lineage into the graph. + attempt.setComment(localReasons[index]); + attempts.add(attempt); + } + payment.__internalSet("children", attempts); + root.__internalSet("children", List.of(payment, shipment)); + root.auditAs("submit order").save(fixture.context); + + int count = localReasons.length + 3; + assertEquals(count, fixture.commands.size()); + assertEquals(count, fixture.results.size()); + assertEquals(count, fixture.audit.size()); + assertEquals(logging ? 2 * count : 0, fixture.sql.size()); + assertTrue("runtime-owned trace must not live on Context", fixture.context.getTraceChain().isEmpty()); + for (EntityPersistenceMutation command : fixture.commands) { + var entity = command.getEntity(); + var expected = new ArrayList(); + expected.add(new TraceNode(TraceKind.AUDIT_REASON, "CustomerOrder", 100L, "submit order")); + if (entity.typeName().equals("Payment") || entity.typeName().equals("PaymentAttempt")) { + expected.add(new TraceNode(TraceKind.AUDIT_REASON, "Payment", 201L, "authorize payment")); + if (entity.typeName().equals("PaymentAttempt") && !inherit) { + expected.add(new TraceNode(TraceKind.AUDIT_REASON, "PaymentAttempt", entity.getId(), + localReasons[Math.toIntExact(entity.getId() - 400)])); + } + } else if (entity.typeName().equals("Shipment")) { + expected.add(new TraceNode(TraceKind.AUDIT_REASON, "Shipment", 301L, "prepare shipment")); + } + assertEquals("planner lost an inherited or sibling branch", expected, command.getTraceChain()); + assertEquals("submit order", command.intent().comment()); + var audit = fixture.audit.stream().filter(event -> event.entityType().equals(entity.typeName()) + && event.entityId().equals(entity.getId())).toList(); + assertEquals("independent committed target must occur exactly once", 1, audit.size()); + assertEquals(expected, audit.get(0).traceChain()); + var result = fixture.results.stream().filter(value -> value.persistedEntity().typeName().equals(entity.typeName()) + && value.persistedEntity().getId().equals(entity.getId())).findFirst().orElseThrow(); + assertEquals(1, result.persistedEntity().getVersion().longValue()); + assertEquals(2, result.statements().size()); + for (int index = 0; index < 2; index++) { + var statement = result.statements().get(index); + assertEquals(expected, statement.getMutationLineage()); + assertEquals(index == 0 ? null : "submit order", statement.getComment()); + assertEquals("submit order", statement.getAuditReason()); + assertEquals("success", statement.getExecutionOutcome()); + assertEquals(index == 0 ? "insert" : "select", statement.getStatementOperation()); + assertEquals("CustomerOrder", statement.getTraceChain().get(0).getName()); + var tail = statement.getTraceChain().size(); + assertEquals("sqlite", statement.getTraceChain().get(tail - 2).getName()); + assertEquals(index == 0 ? "insert" : "select", statement.getTraceChain().get(tail - 1).getName()); + } + for (var diagnostic : fixture.sql.stream().filter(value -> value.getMutationLineage().equals(expected)).toList()) { + assertEquals(diagnostic.getStatementOperation().equals("select") ? "submit order" : null, + diagnostic.getComment()); + assertEquals("submit order", diagnostic.getAuditReason()); + } + } + assertEquals(List.of("submit order", "prepare shipment"), + GraphTraceSqliteTest.reasons(fixture.audit.stream().filter(event -> event.entityType().equals("Shipment")) + .findFirst().orElseThrow().traceChain())); + } + + private static void assertRequestError(RequestIntentException error) { + assertEquals("REQUEST_COMMENT_REQUIRED", error.getCode()); + assertEquals("comment", error.getField()); + assertEquals("mutation", error.getRequestKind()); + } +} diff --git a/teaql-sqlite/src/test/java/io/teaql/sqlite/BlankRouteTailSqliteTest.java b/teaql-sqlite/src/test/java/io/teaql/sqlite/BlankRouteTailSqliteTest.java new file mode 100644 index 00000000..fea7c9fa --- /dev/null +++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/BlankRouteTailSqliteTest.java @@ -0,0 +1,76 @@ +package io.teaql.sqlite; + +import io.teaql.core.*; +import io.teaql.runtime.AppAuditEventSink; +import java.util.List; +import org.junit.Test; +import static org.junit.Assert.*; + +/** TC-REQ-13 deliberate native diagnostic input, not generated graph proof. */ +public class BlankRouteTailSqliteTest { + @Test public void explicitRootCommentSurvivesEveryBlankTypedTailAtRealSinks() throws Exception { + for (boolean logging : new boolean[]{false, true}) { + for (TraceKind kind : List.of(TraceKind.ENTITY, TraceKind.PROVIDER, TraceKind.SQL)) { + var fixture = new GraphTraceSqliteTest.Fixture(); + fixture.queryLogging = logging; + fixture.mutationLogging = logging; + String comment = " explicit owned mutation reason "; + var entity = fixture.create("CustomerOrder", 810, "native route fixture"); + String name = kind == TraceKind.PROVIDER ? "sqlite" + : kind == TraceKind.SQL ? "insert" : "CustomerOrder"; + var tail = new TraceNode(kind, name, null, ""); + var source = List.of(new TraceNode(TraceKind.AUDIT_REASON, "CustomerOrder", 810L, comment), tail); + entity.setTraceChain(source); + entity.setComment(comment); + assertEquals("", tail.getComment()); + // The sink reads through an independent connection. Pending + // rows inside the mutation transaction cannot satisfy this probe. + fixture.context.putAttribute(AppAuditEventSink.class.getName(), (AppAuditEventSink) (caller, event) -> { + try (var connection = fixture.dataSource.getConnection(); + var select = connection.prepareStatement("SELECT id, version FROM customer_order_data WHERE id = ?")) { + assertTrue(connection.getAutoCommit()); + select.setLong(1, 810); + try (var row = select.executeQuery()) { + assertTrue("audit escaped before real commit", row.next()); + assertEquals(810, row.getLong(1)); + assertEquals(1, row.getLong(2)); + assertFalse(row.next()); + } + } catch (java.sql.SQLException error) { + throw new AssertionError("independent committed-row probe failed", error); + } + assertEquals(comment, event.traceChain().get(0).getComment()); + fixture.audit.add(event); + }); + entity.auditAs(comment).save(fixture.context); + assertEquals(1, fixture.commands.size()); + var emitted = fixture.commands.get(0); + assertEquals(source, emitted.getTraceChain()); + assertEquals(tail, emitted.getTraceChain().get(emitted.getTraceChain().size() - 1)); + assertEquals(comment, emitted.comment()); + assertEquals(comment, emitted.intent().readbackIntent().comment()); + assertEquals(1, fixture.results.size()); + var statements = fixture.results.get(0).statements(); + assertEquals(2, statements.size()); + for (int index = 0; index < statements.size(); index++) { + var statement = statements.get(index); + assertEquals(comment, statement.getAuditReason()); + assertEquals(index == 0 ? null : comment, statement.getComment()); + assertEquals(source, statement.getMutationLineage()); + assertEquals("success", statement.getExecutionOutcome()); + assertEquals(index == 0 ? "insert" : "select", statement.getStatementOperation()); + var route = statement.getTraceChain(); + assertEquals("CustomerOrder", route.get(0).getName()); + assertEquals("sqlite", route.get(route.size() - 2).getName()); + assertEquals(statement.getStatementOperation(), route.get(route.size() - 1).getName()); + } + assertEquals(1, fixture.audit.size()); + assertEquals(source, fixture.audit.get(0).traceChain()); + assertEquals(logging ? 2 : 0, fixture.sql.size()); + for (var diagnostic : fixture.sql) assertEquals(comment, diagnostic.getAuditReason()); + assertEquals(comment, source.get(0).getComment()); + assertTrue(fixture.context.getTraceChain().isEmpty()); + } + } + } +} diff --git a/teaql-sqlite/src/test/java/io/teaql/sqlite/DerivedQueryTraceSqliteTest.java b/teaql-sqlite/src/test/java/io/teaql/sqlite/DerivedQueryTraceSqliteTest.java new file mode 100644 index 00000000..a5d63dca --- /dev/null +++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/DerivedQueryTraceSqliteTest.java @@ -0,0 +1,535 @@ +package io.teaql.sqlite; + +import io.teaql.core.*; +import io.teaql.core.criteria.Operator; +import io.teaql.core.meta.*; +import io.teaql.core.sql.*; +import io.teaql.core.sqlite.SqliteDataServiceExecutor; +import io.teaql.provider.jdbc.JdbcSqlExecutor; +import io.teaql.runtime.*; +import java.nio.file.Files; +import java.util.*; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; +import java.util.function.Supplier; +import org.junit.Test; +import org.sqlite.SQLiteDataSource; +import static org.junit.Assert.*; + +/** #202: native dynamic aggregation -> actual SQLite -> safe statement-owned provenance. */ +public class DerivedQueryTraceSqliteTest { + private static final String PRIVATE_NAME = "PRIVATE-DOCUMENT-NAME"; + + public abstract static class Row extends BaseEntity { + private final Map values = new HashMap<>(); + @Override public Object __internalGet(String field) { + return field.equals("name") || field.equals("state") || field.equals("document") || field.equals("lines") || field.equals("documentNumber") + ? values.get(field) : super.__internalGet(field); + } + @Override public void __internalSet(String field, Object value) { + if (field.equals("name") || field.equals("state") || field.equals("document") || field.equals("lines") || field.equals("documentNumber")) + values.put(field,value); + else super.__internalSet(field,value); + } + } + public static final class TraceDocument extends Row { + @Override public String typeName() { return "TraceDocument"; } + } + public static final class TraceLine extends Row { + @Override public String typeName() { return "TraceLine"; } + } + private static final class Request extends BaseRequest { + private final String type; + Request(Class type, Supplier factory) { + super(type,factory); this.type=type.getSimpleName(); + } + @Override public String getTypeName() { return type; } + Request where(String field, Operator operator, Object value) { + appendSearchCriteria(createBasicSearchCriteria(field,operator,value)); return this; + } + Request intent(String comment, String purpose) { + internalComment(comment); internalPurpose(purpose); return this; + } + } + + private static final class Fixture { + final List sql = new CopyOnWriteArrayList<>(); + final List returnedQueries = new CopyOnWriteArrayList<>(); + final SimpleEntityMetaFactory metadata = new SimpleEntityMetaFactory(); + final DefaultUserContext context; + final SqliteDataServiceExecutor provider; + final TraceDocument document; + final TraceLine open; + Fixture(boolean logging) throws Exception { + this(logging, JdbcSqlExecutor::new); + } + Fixture(boolean logging, Function driverFactory) throws Exception { + var source = new SQLiteDataSource(); + source.setUrl("jdbc:sqlite:" + Files.createTempFile("teaql-derived-trace-", ".db")); + var documents = descriptor(TraceDocument.class,TraceDocument::new); + var lines = descriptor(TraceLine.class,TraceLine::new); + var state = (GenericSQLProperty) lines.addSimpleProperty("state",String.class); + state.setColumnType("VARCHAR(255)"); + var documentNumber = (GenericSQLProperty) lines.addSimpleProperty("documentNumber",Long.class); + documentNumber.setColumnType("BIGINT"); + var relation = (GenericSQLRelation) lines.addObjectProperty(metadata,"document", "TraceDocument", "lines",TraceDocument.class); + relation.setColumnType("BIGINT"); + var driver = driverFactory.apply(source); + provider = new SqliteDataServiceExecutor("sqlite",driver,source) { + @Override public QueryResult query(UserContext caller, QueryRequest request) { + var result = super.query(caller, request); + returnedQueries.add((DefaultQueryResult) result); + return result; + } + }; + var runtime = TeaQLRuntime.builder().metadata(metadata).dataService("sqlite",provider) + .queryExecutionLogging(logging).logSink((caller,entry)->sql.add(entry)).build(); + context = new DefaultUserContext(runtime); context.ensureSchema(); + document = create(new TraceDocument(),100,PRIVATE_NAME); + document.auditAs("seed the selected document").save(context); + var unrelated = create(new TraceDocument(),200,"Other document"); + unrelated.auditAs("seed an unrelated document").save(context); + open = create(new TraceLine(),101,"Selected open line"); + seedLine(open,document,"OPEN"); + seedLine(create(new TraceLine(),102,"Selected closed line"),document,"CLOSED"); + seedLine(create(new TraceLine(),201,"Other open line"),unrelated,"OPEN"); + sql.clear(); + } + private SQLEntityDescriptor descriptor(Class type,Supplier supplier) { + var descriptor = new SQLEntityDescriptor(); + descriptor.setType(type.getSimpleName()); descriptor.setTargetType(type); + descriptor.setEntitySupplier(supplier); descriptor.setDataService("sqlite"); + descriptor.setAuditMaskFields(List.of("name")); + for (String field : List.of("id","version","name")) { + var property = (GenericSQLProperty) descriptor.addSimpleProperty(field,field.equals("name")?String.class:Long.class); + property.setColumnType(field.equals("name")?"VARCHAR(255)":"BIGINT"); + } + metadata.register(descriptor); return descriptor; + } + private T create(T row,long id,String name) { + row.__internalInitializeNewEntityId(id); row.updateProperty("name",name); return row; + } + private void seedLine(TraceLine line,TraceDocument owner,String state) { + line.updateProperty("document",owner); line.updateProperty("state",state); + line.updateProperty("documentNumber",owner.getId()); + line.auditAs("seed a document line").save(context); + } + Request documents() { + var request = new Request<>(TraceDocument.class,TraceDocument::new); + request.bindMetadata(metadata); + for (String field : List.of("id","version","name")) request.selectProperty(field); + return request; + } + Request lines() { + var request = new Request<>(TraceLine.class,TraceLine::new); + request.bindMetadata(metadata); + for (String field : List.of("id","version","name","document","documentNumber","state")) request.selectProperty(field); + return request; + } + Request withOpenCount() { + var count = lines().where("state",Operator.EQUAL,"OPEN"); + count.setPartitionProperty("document"); count.count("count"); + var root = documents(); root.addSingleAggregateDynamicProperty("openLineCount",count); return root; + } + } + + @Test public void loadedCollectionFacetsRetainPerParentMembershipCountsAndRequestedEmpty() throws Exception { + for (boolean logging : List.of(false, true)) { + for (boolean includeAll : List.of(false, true)) { + var fixture = new Fixture(logging); + var empty = fixture.create(new TraceDocument(), 300, "Document without lines"); + empty.auditAs("seed a parent with no child membership").save(fixture.context); + fixture.sql.clear(); fixture.returnedQueries.clear(); + var children = fixture.lines(); children.setSize(1); + var targets = fixture.documents().where("id", Operator.IN, List.of(100L, 200L, 300L)); targets.setSize(3); + children.addFacet("documents", "document", targets, includeAll); + var root = fixture.documents().where("id", Operator.IN, List.of(100L, 200L, 300L)) + .intent("load scoped child facets", "verify collection metadata and full membership"); + root.setSize(3); root.enhanceRelation("lines", children); + SmartList rows = fixture.context.getRuntime().executeForList(fixture.context, root); + assertEquals(3, rows.size()); + for (var parent : rows) { + SmartList lines = parent.getProperty("lines"); + assertNotNull("requested empty reverse relation must be loaded", lines); + assertEquals(parent.getId() == 300L ? 0 : 1, lines.size()); + var facets = lines.getFacet("documents"); + assertNotNull("loaded collection must retain its Facet metadata", facets); + int expectedSize = includeAll ? 3 : parent.getId() == 300L ? 0 : 1; + assertEquals("Facet membership must be independently scoped to each parent", expectedSize, facets.size()); + for (Object value : facets) { + var target = (Entity) value; + int expected = target.getId().equals(parent.getId()) ? parent.getId() == 100L ? 2 : parent.getId() == 200L ? 1 : 0 : 0; + assertEquals("counts cover the whole source, not the one-row child page", expected, + ((Number) target.getDynamicProperty("count")).intValue()); + } + } + var raw = fixture.returnedQueries.get(fixture.returnedQueries.size() - 1).statements(); + assertEquals(10, raw.size()); + for (int i = 0; i < raw.size(); i++) { + var expected = new ArrayList<>(List.of(new TraceNode(TraceKind.OPERATION,"TraceDocument",null,"query"), + new TraceNode(TraceKind.REQUEST,"TraceDocument",null,""))); + if (i > 0) expected.add(new TraceNode(TraceKind.RELATION,"lines",null,"TraceDocument.lines")); + if (i > 0 && i % 3 == 0) expected.add(new TraceNode(TraceKind.RELATION,"document",null,"TraceLine.document")); + expected.add(new TraceNode(TraceKind.PROVIDER,"sqlite",null,"")); + expected.add(new TraceNode(TraceKind.SQL,"select",null,"")); + assertEquals(expected, raw.get(i).getTraceChain()); + assertEquals("load scoped child facets",raw.get(i).getComment()); + assertEquals("verify collection metadata and full membership",raw.get(i).getPurpose()); + } + assertEquals(logging ? 10 : 0,fixture.sql.size()); + assertTrue(fixture.context.getTraceChain().isEmpty()); + System.out.printf("JAVA_COLLECTION_FACET logging=%s includeAll=%s parents=3 fullCounts=true requestedEmpty=true physicalStatements=10%n",logging,includeAll); + } + } + } + + /** Hold completed physical root reads, not merely two starts at a barrier. */ + private static final class PausedRoots extends JdbcSqlExecutor { + final CountDownLatch bothReturned = new CountDownLatch(2); + final CountDownLatch release = new CountDownLatch(1); + final AtomicInteger roots = new AtomicInteger(); + volatile boolean armed; + PausedRoots(SQLiteDataSource source) { super(source); } + private void hold(String sql) { + if (!armed || !sql.toLowerCase(Locale.ROOT).contains("trace_document_data")) return; + roots.incrementAndGet(); bothReturned.countDown(); + try { + assertTrue("completed SQLite roots must be released", release.await(10, TimeUnit.SECONDS)); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); throw new AssertionError(failure); + } + } + @Override public List query(String sql, Object[] args, CompiledRowMapper mapper) { + var rows = super.query(sql, args, mapper); hold(sql); return rows; + } + @Override public List> queryForList(String sql, Object[] args) { + var rows = super.queryForList(sql, args); hold(sql); return rows; + } + } + + @Test public void twoLiveSqliteQueriesOnOneContextKeepPhysicalIntentAndRelationsIsolated() throws Exception { + for (boolean logging : List.of(false, true)) { + var driver = new AtomicReference(); + var fixture = new Fixture(logging, source -> { + var value = new PausedRoots(source); driver.set(value); return value; + }); + var gate = driver.get(); gate.armed = true; + fixture.context.pushTrace("unrelated application diagnostic"); + var baseline = fixture.context.getTraceChain(); + var workers = Executors.newFixedThreadPool(2); + try { + var alpha = fixture.documents().where("id", Operator.EQUAL, 100L) + .intent("load alpha graph", "render alpha graph"); + var beta = fixture.documents().where("id", Operator.EQUAL, 200L) + .intent("load beta graph", "render beta graph"); + alpha.setSize(1); beta.setSize(1); + var alphaLines = fixture.lines(); alphaLines.setSize(10); + var betaLines = fixture.lines(); betaLines.setSize(10); + alpha.enhanceRelation("lines", alphaLines); beta.enhanceRelation("lines", betaLines); + var first = workers.submit(() -> fixture.context.getRuntime().executeForList(fixture.context, alpha)); + var second = workers.submit(() -> fixture.context.getRuntime().executeForList(fixture.context, beta)); + assertTrue("two physical roots must return before either query completes", + gate.bothReturned.await(10, TimeUnit.SECONDS)); + assertFalse(first.isDone()); assertFalse(second.isDone()); + assertEquals(2, gate.roots.get()); assertTrue(fixture.sql.isEmpty()); + assertTrue(fixture.returnedQueries.isEmpty()); + assertEquals("live requests must not put their frames on Context", baseline, fixture.context.getTraceChain()); + System.out.printf("LIVE_SQLITE_BARRIER logging=%s physicalRoots=2 unfinishedQueries=2 contextUnchanged=true logs=0%n", logging); + gate.release.countDown(); + var results = List.of(first.get(10, TimeUnit.SECONDS), second.get(10, TimeUnit.SECONDS)); + assertEquals("two roots and two real derived provider requests", 4, fixture.returnedQueries.size()); + for (int i = 0; i < results.size(); i++) { + var rows = results.get(i); var label = i == 0 ? "alpha" : "beta"; + assertEquals(1, rows.size()); + assertEquals(Long.valueOf(i == 0 ? 100 : 200), rows.get(0).getId()); + SmartList children = rows.get(0).getProperty("lines"); + assertEquals(i == 0 ? 2 : 1, children.size()); + assertEquals(i == 0 ? List.of(101L, 102L) : List.of(201L), + children.stream().map(Entity::getId).sorted().toList()); + var ownResults = fixture.returnedQueries.stream() + .filter(r -> r.getResult().get(0).typeName().equals("TraceDocument")) + .filter(r -> r.statements().get(0).getComment().equals("load " + label + " graph")).toList(); + assertEquals(1, ownResults.size()); + var result = ownResults.get(0); assertEquals(2, result.statements().size()); + for (int depth = 0; depth < 2; depth++) { + var statement = result.statements().get(depth); + assertEquals("load " + label + " graph", statement.getComment()); + assertEquals("render " + label + " graph", statement.getPurpose()); + assertPath(statement, "TraceDocument", depth == 0 ? List.of() : List.of("lines")); + assertEquals(depth == 0 ? List.of() : List.of("TraceDocument.lines"), + statement.getTraceChain().stream().filter(n -> n.getKind() == TraceKind.RELATION) + .map(TraceNode::getComment).toList()); + assertEquals("query", statement.getTraceChain().get(0).getComment()); + assertEquals("", statement.getTraceChain().get(1).getComment()); + } + } + assertEquals(logging ? 4 : 0, fixture.sql.size()); + if (logging) { + for (String label : List.of("alpha", "beta")) { + var own = fixture.sql.stream().filter(e -> e.getComment().equals("load " + label + " graph")).toList(); + assertEquals(2, own.size()); + assertTrue(own.stream().allMatch(e -> e.getPurpose().equals("render " + label + " graph"))); + assertPath(own.get(0), "TraceDocument", List.of()); + assertPath(own.get(1), "TraceDocument", List.of("lines")); + } + } + assertEquals(baseline, fixture.context.getTraceChain()); + System.out.printf("LIVE_SQLITE_RESULT logging=%s physicalStatements=4 inheritedRelations=2 safeLogs=%d contextUnchanged=true%n", logging, fixture.sql.size()); + } finally { + gate.release.countDown(); workers.shutdownNow(); + assertTrue(workers.awaitTermination(10, TimeUnit.SECONDS)); + } + } + } + + private static void assertPath(ExecutionMetadata entry,String root,List relations) { + var kinds = new ArrayList<>(List.of(TraceKind.OPERATION,TraceKind.REQUEST)); + relations.forEach(relation -> kinds.add(TraceKind.RELATION)); + kinds.add(TraceKind.PROVIDER); kinds.add(TraceKind.SQL); + assertEquals(kinds,entry.getTraceChain().stream().map(TraceNode::getKind).toList()); + assertEquals(root,entry.getTraceChain().get(0).getName()); + assertEquals(root,entry.getTraceChain().get(1).getName()); + assertEquals("sqlite",entry.getTraceChain().get(entry.getTraceChain().size()-2).getName()); + assertEquals("select",entry.getTraceChain().get(entry.getTraceChain().size()-1).getName()); + assertEquals(relations,entry.getTraceChain().stream().filter(node->node.getKind()==TraceKind.RELATION) + .map(TraceNode::getName).toList()); + } + + @Test public void childMembershipSurvivesFilteredForwardIdentityReferences() throws Exception { + verifyFilteredReferenceMembership(false); + } + + @Test public void nestedChildMembershipSurvivesFilteredForwardIdentityReferences() throws Exception { + verifyFilteredReferenceMembership(true); + } + + private void verifyFilteredReferenceMembership(boolean nested) throws Exception { + for (boolean logging : List.of(false, true)) { + for (boolean filtered : List.of(false, true)) { + for (int threshold : List.of(0, 32)) { + verifyFilteredReferenceMembership(nested, logging, filtered, threshold); + } + } + } + } + + private void verifyFilteredReferenceMembership(boolean nested, boolean logging, boolean filtered, int threshold) throws Exception { + var fixture = new Fixture(logging); + var selectedParent = fixture.documents().where("name", Operator.EQUAL, + filtered ? "ABSENT-DOCUMENT" : PRIVATE_NAME); + var children = fixture.lines(); + children.setSize(10); + children.topNProbeParentThreshold(threshold); + children.enhanceRelation("document", selectedParent); + var documents = fixture.withOpenCount().where("id", Operator.EQUAL, 100L); + documents.setSize(1); + documents.enhanceRelation("lines", children); + Request request = documents; + if (nested) { + var outer = fixture.lines().where("id", Operator.EQUAL, 101L); + outer.setSize(1); + outer.enhanceRelation("document", documents); + request = outer; + } + request.intent("load selected document graph", "verify filtered membership and safe E access"); + var result = (DefaultQueryResult) fixture.provider.query(fixture.context, new DefaultQueryRequest(request)); + assertSame("internal projection must preserve the caller's nested load", + selectedParent, children.enhanceRelations().get("document")); + assertEquals(1, result.getResult().size()); + TraceDocument owner = nested + ? (TraceDocument) result.getResult().get(0).getProperty("document") + : (TraceDocument) result.getResult().get(0); + SmartList loaded = owner.getProperty("lines"); + assertNotNull(loaded); + assertEquals(2, loaded.size()); + assertEquals(1, ((Number) owner.getDynamicProperty("openLineCount")).intValue()); + for (Entity child : loaded) { + var reference = (TraceDocument) child.getProperty("document"); + assertNotNull("Java retains the FK identity stub, not null", reference); + assertEquals(Long.valueOf(100), reference.getId()); + assertEquals(!filtered, reference.isPropertyLoaded("name")); + var expression = new io.teaql.core.value.BaseEntityExpression() { + @Override public TraceDocument eval(TraceDocument value) { return value; } + @Override public TraceDocument $getRoot() { return reference; } + }; + if (filtered) { + assertThrows(io.teaql.core.value.TeaQLNotLoadedException.class, + () -> expression.loaded("name", e -> e.getProperty("name")).eval()); + } else { + assertEquals(PRIVATE_NAME, expression.loaded("name", e -> e.getProperty("name")).eval()); + } + assertTrue(child.getUpdatedProperties().isEmpty()); + } + int expected = nested ? 5 : 4; + assertEquals(expected, result.statements().size()); + assertEquals(threshold == 0, result.statements().get(nested ? 2 : 1) + .getParameterizedQuery().toUpperCase(Locale.ROOT).contains("ROW_NUMBER")); + assertEquals(logging ? expected : 0, fixture.sql.size()); + var relations = nested ? List.of("document", "lines", "document") : List.of("lines", "document"); + assertPath(result.statements().get(expected - 2), nested ? "TraceLine" : "TraceDocument", relations); + assertPath(result.statements().get(expected - 1), nested ? "TraceLine" : "TraceDocument", + nested ? List.of("document", "lines") : List.of("lines")); + assertTrue(fixture.context.getTraceChain().isEmpty()); + var independent = (DefaultQueryResult) fixture.provider.query(fixture.context, new DefaultQueryRequest( + fixture.lines().where("id", Operator.EQUAL, 101L).intent("independent line", "verify original FK"))); + assertEquals(Long.valueOf(100), ((TraceDocument) independent.getResult().get(0).getProperty("document")).getId()); + assertEquals(1, independent.statements().size()); + assertPath(independent.statements().get(0), "TraceLine", List.of()); + System.out.printf("PASS Java relation membership: nested=%s logging=%s filtered=%s threshold=%s; identity stub retained, E guarded%n", + nested, logging, filtered, threshold); + } + + @Test public void dynamicCountRetainsItsRootRelationAndPrivateIntentProvenance() throws Exception { + var fixture = new Fixture(true); + var request = fixture.withOpenCount().where("name",Operator.EQUAL,PRIVATE_NAME) + .intent("inspect " + PRIVATE_NAME,"render document statistics"); + var rows = fixture.context.getRuntime().executeForList(fixture.context,request); + assertEquals(1,rows.size()); assertEquals(Long.valueOf(100),rows.get(0).getId()); + assertEquals(PRIVATE_NAME,rows.get(0).getProperty("name")); + assertEquals(1,((Number) rows.get(0).getDynamicProperty("openLineCount")).intValue()); + assertEquals(2,fixture.sql.size()); + assertPath(fixture.sql.get(0),"TraceDocument",List.of()); + assertPath(fixture.sql.get(1),"TraceDocument",List.of("lines")); + for (var entry : fixture.sql) { + assertFalse(entry.getComment().contains(PRIVATE_NAME)); + assertTrue(entry.getComment().contains("[REDACTED]")); + assertEquals("render document statistics",entry.getPurpose()); + } + assertEquals("inspect " + PRIVATE_NAME,request.comment()); + assertTrue(fixture.context.getTraceChain().isEmpty()); + } + + @Test public void dynamicCountInsideALoadedRelationPreservesAllAncestorFrames() throws Exception { + var fixture = new Fixture(true); + var request = fixture.lines().where("id",Operator.EQUAL,fixture.open.getId()) + .intent("inspect the selected line context","render parent statistics"); + request.selectProperty("id"); request.selectProperty("version"); request.selectProperty("document"); + request.enhanceRelation("document",fixture.withOpenCount()); + var rows = fixture.context.getRuntime().executeForList(fixture.context,request); + assertEquals(1,rows.size()); + var parent = (TraceDocument) rows.get(0).getProperty("document"); + assertEquals(Long.valueOf(100),parent.getId()); + assertEquals(1,((Number) parent.getDynamicProperty("openLineCount")).intValue()); + assertEquals(3,fixture.sql.size()); + assertPath(fixture.sql.get(0),"TraceLine",List.of()); + assertPath(fixture.sql.get(1),"TraceLine",List.of("document")); + assertPath(fixture.sql.get(2),"TraceLine",List.of("document","lines")); + assertTrue(fixture.sql.stream().allMatch(entry -> request.comment().equals(entry.getComment()) + && request.purpose().equals(entry.getPurpose()))); + assertTrue(fixture.context.getTraceChain().isEmpty()); + } + + @Test public void derivedCountStillExecutesWithLoggingDisabled() throws Exception { + var fixture = new Fixture(false); + var request = fixture.withOpenCount().where("id",Operator.EQUAL,100L) + .intent("inspect the selected document","render counts without SQL logging"); + var rows = fixture.context.getRuntime().executeForList(fixture.context,request); + assertEquals(1,rows.size()); + assertEquals(1,((Number) rows.get(0).getDynamicProperty("openLineCount")).intValue()); + assertTrue(fixture.sql.isEmpty()); assertTrue(fixture.context.getTraceChain().isEmpty()); + } + + @Test public void numericPartitionInheritsTheRootWithoutInventingAModelRelation() throws Exception { + var fixture = new Fixture(true); + var count = fixture.lines().where("state",Operator.EQUAL,"OPEN"); + count.setPartitionProperty("documentNumber"); count.count("count"); + var request = fixture.documents().where("id",Operator.EQUAL,100L) + .intent("inspect an explicit numeric partition","render partition statistics"); + request.addSingleAggregateDynamicProperty("openLineCount",count); + var rows = fixture.context.getRuntime().executeForList(fixture.context,request); + assertEquals(1,rows.size()); assertEquals(Long.valueOf(100),rows.get(0).getId()); + assertEquals(1,((Number) rows.get(0).getDynamicProperty("openLineCount")).intValue()); + assertEquals(2,fixture.sql.size()); + for (var entry : fixture.sql) { + assertPath(entry,"TraceDocument",List.of()); + assertEquals(request.comment(),entry.getComment()); assertEquals(request.purpose(),entry.getPurpose()); + } + assertTrue(fixture.context.getTraceChain().isEmpty()); + } + + @Test public void returnedStatementsRetainNestedPathsAndPrivacyInBothLoggingModes() throws Exception { + for (boolean logging : List.of(false, true)) { + var fixture = new Fixture(logging); + var request = fixture.lines().where("id", Operator.EQUAL, fixture.open.getId()) + .intent("inspect " + PRIVATE_NAME, "render nested counts"); + request.enhanceRelation("document", fixture.withOpenCount().where("name", Operator.EQUAL, PRIVATE_NAME)); + var result = fixture.provider.query(fixture.context, new DefaultQueryRequest(request)); + assertEquals(3, result.statements().size()); + assertPath(result.statements().get(0), "TraceLine", List.of()); + assertPath(result.statements().get(1), "TraceLine", List.of("document")); + assertPath(result.statements().get(2), "TraceLine", List.of("document", "lines")); + assertEquals(logging ? 3 : 0, fixture.sql.size()); + for (var entry : result.statements()) { + assertEquals("success", entry.getExecutionOutcome()); + assertEquals(request.comment(), entry.getComment()); + } + // The parent predicate has reached the collector before its derived aggregate. + var safe = io.teaql.runtime.LogPrivacy.sql(result.statements().get(2), false); + assertFalse(safe.getComment().contains(PRIVATE_NAME)); + assertThrows(UnsupportedOperationException.class, () -> result.statements().clear()); + var later = fixture.provider.query(fixture.context, new DefaultQueryRequest( + fixture.documents().where("id", Operator.EQUAL, 200L).intent("independent", "verify request ownership"))); + assertEquals(1, later.statements().size()); + assertEquals("independent", later.statements().get(0).getComment()); + assertEquals(3, result.statements().size()); + assertTrue(fixture.context.getTraceChain().isEmpty()); + } + } + + @Test public void returnedAggregateAndEmptyResultStillCarryPhysicalEvidence() throws Exception { + var fixture = new Fixture(false); + var aggregate = fixture.lines().intent("count lines", "render totals"); + aggregate.count("count"); + var count = fixture.provider.query(fixture.context, new DefaultQueryRequest(aggregate)); + assertEquals(1, count.statements().size()); + assertPath(count.statements().get(0), "TraceLine", List.of()); + var empty = fixture.provider.query(fixture.context, new DefaultQueryRequest( + fixture.documents().where("id", Operator.EQUAL, -1L).intent("find absent row", "verify empty evidence"))); + assertEquals(1, empty.statements().size()); + assertEquals(Integer.valueOf(0), empty.statements().get(0).getResultCount()); + assertTrue(fixture.sql.isEmpty()); + } + + @Test public void returnedCursorsOwnTerminalEvidenceAcrossInterleavedConsumption() throws Exception { + for (boolean logging : List.of(false, true)) { + var fixture = new Fixture(logging); + var request = fixture.documents().where("name", Operator.EQUAL, PRIVATE_NAME) + .intent("inspect " + PRIVATE_NAME, "retain cursor evidence"); + try (var first = fixture.provider.queryForCursor(fixture.context, new DefaultQueryRequest(request)); + var second = fixture.provider.queryForCursor(fixture.context, new DefaultQueryRequest( + fixture.documents().intent("independent stream", "verify early close")))) { + var before = first.statements(); + assertTrue(before.isEmpty()); assertTrue(second.statements().isEmpty()); + assertEquals(1, second.stream().limit(1).toList().size()); + assertTrue("short circuit is not closed yet", second.statements().isEmpty()); + second.close(); + assertEquals("cancelled", second.statements().get(0).getExecutionOutcome()); + assertEquals("independent stream", second.statements().get(0).getComment()); + assertEquals(1, first.stream().toList().size()); + assertTrue("prior snapshots stay immutable", before.isEmpty()); + assertEquals(1, first.statements().size()); + var fact = first.statements().get(0); + assertPath(fact, "TraceDocument", List.of()); + assertEquals("success", fact.getExecutionOutcome()); + assertEquals(Integer.valueOf(1), fact.getResultCount()); + assertFalse(LogPrivacy.sql(fact, false).getComment().contains(PRIVATE_NAME)); + assertThrows(UnsupportedOperationException.class, () -> first.statements().clear()); + } + try (var failed = fixture.provider.queryForCursor(fixture.context, new DefaultQueryRequest( + fixture.documents().intent("failed consumer", "retain failure outcome")))) { + var expected = new IllegalStateException("consumer failure"); + assertSame(expected, assertThrows(IllegalStateException.class, + () -> failed.stream().forEach(row -> { throw expected; }))); + assertEquals("failure", failed.statements().get(0).getExecutionOutcome()); + assertEquals(Integer.valueOf(1), failed.statements().get(0).getResultCount()); + } + assertEquals(logging ? 3 : 0, fixture.sql.size()); + assertTrue(fixture.context.getTraceChain().isEmpty()); + } + } +} diff --git a/teaql-sqlite/src/test/java/io/teaql/sqlite/GraphTraceSqliteTest.java b/teaql-sqlite/src/test/java/io/teaql/sqlite/GraphTraceSqliteTest.java new file mode 100644 index 00000000..f58040ef --- /dev/null +++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/GraphTraceSqliteTest.java @@ -0,0 +1,855 @@ +package io.teaql.sqlite; + +import io.teaql.core.*; +import io.teaql.core.meta.*; +import io.teaql.core.sql.GenericSQLProperty; +import io.teaql.core.sql.SQLEntityDescriptor; +import io.teaql.core.sqlite.SqliteDataServiceExecutor; +import io.teaql.provider.jdbc.JdbcSqlExecutor; +import io.teaql.runtime.*; +import java.nio.file.Files; +import java.util.*; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import io.teaql.core.checker.Checker; +import io.teaql.core.checker.CheckException; +import io.teaql.core.checker.FixEvidence; +import io.teaql.core.checker.ObjectLocation; +import org.junit.Test; +import org.sqlite.SQLiteDataSource; +import static org.junit.Assert.*; + +/** #202: actual SQLite execution and safe audit. Generated API acceptance is a separate gate. */ +public class GraphTraceSqliteTest { + public static final class GraphEntity extends BaseEntity { + private final String type; + private final Map values = new HashMap<>(); + public GraphEntity(String type) { this.type = type; } + @Override public String typeName() { return type; } + @Override public Object __internalGet(String field) { + return field.equals("name") || field.equals("memo") || field.equals("children") ? values.get(field) : super.__internalGet(field); + } + @Override public void __internalSet(String field, Object value) { + if (field.equals("name") || field.equals("memo") || field.equals("children")) values.put(field, value); + else super.__internalSet(field, value); + } + } + + static final class Fixture { + final List sql = new CopyOnWriteArrayList<>(); + final List audit = new CopyOnWriteArrayList<>(); + final List commands = new CopyOnWriteArrayList<>(); + final List results = new CopyOnWriteArrayList<>(); + boolean queryLogging = true; + boolean mutationLogging = true; + final List itemInsertBatchSizes = new CopyOnWriteArrayList<>(); + final List itemUpdateBatchSizes = new CopyOnWriteArrayList<>(); + final List itemDeleteBatchSizes = new CopyOnWriteArrayList<>(); + final List itemRecoverBatchSizes = new CopyOnWriteArrayList<>(); + final JdbcSqlExecutor driver; + final SQLiteDataSource dataSource; + final DefaultUserContext context; + volatile boolean failReadback; + volatile boolean unknownUpdateCounts; + + Fixture() throws Exception { + var ds = new SQLiteDataSource(); + dataSource = ds; + ds.setUrl("jdbc:sqlite:" + Files.createTempFile("teaql-graph-trace-", ".db")); + driver = new JdbcSqlExecutor(ds) { + @Override public int[] batchUpdate(String text, List rows) { + if (text.startsWith("INSERT INTO order_item_data")) { + itemInsertBatchSizes.add(rows.size()); + assertTrue("audit may be emitted only after the transaction commits", audit.isEmpty()); + } + if (text.startsWith("UPDATE order_item_data") && !rows.isEmpty()) { + assertTrue("audit may be emitted only after the transaction commits", audit.isEmpty()); + Object[] first = rows.get(0); + if (first.length != 3) itemUpdateBatchSizes.add(rows.size()); + else if (((Number) first[0]).longValue() < 0) itemDeleteBatchSizes.add(rows.size()); + else itemRecoverBatchSizes.add(rows.size()); + } + int[] counts = super.batchUpdate(text, rows); + if (unknownUpdateCounts && text.startsWith("UPDATE order_item_data")) { + Arrays.fill(counts, java.sql.Statement.SUCCESS_NO_INFO); + } + return counts; + } + @Override public List> queryForList(String text, Object[] args) { + if (failReadback && text.startsWith("SELECT * FROM") && text.contains("customer_order_data")) { + // A real driver failure after a successful write, inside the same transaction. + execute("DROP TABLE customer_order_data"); + } + return super.queryForList(text, args); + } + }; + var metadata = new SimpleEntityMetaFactory(); + for (String type : List.of("CustomerOrder", "OrderItem", "Payment", "PaymentAttempt", "Shipment")) { + var descriptor = new SQLEntityDescriptor(); + descriptor.setType(type); + descriptor.setTargetType(GraphEntity.class); + descriptor.setEntitySupplier(() -> new GraphEntity(type)); + descriptor.setDataService("sqlite"); + descriptor.setAuditMaskFields(List.of("name")); + for (String field : List.of("id", "version", "name", "memo")) { + boolean textField = field.equals("name") || field.equals("memo"); + var property = (GenericSQLProperty) descriptor.addSimpleProperty(field, + textField ? String.class : Long.class); + property.setColumnType(textField ? "VARCHAR(255)" : "BIGINT"); + } + var children = new Relation(); + children.setName("children"); + children.setOwner(descriptor); + children.setType(new SimplePropertyType(SmartList.class)); + var properties = new ArrayList<>(descriptor.getProperties()); + properties.add(children); + descriptor.setProperties(properties); + metadata.register(descriptor); + } + var provider = new SqliteDataServiceExecutor("sqlite", driver, ds) { + @Override public MutationResult mutate(UserContext caller, PersistenceMutation mutation) { + commands.add((EntityPersistenceMutation) mutation); + var result = super.mutate(caller, mutation); + results.add(result); + return result; + } + @Override public List mutateBatch(UserContext caller, MutationBatchRequest request) { + request.items().forEach(item -> commands.add((EntityPersistenceMutation) item)); + var result = super.mutateBatch(caller, request); + results.addAll(result); + return result; + } + }; + var ids = new AtomicLong(1000); + var runtime = TeaQLRuntime.builder().metadata(metadata).dataService("sqlite", provider) + .idGenerationService((caller, entity) -> ids.getAndIncrement()) + .logSink((caller, entry) -> sql.add(entry)).build(); + context = new DefaultUserContext(runtime) { + @Override public boolean isQueryExecutionLoggingEnabled() { return queryLogging; } + @Override public boolean isMutationExecutionLoggingEnabled() { return mutationLogging; } + }; + context.putAttribute(AppAuditEventSink.class.getName(), (AppAuditEventSink) (caller, event) -> audit.add(event)); + context.ensureSchema(); + clear(); + } + + void clear() { + sql.clear(); audit.clear(); commands.clear(); results.clear(); itemInsertBatchSizes.clear(); + itemUpdateBatchSizes.clear(); itemDeleteBatchSizes.clear(); itemRecoverBatchSizes.clear(); + } + + GraphEntity create(String type, long id, String name) { + var entity = new GraphEntity(type); + // Fixed identities are fixture setup, using the runtime bootstrap's new-key contract. + entity.__internalInitializeNewEntityId(id); + entity.updateProperty("name", name); + return entity; + } + } + + private static void await(CountDownLatch latch) { + try { + assertTrue("overlapping execution must reach its checkpoint", latch.await(10, TimeUnit.SECONDS)); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new AssertionError("concurrency checkpoint interrupted", interrupted); + } + } + + record CheckedSave(Throwable failure, List evidence) {} + + @Test public void savingAnotherGraphDoesNotRebindAReadOnlyRelatedEntityLedger() throws Exception { + var fixture = new Fixture(); + var shared = fixture.create("CustomerOrder", 6500, "Shared read-only parent"); + shared.auditAs("seed shared parent").save(fixture.context); + var sharedLedger = shared.getEntityMutationLedger(); + fixture.clear(); + var root = fixture.create("Payment", 6600, "Independent graph"); + root.__internalSet("children", List.of(shared)); + root.auditAs("save independent graph").save(fixture.context); + assertSame("an unmodified loaded relation must not adopt another graph's mutable ledger", + sharedLedger, shared.getEntityMutationLedger()); + assertEquals(1, fixture.commands.size()); + assertEquals(1, fixture.audit.size()); + assertLineage(fixture.audit, "Payment", 6600, List.of("save independent graph")); + } + + private static CheckedSave checkedSave(Fixture fixture, GraphEntity entity, String reason) { + Throwable failure = null; + try { entity.auditAs(reason).save(fixture.context); } + catch (Throwable thrown) { failure = thrown; } + return new CheckedSave(failure, fixture.context.lastFixEvidence()); + } + + @Test public void overlappingCheckerRunsDoNotRejectTheValidGraphOrAdmitTheInvalidGraph() throws Exception { + var fixture = new Fixture(); + var valid = fixture.create("CustomerOrder", 6100, "Valid overlapping graph"); + var invalid = fixture.create("CustomerOrder", 6200, null); + var validEntered = new CountDownLatch(1); + var invalidChecked = new CountDownLatch(1); + var validFinished = new CountDownLatch(1); + var dates = new AtomicLong(0); + fixture.context.putAttribute(io.teaql.core.businessid.BusinessClock.class.getName(), + (io.teaql.core.businessid.BusinessClock) caller -> { + assertSame("application hooks retain the original custom Context", fixture.context, caller); + return java.time.LocalDate.of(2026, 10, 2).plusDays(dates.getAndIncrement()); + }); + fixture.context.getRuntime().install(RuntimeModule.of().withCheckers(new Checker() { + @Override public String type() { return "CustomerOrder"; } + @Override public void checkAndFix(UserContext caller, GraphEntity entity, ObjectLocation location) { + assertSame(fixture.context, caller); + if (entity == invalid) await(validEntered); + assertTrue(needCheck(caller, entity)); + markAsChecked(caller, entity); + java.time.LocalDateTime captured = caller.evaluate("now"); + entity.updateProperty("memo", captured.toString()); + caller.recordFixEvidence(new FixEvidence("CustomerOrder", entity == valid ? "valid_clock" : "invalid_clock", + FixEvidence.Source.CLOCK, "graphClock")); + requiredCheck(caller, newLocation(location, "name"), entity.getProperty("name")); + if (entity == valid) { + validEntered.countDown(); + await(invalidChecked); + } else { + invalidChecked.countDown(); + await(validFinished); + } + } + })); + var workers = Executors.newFixedThreadPool(2); + try { + var first = workers.submit(() -> { + try { return checkedSave(fixture, valid, "save valid overlapping graph"); } + finally { validFinished.countDown(); } + }); + var second = workers.submit(() -> checkedSave(fixture, invalid, "reject invalid overlapping graph")); + var accepted = first.get(20, TimeUnit.SECONDS); + var rejected = second.get(20, TimeUnit.SECONDS); + assertNull("another graph's required-field failure must not reject this valid graph: " + accepted.failure(), accepted.failure()); + assertTrue("invalid graph must fail before provider", rejected.failure() instanceof CheckException); + assertEquals("name", ((CheckException) rejected.failure()).getViolates().get(0).getLocation().modelPath()); + assertEquals(List.of("valid_clock"), accepted.evidence().stream().map(FixEvidence::modelPath).toList()); + assertEquals(List.of("invalid_clock"), rejected.evidence().stream().map(FixEvidence::modelPath).toList()); + assertEquals("2026-10-02T00:00", valid.getProperty("memo")); + assertEquals("2026-10-03T00:00", invalid.getProperty("memo")); + assertEquals(2, dates.get()); + assertEquals(1, fixture.commands.size()); + assertEquals(1, fixture.audit.size()); + assertLineage(fixture.audit, "CustomerOrder", 6100, List.of("save valid overlapping graph")); + assertTrue(fixture.sql.stream().allMatch(entry -> !entry.getMutationLineage().isEmpty() + && reasons(entry.getMutationLineage()).equals(List.of("save valid overlapping graph")))); + assertTrue(fixture.driver.queryForList("SELECT id FROM customer_order_data WHERE id = ?", new Object[]{6200L}).isEmpty()); + assertNull(fixture.context.getAttribute(Checker.TEAQL_DATA_CHECK_RESULT)); + assertNull(fixture.context.getAttribute(Checker.TEAQL_FIX_TIME)); + assertTrue(fixture.context.getTraceChain().isEmpty()); + } finally { + validEntered.countDown(); invalidChecked.countDown(); validFinished.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(10, TimeUnit.SECONDS)); + } + } + + @Test public void reentrantCheckerSaveRestoresOuterViolationsTimeAndEvidence() throws Exception { + var fixture = new Fixture(); + var outer = fixture.create("CustomerOrder", 6300, null); + var inner = fixture.create("CustomerOrder", 6400, "Nested independent graph"); + var dates = new AtomicLong(); + fixture.context.putAttribute(io.teaql.core.businessid.BusinessClock.class.getName(), + (io.teaql.core.businessid.BusinessClock) caller -> java.time.LocalDate.of(2026, 10, 2).plusDays(dates.getAndIncrement())); + fixture.context.getRuntime().install(RuntimeModule.of().withCheckers(new Checker() { + @Override public String type() { return "CustomerOrder"; } + @Override public void checkAndFix(UserContext caller, GraphEntity entity, ObjectLocation location) { + if (!needCheck(caller, entity)) return; + markAsChecked(caller, entity); + var now = caller.evaluate("now"); + caller.recordFixEvidence(new FixEvidence("CustomerOrder", entity == outer ? "outer_clock" : "inner_clock", + FixEvidence.Source.CLOCK, "graphClock")); + requiredCheck(caller, newLocation(location, "name"), entity.getProperty("name")); + if (entity == outer) { + inner.auditAs("save independent nested graph").save(caller); + assertEquals("nested execution must restore the outer captured clock", now, caller.evaluate("now")); + assertFalse("outer entity must remain checked", needCheck(caller, outer)); + assertTrue("inner visited identities must not leak into the outer graph", needCheck(caller, inner)); + } + entity.updateProperty("memo", now.toString()); + } + })); + var result = checkedSave(fixture, outer, "reject outer graph"); + assertTrue("nested save must not clear an outer violation: " + result.failure(), result.failure() instanceof CheckException); + assertEquals(List.of("outer_clock"), result.evidence().stream().map(FixEvidence::modelPath).toList()); + assertEquals(2, dates.get()); + assertEquals(1, fixture.commands.size()); + assertLineage(fixture.audit, "CustomerOrder", 6400, List.of("save independent nested graph")); + assertTrue(fixture.driver.queryForList("SELECT id FROM customer_order_data WHERE id = ?", new Object[]{6300L}).isEmpty()); + assertNull(fixture.context.getAttribute(Checker.TEAQL_DATA_CHECK_RESULT)); + assertTrue(fixture.context.getTraceChain().isEmpty()); + } + + @Test public void mutationResultsRetainPhysicalReadbacksInEveryLoggingMode() throws Exception { + for (boolean queryLogging : List.of(false, true)) { + for (boolean mutationLogging : List.of(false, true)) { + var fixture = new Fixture(); + fixture.queryLogging = queryLogging; + fixture.mutationLogging = mutationLogging; + var root = fixture.create("CustomerOrder", 100, "PRIVATE-READBACK-CANARY"); + for (String operation : List.of("insert", "update", "delete", "recover")) { + fixture.clear(); + if (operation.equals("update")) root.updateProperty("memo", "updated memo"); + if (operation.equals("delete")) root.markForDeletion(); + if (operation.equals("recover")) root.markToRecover(); + root.auditAs("save PRIVATE-READBACK-CANARY").save(fixture.context); + assertEquals(1, fixture.results.size()); + var statements = fixture.results.get(0).statements(); + assertEquals(operation + " query=" + queryLogging + " mutation=" + mutationLogging, 2, statements.size()); + var write = statements.get(0); + var read = statements.get(1); + assertEquals(operation, write.getStatementOperation()); + assertEquals("select", read.getStatementOperation()); + assertEquals(Long.valueOf(1), write.getAffectedRows()); + assertEquals(Integer.valueOf(1), read.getResultCount()); + assertEquals(write.getMutationLineage(), read.getMutationLineage()); + assertEquals("CustomerOrder", read.getTraceChain().get(0).getName()); + assertEquals(TraceKind.REQUEST, read.getTraceChain().get(1).getKind()); + assertEquals(MutationIntent.of("save PRIVATE-READBACK-CANARY").readbackIntent().purpose(), read.getPurpose()); + assertEquals("success", read.getExecutionOutcome()); + assertEquals((queryLogging ? 1 : 0) + (mutationLogging ? 1 : 0), fixture.sql.size()); + assertEquals(1, fixture.audit.size()); + for (var log : fixture.sql) { + assertFalse(log.getTraceChain().toString().contains("PRIVATE-READBACK-CANARY")); + assertFalse(operation + " leaked loaded private value", String.valueOf(log.getComment()).contains("PRIVATE-READBACK-CANARY")); + } + assertThrows(UnsupportedOperationException.class, () -> statements.add(write)); + } + } + } + } + + @Test public void retainedReadbackCanBeSafelyProjectedAfterLoggingWasDisabled() throws Exception { + var fixture = new Fixture(); + var root = fixture.create("CustomerOrder", 100, "PRIVATE-OLD-VALUE"); + root.auditAs("seed privacy fixture").save(fixture.context); + fixture.clear(); + fixture.queryLogging = false; + fixture.mutationLogging = false; + root.updateProperty("name", "PRIVATE-NEW-VALUE"); + root.auditAs("replace PRIVATE-OLD-VALUE with PRIVATE-NEW-VALUE").save(fixture.context); + assertTrue(fixture.sql.isEmpty()); + var statements = fixture.results.get(0).statements(); + assertEquals(2, statements.size()); + for (var raw : statements) { + assertFalse(raw.getParameters().contains("PRIVATE-OLD-VALUE")); + var safe = LogPrivacy.sql(raw, false); + var debug = LogPrivacy.sql(raw, true); + var revoked = LogPrivacy.sql(debug, false); + for (var projection : List.of(safe, revoked)) { + String text = projection.getComment() + " " + projection.getAuditReason() + + projection.getDebugQuery() + projection.getMutationLineage(); + assertFalse(text, text.contains("PRIVATE-OLD-VALUE")); + assertFalse(text, text.contains("PRIVATE-NEW-VALUE")); + } + assertTrue(String.valueOf(debug.getAuditReason()).contains("PRIVATE-OLD-VALUE")); + } + assertTrue(statements.get(0).getParameters().contains("PRIVATE-NEW-VALUE")); + assertEquals("PRIVATE-NEW-VALUE", root.getProperty("name")); + } + + @Test public void preparedMembersKeepSeparateReturnedFactsWhenLogsAreDisabled() throws Exception { + var fixture = new Fixture(); + fixture.queryLogging = false; + fixture.mutationLogging = false; + var root = fixture.create("CustomerOrder", 100, "batch root"); + var first = fixture.create("OrderItem", 201, "first item"); + var second = fixture.create("OrderItem", 202, "second item"); + first.setComment("first member intent"); + second.setComment("second member intent"); + root.__internalSet("children", List.of(first, second)); + root.auditAs("save batch graph").save(fixture.context); + assertEquals(List.of(2), fixture.itemInsertBatchSizes); + assertEquals(3, fixture.results.size()); + assertEquals(3, fixture.audit.size()); + assertTrue(fixture.sql.isEmpty()); + for (var result : fixture.results) { + var entity = result.persistedEntity(); + var command = fixture.commands.stream().filter(value -> value.getEntity().typeName().equals(entity.typeName()) + && value.getEntity().getId().equals(entity.getId())).findFirst().orElseThrow(); + assertEquals(2, result.statements().size()); + assertEquals("insert", result.statements().get(0).getStatementOperation()); + assertEquals("select", result.statements().get(1).getStatementOperation()); + for (var statement : result.statements()) assertEquals(command.getTraceChain(), statement.getMutationLineage()); + } + } + + @Test public void normativeGraphRetainsCommandSqlReadbackAndCommittedAuditLineage() throws Exception { + var fixture = new Fixture(); + var removed = fixture.create("OrderItem", 202, "deleted item"); + removed.auditAs("seed deleted fixture").save(fixture.context); + fixture.clear(); + var root = fixture.create("CustomerOrder", 100, "root value"); + var item = fixture.create("OrderItem", 201, "item value"); + var payment = fixture.create("Payment", 100, "payment value"); // Same ID, distinct model type. + payment.setComment("authorize payment"); + var attempt = fixture.create("PaymentAttempt", 401, "attempt value"); + payment.__internalSet("children", List.of(attempt)); + var shipment = fixture.create("Shipment", 501, "shipment value"); + shipment.setComment("dispatch shipment"); + removed.markForDeletion(); + removed.setComment("remove unavailable item"); + root.__internalSet("children", List.of(item, payment, shipment, removed)); + + root.auditAs("submit order").save(fixture.context); + + assertEquals(6, fixture.commands.size()); + assertEquals(6, fixture.audit.size()); + assertTrue(fixture.context.getTraceChain().isEmpty()); + for (var command : fixture.commands) { + var entity = command.getEntity(); + var event = fixture.audit.stream().filter(value -> value.entityType().equals(entity.typeName()) + && value.entityId().equals(entity.getId())).findFirst().orElseThrow(); + assertEquals(command.getTraceChain(), event.traceChain()); + var writes = fixture.sql.stream().filter(entry -> entry.getOperation() == DataServiceOperation.MUTATION + && entry.getTraceChain().get(1).getName().equals(entity.typeName()) + && entry.getMutationLineage().equals(command.getTraceChain())).toList(); + assertFalse("actual mutation SQL missing for " + entity.typeName(), writes.isEmpty()); + for (var entry : writes) { + assertEquals("success", entry.getExecutionOutcome()); + assertEquals("CustomerOrder", entry.getTraceChain().get(0).getName()); + assertEquals("sqlite", entry.getTraceChain().get(entry.getTraceChain().size() - 2).getName()); + assertEquals(command.getAction() == EntityPersistenceMutation.Action.DELETE ? "delete" : "insert", + entry.getTraceChain().get(entry.getTraceChain().size() - 1).getName()); + } + assertTrue("authoritative readback must retain the same lineage", fixture.sql.stream().anyMatch(entry -> + entry.getOperation() == DataServiceOperation.QUERY && entry.getMutationLineage().equals(command.getTraceChain()))); + } + assertLineage(fixture.audit, "Payment", 100, List.of("submit order", "authorize payment")); + assertLineage(fixture.audit, "PaymentAttempt", 401, List.of("submit order", "authorize payment")); + assertLineage(fixture.audit, "OrderItem", 202, List.of("submit order", "remove unavailable item")); + assertLineage(fixture.audit, "OrderItem", 201, List.of("submit order")); + assertEquals("root value", root.getProperty("name")); + assertEquals(Long.valueOf(1), root.getVersion()); + assertEquals(Long.valueOf(-2), removed.getVersion()); + } + + @Test public void realSqliteProviderFailureKeepsAttemptedLineageWithoutCommittedAudit() throws Exception { + var fixture = new Fixture(); + fixture.driver.execute("CREATE UNIQUE INDEX payment_name_unique ON payment_data(name)"); + fixture.create("Payment", 300, "duplicate payment").auditAs("seed conflict").save(fixture.context); + fixture.clear(); + var root = fixture.create("CustomerOrder", 100, "failed root"); + var payment = fixture.create("Payment", 200, "duplicate payment"); + payment.setComment("reject duplicate payment"); + root.__internalSet("children", List.of(payment)); + assertThrows(RuntimeException.class, () -> root.auditAs("attempt graph transaction").save(fixture.context)); + assertTrue("rollback must not emit committed audit", fixture.audit.isEmpty()); + var failure = fixture.sql.stream().filter(entry -> "failure".equals(entry.getBatchOutcome())).findFirst().orElseThrow(); + assertEquals("JDBC provides no member counts for this failure; do not invent them", "unknown", failure.getExecutionOutcome()); + assertNull(failure.getAffectedRows()); + assertEquals(List.of("attempt graph transaction", "reject [REDACTED]"), reasons(failure.getMutationLineage())); + assertEquals(Long.valueOf(200), failure.getMutationLineage().get(1).getEntityId()); + assertTrue("successful earlier statement is not rewritten as failure", fixture.sql.stream().anyMatch(entry -> + entry.getOperation() == DataServiceOperation.MUTATION && "success".equals(entry.getExecutionOutcome()))); + assertTrue(fixture.driver.queryForList("SELECT id FROM customer_order_data WHERE id = ?", new Object[]{100L}).isEmpty()); + assertTrue(fixture.context.getTraceChain().isEmpty()); + } + + @Test public void realReadbackFailureDoesNotEraseSuccessfulWriteTraceAndCanRetry() throws Exception { + var fixture = new Fixture(); + var root = fixture.create("CustomerOrder", 100, "readback fixture"); + fixture.failReadback = true; + assertThrows(RuntimeException.class, () -> root.auditAs("readback failure intent").save(fixture.context)); + assertTrue(fixture.audit.isEmpty()); + var write = fixture.sql.stream().filter(entry -> entry.getOperation() == DataServiceOperation.MUTATION).findFirst().orElseThrow(); + var readback = fixture.sql.stream().filter(entry -> entry.getOperation() == DataServiceOperation.QUERY + && "failure".equals(entry.getExecutionOutcome())).findFirst().orElseThrow(); + assertEquals("success", write.getExecutionOutcome()); + assertEquals(write.getMutationLineage(), readback.getMutationLineage()); + assertEquals(List.of("readback failure intent"), reasons(write.getMutationLineage())); + assertEquals("insert", write.getStatementOperation()); + assertEquals("select", readback.getStatementOperation()); + fixture.failReadback = false; + fixture.clear(); + root.auditAs("retry completed mutation").save(fixture.context); + assertEquals(1, fixture.audit.size()); + assertLineage(fixture.audit, "CustomerOrder", 100, List.of("retry completed mutation")); + assertEquals(Long.valueOf(1), root.getVersion()); + } + + @Test public void safeSqlAndAuditRetainTypedIdsWhileMaskingReasonSecrets() throws Exception { + var fixture = new Fixture(); + fixture.create("CustomerOrder", 100, "PRIVATE-TRACE-CANARY") + .auditAs("change PRIVATE-TRACE-CANARY").save(fixture.context); + assertFalse(fixture.sql.isEmpty()); + assertEquals(1, fixture.audit.size()); + assertEquals(Long.valueOf(100), fixture.audit.get(0).traceChain().get(0).getEntityId()); + for (var entry : fixture.sql) { + assertEquals(Long.valueOf(100), entry.getMutationLineage().get(0).getEntityId()); + String exported = entry.getDebugQuery() + " " + entry.getAuditReason() + " " + entry.getComment() + + " " + entry.getTraceChain() + " " + entry.getMutationLineage(); + assertFalse(exported, exported.contains("PRIVATE-TRACE-CANARY")); + } + assertFalse(fixture.audit.get(0).traceChain().toString().contains("PRIVATE-TRACE-CANARY")); + } + + @Test public void newlyAllocatedChildIdReachesRealSqlAndCommittedAudit() throws Exception { + var fixture = new Fixture(); + var root = fixture.create("CustomerOrder", 100, "allocated graph"); + var payment = new GraphEntity("Payment"); + payment.updateProperty("name", "allocated payment"); + payment.setComment("authorize newly assigned object"); + root.__internalSet("children", List.of(payment)); + root.auditAs("create allocated child").save(fixture.context); + assertEquals(Long.valueOf(1000), payment.getId()); + var event = fixture.audit.stream().filter(value -> value.entityType().equals("Payment")).findFirst().orElseThrow(); + assertEquals(Long.valueOf(1000), event.traceChain().get(1).getEntityId()); + assertEquals(List.of("create allocated child", "authorize newly assigned object"), reasons(event.traceChain())); + assertTrue(fixture.sql.stream().anyMatch(entry -> entry.getOperation() == DataServiceOperation.MUTATION + && entry.getMutationLineage().equals(event.traceChain()))); + } + + @Test public void sameTypePreparedInsertBatchKeepsEachItemLineage() throws Exception { + var fixture = new Fixture(); + var root = fixture.create("CustomerOrder", 100, "batch owner"); + var first = fixture.create("OrderItem", 201, "alpha value"); + first.setComment("add first entry"); + var second = fixture.create("OrderItem", 202, "beta value"); + second.setComment("add second entry"); + root.__internalSet("children", List.of(first, second)); + + root.auditAs("compose item batch").save(fixture.context); + + assertEquals("observe a real two-row JDBC prepared batch, not two singleton calls", + List.of(2), fixture.itemInsertBatchSizes); + assertEquals(3, fixture.audit.size()); + for (long id : List.of(201L, 202L)) { + var event = fixture.audit.stream().filter(value -> value.entityType().equals("OrderItem") + && value.entityId().equals(id)).findFirst().orElseThrow(); + var write = fixture.sql.stream().filter(value -> value.getOperation() == DataServiceOperation.MUTATION + && value.getTraceChain().get(1).getName().equals("OrderItem") + && Objects.equals(value.getMutationLineage().get(value.getMutationLineage().size() - 1).getEntityId(), id)) + .findFirst().orElseThrow(); + assertEquals(event.traceChain(), write.getMutationLineage()); + assertEquals(List.of("compose item batch", id == 201L ? "add first entry" : "add second entry"), + reasons(write.getMutationLineage())); + assertEquals("success", write.getExecutionOutcome()); + assertEquals(Long.valueOf(1), write.getAffectedRows()); + assertTrue(fixture.sql.stream().anyMatch(value -> value.getOperation() == DataServiceOperation.QUERY + && value.getMutationLineage().equals(event.traceChain()))); + } + assertEquals("alpha value", first.getProperty("name")); + assertEquals("beta value", second.getProperty("name")); + } + + @Test public void realSameTypeBatchFailureKeepsBothAttemptedLineagesAndRollsBack() throws Exception { + var fixture = new Fixture(); + fixture.driver.execute("CREATE UNIQUE INDEX item_name_unique ON order_item_data(name)"); + var root = fixture.create("CustomerOrder", 100, "failure owner"); + var first = fixture.create("OrderItem", 201, "duplicate batch name"); + first.setComment("attempt entry alpha"); + var second = fixture.create("OrderItem", 202, "duplicate batch name"); + second.setComment("attempt entry beta"); + root.__internalSet("children", List.of(first, second)); + + assertThrows(RuntimeException.class, () -> root.auditAs("attempt prepared graph").save(fixture.context)); + + assertEquals(List.of(2), fixture.itemInsertBatchSizes); + assertTrue(fixture.audit.isEmpty()); + var members = fixture.sql.stream().filter(value -> "failure".equals(value.getBatchOutcome()) + && value.getTraceChain().get(1).getName().equals("OrderItem")).toList(); + assertEquals(2, members.size()); + for (int index = 0; index < members.size(); index++) { + var member = members.get(index); + assertEquals(List.of("attempt prepared graph", index == 0 ? "attempt entry alpha" : "attempt entry beta"), + reasons(member.getMutationLineage())); + assertEquals(Long.valueOf(201L + index), member.getMutationLineage().get(1).getEntityId()); + assertEquals("SQLite JDBC reports no per-member counts here", "unknown", member.getExecutionOutcome()); + assertNull(member.getAffectedRows()); + } + assertTrue(fixture.driver.queryForList("SELECT id FROM order_item_data", new Object[]{}).isEmpty()); + assertTrue(fixture.driver.queryForList("SELECT id FROM customer_order_data", new Object[]{}).isEmpty()); + assertTrue(first.newItem()); + assertTrue(second.newItem()); + assertNull(first.getVersion()); + assertNull(second.getVersion()); + } + + @Test public void siblingSecretIsScrubbedFromEveryPreparedMemberAndReadback() throws Exception { + var fixture = new Fixture(); + var root = fixture.create("CustomerOrder", 100, "privacy owner"); + var first = fixture.create("OrderItem", 201, "PUBLIC-LOOKING-ALPHA"); + first.setComment("first member refers to PRIVATE-FUTURE-BETA"); + var second = fixture.create("OrderItem", 202, "PRIVATE-FUTURE-BETA"); + second.setComment("second member request"); + root.__internalSet("children", List.of(first, second)); + + root.auditAs("compose privacy batch").save(fixture.context); + + assertEquals(List.of(2), fixture.itemInsertBatchSizes); + var statements = fixture.sql.stream().filter(value -> !value.getMutationLineage().isEmpty() + && value.getMutationLineage().get(value.getMutationLineage().size() - 1).getName().equals("OrderItem")).toList(); + assertEquals("two writes and two readbacks", 4, statements.size()); + for (var statement : statements) { + assertFalse(statement.getDebugQuery() + " " + statement.getAuditReason() + " " + statement.getComment() + + " " + statement.getMutationLineage(), statement.getMutationLineage().toString().contains("PRIVATE-FUTURE-BETA")); + } + assertEquals("private values still reach the database unchanged", "PRIVATE-FUTURE-BETA", second.getProperty("name")); + assertEquals("caller-owned reason is not modified", "first member refers to PRIVATE-FUTURE-BETA", first.getComment()); + } + + @Test public void sameTypePreparedUpdatesKeepMemberVersionsAndLineage() throws Exception { + var fixture = new Fixture(); + var root = seedTwoItems(fixture); + var children = (List) root.__internalGet("children"); + children.get(0).updateProperty("name", "changed alpha"); + children.get(0).setComment("revise alpha entry"); + children.get(1).updateProperty("name", "changed beta"); + children.get(1).setComment("revise beta entry"); + root.auditAs("revise existing graph").save(fixture.context); + + assertEquals(List.of(2), fixture.itemUpdateBatchSizes); + assertMemberWrites(fixture, "update", "revise existing graph", "revise alpha entry", "revise beta entry"); + assertEquals(Long.valueOf(2), children.get(0).getVersion()); + assertEquals(Long.valueOf(2), children.get(1).getVersion()); + assertEquals("changed alpha", children.get(0).getProperty("name")); + assertEquals("changed beta", children.get(1).getProperty("name")); + } + + @Test public void sameTypePreparedDeleteAndPureRecoveryKeepSeparateMemberLineage() throws Exception { + var fixture = new Fixture(); + var root = seedTwoItems(fixture); + var children = (List) root.__internalGet("children"); + for (var child : children) child.markForDeletion(); + children.get(0).setComment("remove alpha entry"); + children.get(1).setComment("remove beta entry"); + root.auditAs("remove graph entries").save(fixture.context); + + assertEquals(List.of(2), fixture.itemDeleteBatchSizes); + assertMemberWrites(fixture, "delete", "remove graph entries", "remove alpha entry", "remove beta entry"); + for (var child : children) assertEquals(Long.valueOf(-2), child.getVersion()); + fixture.clear(); + for (var child : children) child.markAsRecover(); // No dirty scalar field: the operation itself must enter the ledger. + children.get(0).setComment("restore alpha entry"); + children.get(1).setComment("restore beta entry"); + root.auditAs("restore graph entries").save(fixture.context); + + assertEquals(List.of(2), fixture.itemRecoverBatchSizes); + assertMemberWrites(fixture, "recover", "restore graph entries", "restore alpha entry", "restore beta entry"); + for (var child : children) { + assertEquals(Long.valueOf(3), child.getVersion()); + assertEquals(EntityStatus.PERSISTED, child.get$status()); + } + } + + @Test public void singlePureRecoveryIsNotLostWhenNoScalarFieldsChange() throws Exception { + var fixture = new Fixture(); + var item = fixture.create("OrderItem", 201, "recover fixture"); + item.auditAs("seed recovery fixture").save(fixture.context); + fixture.clear(); + item.markForDeletion(); + item.auditAs("remove recovery fixture").save(fixture.context); + fixture.clear(); + item.markToRecover(); + item.auditAs("restore recovery fixture").save(fixture.context); + + assertEquals(1, fixture.commands.size()); + assertEquals(1, fixture.audit.size()); + assertEquals(MutationAuditKind.RECOVERED, fixture.audit.get(0).kind()); + assertEquals(Long.valueOf(3), item.getVersion()); + assertEquals(EntityStatus.PERSISTED, item.get$status()); + assertTrue(fixture.sql.stream().anyMatch(entry -> entry.getOperation() == DataServiceOperation.MUTATION + && entry.getStatementOperation().equals("recover") + && reasons(entry.getMutationLineage()).equals(List.of("restore recovery fixture")))); + } + + @Test public void staleSecondPreparedUpdateRollsBackFirstButRetainsRealRowCounts() throws Exception { + var fixture = new Fixture(); + var root = seedTwoItems(fixture); + var children = (List) root.__internalGet("children"); + // A separate writer advances only the second row's optimistic version. + fixture.driver.update("UPDATE order_item_data SET version = 2 WHERE id = 202", new Object[]{}); + children.get(0).updateProperty("name", "attempted alpha"); + children.get(0).setComment("attempt alpha revision"); + children.get(1).updateProperty("name", "attempted beta"); + children.get(1).setComment("attempt beta revision"); + assertThrows(RuntimeException.class, () -> root.auditAs("attempt stale graph").save(fixture.context)); + + assertEquals(List.of(2), fixture.itemUpdateBatchSizes); + assertTrue(fixture.audit.isEmpty()); + var writes = fixture.sql.stream().filter(entry -> entry.getOperation() == DataServiceOperation.MUTATION).toList(); + assertEquals(2, writes.size()); + assertEquals(Long.valueOf(1), writes.get(0).getAffectedRows()); + assertEquals(Long.valueOf(0), writes.get(1).getAffectedRows()); + assertEquals(List.of("attempt stale graph", "attempt alpha revision"), reasons(writes.get(0).getMutationLineage())); + assertEquals(List.of("attempt stale graph", "attempt beta revision"), reasons(writes.get(1).getMutationLineage())); + var rows = fixture.driver.queryForList("SELECT id,name,version FROM order_item_data ORDER BY id", new Object[]{}); + assertEquals("original alpha", rows.get(0).get("name")); + assertEquals("original beta", rows.get(1).get("name")); + for (var child : children) { + assertEquals(Long.valueOf(1), child.getVersion()); + assertEquals(EntityStatus.UPDATED, child.get$status()); + } + } + + @Test public void distinctUpdateLayoutsAssociateTracesWithPhysicalRowsNotBatchIndex() throws Exception { + var fixture = new Fixture(); + var root = seedTwoItems(fixture); + var children = (List) root.__internalGet("children"); + children.get(0).updateProperty("name", "renamed alpha"); + children.get(0).setComment("rename alpha entry"); + children.get(1).updateProperty("memo", "beta note"); + children.get(1).setComment("annotate beta entry"); + root.auditAs("revise different projections").save(fixture.context); + + assertEquals("different SET layouts must not be merged into one prepared statement", List.of(1, 1), fixture.itemUpdateBatchSizes); + assertMemberWrites(fixture, "update", "revise different projections", "rename alpha entry", "annotate beta entry"); + assertEquals("beta note", children.get(1).getProperty("memo")); + assertEquals("original beta", children.get(1).getProperty("name")); + } + + @Test public void staleDeleteAndRecoverMembersRollBackAndKeepAttemptedLineages() throws Exception { + for (boolean recover : List.of(false, true)) { + var fixture = new Fixture(); + var root = seedTwoItems(fixture); + var children = (List) root.__internalGet("children"); + if (recover) { + for (var child : children) child.markForDeletion(); + root.auditAs("prepare removed graph").save(fixture.context); + fixture.clear(); + } + long original = recover ? -2L : 1L; + fixture.driver.update("UPDATE order_item_data SET version = ? WHERE id = ?", + new Object[]{recover ? -3L : 2L, 202L}); + for (var child : children) { + if (recover) child.markAsRecover(); else child.markForDeletion(); + } + children.get(0).setComment("attempt first member"); + children.get(1).setComment("attempt second member"); + assertThrows(RuntimeException.class, () -> root.auditAs("attempt stale lifecycle").save(fixture.context)); + + assertEquals(List.of(2), recover ? fixture.itemRecoverBatchSizes : fixture.itemDeleteBatchSizes); + assertTrue(fixture.audit.isEmpty()); + var writes = fixture.sql.stream().filter(entry -> entry.getOperation() == DataServiceOperation.MUTATION).toList(); + assertEquals(2, writes.size()); + assertEquals(Long.valueOf(1), writes.get(0).getAffectedRows()); + assertEquals(Long.valueOf(0), writes.get(1).getAffectedRows()); + assertEquals(recover ? "recover" : "delete", writes.get(0).getStatementOperation()); + assertEquals(List.of("attempt stale lifecycle", "attempt first member"), reasons(writes.get(0).getMutationLineage())); + assertEquals(List.of("attempt stale lifecycle", "attempt second member"), reasons(writes.get(1).getMutationLineage())); + for (var child : children) { + assertEquals(Long.valueOf(original), child.getVersion()); + assertEquals(recover ? EntityStatus.UPDATED_RECOVER : EntityStatus.UPDATED_DELETED, child.get$status()); + } + var row = fixture.driver.queryForList("SELECT version FROM order_item_data WHERE id = ?", new Object[]{201L}).get(0); + assertEquals(original, ((Number) row.get("version")).longValue()); + } + } + + @Test public void unknownPreparedUpdateCountsCannotProveOptimisticSuccess() throws Exception { + var fixture = new Fixture(); + var root = seedTwoItems(fixture); + var children = (List) root.__internalGet("children"); + for (var child : children) child.updateProperty("name", "attempted revision " + child.getId()); + fixture.unknownUpdateCounts = true; + RuntimeException failure = assertThrows(RuntimeException.class, + () -> root.auditAs("attempt unknown counts").save(fixture.context)); + Throwable cause = failure; + while (cause.getCause() != null) cause = cause.getCause(); + assertEquals("Prepared mutation requires an exact per-item affected-row count", cause.getMessage()); + + assertEquals(List.of(2), fixture.itemUpdateBatchSizes); + assertTrue(fixture.audit.isEmpty()); + var writes = fixture.sql.stream().filter(entry -> entry.getOperation() == DataServiceOperation.MUTATION).toList(); + assertEquals(2, writes.size()); + for (var statement : writes) assertNull("SUCCESS_NO_INFO must not become affectedRows=1", statement.getAffectedRows()); + var rows = fixture.driver.queryForList("SELECT name FROM order_item_data ORDER BY id", new Object[]{}); + assertEquals("original alpha", rows.get(0).get("name")); + assertEquals("original beta", rows.get(1).get("name")); + } + + @Test public void detachedLedgerRecoveryKeepsCompleteSpecificLineage() throws Exception { + var fixture = new Fixture(); + var item = fixture.create("OrderItem", 201, "detached entry"); + item.auditAs("seed detached entry").save(fixture.context); + fixture.clear(); + item.markForDeletion(); + item.auditAs("remove detached entry").save(fixture.context); + var root = fixture.create("CustomerOrder", 100, "independent owner"); + root.auditAs("seed independent owner").save(fixture.context); + fixture.clear(); + var key = new EntityKey("OrderItem", 201L); + var complete = List.of(new TraceNode(TraceKind.AUDIT_REASON, "CustomerOrder", 100L, "delegated restore root"), + new TraceNode(TraceKind.AUDIT_REASON, "OrderItem", 201L, "restore detached entry")); + root.getEntityMutationLedger().markAsRecover(key); + root.getEntityMutationLedger().setOriginalVersion(key, -2L); + root.getEntityMutationLedger().setTraceChain(key, complete); + root.auditAs("fallback must be replaced").save(fixture.context); + + assertEquals(1, fixture.audit.size()); + assertEquals(MutationAuditKind.RECOVERED, fixture.audit.get(0).kind()); + assertEquals(complete, fixture.audit.get(0).traceChain()); + assertEquals(complete, fixture.commands.get(0).getTraceChain()); + assertTrue(fixture.sql.stream().anyMatch(entry -> entry.getStatementOperation().equals("recover") + && entry.getMutationLineage().equals(complete))); + var row = fixture.driver.queryForList("SELECT version FROM order_item_data WHERE id = 201", new Object[]{}).get(0); + assertEquals(3, ((Number) row.get("version")).longValue()); + } + + @Test public void oneGraphCanUpdateAndRecoverSameTypeWithoutMixingVersionTransitions() throws Exception { + var fixture = new Fixture(); + var root = seedTwoItems(fixture); + var children = (List) root.__internalGet("children"); + children.get(1).markForDeletion(); + root.auditAs("prepare one removed entry").save(fixture.context); + fixture.clear(); + children.get(0).updateProperty("name", "revised active entry"); + children.get(0).setComment("revise active entry"); + children.get(1).markAsRecover(); + children.get(1).setComment("restore removed entry"); + root.auditAs("compose mixed lifecycle").save(fixture.context); + + assertEquals(Long.valueOf(2), children.get(0).getVersion()); + assertEquals(Long.valueOf(3), children.get(1).getVersion()); + assertEquals(2, fixture.audit.size()); + assertLineage(fixture.audit, "OrderItem", 201, List.of("compose mixed lifecycle", "revise active entry")); + assertLineage(fixture.audit, "OrderItem", 202, List.of("compose mixed lifecycle", "restore removed entry")); + assertEquals(List.of("update", "recover"), fixture.sql.stream() + .filter(entry -> entry.getOperation() == DataServiceOperation.MUTATION) + .map(ExecutionMetadata::getStatementOperation).toList()); + } + + private static GraphEntity seedTwoItems(Fixture fixture) { + var root = fixture.create("CustomerOrder", 100, "batch owner"); + root.__internalSet("children", List.of(fixture.create("OrderItem", 201, "original alpha"), + fixture.create("OrderItem", 202, "original beta"))); + root.auditAs("seed graph fixtures").save(fixture.context); + fixture.clear(); + return root; + } + + private static void assertMemberWrites(Fixture fixture, String operation, String rootReason, + String firstReason, String secondReason) { + assertEquals(2, fixture.commands.size()); + assertEquals(2, fixture.audit.size()); + for (int index = 0; index < 2; index++) { + var command = fixture.commands.get(index); + var expected = List.of(rootReason, index == 0 ? firstReason : secondReason); + assertEquals(expected, reasons(command.getTraceChain())); + assertEquals(Long.valueOf(201L + index), command.getTraceChain().get(1).getEntityId()); + assertLineage(fixture.audit, "OrderItem", 201L + index, expected); + var statement = fixture.sql.stream().filter(entry -> entry.getOperation() == DataServiceOperation.MUTATION + && entry.getMutationLineage().equals(command.getTraceChain())).findFirst().orElseThrow(); + assertEquals(operation, statement.getStatementOperation()); + assertEquals("success", statement.getExecutionOutcome()); + assertEquals(Long.valueOf(1), statement.getAffectedRows()); + assertTrue(fixture.sql.stream().anyMatch(entry -> entry.getOperation() == DataServiceOperation.QUERY + && entry.getMutationLineage().equals(command.getTraceChain()))); + } + } + + static List reasons(List nodes) { return nodes.stream().map(TraceNode::getComment).toList(); } + static void assertLineage(List events, String type, long id, List expected) { + var event = events.stream().filter(value -> value.entityType().equals(type) && Objects.equals(value.entityId(), id)) + .findFirst().orElseThrow(); + assertEquals(expected, reasons(event.traceChain())); + } +} diff --git a/teaql-sqlite/src/test/java/io/teaql/sqlite/LikeIntentPrivacySqliteTest.java b/teaql-sqlite/src/test/java/io/teaql/sqlite/LikeIntentPrivacySqliteTest.java new file mode 100644 index 00000000..457a610a --- /dev/null +++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/LikeIntentPrivacySqliteTest.java @@ -0,0 +1,268 @@ +package io.teaql.sqlite; + +import io.teaql.core.*; +import io.teaql.core.criteria.*; +import io.teaql.core.meta.SimpleEntityMetaFactory; +import io.teaql.core.sql.*; +import io.teaql.core.sqlite.SqliteDataServiceExecutor; +import io.teaql.provider.jdbc.JdbcSqlExecutor; +import io.teaql.runtime.*; +import java.io.*; +import java.nio.file.Files; +import java.util.*; +import java.util.function.Supplier; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.sqlite.SQLiteDataSource; +import static org.junit.Assert.*; + +/** Native typed predicates -> real SQLite binds -> governed SQL diagnostics. No supplied trace frames. */ +@RunWith(Parameterized.class) +public class LikeIntentPrivacySqliteTest { + @Parameterized.Parameters(name="{0}, marked={1}, logging={2}") + public static Collection cases() { + var cases = new ArrayList(); + for (var op : List.of(Operator.CONTAIN, Operator.NOT_CONTAIN, Operator.BEGIN_WITH, + Operator.NOT_BEGIN_WITH, Operator.END_WITH, Operator.NOT_END_WITH)) + for (boolean marked : List.of(false, true)) + for (boolean logging : List.of(false, true)) cases.add(new Object[]{op, marked, logging}); + return cases; + } + private final Operator op; + private final boolean marked, logging; + public LikeIntentPrivacySqliteTest(Operator op, boolean marked, boolean logging) { + this.op = op; this.marked = marked; this.logging = logging; + } + private String field() { return marked ? "name" : "state"; } + private boolean negative() { return op.name().startsWith("NOT_"); } + private String pattern(String operand) { + return switch (op) { + case CONTAIN, NOT_CONTAIN -> "%" + operand + "%"; + case BEGIN_WITH, NOT_BEGIN_WITH -> operand + "%"; + default -> "%" + operand; + }; + } + static final class Request extends BaseRequest { + private final String type; + Request(Class type, Supplier factory) { super(type, factory); this.type = type.getSimpleName(); } + @Override public String getTypeName() { return type; } + Request where(String field, Operator op, String value) { + appendSearchCriteria(createBasicSearchCriteria(field, op, value)); return this; + } + Request intent(String text) { internalComment("inspect " + text); internalPurpose("render " + text); return this; } + } + record Bind(String sql, List values) {} + static final class Fixture { + final List binds = new ArrayList<>(); + final List safe = new ArrayList<>(); + final List policies = new ArrayList<>(); + final ByteArrayOutputStream output = new ByteArrayOutputStream(); + final SimpleEntityMetaFactory metadata = new SimpleEntityMetaFactory(); + final DefaultUserContext context; + final JdbcSqlExecutor driver; + final SqliteDataServiceExecutor provider; + QueryResult last; + Fixture(boolean logging) throws Exception { + var source = new SQLiteDataSource(); + source.setUrl("jdbc:sqlite:" + Files.createTempFile("teaql-like-intent-", ".db")); + var documents = descriptor(DerivedQueryTraceSqliteTest.TraceDocument.class, DerivedQueryTraceSqliteTest.TraceDocument::new); + var lines = descriptor(DerivedQueryTraceSqliteTest.TraceLine.class, DerivedQueryTraceSqliteTest.TraceLine::new); + var relation = (GenericSQLRelation) lines.addObjectProperty(metadata, "document", "TraceDocument", "lines", + DerivedQueryTraceSqliteTest.TraceDocument.class); + relation.setColumnType("BIGINT"); + driver = new JdbcSqlExecutor(source) { + @Override public List> queryForList(String sql, Object[] args) { + binds.add(new Bind(sql, Arrays.asList(args.clone()))); return super.queryForList(sql,args); + } + @Override public List query(String sql, Object[] args, CompiledRowMapper mapper) { + binds.add(new Bind(sql, Arrays.asList(args.clone()))); return super.query(sql,args,mapper); + } + }; + provider = new SqliteDataServiceExecutor("sqlite", driver, source) { + @Override public QueryResult query(UserContext context, QueryRequest request) { + last = super.query(context, request); return last; + } + }; + var text = new DefaultTextRuntimeLogSink(new PrintStream(output)); + var runtime = TeaQLRuntime.builder().metadata(metadata).dataService("sqlite", provider) + .queryExecutionLogging(logging).mutationExecutionLogging(false) + .queryPolicy(new QueryPolicy() { + @Override public void enforceSelect(UserContext caller, SearchRequest request) { + policies.add(QueryIntent.of(request.comment(), request.purpose())); + } + }).logSink((caller, entry) -> { safe.add(entry); text.writeExecutionLog(caller, entry); }).build(); + context = new DefaultUserContext(runtime); context.ensureSchema(); + driver.update("INSERT INTO trace_document_data(id,version,name,state) VALUES(1,1,?,?),(2,1,?,?)", + new Object[]{"FIRST-SECRET", "FIRST-SECRET", "SECOND-SECRET", "SECOND-SECRET"}); + driver.update("INSERT INTO trace_line_data(id,version,name,state,document) VALUES(11,1,?,?,1),(12,1,?,?,1)", + new Object[]{"FIRST-SECRET", "FIRST-SECRET", "SECOND-SECRET", "SECOND-SECRET"}); + clear(); + } + private SQLEntityDescriptor descriptor(Class type, Supplier supplier) { + var d = new SQLEntityDescriptor(); d.setType(type.getSimpleName()); d.setTargetType(type); + d.setEntitySupplier(supplier); d.setDataService("sqlite"); d.setAuditMaskFields(List.of("name")); + for (String name : List.of("id", "version", "name", "state")) { + boolean text = name.equals("name") || name.equals("state"); + var p = (GenericSQLProperty) d.addSimpleProperty(name, text ? String.class : Long.class); + p.setColumnType(text ? "VARCHAR(255)" : "BIGINT"); + } + metadata.register(d); return d; + } + Request documents() { + return request(DerivedQueryTraceSqliteTest.TraceDocument.class, DerivedQueryTraceSqliteTest.TraceDocument::new); + } + Request lines() { + var request = request(DerivedQueryTraceSqliteTest.TraceLine.class, DerivedQueryTraceSqliteTest.TraceLine::new); + request.selectProperty("document"); return request; + } + private Request request(Class type, Supplier factory) { + var request = new Request<>(type, factory); request.bindMetadata(metadata); + for (String field : List.of("id", "version", "name", "state")) request.selectProperty(field); + request.top(10); return request; + } + SmartList run(Request request) { return context.getRuntime().executeForList(context, request); } + void clear() { binds.clear(); safe.clear(); policies.clear(); output.reset(); } + } + private void assertProjection(Fixture f, Request request, String operand, boolean privateOperand, int count) { + assertEquals(count, f.binds.size()); + assertEquals(count, f.last.statements().size()); + assertEquals(logging ? count : 0, f.safe.size()); + assertEquals("inspect " + operand, request.comment()); + assertEquals("render " + operand, request.purpose()); + assertEquals(request.comment(), f.policies.get(0).comment()); + assertEquals(request.purpose(), f.policies.get(0).purpose()); + for (var raw : f.last.statements()) { + assertEquals(request.comment(), raw.getComment()); + assertEquals(request.purpose(), raw.getPurpose()); + var projected = LogPrivacy.sql(raw, false); + String expected = privateOperand ? "[REDACTED]" : operand; + assertEquals("inspect " + expected, projected.getComment()); + assertEquals("render " + expected, projected.getPurpose()); + } + for (var entry : f.safe) { + String expected = privateOperand ? "[REDACTED]" : operand; + assertEquals("inspect " + expected, entry.getComment()); + assertEquals("render " + expected, entry.getPurpose()); + assertNull(entry.getIntentRedactions()); + } + if (logging && privateOperand) assertFalse(f.output.toString().contains(operand)); + if (!logging) assertEquals("", f.output.toString()); + assertTrue(f.context.getTraceChain().isEmpty()); + } + @Test public void typedLikeAndCachedRebindingKeepExactOperandsPrivate() throws Exception { + var f = new Fixture(logging); + for (String operand : List.of("FIRST-SECRET", "SECOND-SECRET")) { + f.clear(); + var request = f.documents().where(field(), op, operand).intent(operand); + var rows = f.run(request); + assertEquals(1, rows.size()); + assertEquals(negative() ? (operand.equals("FIRST-SECRET") ? 2L : 1L) + : (operand.equals("FIRST-SECRET") ? 1L : 2L), rows.get(0).getId().longValue()); + assertTrue(f.binds.get(0).values().contains(pattern(operand))); + assertProjection(f, request, operand, marked, 1); + } + f.clear(); + var independent = f.documents().intent("FIRST-SECRET SECOND-SECRET"); + assertEquals(2, f.run(independent).size()); + assertProjection(f, independent, "FIRST-SECRET SECOND-SECRET", false, 1); + } + @Test public void futureChildOperandIsPrivateBeforeFirstRootStatement() throws Exception { + var f = new Fixture(logging); + var child = f.lines().where(field(), op, "FIRST-SECRET"); + child.topNProbeParentThreshold(0); + var root = f.documents().intent("FIRST-SECRET"); + root.enhanceRelation("lines", child); + var rows = f.run(root); + assertEquals(2, rows.size()); + assertEquals(1, ((SmartList) rows.get(0).getProperty("lines")).size()); + assertFalse(f.binds.get(0).values().contains(pattern("FIRST-SECRET"))); + assertTrue(f.binds.get(1).values().contains(pattern("FIRST-SECRET"))); + assertEquals(List.of(), f.last.statements().get(0).getTraceChain().stream() + .filter(n -> n.getKind() == TraceKind.RELATION).map(TraceNode::getName).toList()); + assertEquals(List.of("lines"), f.last.statements().get(1).getTraceChain().stream() + .filter(n -> n.getKind() == TraceKind.RELATION).map(TraceNode::getName).toList()); + assertProjection(f, root, "FIRST-SECRET", marked, 2); + f.clear(); + var independent = f.documents().intent("FIRST-SECRET"); + assertEquals(2, f.run(independent).size()); + assertProjection(f, independent, "FIRST-SECRET", false, 1); + } + @Test public void literalWildcardsAndRewrittenAstDoNotInventOperands() throws Exception { + var f = new Fixture(logging); + for (String literal : List.of("FIRST%SECRET", "FIRST_SECRET")) { + f.clear(); + var typed = f.documents().where(field(), op, literal).intent(literal); + assertEquals(1, f.run(typed).size()); + assertTrue(f.binds.get(0).values().contains(pattern(literal))); + assertProjection(f, typed, literal, marked, 1); + } + for (boolean rawLike : List.of(false, true)) { + f.clear(); + var request = f.documents().intent("FIRST-SECRET"); + // A raw LIKE uses an undecorated Parameter; changing a typed AST's + // parameter operator to EQUAL is observable and must not infer a secret. + var parameter = new Parameter(field(), "FIRST-SECRET%", op); + parameter.setOperator(Operator.EQUAL); + request.appendSearchCriteria(new TwoOperatorCriteria(rawLike ? op : Operator.EQUAL, + new PropertyReference(field()), parameter)); + assertEquals(rawLike ? 1 : 0, f.run(request).size()); + assertTrue(f.binds.get(0).values().contains("FIRST-SECRET%")); + assertProjection(f, request, "FIRST-SECRET", false, 1); + } + f.clear(); + var rewritten = f.documents().intent("FIRST-SECRET"); + var parameter = new Parameter(field(), "FIRST-SECRET", op); + rewritten.appendSearchCriteria(new TwoOperatorCriteria(Operator.EQUAL, + new PropertyReference(field()), parameter)); + assertEquals(0, f.run(rewritten).size()); + assertTrue(f.binds.get(0).values().contains(pattern("FIRST-SECRET"))); + assertProjection(f, rewritten, "FIRST-SECRET", false, 1); + assertEquals("FIRST-SECRET", parameter.getValue()); + assertEquals(op, parameter.getOperator()); + } + + @Test public void futureEqualityAndSetOperandsArePrivateBeforeRootSql() throws Exception { + var f = new Fixture(logging); + for (Operator predicate : List.of(Operator.EQUAL, Operator.IN)) { + f.clear(); + var child = f.lines().where(field(), predicate, "FIRST-SECRET"); + child.topNProbeParentThreshold(0); + var root = f.documents().intent("FIRST-SECRET"); + root.enhanceRelation("lines", child); + var rows = f.run(root); + assertEquals(2, rows.size()); + assertEquals(1, ((SmartList) rows.get(0).getProperty("lines")).size()); + // This native Row fixture has no generated reverse-list empty initializer. + var absent = (SmartList) rows.get(1).getProperty("lines"); + assertTrue(absent == null || absent.isEmpty()); + assertFalse(f.binds.get(0).values().contains("FIRST-SECRET")); + assertTrue(f.binds.get(1).values().contains("FIRST-SECRET")); + assertProjection(f, root, "FIRST-SECRET", marked, 2); + f.clear(); + var independent = f.documents().intent("FIRST-SECRET"); + assertEquals(2, f.run(independent).size()); + assertProjection(f, independent, "FIRST-SECRET", false, 1); + } + } + + @Test public void futureAggregateEqualityAndSetOperandsArePrivateBeforeRootSql() throws Exception { + var f = new Fixture(logging); + for (Operator predicate : List.of(Operator.EQUAL, Operator.IN)) { + f.clear(); + var count = f.lines().where(field(), predicate, "FIRST-SECRET"); + count.setPartitionProperty("document"); count.count("count"); + var root = f.documents().intent("FIRST-SECRET"); + root.addSingleAggregateDynamicProperty("selectedLineCount", count); + var rows = f.run(root); + assertEquals(2, rows.size()); + assertEquals(1, ((Number) rows.get(0).getDynamicProperty("selectedLineCount")).intValue()); + assertEquals(0, ((Number) rows.get(1).getDynamicProperty("selectedLineCount")).intValue()); + assertFalse(f.binds.get(0).values().contains("FIRST-SECRET")); + assertTrue(f.binds.get(1).values().contains("FIRST-SECRET")); + assertEquals(List.of("lines"), f.last.statements().get(1).getTraceChain().stream() + .filter(n -> n.getKind() == TraceKind.RELATION).map(TraceNode::getName).toList()); + assertProjection(f, root, "FIRST-SECRET", marked, 2); + } + } +} diff --git a/teaql-sqlite/src/test/java/io/teaql/sqlite/MutationRouteSqliteTest.java b/teaql-sqlite/src/test/java/io/teaql/sqlite/MutationRouteSqliteTest.java new file mode 100644 index 00000000..cd6b071c --- /dev/null +++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/MutationRouteSqliteTest.java @@ -0,0 +1,178 @@ +package io.teaql.sqlite; + +import io.teaql.core.*; +import io.teaql.core.meta.*; +import io.teaql.core.sql.GenericSQLProperty; +import io.teaql.core.sql.SQLEntityDescriptor; +import io.teaql.core.sqlite.SqliteDataServiceExecutor; +import io.teaql.provider.jdbc.JdbcSqlExecutor; +import io.teaql.runtime.*; +import io.teaql.sqlite.GraphTraceSqliteTest.GraphEntity; +import java.nio.file.Files; +import java.util.*; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import org.junit.Test; +import org.sqlite.SQLiteDataSource; +import static org.junit.Assert.*; + +/** #202: actual independent SQLite providers share services, not a graph route. */ +public class MutationRouteSqliteTest { + private static final class Fixture { + final List sql = new CopyOnWriteArrayList<>(); + final List audit = new CopyOnWriteArrayList<>(); + final List providerCalls = new CopyOnWriteArrayList<>(); + final JdbcSqlExecutor orders; + final JdbcSqlExecutor payments; + final DefaultUserContext context; + final CountDownLatch entered = new CountDownLatch(2); + final CountDownLatch proceed = new CountDownLatch(1); + volatile boolean pause; + + Fixture() throws Exception { + var orderSource = source(); var paymentSource = source(); + orders = new JdbcSqlExecutor(orderSource); payments = new JdbcSqlExecutor(paymentSource); + var metadata = new SimpleEntityMetaFactory(); + for (String type : List.of("CustomerOrder", "Payment")) { + var descriptor = new SQLEntityDescriptor(); + descriptor.setType(type); descriptor.setTargetType(GraphEntity.class); + descriptor.setEntitySupplier(() -> new GraphEntity(type)); + descriptor.setDataService(type.equals("CustomerOrder") ? "orders" : "payments"); + for (String field : List.of("id", "version", "name")) { + var property = (GenericSQLProperty) descriptor.addSimpleProperty(field, + field.equals("name") ? String.class : Long.class); + property.setColumnType(field.equals("name") ? "VARCHAR(255)" : "BIGINT"); + } + var children = new Relation(); children.setName("children"); children.setOwner(descriptor); + children.setType(new SimplePropertyType(SmartList.class)); + var properties = new ArrayList<>(descriptor.getProperties()); properties.add(children); + descriptor.setProperties(properties); metadata.register(descriptor); + } + var orderProvider = provider("orders", orders, orderSource); + var paymentProvider = provider("payments", payments, paymentSource); + var runtime = TeaQLRuntime.builder().metadata(metadata) + .dataService("orders", orderProvider).dataService("payments", paymentProvider) + .logSink((caller, entry) -> sql.add(entry)).build(); + context = new DefaultUserContext(runtime); + context.putAttribute(AppAuditEventSink.class.getName(), (AppAuditEventSink) (caller, event) -> audit.add(event)); + // Explicit fixture schema capability; do not claim default multi-route schema orchestration. + context.putAttribute(SchemaExecutor.class.getName(), new SchemaExecutor() { + @Override public String name() { return "fixture-schema"; } + @Override public DataServiceCapabilities capabilities() { return orderProvider.capabilities(); } + @Override public void ensureSchema(UserContext caller, Invocation invocation) { + orderProvider.ensureSchema(caller, invocation); paymentProvider.ensureSchema(caller, invocation); + } + }); + context.ensureSchema(); sql.clear(); audit.clear(); providerCalls.clear(); + } + + private static SQLiteDataSource source() throws Exception { + var source = new SQLiteDataSource(); + source.setUrl("jdbc:sqlite:" + Files.createTempFile("teaql-route-isolation-", ".db")); + return source; + } + + private SqliteDataServiceExecutor provider(String route, JdbcSqlExecutor driver, SQLiteDataSource source) { + return new SqliteDataServiceExecutor(route, driver, source) { + @Override public MutationResult mutate(UserContext caller, PersistenceMutation mutation) { + providerCalls.add(route); + if (pause) { + entered.countDown(); + try { + if (!proceed.await(10, TimeUnit.SECONDS)) throw new AssertionError("SQLite route overlap timeout"); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); throw new AssertionError(error); + } + } + return super.mutate(caller, mutation); + } + }; + } + + GraphEntity entity(String type, long id, String name) { + var entity = new GraphEntity(type); + entity.__internalInitializeNewEntityId(id); entity.updateProperty("name", name); + return entity; + } + + long count(JdbcSqlExecutor driver, String table) { + return ((Number) driver.queryForList("SELECT count(*) AS total FROM " + table, + new Object[0]).get(0).get("total")).longValue(); + } + } + + @Test public void independentDifferentRouteSavesCommitOnlyToTheirOwnDatabases() throws Exception { + var fixture = new Fixture(); + fixture.entity("CustomerOrder", 100L, "local order").auditAs("separate order request").save(fixture.context); + fixture.entity("Payment", 100L, "local payment").auditAs("separate payment request").save(fixture.context); + assertEquals(List.of("orders", "payments"), fixture.providerCalls); + assertEquals(1, fixture.count(fixture.orders, "customer_order_data")); + assertEquals(0, fixture.count(fixture.orders, "payment_data")); + assertEquals(0, fixture.count(fixture.payments, "customer_order_data")); + assertEquals(1, fixture.count(fixture.payments, "payment_data")); + assertEquals(2, fixture.audit.size()); + for (var event : fixture.audit) { + String reason = event.entityType().equals("CustomerOrder") ? "separate order request" : "separate payment request"; + assertEquals(List.of(reason), event.traceChain().stream().map(TraceNode::getComment).toList()); + var statements = fixture.sql.stream().filter(entry -> entry.getMutationLineage().equals(event.traceChain())).toList(); + assertEquals("actual write and authoritative readback", 2, statements.size()); + assertTrue(statements.stream().anyMatch(entry -> entry.getOperation() == DataServiceOperation.MUTATION)); + assertTrue(statements.stream().anyMatch(entry -> entry.getOperation() == DataServiceOperation.QUERY)); + assertTrue(statements.stream().allMatch(entry -> reason.equals(entry.getAuditReason()))); + } + assertNull(fixture.context.getAttribute("__teaql_save_graph_route__")); + } + + @Test public void independentOverlappingRoutesCommitToDifferentDatabasesWithSeparateLineage() throws Exception { + var fixture = new Fixture(); fixture.pause = true; + var workers = Executors.newFixedThreadPool(2); + try { + var order = workers.submit(() -> fixture.entity("CustomerOrder", 100L, "parallel order") + .auditAs("parallel order request").save(fixture.context)); + var payment = workers.submit(() -> fixture.entity("Payment", 100L, "parallel payment") + .auditAs("parallel payment request").save(fixture.context)); + assertTrue("both provider invocations must overlap", fixture.entered.await(10, TimeUnit.SECONDS)); + assertNull(fixture.context.getAttribute("__teaql_save_graph_route__")); + assertTrue(fixture.sql.isEmpty()); assertTrue(fixture.audit.isEmpty()); + fixture.proceed.countDown(); order.get(10, TimeUnit.SECONDS); payment.get(10, TimeUnit.SECONDS); + } finally { + fixture.proceed.countDown(); workers.shutdownNow(); + assertTrue(workers.awaitTermination(10, TimeUnit.SECONDS)); + } + assertEquals(1, fixture.count(fixture.orders, "customer_order_data")); + assertEquals(0, fixture.count(fixture.orders, "payment_data")); + assertEquals(0, fixture.count(fixture.payments, "customer_order_data")); + assertEquals(1, fixture.count(fixture.payments, "payment_data")); + assertEquals(2, fixture.audit.size()); + for (var event : fixture.audit) { + assertEquals(List.of("[REDACTED] request"), event.traceChain().stream().map(TraceNode::getComment).toList()); + // The SQL parameter value ("parallel order/payment") occurs in the + // request prose, so BOTH safe SQL and committed audit redact it. + var safeLineage = List.of(new TraceNode(TraceKind.AUDIT_REASON, + event.entityType(), 100L, "[REDACTED] request")); + var statements = fixture.sql.stream().filter(entry -> entry.getMutationLineage().equals(safeLineage)).toList(); + assertEquals("observed safe SQL lineage: " + fixture.sql.stream() + .map(entry -> entry.getMutationLineage().toString()).toList(), 2, statements.size()); + assertTrue(statements.stream().allMatch(entry -> "[REDACTED] request".equals(entry.getAuditReason()))); + assertTrue(statements.stream().anyMatch(entry -> entry.getOperation() == DataServiceOperation.MUTATION)); + assertTrue(statements.stream().anyMatch(entry -> entry.getOperation() == DataServiceOperation.QUERY)); + } + } + + @Test public void mixedGraphFailsBeforeEitherDatabaseWrites() throws Exception { + var fixture = new Fixture(); + var order = fixture.entity("CustomerOrder", 100L, "pending order"); + var payment = fixture.entity("Payment", 100L, "pending payment"); + order.updateProperty("children", List.of(payment)); + var error = assertThrows(TeaQLRuntimeException.class, + () -> order.auditAs("cross-provider graph is not atomic").save(fixture.context)); + assertTrue(error.getMessage().contains("CROSS-PROVIDER MUTATION")); + assertTrue(fixture.providerCalls.isEmpty()); assertTrue(fixture.sql.isEmpty()); assertTrue(fixture.audit.isEmpty()); + assertEquals(0, fixture.count(fixture.orders, "customer_order_data")); + assertEquals(0, fixture.count(fixture.orders, "payment_data")); + assertEquals(0, fixture.count(fixture.payments, "payment_data")); + assertTrue(order.getEntityMutationLedger().isNew(new EntityKey("Payment", 100L))); + } +} diff --git a/teaql-sqlite/src/test/java/io/teaql/sqlite/TypedIntentPrivacySqliteTest.java b/teaql-sqlite/src/test/java/io/teaql/sqlite/TypedIntentPrivacySqliteTest.java new file mode 100644 index 00000000..a0f542c2 --- /dev/null +++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/TypedIntentPrivacySqliteTest.java @@ -0,0 +1,212 @@ +package io.teaql.sqlite; + +import io.teaql.core.*; +import io.teaql.core.criteria.*; +import io.teaql.runtime.LogPrivacy; +import java.util.*; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import static org.junit.Assert.*; + +/** Built-in BETWEEN/phonetic operands must be classified before any future child SQL. */ +@RunWith(Parameterized.class) +public class TypedIntentPrivacySqliteTest { + @Parameterized.Parameters(name="{0}, marked={1}, logging={2}") + public static Collection cases() { + var cases = new ArrayList(); + for (var op : List.of(Operator.BETWEEN, Operator.SOUNDS_LIKE)) + for (boolean marked : List.of(false, true)) + for (boolean logging : List.of(false, true)) cases.add(new Object[]{op, marked, logging}); + return cases; + } + + private final Operator op; + private final boolean marked, logging; + public TypedIntentPrivacySqliteTest(Operator op, boolean marked, boolean logging) { + this.op = op; this.marked = marked; this.logging = logging; + } + private String field() { return marked ? "name" : "state"; } + private List operands(String prefix) { + return op == Operator.BETWEEN ? List.of(prefix + "-SECRET", prefix + "-SECREZ") + : List.of(prefix + "-SECRET"); + } + private String intent(List operands) { return String.join(" / ", operands); } + private void predicate(LikeIntentPrivacySqliteTest.Request request, List operands) { + request.appendSearchCriteria(request.createBasicSearchCriteria(field(), op, operands.toArray())); + } + private void assertPath(ExecutionMetadata entry, boolean child) { + var expected = new ArrayList<>(List.of( + new TraceNode(TraceKind.OPERATION, "TraceDocument", null, "query"), + new TraceNode(TraceKind.REQUEST, "TraceDocument", null, ""))); + if (child) expected.add(new TraceNode(TraceKind.RELATION, "lines", null, "TraceDocument.lines")); + expected.add(new TraceNode(TraceKind.PROVIDER, "sqlite", null, "")); + expected.add(new TraceNode(TraceKind.SQL, "select", null, "")); + assertEquals(expected, entry.getTraceChain()); + } + private void assertProjection(LikeIntentPrivacySqliteTest.Fixture f, + LikeIntentPrivacySqliteTest.Request root, List operands, boolean privateOperand, int count) { + assertEquals(count, f.binds.size()); + assertEquals(count, f.last.statements().size()); + assertEquals(logging ? count : 0, f.safe.size()); + String text = intent(operands); + String projected = privateOperand ? String.join(" / ", Collections.nCopies(operands.size(), "[REDACTED]")) : text; + assertEquals("inspect " + text, root.comment()); + assertEquals("render " + text, root.purpose()); + assertEquals(root.comment(), f.policies.get(0).comment()); + assertEquals(root.purpose(), f.policies.get(0).purpose()); + for (int i = 0; i < count; i++) { + var raw = f.last.statements().get(i); + assertPath(raw, i > 0); + assertEquals(root.comment(), raw.getComment()); + assertEquals(root.purpose(), raw.getPurpose()); + var safe = LogPrivacy.sql(raw, false); + assertEquals("inspect " + projected, safe.getComment()); + assertEquals("render " + projected, safe.getPurpose()); + assertPath(safe, i > 0); + assertNull(safe.getIntentRedactions()); + // Explicit debugging may reveal marked business operands, never by mutating raw intent. + assertEquals(root.comment(), LogPrivacy.sql(raw, true).getComment()); + if (logging) { + assertEquals(safe.getComment(), f.safe.get(i).getComment()); + assertEquals(safe.getPurpose(), f.safe.get(i).getPurpose()); + assertPath(f.safe.get(i), i > 0); + assertNull(f.safe.get(i).getIntentRedactions()); + } + } + if (logging && privateOperand) + for (String operand : operands) assertFalse(f.output.toString().contains(operand)); + if (!logging) assertEquals("", f.output.toString()); + assertTrue(f.context.getTraceChain().isEmpty()); + } + private void independent(LikeIntentPrivacySqliteTest.Fixture f, List operands) { + f.clear(); + var request = f.documents().intent(intent(operands)); + assertEquals(2, f.run(request).size()); + assertProjection(f, request, operands, false, 1); + } + private void assertBinds(LikeIntentPrivacySqliteTest.Fixture f, List operands, int index) { + for (String operand : operands) assertTrue(f.binds.get(index).values().contains(operand)); + assertTrue(f.binds.get(index).sql().toUpperCase(Locale.ROOT) + .contains(op == Operator.BETWEEN ? " BETWEEN " : "SOUNDEX(")); + } + + @Test public void typedRootAndCachedRebindingKeepOriginalOperandsPrivate() throws Exception { + var f = new LikeIntentPrivacySqliteTest.Fixture(logging); + for (String prefix : List.of("FIRST", "SECOND")) { + f.clear(); + var values = operands(prefix); + var root = f.documents().intent(intent(values)); predicate(root, values); + var rows = f.run(root); + assertEquals(1, rows.size()); + assertEquals(prefix.equals("FIRST") ? 1L : 2L, rows.get(0).getId().longValue()); + assertBinds(f, values, 0); + assertProjection(f, root, values, marked, 1); + } + independent(f, operands("FIRST")); + } + + @Test public void futureChildOperandsArePrivateBeforeFirstRootSql() throws Exception { + var f = new LikeIntentPrivacySqliteTest.Fixture(logging); + var values = operands("FIRST"); + var child = f.lines(); predicate(child, values); child.topNProbeParentThreshold(0); + var root = f.documents().intent(intent(values)); root.enhanceRelation("lines", child); + var rows = f.run(root); + assertEquals(2, rows.size()); + var members = (SmartList) rows.get(0).getProperty("lines"); + assertEquals(1, members.size()); assertEquals(Long.valueOf(11), members.get(0).getId()); + assertEquals(Long.valueOf(1), ((Entity) members.get(0).getProperty("document")).getId()); + var absent = (SmartList) rows.get(1).getProperty("lines"); + assertTrue(absent == null || absent.isEmpty()); + for (String value : values) assertFalse(f.binds.get(0).values().contains(value)); + assertBinds(f, values, 1); + assertProjection(f, root, values, marked, 2); + independent(f, values); + } + + @Test public void futureAggregateOperandsArePrivateBeforeFirstRootSql() throws Exception { + var f = new LikeIntentPrivacySqliteTest.Fixture(logging); + var values = operands("FIRST"); + var count = f.lines(); predicate(count, values); + count.setPartitionProperty("document"); count.count("count"); + var root = f.documents().intent(intent(values)); root.addSingleAggregateDynamicProperty("selectedLineCount", count); + var rows = f.run(root); + assertEquals(2, rows.size()); + assertEquals(1, ((Number) rows.get(0).getDynamicProperty("selectedLineCount")).intValue()); + assertEquals(0, ((Number) rows.get(1).getDynamicProperty("selectedLineCount")).intValue()); + for (String value : values) assertFalse(f.binds.get(0).values().contains(value)); + assertBinds(f, values, 1); + assertTrue(f.binds.get(1).sql().toLowerCase(Locale.ROOT).contains("count(")); + assertProjection(f, root, values, marked, 2); + independent(f, values); + } + + @Test public void parameterNamesCannotReplaceTheResolvedFieldPolicy() throws Exception { + var f = new LikeIntentPrivacySqliteTest.Fixture(logging); + var values = operands("FIRST"); + var child = f.lines(); + // Deliberately opposite metadata names: only the property reference determines field policy. + String parameterName = marked ? "state" : "name"; + SearchCriteria criterion = op == Operator.BETWEEN + ? new Between(new PropertyReference(field()), new Parameter(parameterName, values.get(0), op), + new Parameter(parameterName, values.get(1), op)) + : new EQ(new FunctionApply(op, new PropertyReference(field())), + new FunctionApply(op, new Parameter(parameterName, values.get(0), op))); + child.appendSearchCriteria(criterion); child.topNProbeParentThreshold(0); + var root = f.documents().intent(intent(values)); root.enhanceRelation("lines", child); + assertEquals(2, f.run(root).size()); + assertBinds(f, values, 1); + assertProjection(f, root, values, marked, 2); + independent(f, values); + } + + @Test public void rewrittenParameterOperatorsDoNotInventOriginalOperands() throws Exception { + var f = new LikeIntentPrivacySqliteTest.Fixture(logging); + var values = operands("FIRST"); + var child = f.lines(); + var rewritten = new Parameter(field(), values.get(0), Operator.CONTAIN); + SearchCriteria criterion = op == Operator.BETWEEN + ? new Between(new PropertyReference(field()), rewritten, new Parameter(field(), values.get(1), op)) + : new EQ(new FunctionApply(op, new PropertyReference(field())), new FunctionApply(op, rewritten)); + child.appendSearchCriteria(criterion); child.topNProbeParentThreshold(0); + var root = f.documents().intent(values.get(0)); root.enhanceRelation("lines", child); + assertEquals(2, f.run(root).size()); + assertTrue(f.binds.get(1).values().contains("%" + values.get(0) + "%")); + assertFalse(f.binds.get(1).values().contains(values.get(0))); + assertProjection(f, root, List.of(values.get(0)), false, 2); + assertEquals(values.get(0), rewritten.getValue()); + assertEquals(Operator.CONTAIN, rewritten.getOperator()); + } + + @Test public void credentialOperandsStayPrivateEvenInExplicitDebugProjection() throws Exception { + var f = new LikeIntentPrivacySqliteTest.Fixture(logging); + var values = operands("FIRST"); + var child = f.lines(); + SearchCriteria criterion = op == Operator.BETWEEN + ? new Between(new PropertyReference(field()), new Parameter("apiKey", values.get(0), op), + new Parameter("apiKey", values.get(1), op)) + : new EQ(new FunctionApply(op, new PropertyReference(field())), + new FunctionApply(op, new Parameter("apiKey", values.get(0), op))); + child.appendSearchCriteria(criterion); child.topNProbeParentThreshold(0); + var root = f.documents().intent(intent(values)); root.enhanceRelation("lines", child); + assertEquals(2, f.run(root).size()); + assertBinds(f, values, 1); + String projected = String.join(" / ", Collections.nCopies(values.size(), "[REDACTED]")); + assertEquals(2, f.last.statements().size()); + assertEquals(logging ? 2 : 0, f.safe.size()); + for (int i = 0; i < 2; i++) { + var raw = f.last.statements().get(i); assertPath(raw, i > 0); + assertEquals(root.comment(), raw.getComment()); assertEquals(root.purpose(), raw.getPurpose()); + for (boolean allowPlaintext : List.of(false, true)) { + var safe = LogPrivacy.sql(raw, allowPlaintext); + assertEquals("inspect " + projected, safe.getComment()); + assertEquals("render " + projected, safe.getPurpose()); + assertPath(safe, i > 0); assertNull(safe.getIntentRedactions()); + } + if (logging) assertEquals("inspect " + projected, f.safe.get(i).getComment()); + } + for (String value : values) assertFalse(f.output.toString().contains(value)); + assertTrue(f.context.getTraceChain().isEmpty()); + independent(f, values); + } +} diff --git a/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/TfpEndpointHandler.java b/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/TfpEndpointHandler.java index 25683d6d..8bffb4d9 100644 --- a/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/TfpEndpointHandler.java +++ b/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/TfpEndpointHandler.java @@ -125,7 +125,8 @@ private Map handleQueryActive(UserContext context, TrustedFedera requireNonBlank(root, "commentText", "TFP_INVALID_REQUEST"); requireNonBlank(root, "purposeText", "TFP_POLICY_VIOLATION"); - DefaultQueryRequest queryRequest = new DefaultQueryRequest(request); + DefaultQueryRequest queryRequest = new DefaultQueryRequest(request, + io.teaql.core.QueryIntent.of(root.path("commentText").asText(), root.path("purposeText").asText())); var result = queryExecutor.query(context, queryRequest); Map response = new HashMap<>();