Implement checked containsInsensitive string predicates in GraphQL, protobuf, native SQL and bounded residual evaluation. ASCII folding is deterministic; SQL pattern characters remain literal. Retain deferred full-text index design. Studio migration awaits a decision on checked native getter forwarding across conformances; do not duplicate titles or bypass private state ownership. Compiler and local full-stack substring-query acceptance pass.
303 lines
14 KiB
TypeScript
303 lines
14 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { test } from "node:test";
|
|
import { compileCapabilityResourceSource } from "../src/capability-language/index.js";
|
|
import { compileQuery } from "../src/query/compile.js";
|
|
import { QueryCompileError } from "../src/query/types.js";
|
|
import { checkQueryTemplate } from "../src/query/templates.js";
|
|
import { queryPresentation } from "../src/query/presentation.js";
|
|
import { querySelectionToWire } from "../src/query/proto.js";
|
|
import type { InterfaceRevision } from "../src/capability-model/types.js";
|
|
|
|
const source = { repository: "https://example.test/queries.git", commit: "a".repeat(40) };
|
|
const iface = (text: string, dependencies: InterfaceRevision[] = []) => {
|
|
const compiled = compileCapabilityResourceSource(text, {
|
|
source,
|
|
environment: {
|
|
interfaces: new Map(dependencies.map((entry) => [entry.displayName, entry])),
|
|
interfaceClosure: dependencies,
|
|
},
|
|
});
|
|
assert.ok(compiled.ok, JSON.stringify(compiled.diagnostics));
|
|
assert.equal(compiled.resource.kind, "interface");
|
|
if (compiled.resource.kind !== "interface") throw new Error("interface required");
|
|
return compiled.resource.revision;
|
|
};
|
|
const facts = iface(`interface TaskFacts id "facts" revision "facts@1" {
|
|
queryable value title id "title" : string { get id "title:get"; }
|
|
queryable value done id "done" : bool { get id "done:get"; }
|
|
queryable value due id "due" : optional<int64> { get id "due:get"; }
|
|
queryable rpc value score id "score" : int32 { get id "score:get"; }
|
|
value hidden id "hidden" : string { get id "hidden:get"; }
|
|
}`);
|
|
const collection = iface(
|
|
`import interface TaskFacts;
|
|
interface Tasks id "tasks" revision "tasks@1" {
|
|
queryable relation items id "items" : many interface TaskFacts ordered { resolve id "items:resolve"; }
|
|
}`,
|
|
[facts],
|
|
);
|
|
|
|
test("generic query templates check declared bounds before closed specialization", async () => {
|
|
const generic = iface(
|
|
`import interface TaskFacts;
|
|
interface Collection<object Item implements TaskFacts> id "generic-collection" revision "generic-collection@1" {
|
|
queryable relation items id "items" : many object Item { resolve id "items:resolve"; }
|
|
}`,
|
|
[facts],
|
|
);
|
|
const build = (bound: string, workspace = false) =>
|
|
compileCapabilityResourceSource(
|
|
`import interface Collection; import interface TaskFacts;
|
|
package GenericQueries id "generic-queries" revision "generic-queries@1" {
|
|
query Rows<object Item ${bound}> id "rows-template" root ${workspace ? "workspace " : ""}Collection<Item>
|
|
document "rows.graphql" operation "Rows" { view object Item as TaskFacts; max rows 30; }
|
|
query TaskRows id "task-rows" specialize Rows<interface TaskFacts>;
|
|
}`,
|
|
{
|
|
source,
|
|
environment: {
|
|
interfaces: new Map([
|
|
["Collection", generic],
|
|
["TaskFacts", facts],
|
|
]),
|
|
interfaceClosure: [generic, facts],
|
|
},
|
|
},
|
|
);
|
|
const result = build("implements TaskFacts");
|
|
assert.ok(result.ok, JSON.stringify(result.diagnostics));
|
|
if (result.resource.kind !== "package") throw new Error("package expected");
|
|
const pkg = result.resource.revision;
|
|
assert.equal(pkg.queries?.length, 1);
|
|
assert.equal(pkg.queryTemplates?.length, 1);
|
|
assert.equal(pkg.queries![0]!.application!.templateId, "rows-template");
|
|
const read = async () => `query Rows { root { items(first: 3) { entries { node { title } } } } }`;
|
|
const universal = await checkQueryTemplate(pkg.queryTemplates![0]!, [generic, facts], read);
|
|
assert.equal(
|
|
universal.effects.some((effect) => effect.memberId === "title"),
|
|
true,
|
|
);
|
|
const specialized = await compileQuery(
|
|
pkg.queries![0]!,
|
|
[generic, facts, ...(result.resource.specializations ?? [])],
|
|
read,
|
|
);
|
|
assert.equal(
|
|
specialized.effects.some((effect) => effect.memberId === "title"),
|
|
true,
|
|
);
|
|
const invalid = build("");
|
|
assert.ok(invalid.ok, JSON.stringify(invalid.diagnostics));
|
|
if (invalid.resource.kind !== "package") throw new Error("package expected");
|
|
await assert.rejects(
|
|
checkQueryTemplate(invalid.resource.revision.queryTemplates![0]!, [generic, facts], read),
|
|
/bound|guarantee|implement/i,
|
|
);
|
|
const extent = build("implements TaskFacts", true);
|
|
assert.ok(extent.ok, JSON.stringify(extent.diagnostics));
|
|
if (extent.resource.kind !== "package") throw new Error("package expected");
|
|
const extentPackage = extent.resource.revision;
|
|
const readExtent = async () =>
|
|
`query Rows {root {objects(first: 2) {entries {node {items(first: 3) {entries {node {title}}}}}}}}`;
|
|
await checkQueryTemplate(extentPackage.queryTemplates![0]!, [generic, facts], readExtent);
|
|
const closed = await compileQuery(
|
|
extentPackage.queries![0]!,
|
|
[generic, facts, ...(extent.resource.specializations ?? [])],
|
|
readExtent,
|
|
);
|
|
assert.equal(closed.declaration.root.kind, "workspace-interface");
|
|
assert.equal(closed.declaration.root.interface, specialized.declaration.root.interface);
|
|
const invalidExtent = build("", true);
|
|
assert.ok(invalidExtent.ok, JSON.stringify(invalidExtent.diagnostics));
|
|
if (invalidExtent.resource.kind !== "package") throw new Error("package expected");
|
|
await assert.rejects(
|
|
checkQueryTemplate(invalidExtent.resource.revision.queryTemplates![0]!, [generic, facts], readExtent),
|
|
/bound|guarantee|implement/i,
|
|
);
|
|
});
|
|
function fixture(clauses = "", root = "Tasks") {
|
|
const result = compileCapabilityResourceSource(
|
|
`import interface Tasks; import interface TaskFacts;
|
|
package Queries id "queries" revision "queries@1" {
|
|
query Upcoming id "upcoming" root ${root} document "queries/upcoming.graphql" operation "Upcoming" {
|
|
fragments "queries/row.graphql"; max rows 30; ${clauses}
|
|
}
|
|
}`,
|
|
{
|
|
source,
|
|
environment: {
|
|
interfaces: new Map([
|
|
["Tasks", collection],
|
|
["TaskFacts", facts],
|
|
]),
|
|
interfaceClosure: [collection, facts],
|
|
},
|
|
},
|
|
);
|
|
assert.ok(result.ok, JSON.stringify(result.diagnostics));
|
|
assert.equal(result.resource.kind, "package");
|
|
if (result.resource.kind !== "package") throw new Error("package required");
|
|
assert.equal(result.resource.revision.exports.length, 0);
|
|
return result.resource.revision.queries![0]!;
|
|
}
|
|
test("workspace roots expose a bounded interface extent, not a root object or writable relationship", async () => {
|
|
const declaration = { ...fixture("watch;", "workspace TaskFacts"), fragments: [] };
|
|
assert.deepEqual(declaration.root, { kind: "workspace-interface", interface: "facts@1" });
|
|
const checked = await compileQuery(
|
|
declaration,
|
|
[facts, collection],
|
|
async () => `query Upcoming {
|
|
root {
|
|
objects(first: 3, where: {done: {eq: false}}, orderBy: {title: ASC}) {
|
|
entries {key node {_qx {ref} title @live}} pageInfo {hasNextPage endCursor}
|
|
}
|
|
_qx {relations {objects {aggregate {count sum {due}}}}}
|
|
}
|
|
}`,
|
|
);
|
|
assert.deepEqual(new Set(checked.effects.map((effect) => effect.memberId)), new Set(["done", "title", "due"]));
|
|
assert.ok(!checked.effects.some((effect) => effect.memberId.includes("workspace-objects")));
|
|
assert.ok(JSON.stringify(checked.selection).includes("qx-internal-workspace-objects"));
|
|
await assert.rejects(
|
|
compileQuery(declaration, [facts], async () => `query Upcoming {root {_qx {ref}}}`),
|
|
/QUERY_VALIDATION/,
|
|
);
|
|
await assert.rejects(
|
|
compileQuery(declaration, [facts], async () => `query Upcoming {root {objects {entries {key}}}}`),
|
|
/QUERY_ROW_LIMIT/,
|
|
);
|
|
});
|
|
const document = `query Upcoming($first: Int!, $before: Int64!) {
|
|
root { items(first: $first, where: {done: {eq: false}, due: {lt: $before}}, orderBy: [{due: ASC}]) {
|
|
entries { key node { _qx { ref } ...Row } } pageInfo { hasNextPage endCursor }
|
|
} }
|
|
}`;
|
|
const compile = (query = document, row = "fragment Row on TaskFacts { title due }", clauses = "") =>
|
|
compileQuery(fixture(clauses), [collection, facts], async (name) => (name.endsWith("row.graphql") ? row : query));
|
|
|
|
test("live selections preserve aliases, nullable values, fragment conditions and wire identity", async () => {
|
|
const checked = await compile(
|
|
`query Upcoming($show: Boolean!) {root {items(first: 3) {entries {node {plain: title ...Row @include(if: $show)}}}}}`,
|
|
`fragment Row on TaskFacts {label: title @live due @live}`,
|
|
);
|
|
const fields = queryPresentation(checked, [collection, facts]);
|
|
assert.deepEqual(
|
|
fields.map((f) => f.path),
|
|
[
|
|
["root", "items", "entries", "*", "node", "label"],
|
|
["root", "items", "entries", "*", "node", "due"],
|
|
],
|
|
);
|
|
assert.equal(fields[0]!.getOperationId, "title:get");
|
|
assert.equal(fields[0]!.setOperationId, undefined);
|
|
assert.equal(fields[1]!.valueType.kind, "optional");
|
|
assert.equal(fields[0]!.conditions.length, 1);
|
|
assert.ok(JSON.stringify(checked.selection.map(querySelectionToWire)).includes(fields[0]!.selectionId));
|
|
const defaults = await compile(
|
|
`query Upcoming($show: Boolean! = true) {root {items(first: 3) {entries {node {...Row @include(if: $show)}}}}}`,
|
|
"fragment Row on TaskFacts {title @live}",
|
|
);
|
|
assert.equal(queryPresentation(defaults, [collection, facts])[0]!.conditions[0]!.defaultValue, true);
|
|
});
|
|
|
|
test("live selections reject RPC getters, synthetic selections and conflicting presentations", async () => {
|
|
for (const [selected, code] of [
|
|
["score @live", "QUERY_LIVE_UNSUPPORTED"],
|
|
["_qx @live {ref}", "QUERY_LIVE_UNSUPPORTED"],
|
|
["title @live title", "QUERY_LIVE_CONFLICT"],
|
|
["title @live(unchecked: true)", "QUERY_VALIDATION"],
|
|
])
|
|
await assert.rejects(
|
|
compile(
|
|
`query Upcoming {root {items(first: 3) {entries {node {...Row}}}}}`,
|
|
`fragment Row on TaskFacts {${selected}}`,
|
|
'allow TaskFacts.score select "bounded";',
|
|
),
|
|
(error: unknown) => error instanceof QueryCompileError && error.code === code,
|
|
);
|
|
});
|
|
|
|
test("query dependency ports resolve exact exports without declaration ordering constraints", () => {
|
|
const result = compileCapabilityResourceSource(
|
|
`import interface Tasks;
|
|
package Queries id "queries" revision "queries@1" {
|
|
operation load id "read" : unit -> unit mode call receiver interfaces [Tasks]
|
|
requires { query upcoming id "upcoming-port" : Queries.Upcoming; };
|
|
query Upcoming id "upcoming" root Tasks document "upcoming.graphql" operation "Upcoming" { max rows 30; }
|
|
}`,
|
|
{ source, environment: { interfaces: new Map([["Tasks", collection]]), interfaceClosure: [collection, facts] } },
|
|
);
|
|
assert.ok(result.ok, JSON.stringify(result.diagnostics));
|
|
assert.equal(result.resource.kind, "package");
|
|
if (result.resource.kind !== "package") throw new Error("package expected");
|
|
assert.deepEqual(result.resource.revision.exports[0]!.dependencyPorts[0]!.requirement, {
|
|
kind: "query",
|
|
packageRevisionId: "queries@1",
|
|
queryId: "upcoming",
|
|
});
|
|
});
|
|
|
|
test("result types merge repeated selections and preserve conditional fragment fields", async () => {
|
|
const checked = await compile(
|
|
`query Upcoming($show: Boolean!) {
|
|
root { items(first: 3) { entries { node { title } } } }
|
|
root { items(first: 3) { entries { node { ...Row @include(if: $show) } } } }
|
|
}`,
|
|
"fragment Row on TaskFacts { due done }",
|
|
);
|
|
const output = JSON.stringify(checked.output);
|
|
assert.match(output, /\"title\":\{\"kind\":\"scalar\",\"name\":\"string\"\}/);
|
|
assert.match(output, /\"done\":\{\"kind\":\"optional\",\"value\":\{\"kind\":\"scalar\",\"name\":\"bool\"\}\}/);
|
|
assert.match(output, /\"due\"/);
|
|
});
|
|
|
|
test("fixed GraphQL yields exact effects, typed references and distinct query artifacts", async () => {
|
|
const checked = await compile();
|
|
assert.deepEqual(checked.variables, {
|
|
kind: "record",
|
|
fields: {
|
|
first: { kind: "scalar", name: "int32" },
|
|
before: { kind: "scalar", name: "int64" },
|
|
},
|
|
});
|
|
assert.match(JSON.stringify(checked.output), /"kind":"object-ref"/);
|
|
assert.ok(
|
|
checked.effects.some(
|
|
(effect) => effect.memberId === "due" && effect.uses.includes("order") && effect.uses.includes("predicate"),
|
|
),
|
|
);
|
|
assert.equal(checked.definitionDigest, (await compile()).definitionDigest);
|
|
assert.notEqual(
|
|
checked.definitionDigest,
|
|
(await compile(document, "fragment Row on TaskFacts { title }")).definitionDigest,
|
|
);
|
|
});
|
|
|
|
test("query allowlist and two-sided RPC effects fail during compilation", async () => {
|
|
const rejects = (promise: Promise<unknown>, code: string) =>
|
|
assert.rejects(promise, (error: unknown) => error instanceof QueryCompileError && error.code === code);
|
|
const substring = (field: string) =>
|
|
`query Upcoming {root {items(first: 3, where: {${field}: {containsInsensitive: "TASK"}}) {entries {node {...Row}}}}}`;
|
|
await compile(substring("title"));
|
|
await rejects(compile(substring("due")), "QUERY_VALIDATION");
|
|
await rejects(compile(document, "fragment Row on TaskFacts { hidden }"), "QUERY_VALIDATION");
|
|
await rejects(compile(document, "fragment Row on TaskFacts { score }"), "QUERY_RPC_CONSUMER_REASON");
|
|
await compile(
|
|
document,
|
|
"fragment Row on TaskFacts { score }",
|
|
'allow TaskFacts.score select "Only the visible page is enriched";',
|
|
);
|
|
await rejects(
|
|
compile(document, "fragment Row on TaskFacts { score }", 'watch; allow TaskFacts.score select "Visible rows";'),
|
|
"QUERY_WATCH_UNSUPPORTED",
|
|
);
|
|
await compile(
|
|
document,
|
|
"fragment Row on TaskFacts { score }",
|
|
'watch; poll 5000 "External score has no push API"; allow TaskFacts.score select "Visible rows";',
|
|
);
|
|
await rejects(compile(document.replace("first: $first", "first: 31")), "QUERY_VALIDATION");
|
|
await rejects(compile(document.replace("...Row", "__typename ...Row")), "QUERY_UNSUPPORTED_FEATURE");
|
|
await rejects(compile(document, "fragment Row on TaskFacts { ...Row }"), "QUERY_VALIDATION");
|
|
});
|