From 93374f330faf4e503e39257b64b4859167251534 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Fri, 2 Oct 2026 02:54:35 +0800 Subject: [PATCH 01/35] feat(runtime): require request-owned query and mutation intent Refs #202. Validate before policies, inherit derived query intent, and retain immutable mutation reasons. Local source checkpoint only; no publication. Signed-off-by: Philip Z --- README.md | 21 ++ examples/school-management/README.md | 10 +- .../RequestIntentVerifier.java | 90 ++++++ .../SchoolLifecycleVerifier.java | 1 + examples/verify-runtime-examples.sh | 29 +- .../src/main/java/io/teaql/core/Audited.java | 7 +- .../main/java/io/teaql/core/BaseRequest.java | 11 +- .../java/io/teaql/core/MutationIntent.java | 18 ++ .../main/java/io/teaql/core/MutationPlan.java | 1 + .../java/io/teaql/core/MutationRequest.java | 7 + .../io/teaql/core/PersistenceMutation.java | 2 +- .../main/java/io/teaql/core/QueryIntent.java | 20 ++ .../main/java/io/teaql/core/QueryRequest.java | 3 + .../io/teaql/core/RequestIntentException.java | 32 +++ .../java/io/teaql/core/SearchRequest.java | 4 + .../test/java/io/teaql/core/AuditedTest.java | 4 +- .../teaql/core/RequestIntentVectorsTest.java | 62 +++++ .../src/test/resources/request-intent-v1.json | 259 ++++++++++++++++++ .../sql/SqlDataServiceExecutor.java | 1 + .../sql/SqlDataServiceExecutorTest.java | 18 +- .../io/teaql/runtime/DefaultQueryRequest.java | 17 +- .../runtime/EntityPersistenceMutation.java | 16 +- .../java/io/teaql/runtime/TeaQLRuntime.java | 106 +++---- .../teaql/runtime/RequestIntentGateTest.java | 199 ++++++++++++++ .../io/teaql/runtime/TeaQLRuntimeTest.java | 23 +- .../io/teaql/core/internal/TempRequest.java | 16 ++ .../java/io/teaql/core/sql/SqlParameters.java | 7 + .../core/sql/expression/SubQueryParser.java | 8 +- .../sql/portable/PortableSQLDataService.java | 20 +- .../sql/portable/PortableSQLRepository.java | 13 +- .../sql/portable/SqlDiagnosticRequest.java | 27 +- .../portable/SqlParameterPropagationTest.java | 5 +- .../java/io/teaql/tfp/TfpEndpointHandler.java | 3 +- 33 files changed, 947 insertions(+), 113 deletions(-) create mode 100644 examples/school-management/src/main/java/com/example/schoolmanagementservice/RequestIntentVerifier.java create mode 100644 teaql-core/src/main/java/io/teaql/core/MutationIntent.java create mode 100644 teaql-core/src/main/java/io/teaql/core/MutationRequest.java create mode 100644 teaql-core/src/main/java/io/teaql/core/QueryIntent.java create mode 100644 teaql-core/src/main/java/io/teaql/core/RequestIntentException.java create mode 100644 teaql-core/src/test/java/io/teaql/core/RequestIntentVectorsTest.java create mode 100644 teaql-core/src/test/resources/request-intent-v1.json create mode 100644 teaql-runtime/src/test/java/io/teaql/runtime/RequestIntentGateTest.java diff --git a/README.md b/README.md index ad351a67..7fc19461 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,27 @@ 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. + +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. The complete hierarchical Trace Chain and concurrent-context +isolation remain separate, unfinished gates on this branch. + Applications can replace runtime services such as `QueryPolicy`, the `MutationPolicyRegistry`, `MutationPolicyApprovalProvider`, `RuntimeLogSink`, `DataServiceRegistry`, `InternalIdGenerationService`, and `EntityMetaFactory` diff --git a/examples/school-management/README.md b/examples/school-management/README.md index e7773bfe..b4554f69 100644 --- a/examples/school-management/README.md +++ b/examples/school-management/README.md @@ -11,6 +11,11 @@ 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 @@ -22,5 +27,6 @@ and verifies optimistic, single-version reconciliation. 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/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/verify-runtime-examples.sh b/examples/verify-runtime-examples.sh index 9a28d0d9..58829f39 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,13 @@ 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 + printf 'PASS %s run %s (same database)\n' "$name" "$repetition" return 0 fi if ! kill -0 "$active_pid" 2>/dev/null; then @@ -54,14 +61,16 @@ 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' 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/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/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/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/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/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..90bd69eb 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,10 @@ 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; } + /** * Returns the declared purpose of this query. * Purpose describes WHY this query is being executed (business intent). 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/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/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-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..affba157 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 @@ -53,6 +53,7 @@ public QueryResult query(UserContext context, QueryRequest request) { @Override public java.util.stream.Stream queryForStream(UserContext context, io.teaql.core.SearchRequest request) { + io.teaql.core.QueryIntent.of(request.comment(), request.purpose()); // Unlike list execution, this path does not enter TeaQLRuntime.executeForList's trace scope. int pushed = 0; try { 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-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/EntityPersistenceMutation.java b/teaql-runtime/src/main/java/io/teaql/runtime/EntityPersistenceMutation.java index ecd7ca81..d919ad60 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,28 @@ import io.teaql.core.Entity; import io.teaql.core.PersistenceMutation; +import io.teaql.core.MutationIntent; +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; 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 = Objects.requireNonNull(entity, "entity"); + this.action = Objects.requireNonNull(action, "action"); + this.intent = Objects.requireNonNull(intent, "intent"); + } + + @Override public MutationIntent intent() { return intent; } + public Entity getEntity() { return entity; } 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..67fd555c 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java @@ -166,9 +166,7 @@ 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); @@ -177,16 +175,16 @@ public SmartList executeForList(UserContext context, Searc 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()); + if (intent.comment() != null) { + context.pushTrace(TraceKind.COMMENT, request.getTypeName(), intent.comment()); pushedComment = true; } - if (request.purpose() != null && !request.purpose().trim().isEmpty()) { - context.pushTrace(TraceKind.PURPOSE, request.getTypeName(), request.purpose()); + if (intent.purpose() != null) { + context.pushTrace(TraceKind.PURPOSE, request.getTypeName(), intent.purpose()); pushedPurpose = true; } try { - SmartList result = executeForListResolved(context, request); + SmartList result = executeForListResolved(context, request, intent); telemetryScope.success(Map.of("teaql.result.cardinality", result.size())); return result; } finally { @@ -204,6 +202,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 +226,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,10 +241,7 @@ 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.of(request.comment(), request.purpose()); if (queryPolicy != null) { queryPolicy.enforceSelect(context, request); } @@ -258,10 +254,7 @@ 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."); - } + requireInheritedQueryIntent(request); if (queryPolicy != null) { queryPolicy.enforceSelect(context, request); } @@ -306,17 +299,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); + SmartList result = executeForListResolved(context, request, intent); relationScope.success(Map.of("teaql.result.cardinality", result.size())); return result; } finally { @@ -334,6 +324,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 +349,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 +359,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,20 +380,20 @@ 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()); + context.pushTrace(TraceKind.OPERATION, request.getTypeName(), "query"); + context.pushTrace(TraceKind.REQUEST, request.getTypeName(), request.getTypeName()); + if (intent.comment() != null) { + context.pushTrace(TraceKind.COMMENT, request.getTypeName(), intent.comment()); pushedComment = true; } - if (request.purpose() != null && !request.purpose().trim().isEmpty()) { - context.pushTrace(TraceKind.PURPOSE, request.getTypeName(), request.purpose()); + if (intent.purpose() != null) { + context.pushTrace(TraceKind.PURPOSE, request.getTypeName(), intent.purpose()); pushedPurpose = true; } try { @@ -407,7 +406,7 @@ public AggregationResult aggregation(UserContext context, Sea if (queryExecutor == null) { throw new TeaQLRuntimeException("No QueryExecutor registered for route: " + route); } - QueryRequest queryRequest = new DefaultQueryRequest(request); + QueryRequest queryRequest = new DefaultQueryRequest(request, intent); QueryResult queryResult = queryExecutor.query(context, queryRequest); if (queryResult instanceof DefaultQueryResult) { return ((DefaultQueryResult) queryResult).getAggregationResult(); @@ -416,6 +415,8 @@ public AggregationResult aggregation(UserContext context, Sea } finally { if (pushedPurpose) context.popTrace(); if (pushedComment) context.popTrace(); + context.popTrace(); + context.popTrace(); } } @@ -437,14 +438,12 @@ public void saveGraph(UserContext context, Entity entity) { "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()."); - } + MutationIntent intent = MutationIntent.of(entity.getComment()); 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()); + if (intent.comment() != null) { + context.pushTrace(TraceKind.AUDIT_REASON, entity.typeName(), intent.comment()); pushed = true; } try { @@ -501,17 +500,17 @@ 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); 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)); } else { completed = executeLedgerPlan( - context, entityMutationLedger, mutationExecutor, realEntities, governance); + context, entityMutationLedger, mutationExecutor, realEntities, governance, intent); } } catch (RuntimeException | Error failure) { restoreGraphPersistenceState( @@ -697,7 +696,7 @@ 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(); @@ -731,7 +730,7 @@ private MutationPlan buildMutationPlan( UUID.randomUUID().toString(), rootEntity.typeName() + ".saveGraph", rootEntity.typeName(), - rootEntity.getComment(), + intent.auditReason(), operations); } @@ -808,7 +807,8 @@ private List executeLedgerPlan( EntityMutationLedger root, MutationExecutor mutationExecutor, Map realEntities, - MutationGovernanceSnapshot governance) { + MutationGovernanceSnapshot governance, + MutationIntent intent) { List completed = new ArrayList<>(); EntityChangeSet changeSet = root.currentChangeSet(); Set deletedKeys = root.deletedKeys(); @@ -833,12 +833,12 @@ private List executeLedgerPlan( if (root.getComment() != null) deleteEntity.setComment(root.getComment()); EntityPersistenceMutation mutationRequest = new EntityPersistenceMutation( - deleteEntity, EntityPersistenceMutation.Action.DELETE); + deleteEntity, EntityPersistenceMutation.Action.DELETE, intent); MutationResult result = mutateWithTelemetry(context, mutationExecutor, mutationRequest, key.entity(), "delete"); completed.add(new PendingMutation( descriptor, target == null ? deleteEntity : target, result, - MutationAuditKind.DELETED, Collections.emptyMap(), governance)); + MutationAuditKind.DELETED, Collections.emptyMap(), governance, intent)); } // 2. Group changes @@ -881,12 +881,12 @@ private List executeLedgerPlan( if (root.getComment() != null) entity.setComment(root.getComment()); EntityPersistenceMutation mutationRequest = new EntityPersistenceMutation( - entity, EntityPersistenceMutation.Action.SAVE); + entity, EntityPersistenceMutation.Action.SAVE, intent); MutationResult result = mutateWithTelemetry(context, mutationExecutor, mutationRequest, entityName, "save"); completed.add(new PendingMutation( descriptor, target == null ? entity : target, result, - MutationAuditKind.CREATED, snapshotChanges(changes), governance)); + MutationAuditKind.CREATED, snapshotChanges(changes), governance, intent)); } } @@ -916,7 +916,7 @@ private List executeLedgerPlan( if (root.getComment() != null) entity.setComment(root.getComment()); EntityPersistenceMutation mutationRequest = new EntityPersistenceMutation( - entity, EntityPersistenceMutation.Action.SAVE); + entity, EntityPersistenceMutation.Action.SAVE, intent); MutationAuditKind auditKind = target != null && target.recoverItem() ? MutationAuditKind.RECOVERED : MutationAuditKind.UPDATED; @@ -924,7 +924,7 @@ private List executeLedgerPlan( entityName, auditKind.name().toLowerCase(Locale.ROOT)); completed.add(new PendingMutation( descriptor, target == null ? entity : target, result, - auditKind, snapshotChanges(changes), governance)); + auditKind, snapshotChanges(changes), governance, intent)); } } return completed; @@ -935,7 +935,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.target().clearUpdatedProperties(); } } @@ -959,7 +959,7 @@ private record PendingMutation( MutationResult result, MutationAuditKind auditKind, Map changedValues, - MutationGovernanceSnapshot governance) {} + MutationGovernanceSnapshot governance, MutationIntent intent) {} private record PersistenceState( Long version, io.teaql.core.EntityStatus status, boolean versionLoaded) {} @@ -1026,7 +1026,7 @@ private void emitAuditEvent( Entity entity, MutationAuditKind kind, Map changedValues, - MutationGovernanceSnapshot governance) { + MutationGovernanceSnapshot governance, MutationIntent intent) { List changes = new ArrayList<>(); if (changedValues != null) { for (Map.Entry entry : changedValues.entrySet()) { @@ -1043,7 +1043,7 @@ private void emitAuditEvent( context.getTraceChain(), 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); 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..71c64ed8 --- /dev/null +++ b/teaql-runtime/src/test/java/io/teaql/runtime/RequestIntentGateTest.java @@ -0,0 +1,199 @@ +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, SearchRequest 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)); + assertFalse(error.getMessage().contains("SECRET-CANARY")); + } + } + + @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 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/TeaQLRuntimeTest.java b/teaql-runtime/src/test/java/io/teaql/runtime/TeaQLRuntimeTest.java index b3fa2a09..9441c524 100644 --- a/teaql-runtime/src/test/java/io/teaql/runtime/TeaQLRuntimeTest.java +++ b/teaql-runtime/src/test/java/io/teaql/runtime/TeaQLRuntimeTest.java @@ -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,10 @@ 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); 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")); List requests = executor.requests; 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..9936b5b2 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,30 @@ 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 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/SqlParameters.java b/teaql-sql-portable/src/main/java/io/teaql/core/sql/SqlParameters.java index a0920ecd..0981459f 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,13 @@ 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; + + /** 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"); + } 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/SubQueryParser.java b/teaql-sql-portable/src/main/java/io/teaql/core/sql/expression/SubQueryParser.java index 51aedf2a..20dbaeb6 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,13 @@ 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."); + } + SmartList referred = 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..02b95508 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 @@ -75,9 +75,10 @@ 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); + searchRequest = SqlDiagnosticRequest.forExecution(searchRequest, intent, request.intent()); 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); @@ -95,6 +96,7 @@ public QueryResult query(UserContext context, QueryRequest request) { @Override public java.util.stream.Stream queryForStream(UserContext context, SearchRequest request) { + QueryIntent.of(request.comment(), request.purpose()); if (request.hasSimpleAgg() || !request.enhanceRelations().isEmpty() || !request.enhanceChildren().isEmpty()) { throw new TeaQLRuntimeException("Streaming aggregation/relation enhancement is not supported; stream root rows only"); } @@ -124,7 +126,7 @@ private void attachDynamicAggregations( } io.teaql.core.internal.TempRequest request = - new io.teaql.core.internal.TempRequest(aggregateRequest); + new SqlDiagnosticRequest(aggregateRequest, intent, parentRequest.inheritedQueryIntent()); request.groupBy(partitionProperty); request.appendSearchCriteria( request.createBasicSearchCriteria( @@ -189,10 +191,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.inheritedQueryIntent()); return; } - collectChildren(userContext, dataSet, (Relation) property, r, intent); + collectChildren(userContext, dataSet, (Relation) property, r, intent, request.inheritedQueryIntent()); }); } @@ -224,7 +226,7 @@ private void enhanceParent( UserContext userContext, SmartList results, Relation relation, - SearchRequest parentRequest, SqlIntentRedactions intent) { + SearchRequest parentRequest, SqlIntentRedactions intent, QueryIntent rootIntent) { List parents = results.stream() .map(e -> e.getProperty(relation.getName())) @@ -234,7 +236,7 @@ private void enhanceParent( .toList(); if (io.teaql.core.utils.ObjectUtil.isEmpty(parents)) return; - io.teaql.core.internal.TempRequest parentTemp = new SqlDiagnosticRequest(parentRequest, intent); + io.teaql.core.internal.TempRequest parentTemp = new SqlDiagnosticRequest(parentRequest, intent, rootIntent); 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,8 +261,8 @@ 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, QueryIntent rootIntent) { + io.teaql.core.internal.TempRequest childTempRequest = new SqlDiagnosticRequest(childRequest, intent, rootIntent); PropertyDescriptor reverseProperty = relation.getReverseProperty(); childTempRequest.selectProperty(reverseProperty.getName()); Slice slice = childTempRequest.getSlice(); @@ -275,7 +277,7 @@ private void collectChildren( "probe", dataSet.size()); for (Entity parent : dataSet) { io.teaql.core.internal.TempRequest probeRequest = - new SqlDiagnosticRequest(childRequest, intent); + new SqlDiagnosticRequest(childRequest, intent, rootIntent); probeRequest.selectProperty(reverseProperty.getName()); probeRequest.setPartitionProperty(null); ensureStableEntityIdOrder(probeRequest); 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..ec9f2f0d 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 @@ -223,6 +223,13 @@ public Map getExpressionParsers() { // ========================================== public String buildDataSQL(UserContext userContext, SearchRequest request, Map parameters) { + if (parameters instanceof io.teaql.core.sql.SqlParameters tracked) { + var origin = request.inheritedQueryIntent(); + if (origin == null && request.comment() != null && request.purpose() != null) { + origin = io.teaql.core.QueryIntent.of(request.comment(), request.purpose()); + } + if (origin != null) tracked.captureQueryIntent(origin); + } String partitionProperty = request.getPartitionProperty(); if (ObjectUtil.isNotEmpty(partitionProperty) && request.getSlice() != null) { ensureOrderByForPartition(request); @@ -817,7 +824,11 @@ 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); + var rootIntent = request.inheritedQueryIntent() == null + ? io.teaql.core.QueryIntent.of(request.comment(), request.purpose()) + : request.inheritedQueryIntent(); + io.teaql.core.internal.TempRequest fetchRelReq = + new SqlDiagnosticRequest(relationReq, facetIntent, rootIntent); if (facetRequest.isMergeCriteria()) { fetchRelReq.appendSearchCriteria(request.getSearchCriteria()); } 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..463206b4 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,17 +11,25 @@ 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; 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) { + SqlDiagnosticRequest(SearchRequest request, SqlIntentRedactions source, QueryIntent rootIntent) { + this(request, source, rootIntent, false); + } + + private SqlDiagnosticRequest(SearchRequest request, SqlIntentRedactions source, + QueryIntent rootIntent, boolean executionScope) { super(request); 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(); @@ -29,9 +38,17 @@ private SqlDiagnosticRequest(SearchRequest request, SqlIntentRedactions sourc } 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); + } + + @Override public QueryIntent inheritedQueryIntent() { return rootIntent; } + @Override public io.teaql.core.Entity internalNewEntity() { return original.internalNewEntity(); } @Override public boolean tryUseSubQuery() { return original.tryUseSubQuery(); } 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..80796b69 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; } 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<>(); From c704934e2ba95c39ce2067e3fd92ac1595167cbc Mon Sep 17 00:00:00 2001 From: Philip Z Date: Fri, 2 Oct 2026 11:58:50 +0800 Subject: [PATCH 02/35] feat(runtime): preserve typed graph lineage through SQL and committed audit (#202) Signed-off-by: Philip Z --- README.md | 37 ++- .../main/java/io/teaql/core/BaseEntity.java | 13 +- .../src/main/java/io/teaql/core/Entity.java | 6 +- .../io/teaql/core/EntityMutationLedger.java | 11 +- .../java/io/teaql/core/ExecutionMetadata.java | 16 +- .../io/teaql/core/MutationTraceScope.java | 30 +++ .../java/io/teaql/core/SqlExecutionTrace.java | 32 +++ .../main/java/io/teaql/core/SqlTracePath.java | 50 ++++ .../main/java/io/teaql/core/TraceNode.java | 23 +- .../java/io/teaql/core/BaseEntityTest.java | 5 +- .../teaql/core/EntityMutationLedgerTest.java | 5 +- .../teaql/core/SqlTracePathVectorsTest.java | 61 +++++ .../src/test/resources/sql-trace-path-v1.json | 35 +++ .../sql/SqlDataServiceExecutor.java | 2 + .../sql/SqlStreamBatchMaskingTest.java | 3 + .../jackson/BaseEntitySerializationTest.java | 4 +- .../runtime/log/HumanReaderFormatter.java | 6 +- .../runtime/log/JsonReaderFormatter.java | 9 +- .../runtime/log/ExecutionLogPrivacyTest.java | 18 ++ .../runtime/DefaultTextRuntimeLogSink.java | 4 +- .../io/teaql/runtime/DefaultUserContext.java | 29 +-- .../runtime/EntityPersistenceMutation.java | 12 + .../java/io/teaql/runtime/LogPrivacy.java | 9 +- ...SensitiveDiagnosticTextRuntimeLogSink.java | 4 +- .../teaql/runtime/SqlLogProjectionCache.java | 10 +- .../java/io/teaql/runtime/TeaQLRuntime.java | 68 +++-- .../io/teaql/runtime/GraphTraceChainTest.java | 203 +++++++++++++++ .../teaql/runtime/SqlInheritedIntentTest.java | 17 +- .../io/teaql/runtime/TeaQLRuntimeTest.java | 6 +- .../sql/portable/PortableSQLDataService.java | 14 +- .../sql/portable/PortableSQLRepository.java | 57 +++-- .../core/sql/portable/SqlLogBindings.java | 10 +- .../io/teaql/sqlite/GraphTraceSqliteTest.java | 235 ++++++++++++++++++ 33 files changed, 930 insertions(+), 114 deletions(-) create mode 100644 teaql-core/src/main/java/io/teaql/core/MutationTraceScope.java create mode 100644 teaql-core/src/main/java/io/teaql/core/SqlExecutionTrace.java create mode 100644 teaql-core/src/main/java/io/teaql/core/SqlTracePath.java create mode 100644 teaql-core/src/test/java/io/teaql/core/SqlTracePathVectorsTest.java create mode 100644 teaql-core/src/test/resources/sql-trace-path-v1.json create mode 100644 teaql-runtime/src/test/java/io/teaql/runtime/GraphTraceChainTest.java create mode 100644 teaql-sqlite/src/test/java/io/teaql/sqlite/GraphTraceSqliteTest.java diff --git a/README.md b/README.md index 7fc19461..dd79b27a 100644 --- a/README.md +++ b/README.md @@ -128,8 +128,21 @@ Neither a Context default nor a fabricated trace supplies missing intent. 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. The complete hierarchical Trace Chain and concurrent-context -isolation remain separate, unfinished gates on this branch. +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`. These do not close generated normative-graph acceptance, +same-type prepared-batch parity, query scope migration or internal Registry replay. Applications can replace runtime services such as `QueryPolicy`, the `MutationPolicyRegistry`, `MutationPolicyApprovalProvider`, `RuntimeLogSink`, @@ -138,22 +151,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 @@ -164,9 +181,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/teaql-core/src/main/java/io/teaql/core/BaseEntity.java b/teaql-core/src/main/java/io/teaql/core/BaseEntity.java index b0a60b1d..61c8df4b 100644 --- a/teaql-core/src/main/java/io/teaql/core/BaseEntity.java +++ b/teaql-core/src/main/java/io/teaql/core/BaseEntity.java @@ -66,16 +66,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() { @@ -547,7 +550,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); } } 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..320c2114 100644 --- a/teaql-core/src/main/java/io/teaql/core/Entity.java +++ b/teaql-core/src/main/java/io/teaql/core/Entity.java @@ -115,10 +115,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..ee5543d7 100644 --- a/teaql-core/src/main/java/io/teaql/core/EntityMutationLedger.java +++ b/teaql-core/src/main/java/io/teaql/core/EntityMutationLedger.java @@ -13,7 +13,7 @@ public class EntityMutationLedger { private String comment; private final Set deletedKeys = 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 --- @@ -36,6 +36,7 @@ public void clearCurrentChangeSet() { // 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) { @@ -98,11 +99,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); } @@ -143,7 +144,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/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/SqlExecutionTrace.java b/teaql-core/src/main/java/io/teaql/core/SqlExecutionTrace.java new file mode 100644 index 00000000..3d025895 --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/SqlExecutionTrace.java @@ -0,0 +1,32 @@ +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) { + public SqlExecutionTrace { + source = List.copyOf(source); + mutationLineage = List.copyOf(mutationLineage); + } + + public static SqlExecutionTrace mutation(Entity entity, List lineage, String operation) { + var source = new ArrayList<>(lineage); + source.add(new TraceNode(TraceKind.ENTITY, entity.typeName(), entity.getId(), "")); + return new SqlExecutionTrace(source, lineage, operation); + } + + 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"); + } + + 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/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/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/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..23833c25 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)); } 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/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 affba157..8c194733 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 @@ -365,6 +365,8 @@ private void recordBatch(UserContext context, String sql, java.util.List= 0 || count == java.sql.Statement.SUCCESS_NO_INFO ? "success" : count == java.sql.Statement.EXECUTE_FAILED ? "failure" : "unknown"); 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..b9196df3 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 @@ -176,6 +176,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 +184,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 +192,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); 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/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..0f8d62d7 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/DefaultUserContext.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/DefaultUserContext.java @@ -292,34 +292,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(); 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 d919ad60..44cda77a 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/EntityPersistenceMutation.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/EntityPersistenceMutation.java @@ -3,6 +3,9 @@ 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 final class EntityPersistenceMutation implements PersistenceMutation { @@ -11,19 +14,28 @@ public enum Action { SAVE, DELETE } private final Entity entity; private final Action action; private final MutationIntent intent; + private final List traceChain; public EntityPersistenceMutation(Entity entity, 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 = Objects.requireNonNull(entity, "entity"); this.action = Objects.requireNonNull(action, "action"); this.intent = Objects.requireNonNull(intent, "intent"); + this.traceChain = List.copyOf(Objects.requireNonNull(traceChain, "traceChain")); } @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..f754f275 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); 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 67fd555c..1c1b0919 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java @@ -438,15 +438,7 @@ public void saveGraph(UserContext context, Entity entity) { "teaql.entity.type", entity.typeName(), "teaql.mutation.kind", "save"))); try { - MutationIntent intent = MutationIntent.of(entity.getComment()); - boolean pushed = false; - context.pushTrace(TraceKind.OPERATION, entity.typeName(), "mutation"); - context.pushTrace(TraceKind.ENTITY, entity.typeName(), entity.typeName()); - if (intent.comment() != null) { - context.pushTrace(TraceKind.AUDIT_REASON, entity.typeName(), intent.comment()); - pushed = true; - } - try { + MutationIntent intent = MutationIntent.of(entity.getComment()); checkAndFix(context, entity); // Get entity's own EntityMutationLedger EntityMutationLedger entityMutationLedger = ((BaseEntity) entity).getEntityMutationLedger(); @@ -467,6 +459,14 @@ 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()) { @@ -507,10 +507,10 @@ public void saveGraph(UserContext context, Entity entity) { if (mutationExecutor instanceof TransactionExecutor transactionExecutor) { completed = transactionExecutor.executeInTransaction(context, () -> executeLedgerPlan(context, entityMutationLedger, mutationExecutor, - realEntities, governance, intent)); + realEntities, governance, intent, traceScopes, graphScope)); } else { completed = executeLedgerPlan( - context, entityMutationLedger, mutationExecutor, realEntities, governance, intent); + context, entityMutationLedger, mutationExecutor, realEntities, governance, intent, traceScopes, graphScope); } } catch (RuntimeException | Error failure) { restoreGraphPersistenceState( @@ -523,17 +523,31 @@ 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; } } + 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()); @@ -808,7 +822,7 @@ private List executeLedgerPlan( MutationExecutor mutationExecutor, Map realEntities, MutationGovernanceSnapshot governance, - MutationIntent intent) { + MutationIntent intent, Map traceScopes, MutationTraceScope graphScope) { List completed = new ArrayList<>(); EntityChangeSet changeSet = root.currentChangeSet(); Set deletedKeys = root.deletedKeys(); @@ -833,12 +847,12 @@ private List executeLedgerPlan( if (root.getComment() != null) deleteEntity.setComment(root.getComment()); EntityPersistenceMutation mutationRequest = new EntityPersistenceMutation( - deleteEntity, EntityPersistenceMutation.Action.DELETE, intent); + deleteEntity, EntityPersistenceMutation.Action.DELETE, intent, mutationTrace(root, key, traceScopes, graphScope)); MutationResult result = mutateWithTelemetry(context, mutationExecutor, mutationRequest, key.entity(), "delete"); completed.add(new PendingMutation( descriptor, target == null ? deleteEntity : target, result, - MutationAuditKind.DELETED, Collections.emptyMap(), governance, intent)); + MutationAuditKind.DELETED, Collections.emptyMap(), governance, intent, mutationRequest.getTraceChain())); } // 2. Group changes @@ -881,12 +895,12 @@ private List executeLedgerPlan( if (root.getComment() != null) entity.setComment(root.getComment()); EntityPersistenceMutation mutationRequest = new EntityPersistenceMutation( - entity, EntityPersistenceMutation.Action.SAVE, intent); + entity, EntityPersistenceMutation.Action.SAVE, intent, mutationTrace(root, key, traceScopes, graphScope)); MutationResult result = mutateWithTelemetry(context, mutationExecutor, mutationRequest, entityName, "save"); completed.add(new PendingMutation( descriptor, target == null ? entity : target, result, - MutationAuditKind.CREATED, snapshotChanges(changes), governance, intent)); + MutationAuditKind.CREATED, snapshotChanges(changes), governance, intent, mutationRequest.getTraceChain())); } } @@ -916,7 +930,7 @@ private List executeLedgerPlan( if (root.getComment() != null) entity.setComment(root.getComment()); EntityPersistenceMutation mutationRequest = new EntityPersistenceMutation( - entity, EntityPersistenceMutation.Action.SAVE, intent); + entity, EntityPersistenceMutation.Action.SAVE, intent, mutationTrace(root, key, traceScopes, graphScope)); MutationAuditKind auditKind = target != null && target.recoverItem() ? MutationAuditKind.RECOVERED : MutationAuditKind.UPDATED; @@ -924,7 +938,7 @@ private List executeLedgerPlan( entityName, auditKind.name().toLowerCase(Locale.ROOT)); completed.add(new PendingMutation( descriptor, target == null ? entity : target, result, - auditKind, snapshotChanges(changes), governance, intent)); + auditKind, snapshotChanges(changes), governance, intent, mutationRequest.getTraceChain())); } } return completed; @@ -935,7 +949,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.intent()); + mutation.governance(), mutation.intent(), mutation.traceChain()); mutation.target().clearUpdatedProperties(); } } @@ -959,7 +973,7 @@ private record PendingMutation( MutationResult result, MutationAuditKind auditKind, Map changedValues, - MutationGovernanceSnapshot governance, MutationIntent intent) {} + MutationGovernanceSnapshot governance, MutationIntent intent, List traceChain) {} private record PersistenceState( Long version, io.teaql.core.EntityStatus status, boolean versionLoaded) {} @@ -1026,7 +1040,7 @@ private void emitAuditEvent( Entity entity, MutationAuditKind kind, Map changedValues, - MutationGovernanceSnapshot governance, MutationIntent intent) { + MutationGovernanceSnapshot governance, MutationIntent intent, List traceChain) { List changes = new ArrayList<>(); if (changedValues != null) { for (Map.Entry entry : changedValues.entrySet()) { @@ -1040,7 +1054,7 @@ 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), intent.auditReason(), 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..587350a5 --- /dev/null +++ b/teaql-runtime/src/test/java/io/teaql/runtime/GraphTraceChainTest.java @@ -0,0 +1,203 @@ +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 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/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 9441c524..aa9107c5 100644 --- a/teaql-runtime/src/test/java/io/teaql/runtime/TeaQLRuntimeTest.java +++ b/teaql-runtime/src/test/java/io/teaql/runtime/TeaQLRuntimeTest.java @@ -892,10 +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, MutationIntent.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, - MutationIntent.of("root comment")); + 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/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 02b95508..32584c5b 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 @@ -372,6 +372,10 @@ public MutationResult mutate(UserContext context, PersistenceMutation request) { var readbackIntent = context.isQueryExecutionLoggingEnabled() || context.isMutationExecutionLoggingEnabled() ? new io.teaql.core.SqlIntentRedactions() : null; + 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); + if (mutation.getAction() == EntityPersistenceMutation.Action.SAVE) { if (entity.getId() == null) { Long newId = repository.prepareId(context, entity); @@ -379,19 +383,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); @@ -402,7 +406,7 @@ 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); } 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 ec9f2f0d..4754c177 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 @@ -278,7 +278,7 @@ private PositionalSQL withQueryIntent(PositionalSQL sql, io.teaql.core.SqlIntent 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()); } private PositionalSQL toPositional(String namedSql, Map params) { @@ -1047,12 +1047,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"); @@ -1229,6 +1235,11 @@ public void createInternal(UserContext userContext, Collection createItems) { } void createInternal(UserContext userContext, Collection createItems, io.teaql.core.SqlIntentRedactions intent) { + createInternal(userContext, createItems, intent, null); + } + + void createInternal(UserContext userContext, Collection createItems, io.teaql.core.SqlIntentRedactions intent, + io.teaql.core.SqlExecutionTrace trace) { if (intent != null) createItems.forEach(item -> intent.captureTargetId(item.getId())); List sqlEntities = CollectionUtil.map(createItems, i -> convertToSQLEntityForInsert(userContext, i), true); @@ -1260,7 +1271,7 @@ void createInternal(UserContext userContext, Collection createItems, io.teaql List columns = tableColumns.get(k); 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()); + var bindings = logBindings(k, columns, sql, sqlEntity.getTraceChain()).withTrace(trace); if (intent != null) for (Object[] args : v) intent.capture(bindings.policies(), args); database.batchUpdate(userContext, sql, v, withMutationIntent(bindings, intent)); }); @@ -1271,6 +1282,11 @@ public void updateInternal(UserContext userContext, Collection updateItems) { } 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, @@ -1290,42 +1306,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"); @@ -1333,7 +1350,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); @@ -1342,7 +1359,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(); @@ -1353,6 +1370,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(); @@ -1361,7 +1383,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) { @@ -1374,6 +1396,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(); @@ -1382,7 +1409,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) { 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..97afb69b 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,7 +6,7 @@ /** 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) { public static final SqlLogBindings UNKNOWN = new SqlLogBindings(List.of(), false); public SqlLogBindings { policies = List.copyOf(policies); } @@ -14,11 +14,19 @@ public record SqlLogBindings(List policies, boolean gener 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 withTrace(io.teaql.core.SqlExecutionTrace trace) { + return new SqlLogBindings(policies, generated, diagnosticSql, intentRedactions, trace); + } 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-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..2c9812e2 --- /dev/null +++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/GraphTraceSqliteTest.java @@ -0,0 +1,235 @@ +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.atomic.AtomicLong; +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("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 final class Fixture { + final List sql = new CopyOnWriteArrayList<>(); + final List audit = new CopyOnWriteArrayList<>(); + final List commands = new CopyOnWriteArrayList<>(); + final JdbcSqlExecutor driver; + final DefaultUserContext context; + volatile boolean failReadback; + + Fixture() throws Exception { + var ds = new SQLiteDataSource(); + ds.setUrl("jdbc:sqlite:" + Files.createTempFile("teaql-graph-trace-", ".db")); + driver = new JdbcSqlExecutor(ds) { + @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")) { + 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 provider = new SqliteDataServiceExecutor("sqlite", driver, ds) { + @Override public MutationResult mutate(UserContext caller, PersistenceMutation mutation) { + commands.add((EntityPersistenceMutation) mutation); + return super.mutate(caller, mutation); + } + }; + 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); + context.putAttribute(AppAuditEventSink.class.getName(), (AppAuditEventSink) (caller, event) -> audit.add(event)); + context.ensureSchema(); + clear(); + } + + void clear() { sql.clear(); audit.clear(); commands.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; + } + } + + @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()))); + } + + 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())); + } +} From 5dc021b1c66c586b760067d399c29b7470a491db Mon Sep 17 00:00:00 2001 From: Philip Z Date: Fri, 2 Oct 2026 12:52:59 +0800 Subject: [PATCH 03/35] feat(trace): retain generated graph lineage and request-owned relation paths (#202) Signed-off-by: Philip Z --- examples/trace-chain/AGENTS.md | 62 ++ examples/trace-chain/README.md | 61 ++ .../java-assist-create/customer_order.md | 107 ++ .../assist/java-assist-create/order_item.md | 82 ++ .../assist/java-assist-create/payment.md | 103 ++ .../java-assist-create/payment_attempt.md | 82 ++ .../assist/java-assist-create/shipment.md | 82 ++ .../assist/java-assist-delete/order_item.md | 76 ++ .../java-assist-expression/customer_order.md | 135 +++ .../java-assist-expression/order_item.md | 91 ++ .../assist/java-assist-expression/payment.md | 103 ++ .../java-assist-expression/payment_attempt.md | 91 ++ .../assist/java-assist-expression/platform.md | 99 ++ .../java-assist-query/customer_order.md | 111 +++ .../customer_order.order_item_list.md | 66 ++ .../customer_order.payment_list.md | 66 ++ .../customer_order.platform.md | 75 ++ .../customer_order.shipment_list.md | 66 ++ .../assist/java-assist-query/order_item.md | 102 ++ .../java-assist-query/order_item.name.md | 75 ++ .../payment.customer_order.md | 75 ++ .../assist/java-assist-query/payment.md | 108 +++ .../payment.payment_attempt_list.md | 66 ++ .../payment_attempt.payment.md | 75 ++ .../payment_attempt.reference_code.md | 75 ++ .../assist/java-assist-query/platform.md | 107 ++ .../assist/java-assist-query/platform.name.md | 75 ++ .../assist/java-assist-runtime-custom.md | 130 +++ .../java-assist-update/customer_order.md | 93 ++ .../assist/java-assist-update/order_item.md | 91 ++ examples/trace-chain/evidence/evaluation.md | 30 + examples/trace-chain/lib/pom.xml | 142 +++ .../BaseServiceConfiguration.java | 0 .../com/teaql/tracechainservice/Checkers.java | 0 .../teaql/tracechainservice/Constants.java | 9 + .../java/com/teaql/tracechainservice/E.java | 37 + .../tracechainservice/EntityMetaRegistry.java | 493 ++++++++++ .../GeneratedRuntimeModule.java | 40 + .../java/com/teaql/tracechainservice/Q.java | 61 ++ .../teaql/tracechainservice/Repositories.java | 0 .../TraceChainServiceUserContext.java | 10 + .../customerorder/CustomerOrder.java | 189 ++++ .../customerorder/CustomerOrderChecker.java | 86 ++ .../CustomerOrderExpression.java | 84 ++ .../CustomerOrderListExpression.java | 25 + .../customerorder/CustomerOrderRequest.java | 916 ++++++++++++++++++ .../orderitem/OrderItem.java | 105 ++ .../orderitem/OrderItemChecker.java | 56 ++ .../orderitem/OrderItemExpression.java | 53 + .../orderitem/OrderItemListExpression.java | 25 + .../orderitem/OrderItemRequest.java | 618 ++++++++++++ .../tracechainservice/payment/Payment.java | 127 +++ .../payment/PaymentChecker.java | 62 ++ .../payment/PaymentExpression.java | 61 ++ .../payment/PaymentListExpression.java | 25 + .../payment/PaymentRequest.java | 672 +++++++++++++ .../paymentattempt/PaymentAttempt.java | 105 ++ .../paymentattempt/PaymentAttemptChecker.java | 56 ++ .../PaymentAttemptExpression.java | 53 + .../PaymentAttemptListExpression.java | 25 + .../paymentattempt/PaymentAttemptRequest.java | 618 ++++++++++++ .../tracechainservice/platform/Platform.java | 107 ++ .../platform/PlatformChecker.java | 49 + .../platform/PlatformExpression.java | 51 + .../platform/PlatformListExpression.java | 25 + .../platform/PlatformRequest.java | 557 +++++++++++ .../sampledata/SampleDataService.java | 0 .../tracechainservice/shipment/Shipment.java | 105 ++ .../shipment/ShipmentChecker.java | 56 ++ .../shipment/ShipmentExpression.java | 53 + .../shipment/ShipmentListExpression.java | 25 + .../shipment/ShipmentRequest.java | 618 ++++++++++++ .../io.teaql.core.meta.EntityMetaAssembler | 2 + examples/trace-chain/model.xml | 17 + examples/trace-chain/pom.xml | 26 + .../GeneratedTraceChainExampleTest.java | 298 ++++++ .../teaql/examples/tracechain/IdDatabase.java | 22 + examples/trace-chain/verify.sh | 46 + examples/verify-runtime-examples.sh | 3 +- pom.xml | 2 + scripts/verify-examples.sh | 3 +- .../java/io/teaql/core/SearchRequest.java | 4 + .../java/io/teaql/core/SqlExecutionTrace.java | 11 + .../io/teaql/core/SqlExecutionTraceTest.java | 46 + .../sql/SqlDataServiceExecutor.java | 10 +- .../io/teaql/core/internal/TempRequest.java | 3 + .../sql/portable/PortableSQLDataService.java | 16 +- .../sql/portable/PortableSQLRepository.java | 17 +- .../sql/portable/SqlDiagnosticRequest.java | 21 + 89 files changed, 9586 insertions(+), 20 deletions(-) create mode 100644 examples/trace-chain/AGENTS.md create mode 100644 examples/trace-chain/README.md create mode 100644 examples/trace-chain/evidence/assist/java-assist-create/customer_order.md create mode 100644 examples/trace-chain/evidence/assist/java-assist-create/order_item.md create mode 100644 examples/trace-chain/evidence/assist/java-assist-create/payment.md create mode 100644 examples/trace-chain/evidence/assist/java-assist-create/payment_attempt.md create mode 100644 examples/trace-chain/evidence/assist/java-assist-create/shipment.md create mode 100644 examples/trace-chain/evidence/assist/java-assist-delete/order_item.md create mode 100644 examples/trace-chain/evidence/assist/java-assist-expression/customer_order.md create mode 100644 examples/trace-chain/evidence/assist/java-assist-expression/order_item.md create mode 100644 examples/trace-chain/evidence/assist/java-assist-expression/payment.md create mode 100644 examples/trace-chain/evidence/assist/java-assist-expression/payment_attempt.md create mode 100644 examples/trace-chain/evidence/assist/java-assist-expression/platform.md create mode 100644 examples/trace-chain/evidence/assist/java-assist-query/customer_order.md create mode 100644 examples/trace-chain/evidence/assist/java-assist-query/customer_order.order_item_list.md create mode 100644 examples/trace-chain/evidence/assist/java-assist-query/customer_order.payment_list.md create mode 100644 examples/trace-chain/evidence/assist/java-assist-query/customer_order.platform.md create mode 100644 examples/trace-chain/evidence/assist/java-assist-query/customer_order.shipment_list.md create mode 100644 examples/trace-chain/evidence/assist/java-assist-query/order_item.md create mode 100644 examples/trace-chain/evidence/assist/java-assist-query/order_item.name.md create mode 100644 examples/trace-chain/evidence/assist/java-assist-query/payment.customer_order.md create mode 100644 examples/trace-chain/evidence/assist/java-assist-query/payment.md create mode 100644 examples/trace-chain/evidence/assist/java-assist-query/payment.payment_attempt_list.md create mode 100644 examples/trace-chain/evidence/assist/java-assist-query/payment_attempt.payment.md create mode 100644 examples/trace-chain/evidence/assist/java-assist-query/payment_attempt.reference_code.md create mode 100644 examples/trace-chain/evidence/assist/java-assist-query/platform.md create mode 100644 examples/trace-chain/evidence/assist/java-assist-query/platform.name.md create mode 100644 examples/trace-chain/evidence/assist/java-assist-runtime-custom.md create mode 100644 examples/trace-chain/evidence/assist/java-assist-update/customer_order.md create mode 100644 examples/trace-chain/evidence/assist/java-assist-update/order_item.md create mode 100644 examples/trace-chain/evidence/evaluation.md create mode 100644 examples/trace-chain/lib/pom.xml create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/BaseServiceConfiguration.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/Checkers.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/Constants.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/E.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/EntityMetaRegistry.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/GeneratedRuntimeModule.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/Q.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/Repositories.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/TraceChainServiceUserContext.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/customerorder/CustomerOrder.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/customerorder/CustomerOrderChecker.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/customerorder/CustomerOrderExpression.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/customerorder/CustomerOrderListExpression.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/customerorder/CustomerOrderRequest.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/orderitem/OrderItem.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/orderitem/OrderItemChecker.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/orderitem/OrderItemExpression.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/orderitem/OrderItemListExpression.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/orderitem/OrderItemRequest.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/payment/Payment.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/payment/PaymentChecker.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/payment/PaymentExpression.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/payment/PaymentListExpression.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/payment/PaymentRequest.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/paymentattempt/PaymentAttempt.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/paymentattempt/PaymentAttemptChecker.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/paymentattempt/PaymentAttemptExpression.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/paymentattempt/PaymentAttemptListExpression.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/paymentattempt/PaymentAttemptRequest.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/platform/Platform.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/platform/PlatformChecker.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/platform/PlatformExpression.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/platform/PlatformListExpression.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/platform/PlatformRequest.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/sampledata/SampleDataService.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/shipment/Shipment.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/shipment/ShipmentChecker.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/shipment/ShipmentExpression.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/shipment/ShipmentListExpression.java create mode 100644 examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/shipment/ShipmentRequest.java create mode 100644 examples/trace-chain/lib/src/main/resources/META-INF/services/io.teaql.core.meta.EntityMetaAssembler create mode 100644 examples/trace-chain/model.xml create mode 100644 examples/trace-chain/pom.xml create mode 100644 examples/trace-chain/src/test/java/io/teaql/examples/tracechain/GeneratedTraceChainExampleTest.java create mode 100644 examples/trace-chain/src/test/java/io/teaql/examples/tracechain/IdDatabase.java create mode 100644 examples/trace-chain/verify.sh create mode 100644 teaql-core/src/test/java/io/teaql/core/SqlExecutionTraceTest.java 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..d55a8083 --- /dev/null +++ b/examples/trace-chain/README.md @@ -0,0 +1,61 @@ +# 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, 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. + +## 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 five scenarios 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 | +| 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 | + +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. It does not prove same-type prepared batches, complete ledger +override, concurrent saves with all checkers/providers, 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..19cba034 --- /dev/null +++ b/examples/trace-chain/evidence/assist/java-assist-delete/order_item.md @@ -0,0 +1,76 @@ + + +# 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; + } +} +``` + +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. + + +--- + +## 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 tenant-scoped current entity and use the generated hard-delete or + domain-specific soft-delete API; do not invent a deletion method. +- Require an audit reason and optimistic version. Test missing audit and stale + version as explicit failures. diff --git a/examples/trace-chain/evidence/assist/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..002acf28 --- /dev/null +++ b/examples/trace-chain/lib/src/main/java/com/teaql/tracechainservice/EntityMetaRegistry.java @@ -0,0 +1,493 @@ + +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"); + + entityDescriptor.setAuditMaskFields(java.util.List.of()); + 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..4bb33081 --- /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/GeneratedTraceChainExampleTest.java b/examples/trace-chain/src/test/java/io/teaql/examples/tracechain/GeneratedTraceChainExampleTest.java new file mode 100644 index 00000000..fb3880eb --- /dev/null +++ b/examples/trace-chain/src/test/java/io/teaql/examples/tracechain/GeneratedTraceChainExampleTest.java @@ -0,0 +1,298 @@ +package io.teaql.examples.tracechain; + +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.concurrent.CopyOnWriteArrayList; +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 { + static final class Fixture { + final List sql = new CopyOnWriteArrayList<>(); + final List audit = new CopyOnWriteArrayList<>(); + final List commands = new CopyOnWriteArrayList<>(); + final DefaultUserContext context; + final JdbcSqlExecutor driver; + volatile boolean failReadback; + final long base; + + Fixture() 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 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); + } + }; + var ids = new IdSpaceIdGenerator(new IdDatabase(driver)); + var metadata = new SimpleEntityMetaFactory(); + var provider = new SqliteDataServiceExecutor("sqlite", driver, source) { + @Override public MutationResult mutate(UserContext caller, PersistenceMutation mutation) { + commands.add((EntityPersistenceMutation) mutation); + return super.mutate(caller, mutation); + } + }; + var runtime = TeaQLRuntime.builder().metadata(metadata) + .dataService("default", provider).dataService("sqlite", provider) + .idGenerationService(ids).logSink((caller, entry) -> sql.add(entry)).build() + .install(GeneratedRuntimeModule.module()); // Real generated checkers, no bypass. + EntityMetaFactory.registerGlobal(metadata); + context = new DefaultUserContext(runtime); + context.putAttribute(AppAuditEventSink.class.getName(), (AppAuditEventSink) (caller, event) -> audit.add(event)); + context.ensureSchema(); + context.ensureSchema(); + 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 clear() { sql.clear(); audit.clear(); commands.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) {} + + @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()); + 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(); + assertFalse("missing actual SQL 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(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))); + } + 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 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); + } + System.out.println("PASS Java generated three-level SQL Trace Path and inherited request intent"); + } + + @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..b607b725 --- /dev/null +++ b/examples/trace-chain/verify.sh @@ -0,0 +1,46 @@ +#!/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=( + 'PASS Java generated normative Trace Chain graph: six physical writes and committed audits' + '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' +) +for repetition in 1 2; do + log="$run_dir/run-$repetition.log" + if ! mvn -B -f "$example_dir/pom.xml" -Dtest=GeneratedTraceChainExampleTest \ + "-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: 5, Failures: 0, Errors: 0, Skipped: 0' "$log" + 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 58829f39..d2488697 100755 --- a/examples/verify-runtime-examples.sh +++ b/examples/verify-runtime-examples.sh @@ -73,4 +73,5 @@ for repetition in 1 2; do "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..a7b67452 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 @@ -27,4 +27,5 @@ 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 +bash examples/trace-chain/verify.sh echo "PASS: all Java examples" 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 90bd69eb..3b8c5988 100644 --- a/teaql-core/src/main/java/io/teaql/core/SearchRequest.java +++ b/teaql-core/src/main/java/io/teaql/core/SearchRequest.java @@ -54,6 +54,10 @@ default T internalNewEntity() { @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 index 3d025895..3ee2d93a 100644 --- a/teaql-core/src/main/java/io/teaql/core/SqlExecutionTrace.java +++ b/teaql-core/src/main/java/io/teaql/core/SqlExecutionTrace.java @@ -16,6 +16,17 @@ public static SqlExecutionTrace mutation(Entity entity, List lineage, return new SqlExecutionTrace(source, lineage, operation); } + 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(); 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..fe0986d3 --- /dev/null +++ b/teaql-core/src/test/java/io/teaql/core/SqlExecutionTraceTest.java @@ -0,0 +1,46 @@ +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()); + } +} 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 8c194733..9a05f47f 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 @@ -166,10 +166,12 @@ public java.util.stream.Stream> queryForStream(io. if (logging) { 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)); 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 9936b5b2..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 @@ -26,6 +26,9 @@ public TempRequest(SearchRequest request, QueryIntent rootIntent) { } @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(); } 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 32584c5b..4a315478 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 @@ -191,10 +191,10 @@ private void enhanceRelations( if (!(property instanceof Relation)) return; if (shouldHandle(entityDescriptor, (Relation) property)) { - enhanceParent(userContext, dataSet, (Relation) property, r, intent, request.inheritedQueryIntent()); + enhanceParent(userContext, dataSet, (Relation) property, r, intent, request); return; } - collectChildren(userContext, dataSet, (Relation) property, r, intent, request.inheritedQueryIntent()); + collectChildren(userContext, dataSet, (Relation) property, r, intent, request); }); } @@ -226,7 +226,7 @@ private void enhanceParent( UserContext userContext, SmartList results, Relation relation, - SearchRequest parentRequest, SqlIntentRedactions intent, QueryIntent rootIntent) { + SearchRequest parentRequest, SqlIntentRedactions intent, SearchRequest origin) { List parents = results.stream() .map(e -> e.getProperty(relation.getName())) @@ -236,7 +236,8 @@ private void enhanceParent( .toList(); if (io.teaql.core.utils.ObjectUtil.isEmpty(parents)) return; - io.teaql.core.internal.TempRequest parentTemp = new SqlDiagnosticRequest(parentRequest, intent, rootIntent); + 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 @@ -261,8 +262,9 @@ private void collectChildren( UserContext userContext, SmartList dataSet, Relation relation, - SearchRequest childRequest, SqlIntentRedactions intent, QueryIntent rootIntent) { - io.teaql.core.internal.TempRequest childTempRequest = new SqlDiagnosticRequest(childRequest, intent, rootIntent); + 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()); Slice slice = childTempRequest.getSlice(); @@ -277,7 +279,7 @@ private void collectChildren( "probe", dataSet.size()); for (Entity parent : dataSet) { io.teaql.core.internal.TempRequest probeRequest = - new SqlDiagnosticRequest(childRequest, intent, rootIntent); + SqlDiagnosticRequest.forRelation(childRequest, intent, origin, relation.getName()); probeRequest.selectProperty(reverseProperty.getName()); probeRequest.setPartitionProperty(null); ensureStableEntityIdOrder(probeRequest); 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 4754c177..0057ebde 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 @@ -269,11 +269,13 @@ 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(), + io.teaql.core.SqlExecutionTrace.query(request))); } private SqlLogBindings withMutationIntent(SqlLogBindings bindings, io.teaql.core.SqlIntentRedactions intent) { @@ -596,7 +598,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 { @@ -751,7 +753,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 = @@ -804,7 +806,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<>(); @@ -1095,7 +1097,8 @@ public Stream streamInternal(UserContext userContext, SearchRequest reques 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), + SqlDiagnosticRequest.source(userContext, request), request); return database.queryForStream(userContext, psql.sql, psql.args, psql.logBindings) .map(row -> mapRowToEntity(userContext, request, row)); } @@ -2323,7 +2326,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(); 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 463206b4..2f45264a 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 @@ -12,6 +12,7 @@ final class SqlDiagnosticRequest extends TempRequest { private final transient boolean executionScope; private final transient SearchRequest original; private final transient QueryIntent rootIntent; + private final transient java.util.List traceSource; SqlDiagnosticRequest(SearchRequest request, SqlIntentRedactions source) { this(request, source, request.inheritedQueryIntent() == null @@ -24,6 +25,12 @@ final class SqlDiagnosticRequest extends TempRequest { 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) { super(request); this.original = request; // TempRequest's relation-oriented copy omits these root-query semantics. @@ -35,6 +42,11 @@ private SqlDiagnosticRequest(SearchRequest request, SqlIntentRedactions sourc 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) { @@ -48,6 +60,15 @@ static SqlDiagnosticRequest forExecution(SearchRequest request, SqlIntentReda } @Override public QueryIntent inheritedQueryIntent() { return rootIntent; } + @Override public java.util.List sqlTraceSource() { return traceSource; } + + static SqlDiagnosticRequest forRelation(SearchRequest child, SqlIntentRedactions source, + SearchRequest parent, String relationName) { + var trace = new java.util.ArrayList<>(parent.sqlTraceSource()); + trace.add(new io.teaql.core.TraceNode(io.teaql.core.TraceKind.RELATION, relationName, + parent.getTypeName() + "." + relationName)); + return new SqlDiagnosticRequest(child, source, parent.inheritedQueryIntent(), false, trace); + } @Override public io.teaql.core.Entity internalNewEntity() { return original.internalNewEntity(); } @Override public boolean tryUseSubQuery() { return original.tryUseSubQuery(); } From 1219b2ae7f29e7cdef4049930fb6912da7596773 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Fri, 2 Oct 2026 20:40:31 +0800 Subject: [PATCH 04/35] fix(trace): retain per-item lineage in Java prepared graph inserts (#202) Signed-off-by: Philip Z --- README.md | 15 ++- examples/trace-chain/README.md | 13 ++- .../GeneratedTraceChainExampleTest.java | 92 +++++++++++++++- examples/trace-chain/verify.sh | 3 +- scripts/verify-examples.sh | 4 +- .../io/teaql/core/BatchMutationExecutor.java | 8 ++ .../io/teaql/core/MutationBatchRequest.java | 31 ++++++ .../teaql/core/MutationBatchRequestTest.java | 43 ++++++++ .../sql/SqlDataServiceExecutor.java | 10 +- .../sql/SqlStreamBatchMaskingTest.java | 34 ++++++ .../java/io/teaql/runtime/TeaQLRuntime.java | 60 +++++++++- .../io/teaql/runtime/GraphTraceChainTest.java | 58 ++++++++++ .../sql/portable/PortableSQLDataService.java | 48 +++++++- .../sql/portable/PortableSQLRepository.java | 68 +++++++----- .../core/sql/portable/SqlLogBindings.java | 24 +++- .../io/teaql/sqlite/GraphTraceSqliteTest.java | 103 +++++++++++++++++- 16 files changed, 570 insertions(+), 44 deletions(-) create mode 100644 teaql-core/src/main/java/io/teaql/core/BatchMutationExecutor.java create mode 100644 teaql-core/src/main/java/io/teaql/core/MutationBatchRequest.java create mode 100644 teaql-core/src/test/java/io/teaql/core/MutationBatchRequestTest.java diff --git a/README.md b/README.md index dd79b27a..a14824a9 100644 --- a/README.md +++ b/README.md @@ -141,8 +141,19 @@ across types, assigned IDs, complete ledger overrides and concurrent saves shari 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`. These do not close generated normative-graph acceptance, -same-type prepared-batch parity, query scope migration or internal Registry replay. +`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. + +The generated [Trace Chain example](examples/trace-chain/README.md) proves the +normative graph, three-level Q/E queries, prepared insert grouping and complete +ledger replacement. Prepared update/delete/recover batches, complete +entry-point/privacy coverage, query scope migration and immutable internal +Registry replay remain separate open gates. This is local source evidence, not +a merge or release claim. Applications can replace runtime services such as `QueryPolicy`, the `MutationPolicyRegistry`, `MutationPolicyApprovalProvider`, `RuntimeLogSink`, diff --git a/examples/trace-chain/README.md b/examples/trace-chain/README.md index d55a8083..7933875a 100644 --- a/examples/trace-chain/README.md +++ b/examples/trace-chain/README.md @@ -14,7 +14,7 @@ Use Java 21 or newer, Maven, Bash and the normal repository dependencies: bash examples/trace-chain/verify.sh ``` -The script installs local source dependencies, runs all five scenarios twice +The script installs local source dependencies, runs all six scenarios 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 @@ -31,6 +31,7 @@ and the `runtime-examples` Maven profile. | 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 | The first run begins with CustomerOrder and Payment both numbered 100, items 201/202, attempt 401 and shipment 501. IDs come from `IdSpaceIdGenerator`, not @@ -56,6 +57,10 @@ 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. It does not prove same-type prepared batches, complete ledger -override, concurrent saves with all checkers/providers, or immutable internal -Registry replay. The development dependency version is not a new public release. +local Java source, including same-type prepared insert 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. It does not prove prepared update/delete/recover +batches, concurrent saves with all checkers/providers, complete privacy and +entry-point coverage, or immutable internal Registry replay. The development +dependency version is not a new public release. 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 index fb3880eb..790d8800 100644 --- 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 @@ -29,6 +29,7 @@ static final class Fixture { final List sql = new CopyOnWriteArrayList<>(); final List audit = new CopyOnWriteArrayList<>(); final List commands = new CopyOnWriteArrayList<>(); + final List itemInsertBatchSizes = new CopyOnWriteArrayList<>(); final DefaultUserContext context; final JdbcSqlExecutor driver; volatile boolean failReadback; @@ -41,6 +42,10 @@ static final class Fixture { var source = new SQLiteDataSource(); source.setUrl("jdbc:sqlite:" + database); driver = new JdbcSqlExecutor(source) { + @Override public int[] batchUpdate(String text, List rows) { + if (text.startsWith("INSERT INTO order_item_data")) itemInsertBatchSizes.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"); @@ -54,6 +59,10 @@ static final class Fixture { commands.add((EntityPersistenceMutation) mutation); return super.mutate(caller, mutation); } + @Override public List mutateBatch(UserContext caller, MutationBatchRequest request) { + request.items().forEach(item -> commands.add((EntityPersistenceMutation) item)); + return super.mutateBatch(caller, request); + } }; var runtime = TeaQLRuntime.builder().metadata(metadata) .dataService("default", provider).dataService("sqlite", provider) @@ -76,7 +85,7 @@ static final class Fixture { clear(); } - void clear() { sql.clear(); audit.clear(); commands.clear(); } + void clear() { sql.clear(); audit.clear(); commands.clear(); itemInsertBatchSizes.clear(); } Graph saveNormativeGraph() { var platform = Q.platforms().withIdIs(1L).limit(1) @@ -126,6 +135,87 @@ Graph saveNormativeGraph() { record Graph(CustomerOrder order, OrderItem kept, OrderItem removed, Payment payment, PaymentAttempt attempt, Shipment shipment) {} + @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); + 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))); + System.out.println("PASS Java generated prepared batch: per-item lineage and complete ledger replacement"); + } + @Test public void generatedNormativeGraphHasPerItemPhysicalSqlAndCommittedAudit() throws Exception { var fixture = new Fixture(); Graph graph = fixture.saveNormativeGraph(); diff --git a/examples/trace-chain/verify.sh b/examples/trace-chain/verify.sh index b607b725..1ac68458 100644 --- a/examples/trace-chain/verify.sh +++ b/examples/trace-chain/verify.sh @@ -24,6 +24,7 @@ markers=( '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' ) for repetition in 1 2; do log="$run_dir/run-$repetition.log" @@ -38,7 +39,7 @@ for repetition in 1 2; do exit 1 fi done - grep -Fq 'Tests run: 5, Failures: 0, Errors: 0, Skipped: 0' "$log" + grep -Fq 'Tests run: 6, Failures: 0, Errors: 0, Skipped: 0' "$log" printf 'PASS Java generated Trace Chain run %s on the same database\n' "$repetition" done library_manifest > "$run_dir/library-after.sha256" diff --git a/scripts/verify-examples.sh b/scripts/verify-examples.sh index a7b67452..296d1ab1 100755 --- a/scripts/verify-examples.sh +++ b/scripts/verify-examples.sh @@ -26,6 +26,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/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/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/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-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 9a05f47f..0f5ec940 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; @@ -76,6 +76,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); @@ -296,6 +301,7 @@ 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; int[] res; @@ -365,7 +371,7 @@ private void recordBatch(UserContext context, String sql, java.util.List(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-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java b/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java index 1c1b0919..0131e120 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java @@ -879,6 +879,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; @@ -896,11 +900,16 @@ private List executeLedgerPlan( EntityPersistenceMutation mutationRequest = new EntityPersistenceMutation( entity, EntityPersistenceMutation.Action.SAVE, intent, mutationTrace(root, key, traceScopes, graphScope)); - MutationResult result = mutateWithTelemetry(context, mutationExecutor, mutationRequest, - entityName, "save"); + 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, intent, mutationRequest.getTraceChain())); + descriptor, targets.get(index), results.get(index), + MutationAuditKind.CREATED, snapshots.get(index), governance, intent, requests.get(index).getTraceChain())); } } @@ -1035,6 +1044,49 @@ 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, diff --git a/teaql-runtime/src/test/java/io/teaql/runtime/GraphTraceChainTest.java b/teaql-runtime/src/test/java/io/teaql/runtime/GraphTraceChainTest.java index 587350a5..41eb3a10 100644 --- a/teaql-runtime/src/test/java/io/teaql/runtime/GraphTraceChainTest.java +++ b/teaql-runtime/src/test/java/io/teaql/runtime/GraphTraceChainTest.java @@ -35,6 +35,64 @@ static class Provider implements MutationExecutor { @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")) { 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 4a315478..600bb9d2 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); } @@ -413,6 +414,51 @@ public MutationResult mutate(UserContext context, PersistenceMutation request) { return new io.teaql.core.DefaultMutationResult(persisted); } + @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(); + if (items.stream().anyMatch(item -> item.getAction() != EntityPersistenceMutation.Action.SAVE + || !item.getEntity().newItem() || !item.getEntity().typeName().equals(type))) { + throw new TeaQLRuntimeException("Portable SQL prepared batch currently requires same-type new entities"); + } + return executeInTransaction(context, () -> { + PortableSQLRepository repository = getRepository(type); + var redactions = context.isQueryExecutionLoggingEnabled() || context.isMutationExecutionLoggingEnabled() + ? new SqlIntentRedactions() : null; + List entities = new ArrayList<>(); + List traces = new ArrayList<>(); + for (EntityPersistenceMutation item : items) { + var entity = (BaseEntity) item.getEntity(); + if (entity.getId() == null) entity.__internalSet("id", repository.prepareId(context, entity)); + entity.__internalSet("version", 1L); + entities.add(entity); + traces.add(SqlExecutionTrace.mutation(entity, item.getTraceChain(), "insert")); + } + repository.createBatchInternal(context, entities, redactions, traces); + List results = new ArrayList<>(); + for (int index = 0; index < items.size(); index++) { + var item = items.get(index); + var entity = (BaseEntity) item.getEntity(); + 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)); + } + return List.copyOf(results); + }); + } + @Override @SuppressWarnings("unchecked") public T executeInTransaction(UserContext context, TransactionCallback action) { 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 0057ebde..33728a72 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 @@ -280,7 +280,7 @@ private PositionalSQL withQueryIntent(PositionalSQL sql, io.teaql.core.SqlIntent 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(), bindings.executionTrace()); + return new SqlLogBindings(bindings.policies(), bindings.generated(), bindings.diagnosticSql(), intent.copy(), bindings.executionTrace(), bindings.batchTraces()); } private PositionalSQL toPositional(String namedSql, Map params) { @@ -1243,41 +1243,59 @@ void createInternal(UserContext userContext, Collection createItems, io.teaql void createInternal(UserContext userContext, Collection createItems, io.teaql.core.SqlIntentRedactions intent, io.teaql.core.SqlExecutionTrace trace) { - 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; + createRows(userContext, new ArrayList<>(createItems), intent, trace, List.of()); + } - SQLEntity sqlEntity = sqlEntities.get(0); - Map> tableColumns = sqlEntity.getTableColumnNames(); + 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); } + } - Map> rows = new HashMap<>(); - for (SQLEntity entity : sqlEntities) { + 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()).withTrace(trace); - 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)); + } } public void updateInternal(UserContext userContext, Collection updateItems) { 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 97afb69b..0eabbac3 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,10 +6,11 @@ /** 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.SqlExecutionTrace executionTrace) { + 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); @@ -18,10 +19,29 @@ public SqlLogBindings(List policies, boolean generated, S 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 void applyTo(ExecutionMetadata metadata) { metadata.setParameterLogPolicies(policies); metadata.setGeneratedSql(generated); diff --git a/teaql-sqlite/src/test/java/io/teaql/sqlite/GraphTraceSqliteTest.java b/teaql-sqlite/src/test/java/io/teaql/sqlite/GraphTraceSqliteTest.java index 2c9812e2..099cfeac 100644 --- a/teaql-sqlite/src/test/java/io/teaql/sqlite/GraphTraceSqliteTest.java +++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/GraphTraceSqliteTest.java @@ -35,6 +35,7 @@ static final class Fixture { final List sql = new CopyOnWriteArrayList<>(); final List audit = new CopyOnWriteArrayList<>(); final List commands = new CopyOnWriteArrayList<>(); + final List itemInsertBatchSizes = new CopyOnWriteArrayList<>(); final JdbcSqlExecutor driver; final DefaultUserContext context; volatile boolean failReadback; @@ -43,6 +44,13 @@ static final class Fixture { var ds = new SQLiteDataSource(); 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()); + } + return super.batchUpdate(text, rows); + } @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. @@ -78,6 +86,10 @@ static final class Fixture { commands.add((EntityPersistenceMutation) mutation); return super.mutate(caller, mutation); } + @Override public List mutateBatch(UserContext caller, MutationBatchRequest request) { + request.items().forEach(item -> commands.add((EntityPersistenceMutation) item)); + return super.mutateBatch(caller, request); + } }; var ids = new AtomicLong(1000); var runtime = TeaQLRuntime.builder().metadata(metadata).dataService("sqlite", provider) @@ -89,7 +101,7 @@ static final class Fixture { clear(); } - void clear() { sql.clear(); audit.clear(); commands.clear(); } + void clear() { sql.clear(); audit.clear(); commands.clear(); itemInsertBatchSizes.clear(); } GraphEntity create(String type, long id, String name) { var entity = new GraphEntity(type); @@ -226,6 +238,95 @@ GraphEntity create(String type, long id, String name) { && 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()); + } + 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)) From aa357b29b29a42477f654a578407712706c61e8d Mon Sep 17 00:00:00 2001 From: Philip Z Date: Fri, 2 Oct 2026 21:15:31 +0800 Subject: [PATCH 05/35] fix(trace): retain per-item lineage through prepared updates deletes and recovery (#202) Signed-off-by: Philip Z --- examples/trace-chain/README.md | 15 +- .../assist/java-assist-delete/order_item.md | 28 +- .../GeneratedTraceChainExampleTest.java | 97 +++++- examples/trace-chain/verify.sh | 3 +- .../main/java/io/teaql/core/BaseEntity.java | 5 +- .../io/teaql/core/EntityMutationLedger.java | 19 +- .../teaql/core/EntityMutationLedgerTest.java | 32 ++ .../java/io/teaql/runtime/TeaQLRuntime.java | 119 +++++--- .../sql/portable/PortableSQLDataService.java | 41 ++- .../sql/portable/PortableSQLRepository.java | 123 +++++++- .../io/teaql/sqlite/GraphTraceSqliteTest.java | 283 +++++++++++++++++- 11 files changed, 690 insertions(+), 75 deletions(-) diff --git a/examples/trace-chain/README.md b/examples/trace-chain/README.md index 7933875a..3f484bff 100644 --- a/examples/trace-chain/README.md +++ b/examples/trace-chain/README.md @@ -2,7 +2,7 @@ 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, query and expression access use +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. @@ -14,7 +14,7 @@ Use Java 21 or newer, Maven, Bash and the normal repository dependencies: bash examples/trace-chain/verify.sh ``` -The script installs local source dependencies, runs all six scenarios twice +The script installs local source dependencies, runs all seven scenarios 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 @@ -32,6 +32,7 @@ and the `runtime-examples` Maven profile. | 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 | The first run begins with CustomerOrder and Payment both numbered 100, items 201/202, attempt 401 and shipment 501. IDs come from `IdSpaceIdGenerator`, not @@ -57,10 +58,14 @@ 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 batches and a generated +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. It does not prove prepared update/delete/recover -batches, concurrent saves with all checkers/providers, complete privacy and +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. These focused probes do not prove every +auxiliary-table layout, concurrent saves with all checkers/providers, complete privacy and entry-point 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-delete/order_item.md b/examples/trace-chain/evidence/assist/java-assist-delete/order_item.md index 19cba034..53dcc04c 100644 --- a/examples/trace-chain/evidence/assist/java-assist-delete/order_item.md +++ b/examples/trace-chain/evidence/assist/java-assist-delete/order_item.md @@ -29,6 +29,24 @@ public final class OrderItemDeleteService { .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; + } } ``` @@ -37,6 +55,12 @@ 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. + --- @@ -70,7 +94,7 @@ model-aware Assist. Do not inspect generated domain-library source. Capability: `delete`. -- Load the tenant-scoped current entity and use the generated hard-delete or - domain-specific soft-delete API; do not invent a deletion method. +- Load the policy-scoped current entity, mark it for deletion, then use audited + save with the same UserContext. Do not invent a physical-delete API. - Require an audit reason and optimistic version. Test missing audit and stale version as explicit failures. diff --git a/examples/trace-chain/src/test/java/io/teaql/examples/tracechain/GeneratedTraceChainExampleTest.java b/examples/trace-chain/src/test/java/io/teaql/examples/tracechain/GeneratedTraceChainExampleTest.java index 790d8800..d1b5fc71 100644 --- 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 @@ -30,6 +30,9 @@ static final class Fixture { 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; @@ -44,6 +47,13 @@ static final class Fixture { driver = new JdbcSqlExecutor(source) { @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) { @@ -85,7 +95,10 @@ static final class Fixture { clear(); } - void clear() { sql.clear(); audit.clear(); commands.clear(); itemInsertBatchSizes.clear(); } + 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) @@ -267,6 +280,88 @@ record Graph(CustomerOrder order, OrderItem kept, OrderItem removed, Payment pay 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(); diff --git a/examples/trace-chain/verify.sh b/examples/trace-chain/verify.sh index 1ac68458..cf189c80 100644 --- a/examples/trace-chain/verify.sh +++ b/examples/trace-chain/verify.sh @@ -25,6 +25,7 @@ markers=( '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 prepared update/delete/recover: unequal versions and per-item lineage' ) for repetition in 1 2; do log="$run_dir/run-$repetition.log" @@ -39,7 +40,7 @@ for repetition in 1 2; do exit 1 fi done - grep -Fq 'Tests run: 6, Failures: 0, Errors: 0, Skipped: 0' "$log" + grep -Fq 'Tests run: 7, Failures: 0, Errors: 0, Skipped: 0' "$log" printf 'PASS Java generated Trace Chain run %s on the same database\n' "$repetition" done library_manifest > "$run_dir/library-after.sha256" 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 61c8df4b..86dba380 100644 --- a/teaql-core/src/main/java/io/teaql/core/BaseEntity.java +++ b/teaql-core/src/main/java/io/teaql/core/BaseEntity.java @@ -329,6 +329,9 @@ public BaseEntity markForDeletion() { @Override public void markAsRecover() { gotoNextStatus(EntityAction.RECOVER); + if (entityMutationLedger != null && id != null) { + entityMutationLedger.markAsRecover(new EntityKey(typeName(), id)); + } } @Override @@ -580,7 +583,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/EntityMutationLedger.java b/teaql-core/src/main/java/io/teaql/core/EntityMutationLedger.java index ee5543d7..98aca937 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,6 +12,7 @@ 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 originalVersions = new TreeMap<>(); @@ -30,6 +31,7 @@ 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 -> @@ -80,6 +82,7 @@ public Set newKeys() { public void markAsDelete(EntityKey key) { changeSets.clearEntity(key); + recoveredKeys.remove(key); deletedKeys.add(key); } @@ -91,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) { @@ -137,6 +150,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()) { 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 23833c25..4d5c31a5 100644 --- a/teaql-core/src/test/java/io/teaql/core/EntityMutationLedgerTest.java +++ b/teaql-core/src/test/java/io/teaql/core/EntityMutationLedgerTest.java @@ -154,4 +154,36 @@ 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()); + } } 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 0131e120..96fc4af2 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java @@ -630,6 +630,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)); } @@ -717,6 +718,7 @@ private MutationPlan buildMutationPlan( Set keys = new TreeSet<>(); keys.addAll(changeSet.changes().keySet()); keys.addAll(deleted); + keys.addAll(ledger.recoveredKeys()); List operations = new ArrayList<>(); for (EntityKey key : keys) { @@ -730,7 +732,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; @@ -831,36 +833,46 @@ private List executeLedgerPlan( // 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, intent, mutationTrace(root, key, traceScopes, graphScope)); - MutationResult result = mutateWithTelemetry(context, mutationExecutor, mutationRequest, - key.entity(), "delete"); - completed.add(new PendingMutation( - descriptor, target == null ? deleteEntity : target, result, - MutationAuditKind.DELETED, Collections.emptyMap(), governance, intent, mutationRequest.getTraceChain())); + EntityPersistenceMutation mutationRequest = new EntityPersistenceMutation( + deleteEntity, EntityPersistenceMutation.Action.DELETE, intent, mutationTrace(root, key, traceScopes, graphScope)); + 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())); + } } // 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; @@ -913,7 +925,7 @@ private List executeLedgerPlan( } } - // 4. Execute Updates + // 4. Execute Updates and Recoveries for (Map.Entry> entry : updateBatches.entrySet()) { String entityName = entry.getKey(); List keys = entry.getValue(); @@ -921,33 +933,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)); + 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())); } - entity.set$status(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)); - 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, intent, mutationRequest.getTraceChain())); } } return completed; 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 600bb9d2..5c06ea71 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 @@ -427,9 +427,10 @@ public List mutateBatch(UserContext context, MutationBatchReques } if (items.isEmpty()) return List.of(); String type = items.get(0).getEntity().typeName(); - if (items.stream().anyMatch(item -> item.getAction() != EntityPersistenceMutation.Action.SAVE - || !item.getEntity().newItem() || !item.getEntity().typeName().equals(type))) { - throw new TeaQLRuntimeException("Portable SQL prepared batch currently requires same-type new entities"); + 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 executeInTransaction(context, () -> { PortableSQLRepository repository = getRepository(type); @@ -439,16 +440,31 @@ public List mutateBatch(UserContext context, MutationBatchReques List traces = new ArrayList<>(); for (EntityPersistenceMutation item : items) { var entity = (BaseEntity) item.getEntity(); - if (entity.getId() == null) entity.__internalSet("id", repository.prepareId(context, entity)); - entity.__internalSet("version", 1L); + 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); - traces.add(SqlExecutionTrace.mutation(entity, item.getTraceChain(), "insert")); + traces.add(SqlExecutionTrace.mutation(entity, item.getTraceChain(), operation)); + } + 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"); } - repository.createBatchInternal(context, entities, redactions, traces); 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())); @@ -459,6 +475,17 @@ public List mutateBatch(UserContext context, MutationBatchReques }); } + 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 @SuppressWarnings("unchecked") public T executeInTransaction(UserContext context, TransactionCallback action) { 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 33728a72..c1b6ab52 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 @@ -1298,7 +1298,128 @@ private void createRows(UserContext userContext, List createItems, io.teaql.c } } - public void updateInternal(UserContext userContext, Collection updateItems) { + 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) { updateInternal(userContext, updateItems, null); } diff --git a/teaql-sqlite/src/test/java/io/teaql/sqlite/GraphTraceSqliteTest.java b/teaql-sqlite/src/test/java/io/teaql/sqlite/GraphTraceSqliteTest.java index 099cfeac..55062e1f 100644 --- a/teaql-sqlite/src/test/java/io/teaql/sqlite/GraphTraceSqliteTest.java +++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/GraphTraceSqliteTest.java @@ -23,10 +23,10 @@ public static final class GraphEntity extends BaseEntity { 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("children") ? values.get(field) : super.__internalGet(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("children")) values.put(field, value); + if (field.equals("name") || field.equals("memo") || field.equals("children")) values.put(field, value); else super.__internalSet(field, value); } } @@ -36,9 +36,13 @@ static final class Fixture { 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 JdbcSqlExecutor driver; final DefaultUserContext context; volatile boolean failReadback; + volatile boolean unknownUpdateCounts; Fixture() throws Exception { var ds = new SQLiteDataSource(); @@ -49,7 +53,18 @@ static final class Fixture { itemInsertBatchSizes.add(rows.size()); assertTrue("audit may be emitted only after the transaction commits", audit.isEmpty()); } - return super.batchUpdate(text, rows); + 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")) { @@ -67,10 +82,11 @@ static final class Fixture { descriptor.setEntitySupplier(() -> new GraphEntity(type)); descriptor.setDataService("sqlite"); descriptor.setAuditMaskFields(List.of("name")); - for (String field : List.of("id", "version", "name")) { + for (String field : List.of("id", "version", "name", "memo")) { + boolean textField = field.equals("name") || field.equals("memo"); var property = (GenericSQLProperty) descriptor.addSimpleProperty(field, - field.equals("name") ? String.class : Long.class); - property.setColumnType(field.equals("name") ? "VARCHAR(255)" : "BIGINT"); + textField ? String.class : Long.class); + property.setColumnType(textField ? "VARCHAR(255)" : "BIGINT"); } var children = new Relation(); children.setName("children"); @@ -101,7 +117,10 @@ static final class Fixture { clear(); } - void clear() { sql.clear(); audit.clear(); commands.clear(); itemInsertBatchSizes.clear(); } + void clear() { + sql.clear(); audit.clear(); commands.clear(); itemInsertBatchSizes.clear(); + itemUpdateBatchSizes.clear(); itemDeleteBatchSizes.clear(); itemRecoverBatchSizes.clear(); + } GraphEntity create(String type, long id, String name) { var entity = new GraphEntity(type); @@ -327,6 +346,256 @@ GraphEntity create(String type, long id, String 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)) From 54a855e061f71955525d7d1fe4a2e957e592e5e5 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Fri, 2 Oct 2026 22:30:12 +0800 Subject: [PATCH 06/35] fix(trace): isolate Checker invocations and read-only relation ledgers (#202) Signed-off-by: Philip Z --- README.md | 20 +- examples/trace-chain/README.md | 24 ++- .../GeneratedTraceChainExampleTest.java | 185 +++++++++++++++++- examples/trace-chain/verify.sh | 4 +- .../io/teaql/core/EntityMutationLedger.java | 21 ++ .../main/java/io/teaql/core/UserContext.java | 21 +- .../java/io/teaql/core/checker/Checker.java | 17 +- .../checker/internal/CheckerInvocation.java | 113 +++++++++++ teaql-core/src/main/java/module-info.java | 2 + .../teaql/core/EntityMutationLedgerTest.java | 52 +++++ .../io/teaql/runtime/DefaultUserContext.java | 14 +- .../java/io/teaql/runtime/TeaQLRuntime.java | 36 ++-- .../teaql/runtime/CheckerInvocationTest.java | 151 ++++++++++++++ .../io/teaql/sqlite/GraphTraceSqliteTest.java | 144 ++++++++++++++ 14 files changed, 756 insertions(+), 48 deletions(-) create mode 100644 teaql-core/src/main/java/io/teaql/core/checker/internal/CheckerInvocation.java create mode 100644 teaql-runtime/src/test/java/io/teaql/runtime/CheckerInvocationTest.java diff --git a/README.md b/README.md index a14824a9..8300aa55 100644 --- a/README.md +++ b/README.md @@ -150,10 +150,22 @@ Providers without the capability retain individual command execution. The generated [Trace Chain example](examples/trace-chain/README.md) proves the normative graph, three-level Q/E queries, prepared insert grouping and complete -ledger replacement. Prepared update/delete/recover batches, complete -entry-point/privacy coverage, query scope migration and immutable internal -Registry replay remain separate open gates. This is local source evidence, not -a merge or release claim. +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. + +Complete entry-point/privacy coverage, query scope 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`, diff --git a/examples/trace-chain/README.md b/examples/trace-chain/README.md index 3f484bff..d70f3ae6 100644 --- a/examples/trace-chain/README.md +++ b/examples/trace-chain/README.md @@ -14,7 +14,7 @@ Use Java 21 or newer, Maven, Bash and the normal repository dependencies: bash examples/trace-chain/verify.sh ``` -The script installs local source dependencies, runs all seven scenarios twice +The script installs local source dependencies, runs all nine scenarios 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 @@ -33,6 +33,8 @@ and the `runtime-examples` Maven profile. | 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 | The first run begins with CustomerOrder and Payment both numbered 100, items 201/202, attempt 401 and shipment 501. IDs come from `IdSpaceIdGenerator`, not @@ -65,7 +67,19 @@ 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. These focused probes do not prove every -auxiliary-table layout, concurrent saves with all checkers/providers, complete privacy and -entry-point coverage, or immutable internal Registry replay. The development -dependency version is not a new public release. +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 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/src/test/java/io/teaql/examples/tracechain/GeneratedTraceChainExampleTest.java b/examples/trace-chain/src/test/java/io/teaql/examples/tracechain/GeneratedTraceChainExampleTest.java index d1b5fc71..2db1920c 100644 --- 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 @@ -19,6 +19,12 @@ import java.nio.file.Path; import java.util.List; 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.*; @@ -36,6 +42,9 @@ static final class Fixture { final DefaultUserContext context; final JdbcSqlExecutor driver; volatile boolean failReadback; + volatile boolean serializeTransactions; + volatile Consumer checkerBegin; + volatile Consumer checkerFinish; final long base; Fixture() throws Exception { @@ -45,6 +54,13 @@ static final class Fixture { 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()) { @@ -79,7 +95,16 @@ static final class Fixture { .idGenerationService(ids).logSink((caller, entry) -> sql.add(entry)).build() .install(GeneratedRuntimeModule.module()); // Real generated checkers, no bypass. EntityMetaFactory.registerGlobal(metadata); - context = new DefaultUserContext(runtime); + 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(); context.ensureSchema(); @@ -148,6 +173,164 @@ Graph saveNormativeGraph() { 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) diff --git a/examples/trace-chain/verify.sh b/examples/trace-chain/verify.sh index cf189c80..dccddf14 100644 --- a/examples/trace-chain/verify.sh +++ b/examples/trace-chain/verify.sh @@ -26,6 +26,8 @@ markers=( '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 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' ) for repetition in 1 2; do log="$run_dir/run-$repetition.log" @@ -40,7 +42,7 @@ for repetition in 1 2; do exit 1 fi done - grep -Fq 'Tests run: 7, Failures: 0, Errors: 0, Skipped: 0' "$log" + grep -Fq 'Tests run: 9, Failures: 0, Errors: 0, Skipped: 0' "$log" printf 'PASS Java generated Trace Chain run %s on the same database\n' "$repetition" done library_manifest > "$run_dir/library-after.sha256" 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 98aca937..335cdaca 100644 --- a/teaql-core/src/main/java/io/teaql/core/EntityMutationLedger.java +++ b/teaql-core/src/main/java/io/teaql/core/EntityMutationLedger.java @@ -130,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). 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/EntityMutationLedgerTest.java b/teaql-core/src/test/java/io/teaql/core/EntityMutationLedgerTest.java index 4d5c31a5..d95112d9 100644 --- a/teaql-core/src/test/java/io/teaql/core/EntityMutationLedgerTest.java +++ b/teaql-core/src/test/java/io/teaql/core/EntityMutationLedgerTest.java @@ -186,4 +186,56 @@ public void deleteAndRecoverKeysAreMutuallyExclusiveAndTypeQualified() { 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-runtime/src/main/java/io/teaql/runtime/DefaultUserContext.java b/teaql-runtime/src/main/java/io/teaql/runtime/DefaultUserContext.java index 0f8d62d7..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; } @@ -328,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/TeaQLRuntime.java b/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java index 96fc4af2..b4d382dd 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java @@ -557,26 +557,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(); } } } @@ -608,10 +598,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); 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-sqlite/src/test/java/io/teaql/sqlite/GraphTraceSqliteTest.java b/teaql-sqlite/src/test/java/io/teaql/sqlite/GraphTraceSqliteTest.java index 55062e1f..f85960ca 100644 --- a/teaql-sqlite/src/test/java/io/teaql/sqlite/GraphTraceSqliteTest.java +++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/GraphTraceSqliteTest.java @@ -10,7 +10,14 @@ 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.*; @@ -131,6 +138,143 @@ GraphEntity create(String type, long id, String name) { } } + 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 normativeGraphRetainsCommandSqlReadbackAndCommittedAuditLineage() throws Exception { var fixture = new Fixture(); var removed = fixture.create("OrderItem", 202, "deleted item"); From 5ca457c2f0ba6df6fe8862f24425e3bb4f25c4e5 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Fri, 2 Oct 2026 22:52:09 +0800 Subject: [PATCH 07/35] fix(trace): validate atomic mutation routes per plan instead of Context (#202) Signed-off-by: Philip Z --- README.md | 5 + .../java/io/teaql/runtime/TeaQLRuntime.java | 31 +-- .../runtime/MutationRouteIsolationTest.java | 130 +++++++++++++ .../teaql/sqlite/MutationRouteSqliteTest.java | 179 ++++++++++++++++++ 4 files changed, 332 insertions(+), 13 deletions(-) create mode 100644 teaql-runtime/src/test/java/io/teaql/runtime/MutationRouteIsolationTest.java create mode 100644 teaql-sqlite/src/test/java/io/teaql/sqlite/MutationRouteSqliteTest.java diff --git a/README.md b/README.md index 8300aa55..bd8b3087 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,11 @@ 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. Complete entry-point/privacy coverage, query scope migration, asynchronous handoff/cancellation and immutable internal Registry replay remain separate open 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 b4d382dd..3afe50c8 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java @@ -430,8 +430,6 @@ 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( @@ -473,17 +471,6 @@ public void saveGraph(UserContext context, Entity entity) { 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,6 +488,7 @@ public void saveGraph(UserContext context, Entity entity) { value.getVersion(), value.get$status(), value.isPropertyLoaded(BaseEntity.VERSION_PROPERTY)))); MutationPlan mutationPlan = buildMutationPlan(entity, entityMutationLedger, realEntities, intent); + requireSingleMutationRoute(mutationPlan, route); MutationGovernanceSnapshot governance = reviewMutationPlan(context, mutationPlan); List completed; try { @@ -529,6 +517,23 @@ public void saveGraph(UserContext context, Entity entity) { } } + /** 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; 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-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..18fe80c4 --- /dev/null +++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/MutationRouteSqliteTest.java @@ -0,0 +1,179 @@ +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) { + String reason = event.entityType().equals("CustomerOrder") ? "parallel order request" : "parallel payment request"; + assertEquals(List.of(reason), event.traceChain().stream().map(TraceNode::getComment).toList()); + // The SQL parameter value ("parallel order/payment") occurs in the + // request prose, so the safe SQL sink must redact that substring. + 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))); + } +} From 2d08920ffd1630871ea805bfe31e4c0266375429 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Fri, 2 Oct 2026 23:16:38 +0800 Subject: [PATCH 08/35] fix(trace): keep query and stream provenance off shared Context (#202) Signed-off-by: Philip Z --- README.md | 16 +- examples/trace-chain/README.md | 14 +- .../GeneratedTraceChainExampleTest.java | 93 +++++++++++ examples/trace-chain/verify.sh | 4 +- .../io/teaql/core/StreamingQueryExecutor.java | 5 +- .../sql/SqlDataServiceExecutor.java | 19 +-- .../java/io/teaql/runtime/TeaQLRuntime.java | 97 +++-------- .../runtime/QueryTraceIsolationTest.java | 155 ++++++++++++++++++ .../teaql/runtime/RequestIntentGateTest.java | 2 +- .../io/teaql/runtime/TeaQLRuntimeTest.java | 4 +- .../sql/portable/PortableSQLDataService.java | 14 +- 11 files changed, 323 insertions(+), 100 deletions(-) create mode 100644 teaql-runtime/src/test/java/io/teaql/runtime/QueryTraceIsolationTest.java diff --git a/README.md b/README.md index bd8b3087..e261d662 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,17 @@ Missing or Unicode-whitespace-only comment fails with 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 @@ -149,8 +160,9 @@ Root intent remains required even if children are annotated or logs are disabled Providers without the capability retain individual command execution. The generated [Trace Chain example](examples/trace-chain/README.md) proves the -normative graph, three-level Q/E queries, prepared insert grouping and complete -ledger replacement, plus prepared update/delete/recover batches with independent +normative graph, overlapping three-level Q/E queries, late-consumed streams, +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 diff --git a/examples/trace-chain/README.md b/examples/trace-chain/README.md index d70f3ae6..abbcb9e1 100644 --- a/examples/trace-chain/README.md +++ b/examples/trace-chain/README.md @@ -14,7 +14,7 @@ Use Java 21 or newer, Maven, Bash and the normal repository dependencies: bash examples/trace-chain/verify.sh ``` -The script installs local source dependencies, runs all nine scenarios twice +The script installs local source dependencies, runs all eleven scenarios 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 @@ -35,6 +35,8 @@ and the `runtime-examples` Maven profile. | 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 | The first run begins with CustomerOrder and Payment both numbered 100, items 201/202, attempt 401 and shipment 501. IDs come from `IdSpaceIdGenerator`, not @@ -75,6 +77,16 @@ 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 eleven scenario markers. 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. + 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 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 index 2db1920c..c2cbca69 100644 --- 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 @@ -45,6 +45,8 @@ static final class Fixture { volatile boolean serializeTransactions; volatile Consumer checkerBegin; volatile Consumer checkerFinish; + volatile java.util.function.BiConsumer queryBegin; + volatile Consumer streamOpen; final long base; Fixture() throws Exception { @@ -77,10 +79,18 @@ static final class Fixture { 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 QueryResult query(UserContext caller, QueryRequest request) { + if (queryBegin != null) queryBegin.accept(caller, request); + return super.query(caller, request); + } @Override public MutationResult mutate(UserContext caller, PersistenceMutation mutation) { commands.add((EntityPersistenceMutation) mutation); return super.mutate(caller, mutation); @@ -579,6 +589,89 @@ private static void assertCycleBoundaries(Fixture fixture, Graph graph, String o System.out.println("PASS Java generated three-level SQL Trace Path and inherited request intent"); } + 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()); + 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 generatedCheckerRejectsInvalidBusinessStateBeforeProvider() throws Exception { var fixture = new Fixture(); var platform = Q.platforms().withIdIs(1L).limit(1).comment("what: reuse root") diff --git a/examples/trace-chain/verify.sh b/examples/trace-chain/verify.sh index dccddf14..83a3807f 100644 --- a/examples/trace-chain/verify.sh +++ b/examples/trace-chain/verify.sh @@ -28,6 +28,8 @@ markers=( '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' ) for repetition in 1 2; do log="$run_dir/run-$repetition.log" @@ -42,7 +44,7 @@ for repetition in 1 2; do exit 1 fi done - grep -Fq 'Tests run: 9, Failures: 0, Errors: 0, Skipped: 0' "$log" + grep -Fq 'Tests run: 11, Failures: 0, Errors: 0, Skipped: 0' "$log" printf 'PASS Java generated Trace Chain run %s on the same database\n' "$repetition" done library_manifest > "$run_dir/library-after.sha256" 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..b4cc33a5 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,8 @@ 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); } 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 0f5ec940..de687c77 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 @@ -52,23 +52,8 @@ public QueryResult query(UserContext context, QueryRequest request) { } @Override - public java.util.stream.Stream queryForStream(UserContext context, io.teaql.core.SearchRequest request) { - io.teaql.core.QueryIntent.of(request.comment(), request.purpose()); - // 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 getPortableService(context).queryForStream(context, request); } @Override 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 3afe50c8..aa528f3c 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java @@ -171,28 +171,9 @@ public SmartList executeForList(UserContext context, Searc 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 (intent.comment() != null) { - context.pushTrace(TraceKind.COMMENT, request.getTypeName(), intent.comment()); - pushedComment = true; - } - if (intent.purpose() != null) { - context.pushTrace(TraceKind.PURPOSE, request.getTypeName(), intent.purpose()); - pushedPurpose = true; - } - try { - SmartList result = executeForListResolved(context, request, intent); - 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; @@ -241,11 +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) { - QueryIntent.of(request.comment(), request.purpose()); + QueryIntent intent = QueryIntent.of(request.comment(), request.purpose()); if (queryPolicy != null) { queryPolicy.enforceSelect(context, request); } - return executeForStreamResolved(context, request); + return executeForStreamResolved(context, request, intent); } /** @@ -254,16 +235,16 @@ public Stream executeForStream( */ public Stream internalExecuteForStream( UserContext context, SearchRequest request) { - requireInheritedQueryIntent(request); + 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()) { @@ -271,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) { @@ -304,14 +285,9 @@ public SmartList internalExecuteForList( if (queryPolicy != null) { queryPolicy.enforceSelect(context, request); } - context.pushTrace(TraceKind.RELATION, request.getTypeName(), request.getTypeName()); - try { - SmartList result = executeForListResolved(context, request, intent); - 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; @@ -384,40 +360,21 @@ public AggregationResult aggregation(UserContext context, Sea 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 (intent.comment() != null) { - context.pushTrace(TraceKind.COMMENT, request.getTypeName(), intent.comment()); - pushedComment = true; + EntityDescriptor descriptor = metadata.resolveEntityDescriptor(request.getTypeName()); + String route = descriptor.getDataService(); + if (route == null || route.isEmpty()) { + route = "default"; } - if (intent.purpose() != null) { - context.pushTrace(TraceKind.PURPOSE, request.getTypeName(), intent.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, intent); - 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(); - context.popTrace(); - 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) { 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 index 71c64ed8..5da296c2 100644 --- a/teaql-runtime/src/test/java/io/teaql/runtime/RequestIntentGateTest.java +++ b/teaql-runtime/src/test/java/io/teaql/runtime/RequestIntentGateTest.java @@ -14,7 +14,7 @@ private static final class CountingProvider implements QueryExecutor, StreamingQ calls.incrementAndGet(); return new DefaultQueryResult(new SmartList<>()); } @Override public java.util.stream.Stream queryForStream( - UserContext context, SearchRequest request) { + UserContext context, QueryRequest request) { calls.incrementAndGet(); return java.util.stream.Stream.empty(); } @Override public MutationResult mutate(UserContext context, PersistenceMutation request) { 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 aa9107c5..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(); } 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 5c06ea71..fcb25332 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 @@ -96,12 +96,18 @@ public QueryResult query(UserContext context, QueryRequest request) { } @Override - public java.util.stream.Stream queryForStream(UserContext context, SearchRequest request) { - QueryIntent.of(request.comment(), request.purpose()); - if (request.hasSimpleAgg() || !request.enhanceRelations().isEmpty() || !request.enhanceChildren().isEmpty()) { + @SuppressWarnings("unchecked") + public java.util.stream.Stream queryForStream(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); + SearchRequest scoped = SqlDiagnosticRequest.forExecution(searchRequest, source, request.intent()); + 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 this.getRepository(scoped.getTypeName()).streamInternal(context, scoped); } private void attachDynamicAggregations( From a2049903e93bdcc0147887564a333f237742e2dd Mon Sep 17 00:00:00 2001 From: Philip Z Date: Fri, 2 Oct 2026 23:51:45 +0800 Subject: [PATCH 09/35] fix(trace): preserve root provenance in derived SQL queries (#202) Signed-off-by: Philip Z --- README.md | 7 +- examples/trace-chain/README.md | 16 +- .../GeneratedTraceChainExampleTest.java | 54 +++++ examples/trace-chain/verify.sh | 4 +- .../sql/portable/PortableSQLDataService.java | 21 +- .../sql/portable/PortableSQLRepository.java | 6 +- .../sql/portable/SqlDiagnosticRequest.java | 29 ++- .../portable/SqlDiagnosticRequestTest.java | 60 ++++++ .../sqlite/DerivedQueryTraceSqliteTest.java | 204 ++++++++++++++++++ 9 files changed, 386 insertions(+), 15 deletions(-) create mode 100644 teaql-sql-portable/src/test/java/io/teaql/core/sql/portable/SqlDiagnosticRequestTest.java create mode 100644 teaql-sqlite/src/test/java/io/teaql/sqlite/DerivedQueryTraceSqliteTest.java diff --git a/README.md b/README.md index e261d662..f220624b 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,7 @@ Providers without the capability retain individual command execution. 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 @@ -179,7 +180,11 @@ 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. -Complete entry-point/privacy coverage, query scope migration, asynchronous +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. diff --git a/examples/trace-chain/README.md b/examples/trace-chain/README.md index abbcb9e1..c3de1c18 100644 --- a/examples/trace-chain/README.md +++ b/examples/trace-chain/README.md @@ -14,7 +14,7 @@ Use Java 21 or newer, Maven, Bash and the normal repository dependencies: bash examples/trace-chain/verify.sh ``` -The script installs local source dependencies, runs all eleven scenarios twice +The script installs local source dependencies, runs all thirteen scenarios 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 @@ -37,6 +37,8 @@ and the `runtime-examples` Maven profile. | 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 | The first run begins with CustomerOrder and Payment both numbered 100, items 201/202, attempt 401 and shipment 501. IDs come from `IdSpaceIdGenerator`, not @@ -77,7 +79,7 @@ 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 eleven scenario markers. Query provenance is carried +The verifier now requires thirteen scenario markers. 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 @@ -87,6 +89,16 @@ 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. +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 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 index c2cbca69..109cd349 100644 --- 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 @@ -672,6 +672,60 @@ private static PaymentAttempt loadPaymentContext(Fixture fixture, Graph graph, S System.out.println("PASS Java generated stream: request-owned SQL path and delayed consumption intent"); } + 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") diff --git a/examples/trace-chain/verify.sh b/examples/trace-chain/verify.sh index 83a3807f..0691beeb 100644 --- a/examples/trace-chain/verify.sh +++ b/examples/trace-chain/verify.sh @@ -30,6 +30,8 @@ markers=( '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" @@ -44,7 +46,7 @@ for repetition in 1 2; do exit 1 fi done - grep -Fq 'Tests run: 11, Failures: 0, Errors: 0, Skipped: 0' "$log" + grep -Fq 'Tests run: 13, Failures: 0, Errors: 0, Skipped: 0' "$log" printf 'PASS Java generated Trace Chain run %s on the same database\n' "$repetition" done library_manifest > "$run_dir/library-after.sha256" 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 fcb25332..ced15091 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 @@ -133,7 +133,7 @@ private void attachDynamicAggregations( } io.teaql.core.internal.TempRequest request = - new SqlDiagnosticRequest(aggregateRequest, intent, parentRequest.inheritedQueryIntent()); + dynamicAggregateRequest(aggregateRequest, partitionProperty, intent, parentRequest); request.groupBy(partitionProperty); request.appendSearchCriteria( request.createBasicSearchCriteria( @@ -169,6 +169,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()); 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 c1b6ab52..854ccb73 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 @@ -826,11 +826,9 @@ private SmartList loadWithIntent(UserContext userContext, SearchRequest re idToCount.put(io.teaql.core.utils.Convert.convert(Long.class, relId), countVal); } } - var rootIntent = request.inheritedQueryIntent() == null - ? io.teaql.core.QueryIntent.of(request.comment(), request.purpose()) - : request.inheritedQueryIntent(); io.teaql.core.internal.TempRequest fetchRelReq = - new SqlDiagnosticRequest(relationReq, facetIntent, rootIntent); + SqlDiagnosticRequest.forRelation( + relationReq, facetIntent, request, facetRequest.getRelationName()); if (facetRequest.isMergeCriteria()) { fetchRelReq.appendSearchCriteria(request.getSearchCriteria()); } 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 2f45264a..97271d94 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 @@ -19,10 +19,6 @@ final class SqlDiagnosticRequest extends TempRequest { ? QueryIntent.of(request.comment(), request.purpose()) : request.inheritedQueryIntent(), false); } - SqlDiagnosticRequest(SearchRequest request, SqlIntentRedactions source, QueryIntent rootIntent) { - this(request, source, rootIntent, false); - } - private SqlDiagnosticRequest(SearchRequest request, SqlIntentRedactions source, QueryIntent rootIntent, boolean executionScope) { this(request, source, rootIntent, executionScope, request.sqlTraceSource()); @@ -62,12 +58,33 @@ static SqlDiagnosticRequest forExecution(SearchRequest request, SqlIntentReda @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)); + } + static SqlDiagnosticRequest forRelation(SearchRequest child, SqlIntentRedactions source, SearchRequest parent, String relationName) { - var trace = new java.util.ArrayList<>(parent.sqlTraceSource()); + 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, parent.inheritedQueryIntent(), false, trace); + return new SqlDiagnosticRequest(child, source, intent, false, trace); + } + + 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(); } 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..cb68837b --- /dev/null +++ b/teaql-sql-portable/src/test/java/io/teaql/core/sql/portable/SqlDiagnosticRequestTest.java @@ -0,0 +1,60 @@ +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")); + } + } +} 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..f457960e --- /dev/null +++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/DerivedQueryTraceSqliteTest.java @@ -0,0 +1,204 @@ +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.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 SimpleEntityMetaFactory metadata = new SimpleEntityMetaFactory(); + final DefaultUserContext context; + final TraceDocument document; + final TraceLine open; + Fixture(boolean logging) 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 = new JdbcSqlExecutor(source); + var provider = new SqliteDataServiceExecutor("sqlite",driver,source); + 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; + } + } + + 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 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()); + } +} From a2b72ca61b761e5e5b350b20092a172c869c1dbe Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sat, 3 Oct 2026 11:52:02 +0800 Subject: [PATCH 10/35] fix(trace): retain Java mutation statements with logging disabled (#202) Signed-off-by: Philip Z --- examples/trace-chain/README.md | 11 ++ .../GeneratedTraceChainExampleTest.java | 26 +++- .../io/teaql/core/DefaultMutationResult.java | 10 ++ .../java/io/teaql/core/MutationResult.java | 6 + .../java/io/teaql/core/SqlExecutionTrace.java | 23 +++- .../sql/SqlDataServiceExecutor.java | 39 +++--- .../runtime/EntityPersistenceMutation.java | 11 ++ .../java/io/teaql/runtime/TeaQLRuntime.java | 6 +- .../sql/portable/PortableSQLDataService.java | 28 +++-- .../sql/portable/PortableSQLRepository.java | 13 ++ .../core/sql/portable/SqlLogBindings.java | 9 ++ .../io/teaql/sqlite/GraphTraceSqliteTest.java | 112 +++++++++++++++++- 12 files changed, 261 insertions(+), 33 deletions(-) diff --git a/examples/trace-chain/README.md b/examples/trace-chain/README.md index c3de1c18..c95b3a7d 100644 --- a/examples/trace-chain/README.md +++ b/examples/trace-chain/README.md @@ -6,6 +6,17 @@ Business creation, graph attachment, deletion, recovery, query and expression ac 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. +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. + ## Run the example Use Java 21 or newer, Maven, Bash and the normal repository dependencies: 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 index 109cd349..bc4b8ad3 100644 --- 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 @@ -31,6 +31,23 @@ /** #202: generated public APIs -> real SQLite -> physical SQL and committed safe audit. */ public class GeneratedTraceChainExampleTest { + 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(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 audit = new CopyOnWriteArrayList<>(); @@ -93,11 +110,16 @@ static final class Fixture { } @Override public MutationResult mutate(UserContext caller, PersistenceMutation mutation) { commands.add((EntityPersistenceMutation) mutation); - return super.mutate(caller, 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)); - return super.mutateBatch(caller, request); + 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) 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/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/SqlExecutionTrace.java b/teaql-core/src/main/java/io/teaql/core/SqlExecutionTrace.java index 3ee2d93a..61bada81 100644 --- a/teaql-core/src/main/java/io/teaql/core/SqlExecutionTrace.java +++ b/teaql-core/src/main/java/io/teaql/core/SqlExecutionTrace.java @@ -4,7 +4,26 @@ import java.util.List; /** Immutable statement-owned source path and separate graph mutation lineage. */ -public record SqlExecutionTrace(List source, List mutationLineage, String operation) { +public record SqlExecutionTrace(List source, List mutationLineage, String operation, + @com.fasterxml.jackson.annotation.JsonIgnore java.util.function.Consumer statementObserver) { + public SqlExecutionTrace(List source, List mutationLineage, String operation) { + this(source, mutationLineage, operation, 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); + } + + public void recordStatement(ExecutionMetadata metadata) { + if (statementObserver == null) return; + var path = SqlTracePath.canonical(metadata.getTraceChain(), metadata.getBackend(), operation); + metadata.setTraceChain(path.path()); + metadata.setComment(path.comment()); + metadata.setPurpose(path.purpose()); + metadata.setAuditReason(path.auditReason()); + statementObserver.accept(metadata); + } public SqlExecutionTrace { source = List.copyOf(source); mutationLineage = List.copyOf(mutationLineage); @@ -32,7 +51,7 @@ public SqlExecutionTrace readback(MutationIntent intent) { 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"); + return new SqlExecutionTrace(frames, mutationLineage, "select", statementObserver); } public void applyTo(ExecutionMetadata metadata) { 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 de687c77..d0a5d2df 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 @@ -197,11 +197,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)); @@ -213,7 +214,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; } @@ -258,11 +259,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)); @@ -274,7 +276,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; } @@ -288,16 +290,17 @@ public int[] batchUpdate(io.teaql.core.UserContext context, String sql, java.uti 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; } @@ -351,7 +354,8 @@ 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++) { @@ -368,7 +372,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); @@ -396,7 +400,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. } @@ -405,6 +409,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-runtime/src/main/java/io/teaql/runtime/EntityPersistenceMutation.java b/teaql-runtime/src/main/java/io/teaql/runtime/EntityPersistenceMutation.java index 44cda77a..78b14d70 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/EntityPersistenceMutation.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/EntityPersistenceMutation.java @@ -15,6 +15,7 @@ public enum Action { SAVE, DELETE } private final Action action; private final MutationIntent intent; private final List traceChain; + private final transient Entity diagnosticSource; public EntityPersistenceMutation(Entity entity, Action action) { this(entity, action, MutationIntent.of(entity.getComment())); @@ -26,12 +27,22 @@ public EntityPersistenceMutation(Entity entity, Action action, MutationIntent in } 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 = 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; } + /** 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; } 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 aa528f3c..5866f14b 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java @@ -808,7 +808,7 @@ private List executeLedgerPlan( if (root.getComment() != null) deleteEntity.setComment(root.getComment()); EntityPersistenceMutation mutationRequest = new EntityPersistenceMutation( - deleteEntity, EntityPersistenceMutation.Action.DELETE, intent, mutationTrace(root, key, traceScopes, graphScope)); + deleteEntity, EntityPersistenceMutation.Action.DELETE, intent, mutationTrace(root, key, traceScopes, graphScope), target); requests.add(mutationRequest); targets.add(target == null ? deleteEntity : target); } @@ -865,7 +865,7 @@ private List executeLedgerPlan( if (root.getComment() != null) entity.setComment(root.getComment()); EntityPersistenceMutation mutationRequest = new EntityPersistenceMutation( - entity, EntityPersistenceMutation.Action.SAVE, intent, mutationTrace(root, key, traceScopes, graphScope)); + entity, EntityPersistenceMutation.Action.SAVE, intent, mutationTrace(root, key, traceScopes, graphScope), target); requests.add(mutationRequest); targets.add(target == null ? entity : target); snapshots.add(snapshotChanges(changes)); @@ -911,7 +911,7 @@ private List executeLedgerPlan( if (root.getComment() != null) entity.setComment(root.getComment()); EntityPersistenceMutation mutationRequest = new EntityPersistenceMutation( - entity, EntityPersistenceMutation.Action.SAVE, intent, mutationTrace(root, key, traceScopes, graphScope)); + entity, EntityPersistenceMutation.Action.SAVE, intent, mutationTrace(root, key, traceScopes, graphScope), target); requests.add(mutationRequest); targets.add(target == null ? entity : target); snapshots.add(snapshotChanges(changes)); 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 ced15091..f1e68a0e 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 @@ -397,12 +397,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 = new io.teaql.core.SqlIntentRedactions(); + 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); + var trace = io.teaql.core.SqlExecutionTrace.mutation(entity, mutation.getTraceChain(), operation) + .collecting(statements::add); if (mutation.getAction() == EntityPersistenceMutation.Action.SAVE) { if (entity.getId() == null) { @@ -436,7 +440,7 @@ public MutationResult mutate(UserContext context, PersistenceMutation request) { || mutation.getAction() == EntityPersistenceMutation.Action.DELETE)) { persisted = repository.loadPersistedById(context, entity.getId(), readbackIntent, trace.readback(mutation.intent())); } - return new io.teaql.core.DefaultMutationResult(persisted); + return new io.teaql.core.DefaultMutationResult(persisted, statements); } @Override @@ -459,10 +463,15 @@ public List mutateBatch(UserContext context, MutationBatchReques } return executeInTransaction(context, () -> { PortableSQLRepository repository = getRepository(type); - var redactions = context.isQueryExecutionLoggingEnabled() || context.isMutationExecutionLoggingEnabled() - ? new SqlIntentRedactions() : null; + var redactions = new SqlIntentRedactions(); + for (var item : items) { + 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")) { @@ -472,7 +481,10 @@ public List mutateBatch(UserContext context, MutationBatchReques throw new TeaQLRuntimeException("Prepared persisted mutation requires identity and optimistic version"); } entities.add(entity); - traces.add(SqlExecutionTrace.mutation(entity, item.getTraceChain(), operation)); + var memberStatements = new ArrayList(); + statements.add(memberStatements); + traces.add(SqlExecutionTrace.mutation(entity, item.getTraceChain(), operation) + .collecting(memberStatements::add)); } switch (operation) { case "insert" -> repository.createBatchInternal(context, entities, redactions, traces); @@ -494,7 +506,7 @@ public List mutateBatch(UserContext context, MutationBatchReques 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)); + results.add(new DefaultMutationResult(persisted, statements.get(index))); } return List.copyOf(results); }); 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 854ccb73..251961e9 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 @@ -1040,6 +1040,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); 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 0eabbac3..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 @@ -42,6 +42,15 @@ public SqlLogBindings forBatchRow(int index) { : 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); diff --git a/teaql-sqlite/src/test/java/io/teaql/sqlite/GraphTraceSqliteTest.java b/teaql-sqlite/src/test/java/io/teaql/sqlite/GraphTraceSqliteTest.java index f85960ca..a659df26 100644 --- a/teaql-sqlite/src/test/java/io/teaql/sqlite/GraphTraceSqliteTest.java +++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/GraphTraceSqliteTest.java @@ -42,6 +42,9 @@ 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<>(); @@ -107,25 +110,32 @@ static final class Fixture { var provider = new SqliteDataServiceExecutor("sqlite", driver, ds) { @Override public MutationResult mutate(UserContext caller, PersistenceMutation mutation) { commands.add((EntityPersistenceMutation) mutation); - return super.mutate(caller, 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)); - return super.mutateBatch(caller, request); + 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); + 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(); itemInsertBatchSizes.clear(); + sql.clear(); audit.clear(); commands.clear(); results.clear(); itemInsertBatchSizes.clear(); itemUpdateBatchSizes.clear(); itemDeleteBatchSizes.clear(); itemRecoverBatchSizes.clear(); } @@ -275,6 +285,100 @@ private static CheckedSave checkedSave(Fixture fixture, GraphEntity entity, Stri 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"); From 95a4922b9dd137e2440a83bf3f01ac2e712dfe21 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sat, 3 Oct 2026 13:33:03 +0800 Subject: [PATCH 11/35] fix: retain graph-local cross-type mutation privacy (#202) Signed-off-by: Philip Z --- examples/trace-chain/README.md | 11 +++- .../tracechainservice/EntityMetaRegistry.java | 5 +- examples/trace-chain/model.xml | 2 +- .../GeneratedTraceChainExampleTest.java | 61 +++++++++++++++++++ examples/trace-chain/verify.sh | 3 +- .../java/io/teaql/core/SqlFieldLogPolicy.java | 36 +++++++++++ .../io/teaql/core/SqlIntentRedactions.java | 18 ++++++ .../teaql/core/SqlIntentRedactionsTest.java | 34 +++++++++++ .../runtime/EntityPersistenceMutation.java | 10 +++ .../java/io/teaql/runtime/LogPrivacy.java | 5 ++ .../java/io/teaql/runtime/TeaQLRuntime.java | 35 +++++++---- .../java/io/teaql/runtime/LogPrivacyTest.java | 30 +++++++++ .../sql/portable/PortableSQLDataService.java | 3 +- .../sql/portable/PortableSQLRepository.java | 13 +--- .../teaql/sqlite/MutationRouteSqliteTest.java | 5 +- 15 files changed, 238 insertions(+), 33 deletions(-) create mode 100644 teaql-core/src/main/java/io/teaql/core/SqlFieldLogPolicy.java diff --git a/examples/trace-chain/README.md b/examples/trace-chain/README.md index c95b3a7d..8d3b7eb8 100644 --- a/examples/trace-chain/README.md +++ b/examples/trace-chain/README.md @@ -90,7 +90,16 @@ 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 thirteen scenario markers. Query provenance is carried +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 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 index 002acf28..bdb577ae 100644 --- 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 @@ -193,9 +193,10 @@ private void registerOrderItem() { entityDescriptor.setEntitySupplier(com.teaql.tracechainservice.orderitem.OrderItem::new); entityDescriptor.with("name", "Order Item") .with("module", "Trace Chain") - .with("module_key", "trace-chain"); + .with("module_key", "trace-chain") + .with("audit_mask_fields", "name"); - entityDescriptor.setAuditMaskFields(java.util.List.of()); + 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) ; diff --git a/examples/trace-chain/model.xml b/examples/trace-chain/model.xml index 4bb33081..af681a9c 100644 --- a/examples/trace-chain/model.xml +++ b/examples/trace-chain/model.xml @@ -7,7 +7,7 @@ platform="platform()" order_number="TRACE-ORDER-001" description="Draft order"/> + customer_order="customer_order()" name="Available item" _audit_mask_fields="name"/> 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(); + root.auditAs("page 1 replace " + oldValue + " with " + next).save(fixture.context); + assertEquals(2, fixture.commands.size()); + assertEquals(4, fixture.sql.size()); + assertEquals(2, fixture.audit.size()); + 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); + 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"); + } + 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()); diff --git a/examples/trace-chain/verify.sh b/examples/trace-chain/verify.sh index 0691beeb..ac5cfb6d 100644 --- a/examples/trace-chain/verify.sh +++ b/examples/trace-chain/verify.sh @@ -19,6 +19,7 @@ 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=( + '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 generated three-level SQL Trace Path and inherited request intent' 'PASS Java generated Checker rejection before provider access' @@ -46,7 +47,7 @@ for repetition in 1 2; do exit 1 fi done - grep -Fq 'Tests run: 13, Failures: 0, Errors: 0, Skipped: 0' "$log" + grep -Fq 'Tests run: 14, Failures: 0, Errors: 0, Skipped: 0' "$log" printf 'PASS Java generated Trace Chain run %s on the same database\n' "$repetition" done library_manifest > "$run_dir/library-after.sha256" 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..d427cac5 100644 --- a/teaql-core/src/main/java/io/teaql/core/SqlIntentRedactions.java +++ b/teaql-core/src/main/java/io/teaql/core/SqlIntentRedactions.java @@ -17,6 +17,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/test/java/io/teaql/core/SqlIntentRedactionsTest.java b/teaql-core/src/test/java/io/teaql/core/SqlIntentRedactionsTest.java index b244c491..a42f41ca 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,40 @@ import static org.junit.Assert.*; public class SqlIntentRedactionsTest { + @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-runtime/src/main/java/io/teaql/runtime/EntityPersistenceMutation.java b/teaql-runtime/src/main/java/io/teaql/runtime/EntityPersistenceMutation.java index 78b14d70..46d685e5 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/EntityPersistenceMutation.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/EntityPersistenceMutation.java @@ -16,6 +16,7 @@ public enum Action { SAVE, DELETE } 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, action, MutationIntent.of(entity.getComment())); @@ -32,13 +33,22 @@ public EntityPersistenceMutation(Entity entity, Action action, MutationIntent in 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; } 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 f754f275..62c36cef 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/LogPrivacy.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/LogPrivacy.java @@ -195,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/TeaQLRuntime.java b/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java index 5866f14b..e855c33b 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java @@ -783,6 +783,14 @@ private List executeLedgerPlan( 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); @@ -808,7 +816,7 @@ private List executeLedgerPlan( if (root.getComment() != null) deleteEntity.setComment(root.getComment()); EntityPersistenceMutation mutationRequest = new EntityPersistenceMutation( - deleteEntity, EntityPersistenceMutation.Action.DELETE, intent, mutationTrace(root, key, traceScopes, graphScope), target); + deleteEntity, EntityPersistenceMutation.Action.DELETE, intent, mutationTrace(root, key, traceScopes, graphScope), target, graphRedactions); requests.add(mutationRequest); targets.add(target == null ? deleteEntity : target); } @@ -816,7 +824,7 @@ private List executeLedgerPlan( 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())); + MutationAuditKind.DELETED, Collections.emptyMap(), governance, intent, requests.get(index).getTraceChain(), graphRedactions)); } } @@ -865,7 +873,7 @@ private List executeLedgerPlan( if (root.getComment() != null) entity.setComment(root.getComment()); EntityPersistenceMutation mutationRequest = new EntityPersistenceMutation( - entity, EntityPersistenceMutation.Action.SAVE, intent, mutationTrace(root, key, traceScopes, graphScope), target); + 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)); @@ -875,7 +883,7 @@ private List executeLedgerPlan( for (int index = 0; index < requests.size(); index++) { completed.add(new PendingMutation( descriptor, targets.get(index), results.get(index), - MutationAuditKind.CREATED, snapshots.get(index), governance, intent, requests.get(index).getTraceChain())); + MutationAuditKind.CREATED, snapshots.get(index), governance, intent, requests.get(index).getTraceChain(), graphRedactions)); } } @@ -911,7 +919,7 @@ private List executeLedgerPlan( if (root.getComment() != null) entity.setComment(root.getComment()); EntityPersistenceMutation mutationRequest = new EntityPersistenceMutation( - entity, EntityPersistenceMutation.Action.SAVE, intent, mutationTrace(root, key, traceScopes, graphScope), target); + 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)); @@ -921,7 +929,7 @@ private List executeLedgerPlan( 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())); + auditKind, snapshots.get(index), governance, intent, requests.get(index).getTraceChain(), graphRedactions)); } } } @@ -933,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.intent(), mutation.traceChain()); + mutation.governance(), mutation.intent(), mutation.traceChain(), mutation.redactions()); mutation.target().clearUpdatedProperties(); } } @@ -957,7 +965,8 @@ private record PendingMutation( MutationResult result, MutationAuditKind auditKind, Map changedValues, - MutationGovernanceSnapshot governance, MutationIntent intent, List traceChain) {} + MutationGovernanceSnapshot governance, MutationIntent intent, List traceChain, + SqlIntentRedactions redactions) {} private record PersistenceState( Long version, io.teaql.core.EntityStatus status, boolean versionLoaded) {} @@ -1067,7 +1076,8 @@ private void emitAuditEvent( Entity entity, MutationAuditKind kind, Map changedValues, - MutationGovernanceSnapshot governance, MutationIntent intent, List traceChain) { + MutationGovernanceSnapshot governance, MutationIntent intent, List traceChain, + SqlIntentRedactions redactions) { List changes = new ArrayList<>(); if (changedValues != null) { for (Map.Entry entry : changedValues.entrySet()) { @@ -1099,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) { @@ -1113,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() @@ -1122,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/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-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 f1e68a0e..3db598a9 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 @@ -397,7 +397,7 @@ 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 = new io.teaql.core.SqlIntentRedactions(); + var readbackIntent = mutation.diagnosticRedactions(); repository.captureMutationIntent(entity, readbackIntent); if (mutation.diagnosticSource() != entity) repository.captureMutationIntent(mutation.diagnosticSource(), readbackIntent); @@ -465,6 +465,7 @@ public List mutateBatch(UserContext context, MutationBatchReques 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); 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 251961e9..854f9153 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 @@ -2369,18 +2369,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; } diff --git a/teaql-sqlite/src/test/java/io/teaql/sqlite/MutationRouteSqliteTest.java b/teaql-sqlite/src/test/java/io/teaql/sqlite/MutationRouteSqliteTest.java index 18fe80c4..cd6b071c 100644 --- a/teaql-sqlite/src/test/java/io/teaql/sqlite/MutationRouteSqliteTest.java +++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/MutationRouteSqliteTest.java @@ -147,10 +147,9 @@ long count(JdbcSqlExecutor driver, String table) { assertEquals(1, fixture.count(fixture.payments, "payment_data")); assertEquals(2, fixture.audit.size()); for (var event : fixture.audit) { - String reason = event.entityType().equals("CustomerOrder") ? "parallel order request" : "parallel payment request"; - assertEquals(List.of(reason), event.traceChain().stream().map(TraceNode::getComment).toList()); + 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 the safe SQL sink must redact that substring. + // 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(); From f2da061ef02b602424db1ad69d24749498b6fb06 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sat, 3 Oct 2026 15:05:46 +0800 Subject: [PATCH 12/35] fix: retain materialized query evidence independently of logging (#202) Signed-off-by: Philip Z --- examples/trace-chain/README.md | 10 +++- .../GeneratedTraceChainExampleTest.java | 39 +++++++++++++++- examples/trace-chain/verify.sh | 3 +- .../main/java/io/teaql/core/QueryResult.java | 5 ++ .../sql/SqlDataServiceExecutor.java | 7 +-- .../io/teaql/runtime/DefaultQueryResult.java | 9 ++++ .../sql/portable/PortableSQLDataService.java | 8 ++-- .../sql/portable/PortableSQLRepository.java | 2 +- .../sql/portable/SqlDiagnosticRequest.java | 32 +++++++++++-- .../sqlite/DerivedQueryTraceSqliteTest.java | 46 ++++++++++++++++++- 10 files changed, 147 insertions(+), 14 deletions(-) diff --git a/examples/trace-chain/README.md b/examples/trace-chain/README.md index 8d3b7eb8..41b8ceef 100644 --- a/examples/trace-chain/README.md +++ b/examples/trace-chain/README.md @@ -17,6 +17,14 @@ 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. +This does not yet qualify returned stream lifecycle evidence or failed-query +results: those require their own cursor/error contracts. + ## Run the example Use Java 21 or newer, Maven, Bash and the normal repository dependencies: @@ -25,7 +33,7 @@ Use Java 21 or newer, Maven, Bash and the normal repository dependencies: bash examples/trace-chain/verify.sh ``` -The script installs local source dependencies, runs all thirteen scenarios twice +The script installs local source dependencies, runs all fifteen scenarios 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 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 index bba915f7..aab195f8 100644 --- 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 @@ -111,6 +111,7 @@ private static void verifyReturnedStatements(MutationResult result, EntityPersis static final class Fixture { final List sql = new CopyOnWriteArrayList<>(); + final List queryResults = new CopyOnWriteArrayList<>(); final List audit = new CopyOnWriteArrayList<>(); final List commands = new CopyOnWriteArrayList<>(); final List itemInsertBatchSizes = new CopyOnWriteArrayList<>(); @@ -128,6 +129,10 @@ static final class Fixture { 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(); @@ -167,7 +172,9 @@ static final class Fixture { var provider = new SqliteDataServiceExecutor("sqlite", driver, source) { @Override public QueryResult query(UserContext caller, QueryRequest request) { if (queryBegin != null) queryBegin.accept(caller, request); - return super.query(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); @@ -185,6 +192,7 @@ static final class Fixture { }; 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); @@ -672,6 +680,30 @@ private static void assertCycleBoundaries(Fixture fixture, Graph graph, String o System.out.println("PASS Java generated three-level SQL Trace Path and inherited request intent"); } + @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"); + } + 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) @@ -712,6 +744,11 @@ private static PaymentAttempt loadPaymentContext(Fixture fixture, Graph graph, S 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); diff --git a/examples/trace-chain/verify.sh b/examples/trace-chain/verify.sh index ac5cfb6d..6c68a1c6 100644 --- a/examples/trace-chain/verify.sh +++ b/examples/trace-chain/verify.sh @@ -19,6 +19,7 @@ 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=( + '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 generated three-level SQL Trace Path and inherited request intent' @@ -47,7 +48,7 @@ for repetition in 1 2; do exit 1 fi done - grep -Fq 'Tests run: 14, Failures: 0, Errors: 0, Skipped: 0' "$log" + grep -Fq 'Tests run: 15, Failures: 0, Errors: 0, Skipped: 0' "$log" printf 'PASS Java generated Trace Chain run %s on the same database\n' "$repetition" done library_manifest > "$run_dir/library-after.sha256" 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-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 d0a5d2df..d93216e8 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 @@ -230,11 +230,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)); @@ -246,7 +247,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; } 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-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 3db598a9..47a7021e 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 @@ -77,13 +77,15 @@ public QueryResult query(UserContext context, QueryRequest request) { } SearchRequest searchRequest = ((DefaultQueryRequest) request).getSearchRequest(); SqlIntentRedactions intent = SqlDiagnosticRequest.source(context, searchRequest); - searchRequest = SqlDiagnosticRequest.forExecution(searchRequest, intent, request.intent()); + 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); 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); @@ -92,7 +94,7 @@ 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 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 854f9153..d7c08f28 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 @@ -275,7 +275,7 @@ private PositionalSQL withQueryIntent(PositionalSQL sql, io.teaql.core.SqlIntent 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(), - io.teaql.core.SqlExecutionTrace.query(request))); + SqlDiagnosticRequest.statementTrace(request))); } private SqlLogBindings withMutationIntent(SqlLogBindings bindings, io.teaql.core.SqlIntentRedactions intent) { 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 97271d94..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 @@ -13,6 +13,7 @@ final class SqlDiagnosticRequest extends TempRequest { 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, request.inheritedQueryIntent() == null @@ -27,7 +28,15 @@ private SqlDiagnosticRequest(SearchRequest request, SqlIntentRedactions sourc 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.rootIntent = java.util.Objects.requireNonNull(rootIntent, "rootIntent"); @@ -55,6 +64,23 @@ static SqlDiagnosticRequest forExecution(SearchRequest request, SqlIntentReda 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; } @@ -62,7 +88,7 @@ static SqlDiagnosticRequest forExecution(SearchRequest request, SqlIntentReda static SqlDiagnosticRequest forDerived(SearchRequest child, SqlIntentRedactions source, SearchRequest parent) { QueryIntent intent = parentIntent(parent); - return new SqlDiagnosticRequest(child, source, intent, false, parentTrace(parent, intent)); + return new SqlDiagnosticRequest(child, source, intent, false, parentTrace(parent, intent), observer(parent)); } static SqlDiagnosticRequest forRelation(SearchRequest child, SqlIntentRedactions source, @@ -71,7 +97,7 @@ static SqlDiagnosticRequest forRelation(SearchRequest child, SqlIntentRedacti 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); + return new SqlDiagnosticRequest(child, source, intent, false, trace, observer(parent)); } private static QueryIntent parentIntent(SearchRequest parent) { @@ -91,7 +117,7 @@ private static java.util.List parentTrace(SearchRequest @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-sqlite/src/test/java/io/teaql/sqlite/DerivedQueryTraceSqliteTest.java b/teaql-sqlite/src/test/java/io/teaql/sqlite/DerivedQueryTraceSqliteTest.java index f457960e..75d1afd8 100644 --- a/teaql-sqlite/src/test/java/io/teaql/sqlite/DerivedQueryTraceSqliteTest.java +++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/DerivedQueryTraceSqliteTest.java @@ -55,6 +55,7 @@ private static final class Fixture { final List sql = new CopyOnWriteArrayList<>(); final SimpleEntityMetaFactory metadata = new SimpleEntityMetaFactory(); final DefaultUserContext context; + final SqliteDataServiceExecutor provider; final TraceDocument document; final TraceLine open; Fixture(boolean logging) throws Exception { @@ -69,7 +70,7 @@ private static final class Fixture { var relation = (GenericSQLRelation) lines.addObjectProperty(metadata,"document", "TraceDocument", "lines",TraceDocument.class); relation.setColumnType("BIGINT"); var driver = new JdbcSqlExecutor(source); - var provider = new SqliteDataServiceExecutor("sqlite",driver,source); + provider = new SqliteDataServiceExecutor("sqlite",driver,source); var runtime = TeaQLRuntime.builder().metadata(metadata).dataService("sqlite",provider) .queryExecutionLogging(logging).logSink((caller,entry)->sql.add(entry)).build(); context = new DefaultUserContext(runtime); context.ensureSchema(); @@ -201,4 +202,47 @@ private static void assertPath(ExecutionMetadata entry,String root,List } 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()); + } } From 181d33d24c278402c65f58a15f3cfde998f3a0e3 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sat, 3 Oct 2026 15:20:24 +0800 Subject: [PATCH 13/35] fix: retain query cursor terminal evidence with logging disabled (#202) Signed-off-by: Philip Z --- examples/trace-chain/README.md | 15 ++++-- .../GeneratedTraceChainExampleTest.java | 35 ++++++++++++++ examples/trace-chain/verify.sh | 3 +- .../main/java/io/teaql/core/QueryCursor.java | 31 ++++++++++++ .../io/teaql/core/StreamingQueryExecutor.java | 5 ++ .../sql/SqlDataServiceExecutor.java | 14 ++++-- .../dataservice/sql/SqlDiagnosticStream.java | 15 ++++-- .../sql/SqlStreamBatchMaskingTest.java | 47 ++++++++++++++++++- .../sql/portable/PortableSQLDataService.java | 13 ++++- .../sqlite/DerivedQueryTraceSqliteTest.java | 38 +++++++++++++++ 10 files changed, 201 insertions(+), 15 deletions(-) create mode 100644 teaql-core/src/main/java/io/teaql/core/QueryCursor.java diff --git a/examples/trace-chain/README.md b/examples/trace-chain/README.md index 41b8ceef..e9bb3ca9 100644 --- a/examples/trace-chain/README.md +++ b/examples/trace-chain/README.md @@ -22,8 +22,17 @@ 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. -This does not yet qualify returned stream lifecycle evidence or failed-query -results: those require their own cursor/error contracts. +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 @@ -33,7 +42,7 @@ Use Java 21 or newer, Maven, Bash and the normal repository dependencies: bash examples/trace-chain/verify.sh ``` -The script installs local source dependencies, runs all fifteen scenarios twice +The script installs local source dependencies, runs all sixteen scenarios 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 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 index aab195f8..a49ba0e9 100644 --- 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 @@ -112,6 +112,7 @@ private static void verifyReturnedStatements(MutationResult result, EntityPersis 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<>(); @@ -170,6 +171,11 @@ static final class Fixture { 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); @@ -792,6 +798,35 @@ private static PaymentAttempt loadPaymentContext(Fixture fixture, Graph graph, S 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()); diff --git a/examples/trace-chain/verify.sh b/examples/trace-chain/verify.sh index 6c68a1c6..24302d28 100644 --- a/examples/trace-chain/verify.sh +++ b/examples/trace-chain/verify.sh @@ -19,6 +19,7 @@ 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=( + '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' @@ -48,7 +49,7 @@ for repetition in 1 2; do exit 1 fi done - grep -Fq 'Tests run: 15, Failures: 0, Errors: 0, Skipped: 0' "$log" + grep -Fq 'Tests run: 16, Failures: 0, Errors: 0, Skipped: 0' "$log" printf 'PASS Java generated Trace Chain run %s on the same database\n' "$repetition" done library_manifest > "$run_dir/library-after.sha256" 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/StreamingQueryExecutor.java b/teaql-core/src/main/java/io/teaql/core/StreamingQueryExecutor.java index b4cc33a5..15c66445 100644 --- a/teaql-core/src/main/java/io/teaql/core/StreamingQueryExecutor.java +++ b/teaql-core/src/main/java/io/teaql/core/StreamingQueryExecutor.java @@ -6,4 +6,9 @@ public interface StreamingQueryExecutor extends DataServiceExecutor { /** 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-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 d93216e8..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 @@ -53,7 +53,12 @@ public QueryResult query(UserContext context, QueryRequest request) { @Override public java.util.stream.Stream queryForStream(UserContext context, QueryRequest request) { - return getPortableService(context).queryForStream(context, 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 @@ -151,9 +156,10 @@ 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. if (bindings.executionTrace() == null) { @@ -165,7 +171,7 @@ public java.util.stream.Stream> queryForStream(io. } 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) { 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/SqlStreamBatchMaskingTest.java b/teaql-data-service-sql/src/test/java/io/teaql/dataservice/sql/SqlStreamBatchMaskingTest.java index 14fdf0d9..51d81882 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; 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 47a7021e..56dbe70f 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 @@ -100,16 +100,25 @@ public QueryResult query(UserContext context, QueryRequest request) { @Override @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); - SearchRequest scoped = SqlDiagnosticRequest.forExecution(searchRequest, source, request.intent()); + 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(scoped.getTypeName()).streamInternal(context, scoped); + return new QueryCursor<>(this.getRepository(scoped.getTypeName()).streamInternal(context, scoped), + () -> statements); } private void attachDynamicAggregations( diff --git a/teaql-sqlite/src/test/java/io/teaql/sqlite/DerivedQueryTraceSqliteTest.java b/teaql-sqlite/src/test/java/io/teaql/sqlite/DerivedQueryTraceSqliteTest.java index 75d1afd8..8ba70e1c 100644 --- a/teaql-sqlite/src/test/java/io/teaql/sqlite/DerivedQueryTraceSqliteTest.java +++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/DerivedQueryTraceSqliteTest.java @@ -245,4 +245,42 @@ private static void assertPath(ExecutionMetadata entry,String root,List 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()); + } + } } From 2fbe5e150c8848f119d5bcf6125daa1714199169 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sat, 3 Oct 2026 20:15:12 +0800 Subject: [PATCH 14/35] fix: preserve nested loads when projecting relation keys (#202) Signed-off-by: Philip Z --- README.md | 9 ++ scripts/verify-examples.sh | 1 + .../sql/portable/PortableSQLDataService.java | 12 ++- .../sqlite/DerivedQueryTraceSqliteTest.java | 84 +++++++++++++++++++ 4 files changed, 104 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f220624b..7e708fb4 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,15 @@ and readback diagnostics; incompatible insert column layouts are grouped separat 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. + 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, diff --git a/scripts/verify-examples.sh b/scripts/verify-examples.sh index 296d1ab1..a19ac212 100755 --- a/scripts/verify-examples.sh +++ b/scripts/verify-examples.sh @@ -13,6 +13,7 @@ fi cd "$repo" mvn -q -DskipTests install +mvn -q -pl teaql-sqlite -Dtest=DerivedQueryTraceSqliteTest test mvn -q -pl examples/business-id-runtime \ -Dtest=BusinessIdRuntimeExampleTest test mvn -q -pl examples/security-foundations \ 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 56dbe70f..d028ac08 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 @@ -303,7 +303,7 @@ private void collectChildren( 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); @@ -317,7 +317,7 @@ private void collectChildren( for (Entity parent : dataSet) { io.teaql.core.internal.TempRequest probeRequest = SqlDiagnosticRequest.forRelation(childRequest, intent, origin, relation.getName()); - probeRequest.selectProperty(reverseProperty.getName()); + selectRelationAttachmentKey(probeRequest, reverseProperty.getName()); probeRequest.setPartitionProperty(null); ensureStableEntityIdOrder(probeRequest); probeRequest.appendSearchCriteria( @@ -369,6 +369,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); diff --git a/teaql-sqlite/src/test/java/io/teaql/sqlite/DerivedQueryTraceSqliteTest.java b/teaql-sqlite/src/test/java/io/teaql/sqlite/DerivedQueryTraceSqliteTest.java index 8ba70e1c..f9e8aa5b 100644 --- a/teaql-sqlite/src/test/java/io/teaql/sqlite/DerivedQueryTraceSqliteTest.java +++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/DerivedQueryTraceSqliteTest.java @@ -135,6 +135,90 @@ private static void assertPath(ExecutionMetadata entry,String root,List .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) From 3de0c4ae7c7416567517885459095ba98a576aae Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sat, 3 Oct 2026 21:13:54 +0800 Subject: [PATCH 15/35] test: verify request intent diagnostics before execution (#202) Signed-off-by: Philip Z --- .../teaql/runtime/RequestIntentGateTest.java | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/teaql-runtime/src/test/java/io/teaql/runtime/RequestIntentGateTest.java b/teaql-runtime/src/test/java/io/teaql/runtime/RequestIntentGateTest.java index 5da296c2..150ac60e 100644 --- a/teaql-runtime/src/test/java/io/teaql/runtime/RequestIntentGateTest.java +++ b/teaql-runtime/src/test/java/io/teaql/runtime/RequestIntentGateTest.java @@ -36,10 +36,73 @@ private static void required(String code, Runnable action) { 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", () -> From 5c339f4c9f679a92c03e08cc63f7dc9255edb86c Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sat, 3 Oct 2026 23:07:47 +0800 Subject: [PATCH 16/35] fix: remove unaudited Portable bootstrap entry points (#202) Signed-off-by: Philip Z --- DIALECT_INTEGRATION_GUIDE.md | 10 +- README.md | 9 + examples/school-management/README.md | 16 +- .../example/schoolmanagementservice/App.java | 4 +- .../BootstrapTraceVerifier.java | 115 ++++++ examples/verify-runtime-examples.sh | 11 + teaql-sql-portable/ANDROID_GUIDE.md | 4 +- .../sql/portable/PortableSQLDataService.java | 4 - .../sql/portable/PortableSQLRepository.java | 270 ------------- .../PortableSQLBootstrapBoundaryTest.java | 71 ++++ .../PortableSQLBootstrapFailureTest.java | 372 ------------------ .../sql/portable/PortableSQLDatabaseTest.java | 70 +--- 12 files changed, 242 insertions(+), 714 deletions(-) create mode 100644 examples/school-management/src/main/java/com/example/schoolmanagementservice/BootstrapTraceVerifier.java create mode 100644 teaql-sql-portable/src/test/java/io/teaql/core/sql/portable/PortableSQLBootstrapBoundaryTest.java delete mode 100644 teaql-sql-portable/src/test/java/io/teaql/core/sql/portable/PortableSQLBootstrapFailureTest.java 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 7e708fb4..a55147cb 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,15 @@ 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, diff --git a/examples/school-management/README.md b/examples/school-management/README.md index b4554f69..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`, @@ -22,8 +30,10 @@ 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 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/verify-runtime-examples.sh b/examples/verify-runtime-examples.sh index d2488697..883e4e63 100755 --- a/examples/verify-runtime-examples.sh +++ b/examples/verify-runtime-examples.sh @@ -43,6 +43,17 @@ run_example() { 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 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/sql/portable/PortableSQLDataService.java b/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/PortableSQLDataService.java index d028ac08..9052144c 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 @@ -565,8 +565,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 d7c08f28..d04c2b13 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; @@ -1593,11 +1592,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 @@ -1947,226 +1941,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()) { @@ -2184,16 +1958,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 @@ -2298,37 +2062,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 @@ -2529,7 +2262,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/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 From aa1ceece79a06c2d7499c74910c20d87d613450e Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sun, 4 Oct 2026 06:58:28 +0800 Subject: [PATCH 17/35] test: exercise request-owned traces at Java intent gates (#202) Signed-off-by: Philip Z --- .../teaql/runtime/RequestIntentGateTest.java | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/teaql-runtime/src/test/java/io/teaql/runtime/RequestIntentGateTest.java b/teaql-runtime/src/test/java/io/teaql/runtime/RequestIntentGateTest.java index 150ac60e..9ecc234b 100644 --- a/teaql-runtime/src/test/java/io/teaql/runtime/RequestIntentGateTest.java +++ b/teaql-runtime/src/test/java/io/teaql/runtime/RequestIntentGateTest.java @@ -110,6 +110,117 @@ private static void requiredIntent(String code, String kind, Runnable action) { } } + @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", () -> From 576a91b11de504f5b70c5b2bcdf249843cb25d83 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sun, 4 Oct 2026 09:40:07 +0800 Subject: [PATCH 18/35] fix: preserve typed LIKE operands in private query intent (#202) Signed-off-by: Philip Z --- .../core/sql/expression/ExpressionHelper.java | 32 ++- .../sql/portable/PortableSQLRepository.java | 8 +- .../core/sql/portable/SqlLikeIntent.java | 75 ++++++ .../portable/SqlParameterPropagationTest.java | 6 + .../sqlite/LikeIntentPrivacySqliteTest.java | 220 ++++++++++++++++++ 5 files changed, 332 insertions(+), 9 deletions(-) create mode 100644 teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/SqlLikeIntent.java create mode 100644 teaql-sqlite/src/test/java/io/teaql/sqlite/LikeIntentPrivacySqliteTest.java 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/portable/PortableSQLRepository.java b/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/PortableSQLRepository.java index d04c2b13..1c5717b6 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 @@ -708,6 +708,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()); @@ -1104,11 +1107,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 = withQueryIntent(toPositional(sql, params), - SqlDiagnosticRequest.source(userContext, request), request); + intent, request); return database.queryForStream(userContext, psql.sql, psql.args, psql.logBindings) .map(row -> mapRowToEntity(userContext, request, row)); } @@ -2187,6 +2192,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); 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..61cf4461 --- /dev/null +++ b/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/SqlLikeIntent.java @@ -0,0 +1,75 @@ +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, invocation-local provenance from typed operands before SQL wildcard decoration. */ +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 TwoOperatorCriteria && function.getExpressions().size() == 2 + && function.getOperator() instanceof Operator operator && decorated(operator) + && function.second() instanceof Parameter parameter + && ExpressionHelper.hasBuiltinParser(parameter, repository) + && parameter.getOperator() == operator) { + var policy = ExpressionHelper.parameterPolicy(context, function, repository); + if (io.teaql.core.utils.SensitiveLogNames.credential(parameter.getName())) + policy = SqlParameterLogPolicy.CREDENTIAL; + output.capture(List.of(policy), new Object[]{parameter.getValue()}); + } + for (var child : function.getExpressions()) expression(child, repository); + } + } + + private static boolean decorated(Operator operator) { + return switch (operator) { + case CONTAIN, NOT_CONTAIN, BEGIN_WITH, NOT_BEGIN_WITH, END_WITH, NOT_END_WITH -> true; + default -> false; + }; + } +} 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 80796b69..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 @@ -109,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/LikeIntentPrivacySqliteTest.java b/teaql-sqlite/src/test/java/io/teaql/sqlite/LikeIntentPrivacySqliteTest.java new file mode 100644 index 00000000..33cd276e --- /dev/null +++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/LikeIntentPrivacySqliteTest.java @@ -0,0 +1,220 @@ +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; + }; + } + 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 op, String value) { + appendSearchCriteria(createBasicSearchCriteria(field, op, value)); return this; + } + Request intent(String text) { internalComment("inspect " + text); internalPurpose("render " + text); return this; } + } + private record Bind(String sql, List values) {} + private 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()); + } + 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()); + } +} From 28a100553cd6375d477d9f073984c040a9ec2fc7 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sun, 4 Oct 2026 15:18:02 +0800 Subject: [PATCH 19/35] test: verify filtered forward identity through generated Q/E (#202) Signed-off-by: Philip Z --- .../GeneratedTraceChainExampleTest.java | 22 +++++++++++++++++++ examples/trace-chain/verify.sh | 1 + 2 files changed, 23 insertions(+) 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 index a49ba0e9..b4312524 100644 --- 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 @@ -684,6 +684,27 @@ private static void assertCycleBoundaries(Fixture fixture, Graph graph, String o .subList(0, depth), details); } 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 { @@ -708,6 +729,7 @@ private static void assertCycleBoundaries(Fixture fixture, Graph graph, String o 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) { diff --git a/examples/trace-chain/verify.sh b/examples/trace-chain/verify.sh index 24302d28..5b105273 100644 --- a/examples/trace-chain/verify.sh +++ b/examples/trace-chain/verify.sh @@ -19,6 +19,7 @@ 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=( + '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' From 120b48a4d16b87df3375564c1169a91be3888077 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Mon, 5 Oct 2026 01:44:44 +0800 Subject: [PATCH 20/35] test: prove same-context live SQLite query trace isolation (#202) --- .../sqlite/DerivedQueryTraceSqliteTest.java | 119 +++++++++++++++++- 1 file changed, 117 insertions(+), 2 deletions(-) diff --git a/teaql-sqlite/src/test/java/io/teaql/sqlite/DerivedQueryTraceSqliteTest.java b/teaql-sqlite/src/test/java/io/teaql/sqlite/DerivedQueryTraceSqliteTest.java index f9e8aa5b..174064df 100644 --- a/teaql-sqlite/src/test/java/io/teaql/sqlite/DerivedQueryTraceSqliteTest.java +++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/DerivedQueryTraceSqliteTest.java @@ -10,6 +10,12 @@ 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; @@ -53,12 +59,16 @@ Request intent(String comment, String purpose) { 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); @@ -69,8 +79,14 @@ private static final class Fixture { documentNumber.setColumnType("BIGINT"); var relation = (GenericSQLRelation) lines.addObjectProperty(metadata,"document", "TraceDocument", "lines",TraceDocument.class); relation.setColumnType("BIGINT"); - var driver = new JdbcSqlExecutor(source); - provider = new SqliteDataServiceExecutor("sqlite",driver,source); + 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(); @@ -122,6 +138,105 @@ Request withOpenCount() { } } + /** 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)); From 9c9889d6f8aa08876dc1bca9a8840309e1772d9c Mon Sep 17 00:00:00 2001 From: Philip Z Date: Mon, 5 Oct 2026 02:17:58 +0800 Subject: [PATCH 21/35] test: assert canonical generated three-relation SQL trace paths --- .../tracechain/GeneratedTraceChainExampleTest.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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 index b4312524..9cae041b 100644 --- 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 @@ -1,5 +1,7 @@ package io.teaql.examples.tracechain; +import java.util.ArrayList; + import com.teaql.tracechainservice.E; import com.teaql.tracechainservice.Q; import com.teaql.tracechainservice.GeneratedRuntimeModule; @@ -682,6 +684,18 @@ private static void assertCycleBoundaries(Fixture fixture, Graph graph, String o .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); From 7e7ae1ad6c8ea3198dc570816412f47535d3c9c7 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Mon, 5 Oct 2026 04:03:32 +0800 Subject: [PATCH 22/35] test: require exact six-entity command and audit identities (#202) --- examples/trace-chain/README.md | 5 +- .../GeneratedTraceChainExampleTest.java | 58 ++++++++++++++++++- examples/trace-chain/verify.sh | 3 +- 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/examples/trace-chain/README.md b/examples/trace-chain/README.md index e9bb3ca9..66b67289 100644 --- a/examples/trace-chain/README.md +++ b/examples/trace-chain/README.md @@ -42,7 +42,7 @@ Use Java 21 or newer, Maven, Bash and the normal repository dependencies: bash examples/trace-chain/verify.sh ``` -The script installs local source dependencies, runs all sixteen scenarios twice +The script installs local source dependencies, runs all seventeen scenarios 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 @@ -54,7 +54,8 @@ and the `runtime-examples` Maven profile. | 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 | +| 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 | 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 index 9cae041b..6098f246 100644 --- 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 @@ -20,6 +20,8 @@ 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; @@ -521,6 +523,39 @@ private static Throwable saveFailure(CustomerOrder order, UserContext context, S 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(); @@ -528,6 +563,17 @@ private static Throwable saveFailure(CustomerOrder order, UserContext context, S 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()); @@ -540,7 +586,10 @@ private static Throwable saveFailure(CustomerOrder order, UserContext context, S var writes = fixture.sql.stream().filter(value -> value.getOperation() == DataServiceOperation.MUTATION && value.getTraceChain().get(1).getName().equals(entity.typeName()) && value.getMutationLineage().equals(expected)).toList(); - assertFalse("missing actual SQL for " + entity.typeName(), writes.isEmpty()); + 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()); @@ -550,6 +599,13 @@ private static Throwable saveFailure(CustomerOrder order, UserContext context, S 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()); diff --git a/examples/trace-chain/verify.sh b/examples/trace-chain/verify.sh index 5b105273..fba86f02 100644 --- a/examples/trace-chain/verify.sh +++ b/examples/trace-chain/verify.sh @@ -24,6 +24,7 @@ markers=( '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' @@ -50,7 +51,7 @@ for repetition in 1 2; do exit 1 fi done - grep -Fq 'Tests run: 16, Failures: 0, Errors: 0, Skipped: 0' "$log" + grep -Fq 'Tests run: 17, Failures: 0, Errors: 0, Skipped: 0' "$log" printf 'PASS Java generated Trace Chain run %s on the same database\n' "$repetition" done library_manifest > "$run_dir/library-after.sha256" From cb0ab8e58d104294e3380423e09ed1f01a4fc897 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Mon, 5 Oct 2026 07:17:41 +0800 Subject: [PATCH 23/35] fix: retain request-owned root intent across Java mutation SQL --- .../java/io/teaql/core/SqlExecutionTrace.java | 27 ++-- .../io/teaql/core/SqlExecutionTraceTest.java | 48 ++++++ .../sql/SqlStreamBatchMaskingTest.java | 3 +- .../sql/portable/PortableSQLDataService.java | 4 +- .../sqlite/BlankLocalReasonSqliteTest.java | 146 ++++++++++++++++++ .../io/teaql/sqlite/GraphTraceSqliteTest.java | 2 + 6 files changed, 218 insertions(+), 12 deletions(-) create mode 100644 teaql-sqlite/src/test/java/io/teaql/sqlite/BlankLocalReasonSqliteTest.java diff --git a/teaql-core/src/main/java/io/teaql/core/SqlExecutionTrace.java b/teaql-core/src/main/java/io/teaql/core/SqlExecutionTrace.java index 61bada81..65304eb5 100644 --- a/teaql-core/src/main/java/io/teaql/core/SqlExecutionTrace.java +++ b/teaql-core/src/main/java/io/teaql/core/SqlExecutionTrace.java @@ -5,34 +5,43 @@ /** 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 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); + 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); + return new SqlExecutionTrace(source, mutationLineage, operation, observer, mutationIntent); } public void recordStatement(ExecutionMetadata metadata) { - if (statementObserver == null) return; var path = SqlTracePath.canonical(metadata.getTraceChain(), metadata.getBackend(), operation); metadata.setTraceChain(path.path()); metadata.setComment(path.comment()); metadata.setPurpose(path.purpose()); - metadata.setAuditReason(path.auditReason()); - statementObserver.accept(metadata); + // 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) { + 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); + return new SqlExecutionTrace(source, lineage, operation, null, intent); } public static SqlExecutionTrace query(SearchRequest request) { @@ -51,7 +60,7 @@ public SqlExecutionTrace readback(MutationIntent intent) { 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); + return new SqlExecutionTrace(frames, mutationLineage, "select", statementObserver, intent); } public void applyTo(ExecutionMetadata metadata) { diff --git a/teaql-core/src/test/java/io/teaql/core/SqlExecutionTraceTest.java b/teaql-core/src/test/java/io/teaql/core/SqlExecutionTraceTest.java index fe0986d3..a54f9694 100644 --- a/teaql-core/src/test/java/io/teaql/core/SqlExecutionTraceTest.java +++ b/teaql-core/src/test/java/io/teaql/core/SqlExecutionTraceTest.java @@ -43,4 +43,52 @@ static final class Request extends BaseRequest { 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-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 51d81882..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 @@ -250,7 +250,8 @@ 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"); + List.of(new TraceNode(TraceKind.AUDIT_REASON, "OrderItem", id, reason)), "insert", + io.teaql.core.MutationIntent.of(reason)); } @Test public void partialPreparedBatchKeepsPerRowTraceForSuccessFailureAndUnknown() throws Exception { 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 9052144c..76ae89b1 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 @@ -424,7 +424,7 @@ public MutationResult mutate(UserContext context, PersistenceMutation request) { 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) + var trace = io.teaql.core.SqlExecutionTrace.mutation(entity, mutation.getTraceChain(), operation, mutation.intent()) .collecting(statements::add); if (mutation.getAction() == EntityPersistenceMutation.Action.SAVE) { @@ -503,7 +503,7 @@ public List mutateBatch(UserContext context, MutationBatchReques entities.add(entity); var memberStatements = new ArrayList(); statements.add(memberStatements); - traces.add(SqlExecutionTrace.mutation(entity, item.getTraceChain(), operation) + traces.add(SqlExecutionTrace.mutation(entity, item.getTraceChain(), operation, item.intent()) .collecting(memberStatements::add)); } switch (operation) { 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/GraphTraceSqliteTest.java b/teaql-sqlite/src/test/java/io/teaql/sqlite/GraphTraceSqliteTest.java index a659df26..f58040ef 100644 --- a/teaql-sqlite/src/test/java/io/teaql/sqlite/GraphTraceSqliteTest.java +++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/GraphTraceSqliteTest.java @@ -50,12 +50,14 @@ static final class Fixture { 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) { From 8c5b45e36361c8e766043ac07fb3f119099ddb85 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Mon, 5 Oct 2026 11:49:52 +0800 Subject: [PATCH 24/35] test: assert complete ledger override leaves generated sibling fallback intact --- .../GeneratedTraceChainExampleTest.java | 35 +++++++++++++++++++ examples/trace-chain/verify.sh | 1 + 2 files changed, 36 insertions(+) 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 index 6098f246..8249ea1b 100644 --- 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 @@ -508,6 +508,10 @@ private static Throwable saveFailure(CustomerOrder order, UserContext context, S 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") @@ -520,6 +524,37 @@ private static Throwable saveFailure(CustomerOrder order, UserContext context, S && 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"); } diff --git a/examples/trace-chain/verify.sh b/examples/trace-chain/verify.sh index fba86f02..6fa8ccb6 100644 --- a/examples/trace-chain/verify.sh +++ b/examples/trace-chain/verify.sh @@ -30,6 +30,7 @@ markers=( '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' From 5560aaa6e4a9b89826d4aecc161d5aec03cb97e1 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Mon, 5 Oct 2026 12:31:35 +0800 Subject: [PATCH 25/35] test: retain complete private root lineage in generated SQL and audit --- .../GeneratedTraceChainExampleTest.java | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) 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 index 8249ea1b..4edb611d 100644 --- 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 @@ -61,10 +61,12 @@ public class GeneratedTraceChainExampleTest { assertEquals(oldValue, E.orderItem(unchanged).getName().eval()); } fixture.clear(); - root.auditAs("page 1 replace " + oldValue + " with " + next).save(fixture.context); + 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); @@ -80,6 +82,7 @@ public class GeneratedTraceChainExampleTest { } 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) @@ -89,6 +92,24 @@ public class GeneratedTraceChainExampleTest { .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()), From 7f0deb2bc08d66456afb251b1b6864ed45b014fb Mon Sep 17 00:00:00 2001 From: Philip Z Date: Mon, 5 Oct 2026 12:59:26 +0800 Subject: [PATCH 26/35] test: retain generated bootstrap request ownership before clearing evidence --- .../GeneratedTraceChainExampleTest.java | 37 +++++++++++++++++++ examples/trace-chain/verify.sh | 3 ++ 2 files changed, 40 insertions(+) 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 index 4edb611d..7745952a 100644 --- 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 @@ -239,7 +239,12 @@ static final class Fixture { }; 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); @@ -252,6 +257,38 @@ static final class Fixture { 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(); diff --git a/examples/trace-chain/verify.sh b/examples/trace-chain/verify.sh index 6fa8ccb6..16703514 100644 --- a/examples/trace-chain/verify.sh +++ b/examples/trace-chain/verify.sh @@ -19,6 +19,9 @@ 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' From 0d514a37a6119573366a4d82ff68217a0939aaac Mon Sep 17 00:00:00 2001 From: Philip Z Date: Mon, 5 Oct 2026 15:25:42 +0800 Subject: [PATCH 27/35] test: assert generated Java readback inherits captured mutation intent --- .../examples/tracechain/GeneratedTraceChainExampleTest.java | 3 +++ 1 file changed, 3 insertions(+) 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 index 7745952a..dece0c3d 100644 --- 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 @@ -124,6 +124,9 @@ private static void verifyReturnedStatements(MutationResult result, EntityPersis 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()); From b5b50b94805be8d0d9a0ccf75d4a841f3f061173 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Mon, 5 Oct 2026 19:46:33 +0800 Subject: [PATCH 28/35] fix: preserve nested facet metadata and request-owned membership queries --- examples/trace-chain/README.md | 10 ++ .../GeneratedFacetTraceExampleTest.java | 125 ++++++++++++++++++ examples/trace-chain/verify.sh | 12 +- .../io/teaql/core/sql/SqlAstCompiler.java | 7 +- .../java/io/teaql/core/sql/SqlParameters.java | 13 ++ .../core/sql/expression/SubQueryParser.java | 6 +- .../sql/portable/PortableSQLRepository.java | 31 ++++- .../portable/SqlDiagnosticRequestTest.java | 24 ++++ 8 files changed, 217 insertions(+), 11 deletions(-) create mode 100644 examples/trace-chain/src/test/java/io/teaql/examples/tracechain/GeneratedFacetTraceExampleTest.java diff --git a/examples/trace-chain/README.md b/examples/trace-chain/README.md index 66b67289..63f8717b 100644 --- a/examples/trace-chain/README.md +++ b/examples/trace-chain/README.md @@ -68,6 +68,8 @@ and the `runtime-examples` Maven profile. | 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 | The first run begins with CustomerOrder and Payment both numbered 100, items 201/202, attempt 401 and shipment 501. IDs come from `IdSpaceIdGenerator`, not @@ -129,6 +131,14 @@ 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 both generated test classes +(19 JUnit methods), twice against the same retained SQLite database. 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. 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..0ee4941e --- /dev/null +++ b/examples/trace-chain/src/test/java/io/teaql/examples/tracechain/GeneratedFacetTraceExampleTest.java @@ -0,0 +1,125 @@ +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 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 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/verify.sh b/examples/trace-chain/verify.sh index 16703514..07686c9d 100644 --- a/examples/trace-chain/verify.sh +++ b/examples/trace-chain/verify.sh @@ -44,7 +44,7 @@ markers=( ) for repetition in 1 2; do log="$run_dir/run-$repetition.log" - if ! mvn -B -f "$example_dir/pom.xml" -Dtest=GeneratedTraceChainExampleTest \ + if ! mvn -B -f "$example_dir/pom.xml" -Dtest=GeneratedTraceChainExampleTest,GeneratedFacetTraceExampleTest \ "-Dteaql.trace.database=$database" test > "$log" 2>&1; then tail -n 100 "$log" >&2 exit 1 @@ -55,7 +55,15 @@ for repetition in 1 2; do exit 1 fi done - grep -Fq 'Tests run: 17, Failures: 0, Errors: 0, Skipped: 0' "$log" + grep -Fq 'Tests run: 19, Failures: 0, Errors: 0, Skipped: 0' "$log" + grep -Fq 'JAVA_NESTED_FACET_CARRIER returned nested metadata and count verified' "$log" + for logging in false true; do + for all in false true; do + 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" + done + done printf 'PASS Java generated Trace Chain run %s on the same database\n' "$repetition" done library_manifest > "$run_dir/library-after.sha256" 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 0981459f..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 @@ -10,12 +10,25 @@ public final class SqlParameters extends 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/SubQueryParser.java b/teaql-sql-portable/src/main/java/io/teaql/core/sql/expression/SubQueryParser.java index 20dbaeb6..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 @@ -89,7 +89,11 @@ public String toSql( throw new io.teaql.core.TeaQLRuntimeException( "[INTERNAL QUERY CONTEXT REQUIRED] Materialized relation predicate requires originating query intent."); } - SmartList referred = userContext.internalExecuteForList(new TempRequest(dependsOn, rootIntent)); + 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/PortableSQLRepository.java b/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/PortableSQLRepository.java index 1c5717b6..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 @@ -223,11 +223,7 @@ public Map getExpressionParsers() { public String buildDataSQL(UserContext userContext, SearchRequest request, Map parameters) { if (parameters instanceof io.teaql.core.sql.SqlParameters tracked) { - var origin = request.inheritedQueryIntent(); - if (origin == null && request.comment() != null && request.purpose() != null) { - origin = io.teaql.core.QueryIntent.of(request.comment(), request.purpose()); - } - if (origin != null) tracked.captureQueryIntent(origin); + tracked.captureQueryContext(request); } String partitionProperty = request.getPartitionProperty(); if (ObjectUtil.isNotEmpty(partitionProperty) && request.getSlice() != null) { @@ -238,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 // ========================================== @@ -798,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"); @@ -832,7 +840,12 @@ private SmartList loadWithIntent(UserContext userContext, SearchRequest re 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() @@ -849,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); 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 index cb68837b..53f76011 100644 --- 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 @@ -57,4 +57,28 @@ private static class Request extends BaseRequest { 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()); + } } From 319c65737f523431eeec8563176c824dbdefdbfa Mon Sep 17 00:00:00 2001 From: Philip Z Date: Mon, 5 Oct 2026 20:43:37 +0800 Subject: [PATCH 29/35] fix: retain independently scoped loaded relation Facet results --- examples/trace-chain/README.md | 15 +++- .../GeneratedFacetTraceExampleTest.java | 88 +++++++++++++++++++ examples/trace-chain/verify.sh | 10 ++- .../main/java/io/teaql/core/BaseEntity.java | 14 +++ .../src/main/java/io/teaql/core/Entity.java | 10 +++ .../io/teaql/core/EntityQueryFacetsTest.java | 46 ++++++++++ .../sql/portable/PortableSQLDataService.java | 51 +++++++++++ .../sqlite/DerivedQueryTraceSqliteTest.java | 50 +++++++++++ 8 files changed, 282 insertions(+), 2 deletions(-) create mode 100644 teaql-core/src/test/java/io/teaql/core/EntityQueryFacetsTest.java diff --git a/examples/trace-chain/README.md b/examples/trace-chain/README.md index 63f8717b..8820abfe 100644 --- a/examples/trace-chain/README.md +++ b/examples/trace-chain/README.md @@ -70,6 +70,8 @@ and the `runtime-examples` Maven profile. | 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 | The first run begins with CustomerOrder and Payment both numbered 100, items 201/202, attempt 401 and shipment 501. IDs come from `IdSpaceIdGenerator`, not @@ -138,7 +140,18 @@ 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 both generated test classes -(19 JUnit methods), twice against the same retained SQLite database. +(21 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. 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 index 0ee4941e..b1e9c528 100644 --- 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 @@ -16,6 +16,55 @@ /** 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(); @@ -35,6 +84,45 @@ public class GeneratedFacetTraceExampleTest { 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)) { diff --git a/examples/trace-chain/verify.sh b/examples/trace-chain/verify.sh index 07686c9d..8f7aacec 100644 --- a/examples/trace-chain/verify.sh +++ b/examples/trace-chain/verify.sh @@ -55,13 +55,21 @@ for repetition in 1 2; do exit 1 fi done - grep -Fq 'Tests run: 19, Failures: 0, Errors: 0, Skipped: 0' "$log" + grep -Fq 'Tests run: 21, Failures: 0, Errors: 0, Skipped: 0' "$log" 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 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" 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 86dba380..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; 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 320c2114..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. 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-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 76ae89b1..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 @@ -273,6 +273,35 @@ private void enhanceParent( .toList(); if (io.teaql.core.utils.ObjectUtil.isEmpty(parents)) return; + // 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)); @@ -309,6 +338,28 @@ private void collectChildren( 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) { diff --git a/teaql-sqlite/src/test/java/io/teaql/sqlite/DerivedQueryTraceSqliteTest.java b/teaql-sqlite/src/test/java/io/teaql/sqlite/DerivedQueryTraceSqliteTest.java index 174064df..a5d63dca 100644 --- a/teaql-sqlite/src/test/java/io/teaql/sqlite/DerivedQueryTraceSqliteTest.java +++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/DerivedQueryTraceSqliteTest.java @@ -138,6 +138,56 @@ Request withOpenCount() { } } + @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); From 1cedbe7921eb3a3a42722830fe5cf9e2bf8d4930 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Mon, 5 Oct 2026 21:21:44 +0800 Subject: [PATCH 30/35] test: verify generated reverse collection Facet trace and full membership --- examples/trace-chain/README.md | 3 +- ...GeneratedReverseFacetTraceExampleTest.java | 98 +++++++++++++++++++ examples/trace-chain/verify.sh | 7 +- 3 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 examples/trace-chain/src/test/java/io/teaql/examples/tracechain/GeneratedReverseFacetTraceExampleTest.java diff --git a/examples/trace-chain/README.md b/examples/trace-chain/README.md index 8820abfe..18f1f56d 100644 --- a/examples/trace-chain/README.md +++ b/examples/trace-chain/README.md @@ -72,6 +72,7 @@ and the `runtime-examples` Maven profile. | 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 @@ -140,7 +141,7 @@ 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 both generated test classes -(21 JUnit methods), twice against the same retained SQLite database. +(22 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 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/verify.sh b/examples/trace-chain/verify.sh index 8f7aacec..10efee43 100644 --- a/examples/trace-chain/verify.sh +++ b/examples/trace-chain/verify.sh @@ -44,7 +44,7 @@ markers=( ) for repetition in 1 2; do log="$run_dir/run-$repetition.log" - if ! mvn -B -f "$example_dir/pom.xml" -Dtest=GeneratedTraceChainExampleTest,GeneratedFacetTraceExampleTest \ + if ! mvn -B -f "$example_dir/pom.xml" -Dtest=GeneratedTraceChainExampleTest,GeneratedFacetTraceExampleTest,GeneratedReverseFacetTraceExampleTest \ "-Dteaql.trace.database=$database" test > "$log" 2>&1; then tail -n 100 "$log" >&2 exit 1 @@ -55,13 +55,16 @@ for repetition in 1 2; do exit 1 fi done - grep -Fq 'Tests run: 21, Failures: 0, Errors: 0, Skipped: 0' "$log" + grep -Fq 'Tests run: 22, Failures: 0, Errors: 0, Skipped: 0' "$log" 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" From ff77320020629abe332f1947bf352fce7a200c45 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Mon, 5 Oct 2026 21:22:02 +0800 Subject: [PATCH 31/35] docs: name the three generated Trace Chain test classes --- examples/trace-chain/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/trace-chain/README.md b/examples/trace-chain/README.md index 18f1f56d..e7a5294e 100644 --- a/examples/trace-chain/README.md +++ b/examples/trace-chain/README.md @@ -140,7 +140,7 @@ 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 both generated test classes +mutable map. The all-examples gate includes all three generated test classes (22 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. From 38dab2d1231ca1fab679e3e0449dc4a567d70201 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Tue, 6 Oct 2026 00:13:27 +0800 Subject: [PATCH 32/35] test: resolve runtime module for HANA privacy tests --- teaql-hana/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 698b8c105b6e8fb407fbbf7354839d59f0ff77ad Mon Sep 17 00:00:00 2001 From: Philip Z Date: Tue, 6 Oct 2026 00:13:27 +0800 Subject: [PATCH 33/35] fix: classify private aggregate predicates before root SQL diagnostics --- examples/trace-chain/README.md | 17 +- .../GeneratedAggregateTraceExampleTest.java | 182 ++++++++++++++++++ examples/trace-chain/verify.sh | 9 +- .../io/teaql/core/SqlIntentRedactions.java | 4 +- .../teaql/core/SqlIntentRedactionsTest.java | 13 ++ .../core/sql/portable/SqlLikeIntent.java | 10 +- .../sqlite/LikeIntentPrivacySqliteTest.java | 48 +++++ 7 files changed, 269 insertions(+), 14 deletions(-) create mode 100644 examples/trace-chain/src/test/java/io/teaql/examples/tracechain/GeneratedAggregateTraceExampleTest.java diff --git a/examples/trace-chain/README.md b/examples/trace-chain/README.md index e7a5294e..0a5a74bb 100644 --- a/examples/trace-chain/README.md +++ b/examples/trace-chain/README.md @@ -12,6 +12,17 @@ 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 @@ -42,7 +53,7 @@ Use Java 21 or newer, Maven, Bash and the normal repository dependencies: bash examples/trace-chain/verify.sh ``` -The script installs local source dependencies, runs all seventeen scenarios twice +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 @@ -140,8 +151,8 @@ 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 three generated test classes -(22 JUnit methods), twice against the same retained SQLite database. +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 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/verify.sh b/examples/trace-chain/verify.sh index 10efee43..71eaf901 100644 --- a/examples/trace-chain/verify.sh +++ b/examples/trace-chain/verify.sh @@ -44,7 +44,7 @@ markers=( ) for repetition in 1 2; do log="$run_dir/run-$repetition.log" - if ! mvn -B -f "$example_dir/pom.xml" -Dtest=GeneratedTraceChainExampleTest,GeneratedFacetTraceExampleTest,GeneratedReverseFacetTraceExampleTest \ + 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 @@ -55,7 +55,12 @@ for repetition in 1 2; do exit 1 fi done - grep -Fq 'Tests run: 22, Failures: 0, Errors: 0, Skipped: 0' "$log" + 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 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 d427cac5..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() { 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 a42f41ca..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,19 @@ 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(); 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 index 61cf4461..3fe556dd 100644 --- 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 @@ -5,7 +5,7 @@ import io.teaql.core.sql.expression.ExpressionHelper; import java.util.*; -/** Read-only, invocation-local provenance from typed operands before SQL wildcard decoration. */ +/** Read-only provenance from typed operands before any root or derived SQL executes. */ final class SqlLikeIntent { private final UserContext context; private final SqlIntentRedactions output; @@ -53,7 +53,7 @@ private void expression(Expression expression, PortableSQLRepository reposito child(subquery.getDependsOn(), repository); } else if (expression instanceof FunctionApply function) { if (function instanceof TwoOperatorCriteria && function.getExpressions().size() == 2 - && function.getOperator() instanceof Operator operator && decorated(operator) + && function.getOperator() instanceof Operator operator && function.second() instanceof Parameter parameter && ExpressionHelper.hasBuiltinParser(parameter, repository) && parameter.getOperator() == operator) { @@ -66,10 +66,4 @@ private void expression(Expression expression, PortableSQLRepository reposito } } - private static boolean decorated(Operator operator) { - return switch (operator) { - case CONTAIN, NOT_CONTAIN, BEGIN_WITH, NOT_BEGIN_WITH, END_WITH, NOT_END_WITH -> true; - default -> false; - }; - } } diff --git a/teaql-sqlite/src/test/java/io/teaql/sqlite/LikeIntentPrivacySqliteTest.java b/teaql-sqlite/src/test/java/io/teaql/sqlite/LikeIntentPrivacySqliteTest.java index 33cd276e..f2dae3c7 100644 --- a/teaql-sqlite/src/test/java/io/teaql/sqlite/LikeIntentPrivacySqliteTest.java +++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/LikeIntentPrivacySqliteTest.java @@ -135,6 +135,10 @@ private void assertProjection(Fixture f, Request request, String operand, boo 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; @@ -217,4 +221,48 @@ private void assertProjection(Fixture f, Request request, String operand, boo 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); + } + } } From 67d0f946f104948e1aff109d52ca20aa1897c294 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Tue, 6 Oct 2026 00:45:42 +0800 Subject: [PATCH 34/35] fix: classify BETWEEN and phonetic operands before root SQL logs --- scripts/verify-examples.sh | 3 +- .../core/sql/portable/SqlLikeIntent.java | 44 +++- .../sqlite/LikeIntentPrivacySqliteTest.java | 6 +- .../sqlite/TypedIntentPrivacySqliteTest.java | 212 ++++++++++++++++++ 4 files changed, 252 insertions(+), 13 deletions(-) create mode 100644 teaql-sqlite/src/test/java/io/teaql/sqlite/TypedIntentPrivacySqliteTest.java diff --git a/scripts/verify-examples.sh b/scripts/verify-examples.sh index a19ac212..7922135a 100755 --- a/scripts/verify-examples.sh +++ b/scripts/verify-examples.sh @@ -13,7 +13,8 @@ fi cd "$repo" mvn -q -DskipTests install -mvn -q -pl teaql-sqlite -Dtest=DerivedQueryTraceSqliteTest test +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 \ 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 index 3fe556dd..3043aeac 100644 --- 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 @@ -52,18 +52,44 @@ private void expression(Expression expression, PortableSQLRepository reposito } else if (expression instanceof SubQuerySearchCriteria subquery) { child(subquery.getDependsOn(), repository); } else if (expression instanceof FunctionApply function) { - if (function instanceof TwoOperatorCriteria && function.getExpressions().size() == 2 - && function.getOperator() instanceof Operator operator - && function.second() instanceof Parameter parameter - && ExpressionHelper.hasBuiltinParser(parameter, repository) - && parameter.getOperator() == operator) { - var policy = ExpressionHelper.parameterPolicy(context, function, repository); - if (io.teaql.core.utils.SensitiveLogNames.credential(parameter.getName())) - policy = SqlParameterLogPolicy.CREDENTIAL; - output.capture(List.of(policy), new Object[]{parameter.getValue()}); + 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-sqlite/src/test/java/io/teaql/sqlite/LikeIntentPrivacySqliteTest.java b/teaql-sqlite/src/test/java/io/teaql/sqlite/LikeIntentPrivacySqliteTest.java index f2dae3c7..457a610a 100644 --- a/teaql-sqlite/src/test/java/io/teaql/sqlite/LikeIntentPrivacySqliteTest.java +++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/LikeIntentPrivacySqliteTest.java @@ -43,7 +43,7 @@ private String pattern(String operand) { default -> "%" + operand; }; } - private static final class Request extends BaseRequest { + 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; } @@ -52,8 +52,8 @@ Request where(String field, Operator op, String value) { } Request intent(String text) { internalComment("inspect " + text); internalPurpose("render " + text); return this; } } - private record Bind(String sql, List values) {} - private static final class Fixture { + 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<>(); 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); + } +} From 9b0156f5110539ead4d2290cfd74ea9c17f661a4 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Tue, 6 Oct 2026 09:22:23 +0800 Subject: [PATCH 35/35] test: preserve owned mutation comment through blank route tails at SQLite --- .../sqlite/BlankRouteTailSqliteTest.java | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 teaql-sqlite/src/test/java/io/teaql/sqlite/BlankRouteTailSqliteTest.java 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()); + } + } + } +}