diff --git a/packages/bridge-core/src/ExecutionTree.ts b/packages/bridge-core/src/ExecutionTree.ts index 73f75f78..24b3ff95 100644 --- a/packages/bridge-core/src/ExecutionTree.ts +++ b/packages/bridge-core/src/ExecutionTree.ts @@ -1751,7 +1751,30 @@ export class ExecutionTree implements TreeContext { return response; } - // Array: create shadow trees for per-element resolution + // Array: create shadow trees for per-element resolution. + // However, when the field is a scalar type (e.g. [JSONObject]) and + // the array is a pure passthrough (no element-level field mappings), + // GraphQL won't call sub-field resolvers so shadow trees are + // unnecessary — return the plain resolved array directly. + if (scalar) { + const { type, field } = this.trunk; + const hasElementWires = this.bridge?.wires.some( + (w) => + "from" in w && + ((w.from as NodeRef).element === true || + this.isElementScopedTrunk(w.from as NodeRef) || + w.to.element === true) && + w.to.module === SELF_MODULE && + w.to.type === type && + w.to.field === field && + w.to.path.length > cleanPath.length && + cleanPath.every((seg, i) => w.to.path[i] === seg), + ); + if (!hasElementWires) { + return response; + } + } + const resolved = await response; if (resolved == null || !Array.isArray(resolved)) return resolved; const arrayPathKey = cleanPath.join("."); @@ -1820,6 +1843,12 @@ export class ExecutionTree implements TreeContext { if (fieldName !== undefined && fieldName in elementData) { const value = (elementData as Record)[fieldName]; if (array && Array.isArray(value)) { + // Nested array: when the field is a scalar type (e.g. [JSONObject]) + // GraphQL won't call sub-field resolvers, so return the plain + // data directly instead of wrapping in shadow trees. + if (scalar) { + return value; + } // Nested array: wrap items in shadow trees so they can // resolve their own fields via this same fallback path. return value.map((item: any) => { @@ -1833,6 +1862,13 @@ export class ExecutionTree implements TreeContext { } } + // Scalar sub-field fallback: when the GraphQL schema declares this + // field as a scalar type (e.g. JSONObject), sub-field resolvers won't + // fire, so we must eagerly materialise the sub-field from deeper wires. + if (scalar && cleanPath.length > 0) { + return this.resolveNestedField(cleanPath); + } + // Return self to trigger downstream resolvers return this; } diff --git a/packages/bridge-graphql/test/jsonobject-fields.test.ts b/packages/bridge-graphql/test/jsonobject-fields.test.ts new file mode 100644 index 00000000..0e5bb140 --- /dev/null +++ b/packages/bridge-graphql/test/jsonobject-fields.test.ts @@ -0,0 +1,215 @@ +/** + * Tests for JSONObject and [JSONObject] field handling in bridgeTransform. + * + * When a field is typed as JSONObject (scalar) in the schema, the bridge + * engine must eagerly materialise its output instead of deferring to + * sub-field resolvers. This applies to both: + * - `legs: JSONObject` — single object passthrough + * - `legs: [JSONObject]` — array of objects passthrough + */ +import { buildHTTPExecutor } from "@graphql-tools/executor-http"; +import { parse } from "graphql"; +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import { parseBridgeFormat as parseBridge } from "@stackables/bridge-parser"; +import { createGateway } from "./utils/gateway.ts"; +import { bridge } from "@stackables/bridge-core"; + +describe("bridgeTransform: JSONObject field passthrough", () => { + test("legs: JSONObject — single object passthrough via wire", async () => { + const typeDefs = /* GraphQL */ ` + scalar JSONObject + type Query { + trip(id: Int): TripResult + } + type TripResult { + id: Int + legs: JSONObject + } + `; + + const bridgeText = bridge` + version 1.5 + bridge Query.trip { + with input as i + with api as a + with output as o + + a.id <- i.id + + o.id <- a.id + o.legs <- a.legs + } + `; + + const instructions = parseBridge(bridgeText); + const gateway = createGateway(typeDefs, instructions, { + tools: { + api: async (p: any) => ({ + id: p.id, + legs: { duration: "2h", distance: 150 }, + }), + }, + }); + const executor = buildHTTPExecutor({ fetch: gateway.fetch as any }); + const result: any = await executor({ + document: parse(`{ trip(id: 42) { id legs } }`), + }); + + assert.deepStrictEqual(result.data.trip, { + id: 42, + legs: { duration: "2h", distance: 150 }, + }); + }); + + test("legs: [JSONObject] — array of objects passthrough via wire", async () => { + const typeDefs = /* GraphQL */ ` + scalar JSONObject + type Query { + trip(id: Int): TripResult2 + } + type TripResult2 { + id: Int + legs: [JSONObject] + } + `; + + const bridgeText = bridge` + version 1.5 + bridge Query.trip { + with input as i + with api as a + with output as o + + a.id <- i.id + + o.id <- a.id + o.legs <- a.legs + } + `; + + const instructions = parseBridge(bridgeText); + const gateway = createGateway(typeDefs, instructions, { + tools: { + api: async (p: any) => ({ + id: p.id, + legs: [{ name: "L1" }, { name: "L2" }], + }), + }, + }); + const executor = buildHTTPExecutor({ fetch: gateway.fetch as any }); + const result: any = await executor({ + document: parse(`{ trip(id: 42) { id legs } }`), + }); + + assert.deepStrictEqual(result.data.trip, { + id: 42, + legs: [{ name: "L1" }, { name: "L2" }], + }); + }); + + test("legs: JSONObject — structured output (not passthrough)", async () => { + const typeDefs = /* GraphQL */ ` + scalar JSONObject + type Query { + trip(id: Int): TripResult3 + } + type TripResult3 { + id: Int + legs: JSONObject + } + `; + + const bridgeText = bridge` + version 1.5 + bridge Query.trip { + with input as i + with api as a + with output as o + + a.id <- i.id + + o.id <- a.id + o.legs { + .duration <- a.duration + .distance <- a.distance + } + } + `; + + const instructions = parseBridge(bridgeText); + const gateway = createGateway(typeDefs, instructions, { + tools: { + api: async (p: any) => ({ + id: p.id, + duration: "2h", + distance: 150, + }), + }, + }); + const executor = buildHTTPExecutor({ fetch: gateway.fetch as any }); + const result: any = await executor({ + document: parse(`{ trip(id: 42) { id legs } }`), + }); + + assert.deepStrictEqual(result.data.trip, { + id: 42, + legs: { duration: "2h", distance: 150 }, + }); + }); + + test("legs: [JSONObject] — array passthrough in array-mapped output", async () => { + const typeDefs = /* GraphQL */ ` + scalar JSONObject + type Query { + search(from: String, to: String): [SearchResult] + } + type SearchResult { + id: Int + provider: String + price: Int + legs: [JSONObject] + } + `; + + const bridgeText = bridge` + version 1.5 + bridge Query.search { + with input as i + with api as a + with output as o + + a.from <- i.from + a.to <- i.to + + o <- a.items[] as item { + .id <- item.id + .provider <- item.provider + .price <- item.price + .legs <- item.legs + } + } + `; + + const instructions = parseBridge(bridgeText); + const gateway = createGateway(typeDefs, instructions, { + tools: { + api: async () => ({ + items: [ + { id: 1, provider: "X", price: 50, legs: [{ name: "L1" }] }, + { id: 2, provider: "Y", price: 80, legs: [{ name: "L2" }] }, + ], + }), + }, + }); + const executor = buildHTTPExecutor({ fetch: gateway.fetch as any }); + const result: any = await executor({ + document: parse(`{ search(from: "A", to: "B") { id legs } }`), + }); + + assert.deepStrictEqual(result.data.search, [ + { id: 1, legs: [{ name: "L1" }] }, + { id: 2, legs: [{ name: "L2" }] }, + ]); + }); +}); diff --git a/packages/bridge/package.json b/packages/bridge/package.json index 610e5917..ddc6e926 100644 --- a/packages/bridge/package.json +++ b/packages/bridge/package.json @@ -16,9 +16,9 @@ "build": "tsc -p tsconfig.build.json", "prepack": "pnpm build", "lint:types": "tsc -p tsconfig.json", - "test": "node --experimental-transform-types --test test/*.test.ts test/bugfixes/*.test.ts test/legacy/*.test.ts", + "test": "node --experimental-transform-types --test test/*.test.ts test/bugfixes/*.test.ts test/legacy/*.test.ts test/utils/*.test.ts", "fuzz": "node --experimental-transform-types --test test/*.fuzz.ts", - "test:coverage": "node --experimental-test-coverage --test-coverage-exclude=\"test/**\" --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=lcov.info --experimental-transform-types --test test/*.test.ts test/bugfixes/*.test.ts test/legacy/*.test.ts", + "test:coverage": "node --experimental-test-coverage --test-coverage-exclude=\"test/**\" --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=lcov.info --experimental-transform-types --test test/*.test.ts test/bugfixes/*.test.ts test/legacy/*.test.ts test/utils/*.test.ts", "bench": "node --experimental-transform-types bench/engine.bench.ts", "bench:compiler": "node --experimental-transform-types bench/compiler.bench.ts" }, diff --git a/packages/bridge/test/language-spec/README.md b/packages/bridge/test/language-spec/README.md new file mode 100644 index 00000000..a82bd88d --- /dev/null +++ b/packages/bridge/test/language-spec/README.md @@ -0,0 +1,159 @@ +# Bridge Language Specification by Example + +This folder contains a curated set of regression tests that double as +**executable documentation** for the Bridge language. Each file focuses on one +language concept and is written to be read top-to-bottom — the Bridge code +explains the feature, and the scenario titles describe the expected behaviour in +plain English. + +> **Rule:** one file · one concept · every facet covered. + +--- + +## Test Index + +### 1. `wires.test.ts` — How data flows through a Bridge program + +The two fundamental wire types and where they can read from. + +- **Pull wires** (`<-`) connect data sources to tool inputs and output fields. +- **Constant wires** (`=`) assign literal JSON values. +- **Passthrough wires** return an entire object or array as the root output. +- Sources: `input`, `context`, `const`, tool output, nested paths. + +### 2. `constants.test.ts` — Static values embedded in the program + +`const` blocks hold JSON data that can be referenced anywhere via +`with const as c`. Covers primitives, objects, arrays, and nested access paths. + +### 3. `tools.test.ts` — Calling external functions + +How tools are declared (`tool X from source`), configured with input parameters, +and composed through inheritance (`tool child from parent`). Also covers +tool-level `on error` fallback. + +### 4. `scope-and-handles.test.ts` — `with` bindings and handle visibility + +Every dependency enters a block through a `with` declaration. Shows +`with input`, `with context`, `with const`, `with tool`, and `with define`; +handle naming; and the rule that handles are block-scoped (no leaking, no +implicit access). + +### 5. `path-scoping.test.ts` — Nested scope blocks and spread + +Scope blocks (`target { .a <- x; .b <- y }`) factor out repeated path prefixes. +The spread operator (`... <- source`) unpacks all fields. Covers nesting depth, +path concatenation, and spread-then-override. + +### 6. `expressions.test.ts` — Arithmetic, comparison, and boolean logic + +Inline expressions: `+`, `-`, `*`, `/`, `==`, `!=`, `>`, `>=`, `<`, `<=`, +`and`, `or`, `not`, and parentheses. Shows operator precedence and +short-circuit evaluation of `and`/`or`. + +### 7. `string-interpolation.test.ts` — Template strings + +`"/users/{i.id}/profile"` — placeholder resolution at runtime. Covers multiple +placeholders, type coercion (null → `""`), escaping (`\{`), and usage inside +different wire positions. + +### 8. `ternary.test.ts` — Conditional wires + +`condition ? trueSource : falseSource` — the only branching primitive. Lazy +evaluation (only the chosen branch executes), expression conditions, nesting, +and combination with fallback operators. + +### 9. `array-mapping.test.ts` — Iterating over arrays + +`source[] as item { ... }` maps each element into a new shape. Covers flat +mapping, nested arrays, empty/null arrays, shadow scopes (element isolation), +and the iterator variable. + +### 10. `continue-and-break.test.ts` — Array control flow + +`continue` skips an element, `break` stops the loop. Multi-level variants +`continue N` / `break N` pierce nested array boundaries. Only valid inside +array blocks. + +### 11. `fallback-chain.test.ts` — Resilience operators + +The four fallback layers, each triggered by a different condition: + +- `||` — falsy (0, "", false, null, undefined) +- `??` — nullish only (null, undefined) +- `catch` — tool/resolution error +- `?.` — safe navigation (error → undefined) + +Shows individual behaviour and composed chains. + +### 12. `throw-and-panic.test.ts` — Raising errors + +`throw "msg"` fails one field (partial error, GraphQL-compatible). +`panic "msg"` kills the entire request (fatal). Shows how `catch` swallows +`throw` but not `panic`, and how `?.` handles each. + +### 13. `overdefinition.test.ts` — Cost-based resolution + +When multiple wires target the same field, the engine tries the cheapest source +first (input/context = cost 0, tools = cost 1) and returns the first non-null +result. This enables progressive enrichment without explicit conditionals. + +### 14. `alias.test.ts` — Caching resolved values + +`alias source as name` evaluates once and caches the result. Prevents duplicate +tool calls. Top-level aliases live for the whole request; iterator-scoped +aliases are re-evaluated per array element. + +### 15. `memoization.test.ts` — Deduplicating tool calls + +`with tool as handle memoize` caches tool results keyed by input. When the same +inputs appear (e.g., in a loop), the tool is called only once. Different from +`alias` (which caches a value, not a tool invocation). + +### 16. `pipes.test.ts` — Tool chaining shorthand + +`target <- trim:upper:i.name` chains tools right-to-left. Each segment is an +independent tool call. Multiple pipes to the same tool produce parallel, +independent invocations. + +### 17. `define-blocks.test.ts` — Reusable subgraphs + +`define` blocks are parameterised, inlined subgraphs. They have their own +`input`/`output` and can be invoked multiple times with different wiring. +Bridge's only abstraction/reuse mechanism within a file. + +### 18. `force.test.ts` — Eager side-effect scheduling + +`force handle` runs a tool even if no output field reads from it. Used for +audit logging, analytics, webhooks. `force handle catch null` makes it +fire-and-forget (errors silently swallowed). + +### 19. `array-batching.test.ts` — Native call batching in loops + +Loop-scoped tools can receive all iterations as a single batched call instead of +N individual calls. Reduces network round-trips. Shows the batching contract +and partial-failure semantics per element. + +### 20. `builtin-tools.test.ts` — The `std.*` standard library + +Built-in tools: `std.httpCall`, `std.str.*` (toUpperCase, toLowerCase, trim, +length), `std.arr.*` (filter, find, first, toArray), and `std.audit`. The +batteries-included toolkit. + +### 21. `error-reporting.test.ts` — Error locations and formatting + +How Bridge reports errors: source locations pointing to the failing tool/wire, +structured messages, and context for nested errors (inside ternaries, arrays, +scope blocks). + +### 22. `circular-dependency.test.ts` — Cycle detection + +When wires form A → B → A loops, the engine detects the cycle before execution +and raises a panic. Shows what cyclical graphs look like and how the engine +rejects them. + +### 23. `tracing.test.ts` — Execution traces and observability + +Traces record every tool call with name, inputs, outputs, and duration. +Verifies trace counts (important for deduplication/memoization/batching) and +the trace output shape. diff --git a/packages/bridge/test/language-spec/wires.test.ts b/packages/bridge/test/language-spec/wires.test.ts new file mode 100644 index 00000000..d3a280c3 --- /dev/null +++ b/packages/bridge/test/language-spec/wires.test.ts @@ -0,0 +1,104 @@ +/** + * Wires — How data flows through a Bridge program + * + * Everything in Bridge is a wire. This file shows every wire variant + * in one bridge block so you can see how they work together. + */ + +import { regressionTest } from "../utils/regression.ts"; +import { tools } from "../utils/bridge-tools.ts"; +import { bridge } from "@stackables/bridge"; + +regressionTest("wires", { + bridge: bridge` + version 1.5 + + # ── const blocks live outside bridges and hold static JSON ── + const defaults = { "currency": "EUR", "locale": "de-CH" } + + bridge Wires.showcase { + with test.multitool as api + with test.multitool as second + with input as i + with context as ctx + with const as c + with output as o + + # ── pull wire (<-): read a value from a source ────────────── + # from input — flat and nested paths, including deep access + o.name <- i.name + o.city <- i.address.city + o.zip <- i.address.postal.zip + + # from context — server-side values the caller can't control + o.region <- ctx.region + + # from a const block — accessed via c.. + o.currency <- c.defaults.currency + + # ── constant wire (=): literal value ───────────────────────── + o.greeting = "hello" + o.limit = 100 + o.enabled = true + + # ── wiring into tool inputs ───────────────────────────────── + # root wire: pass entire object as the tool's input + api <- i.request + + # field wires: set individual tool input fields + api.token <- ctx.apiKey + + # ── pull from tool output ─────────────────────────────────── + # ?. = safe access — if the tool errors, yield null instead of failing + # (strict access without ?. propagates errors — see throw-and-panic) + o.itemCount <- api?.count + + # ── chaining: one tool's output feeds the next ────────────── + second.value <- api?.processed + o.chained <- second?.value + } + + # ── passthrough wire: return entire object as root output ───── + bridge Wires.passthrough { + with test.multitool as api + with input as i + with output as o + + api <- i.request + o <- api?.user + } + `, + tools, + scenarios: { + "Wires.showcase": { + "all wire types producing output together": { + input: { + name: "Alice", + address: { city: "Zürich", postal: { zip: "8001" } }, + request: { count: 42, processed: "step-1" }, + }, + context: { region: "eu", apiKey: "sk-1" }, + assertData: { + name: "Alice", + city: "Zürich", + zip: "8001", + region: "eu", + currency: "EUR", + greeting: "hello", + limit: 100, + enabled: true, + itemCount: 42, + chained: "step-1", + }, + assertTraces: 2, + }, + }, + "Wires.passthrough": { + "passthrough returns entire nested object as root": { + input: { request: { user: { id: 1, name: "Alice", role: "admin" } } }, + assertData: { id: 1, name: "Alice", role: "admin" }, + assertTraces: 1, + }, + }, + }, +}); diff --git a/packages/bridge/test/shared-parity.test.ts b/packages/bridge/test/shared-parity.test.ts index 13397214..36c3f6cf 100644 --- a/packages/bridge/test/shared-parity.test.ts +++ b/packages/bridge/test/shared-parity.test.ts @@ -1718,7 +1718,6 @@ regressionTest("parity: sparse fieldsets — wildcard and chains", { }, fields: ["id", "legs.*"], assertData: { id: 42, legs: { duration: "2h", distance: 150 } }, - disable: ["graphql"], assertTraces: 1, }, "requesting price returns price": { @@ -1883,7 +1882,6 @@ regressionTest("parity: sparse fieldsets — nested and array paths", { { id: 1, legs: [{ name: "L1" }] }, { id: 2, legs: [{ name: "L2" }] }, ], - disable: ["graphql"], assertTraces: 1, }, "all fields returned when no requestedFields": { @@ -2061,3 +2059,105 @@ regressionTest("parity: sparse fieldsets — nested and array paths", { }, }, }); + +regressionTest("parity: sparse fieldsets — non-array object selection", { + bridge: bridge` + version 1.5 + + bridge Query.sparseObjPassthrough { + with input as i + with api as a + with output as o + + a.id <- i.id + + o.id <- a.id + o.legs <- a.legs + o.price <- a.price + } + + bridge Query.sparseObjStructured { + with input as i + with api as a + with output as o + + a.id <- i.id + + o.id <- a.id + o.legs { + .duration <- a.duration + .distance <- a.distance + } + o.price <- a.price + } + `, + scenarios: { + "Query.sparseObjPassthrough": { + "bare legs selector passes through object via JSONObject": { + input: { id: 42 }, + tools: { + api: (p: any) => ({ + id: p.id, + legs: { duration: "2h", distance: 150 }, + price: 99, + }), + }, + fields: ["id", "legs"], + assertData: { id: 42, legs: { duration: "2h", distance: 150 } }, + assertTraces: 1, + }, + "all fields returned when no requestedFields": { + input: { id: 42 }, + tools: { + api: (p: any) => ({ + id: p.id, + legs: { duration: "2h", distance: 150 }, + price: 99, + }), + }, + assertData: { + id: 42, + legs: { duration: "2h", distance: 150 }, + price: 99, + }, + assertTraces: 1, + }, + }, + "Query.sparseObjStructured": { + "bare legs selector on structured output via JSONObject": { + input: { id: 42 }, + tools: { + api: (p: any) => ({ + id: p.id, + duration: "2h", + distance: 150, + price: 99, + }), + }, + fields: ["id", "legs"], + assertData: { + id: 42, + legs: { duration: "2h", distance: 150 }, + }, + assertTraces: 1, + }, + "all fields returned when no requestedFields": { + input: { id: 42 }, + tools: { + api: (p: any) => ({ + id: p.id, + duration: "2h", + distance: 150, + price: 99, + }), + }, + assertData: { + id: 42, + legs: { duration: "2h", distance: 150 }, + price: 99, + }, + assertTraces: 1, + }, + }, + }, +}); diff --git a/packages/bridge/test/utils/field-selection-harness.test.ts b/packages/bridge/test/utils/field-selection-harness.test.ts new file mode 100644 index 00000000..1b412815 --- /dev/null +++ b/packages/bridge/test/utils/field-selection-harness.test.ts @@ -0,0 +1,235 @@ +/** + * Regression tests for the GraphQL field selection harness helpers. + * + * Documents the semantics of the `fields` option used in regression tests: + * + * - `field` or `field.subfield` — **full selector** (cascades to full + * sub-object). Represented in GraphQL by replacing the output type + * with `JSONObject` so no sub-field selection is required in the query. + * + * - `field.*` or `field.subfield.*` — **shallow sub-select** of scalar + * values only. Object-typed children are excluded from the query. + */ + +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import { + buildGraphQLSchema, + buildSelectionTreeFromPaths, + buildGraphQLOperationSource, + collectFieldsRequiringJSONObject, + replaceFieldTypesWithJSONObject, + type Scenario, +} from "./regression.ts"; + +// ── Shared test schema ────────────────────────────────────────────────────── +// +// Schema shape: +// id: Int +// legs: +// a: String +// b: Int +// c: +// c1: String +// c2: Float + +const testSDL = ` +type Query { + travel(id: Int): TravelResult +} + +type TravelResult { + id: Int + legs: TravelLegs +} + +type TravelLegs { + a: String + b: Int + c: TravelLegsC +} + +type TravelLegsC { + c1: String + c2: Float +} +`; + +describe("field selection harness", () => { + // ── buildSelectionTreeFromPaths ────────────────────────────────────────── + + describe("buildSelectionTreeFromPaths", () => { + test("scalar path produces empty leaf", () => { + const tree = buildSelectionTreeFromPaths(["id"]); + assert.deepStrictEqual(tree, { id: {} }); + }); + + test("wildcard path creates literal * node", () => { + const tree = buildSelectionTreeFromPaths(["legs.*"]); + assert.deepStrictEqual(tree, { legs: { "*": {} } }); + }); + + test("bare object path creates empty leaf", () => { + const tree = buildSelectionTreeFromPaths(["legs"]); + assert.deepStrictEqual(tree, { legs: {} }); + }); + + test("nested path creates nested tree", () => { + const tree = buildSelectionTreeFromPaths(["legs.c.c1"]); + assert.deepStrictEqual(tree, { legs: { c: { c1: {} } } }); + }); + }); + + // ── Wildcard: fields: ["id", "legs.*"] ────────────────────────────────── + // + // Should generate: { travel(id: $id) { id legs { a b } } } + // Only scalar sub-fields of `legs` are included (not `c` which is an object). + + describe('fields: ["id", "legs.*"] — wildcard selects scalars only', () => { + test("generated query includes only scalar sub-fields of legs", () => { + const schema = buildGraphQLSchema(testSDL); + const expectedData = { id: 1, legs: { a: "x", b: 2 } }; + + const source = buildGraphQLOperationSource( + schema, + "Query.travel", + expectedData, + ["id", "legs.*"], + ); + + // Should select `a` and `b` (scalars), but NOT `c` (object) + assert.ok(source.includes("legs"), "query should include legs"); + assert.ok(source.includes(" a"), "query should include scalar field a"); + assert.ok(source.includes(" b"), "query should include scalar field b"); + assert.ok( + !source.includes(" c"), + "query should NOT include object field c", + ); + }); + }); + + // ── Full selector: fields: ["id", "legs"] ─────────────────────────────── + // + // Should generate: { travel(id: $id) { id legs } } + // The schema must have `legs` typed as JSONObject (no sub-selection needed). + + describe('fields: ["id", "legs"] — bare leaf uses JSONObject schema', () => { + test("collectFieldsRequiringJSONObject finds legs as needing JSONObject", () => { + const schema = buildGraphQLSchema(testSDL); + const scenarios: Record = { + "full object": { + input: { id: 1 }, + fields: ["id", "legs"], + assertData: { id: 1, legs: { a: "x", b: 2, c: { c1: "y", c2: 3 } } }, + assertTraces: 1, + }, + }; + + const result = collectFieldsRequiringJSONObject( + schema, + "Query.travel", + scenarios, + Object.keys(scenarios), + ); + + assert.ok(result.has("TravelResult"), "should identify TravelResult"); + assert.ok( + result.get("TravelResult")!.has("legs"), + "should flag legs for JSONObject replacement", + ); + }); + + test("replaceFieldTypesWithJSONObject rewrites legs type in SDL", () => { + const fieldsToReplace = new Map([ + ["TravelResult", new Set(["legs"])], + ]); + const modified = replaceFieldTypesWithJSONObject(testSDL, fieldsToReplace); + + assert.ok( + modified.includes("scalar JSONObject"), + "should add JSONObject scalar declaration", + ); + assert.ok( + modified.includes("legs: JSONObject"), + "should replace legs type with JSONObject", + ); + }); + + test("generated query has bare legs field with no sub-selection", () => { + const fieldsToReplace = new Map([ + ["TravelResult", new Set(["legs"])], + ]); + const modifiedSDL = replaceFieldTypesWithJSONObject( + testSDL, + fieldsToReplace, + ); + const schema = buildGraphQLSchema(modifiedSDL); + const expectedData = { id: 1, legs: { a: "x", b: 2 } }; + + const source = buildGraphQLOperationSource( + schema, + "Query.travel", + expectedData, + ["id", "legs"], + ); + + // legs should appear without sub-selection because it's now JSONObject + assert.ok(source.includes("legs"), "query should include legs"); + assert.ok( + !source.includes("legs {"), + "legs should NOT have sub-field selection", + ); + }); + }); + + // ── Scalar leaf is not affected ───────────────────────────────────────── + + describe("scalar field is not flagged for JSONObject", () => { + test("scalar leaf field is not collected for replacement", () => { + const schema = buildGraphQLSchema(testSDL); + const scenarios: Record = { + scalar: { + input: { id: 1 }, + fields: ["id"], + assertData: { id: 1 }, + assertTraces: 0, + }, + }; + + const result = collectFieldsRequiringJSONObject( + schema, + "Query.travel", + scenarios, + Object.keys(scenarios), + ); + + assert.equal(result.size, 0, "no fields should need JSONObject"); + }); + }); + + // ── Nested dotted path is not affected ────────────────────────────────── + + describe("dotted sub-field path does not trigger JSONObject", () => { + test("path like legs.c.c1 does not replace legs", () => { + const schema = buildGraphQLSchema(testSDL); + const scenarios: Record = { + nested: { + input: { id: 1 }, + fields: ["legs.c.c1"], + assertData: { legs: { c: { c1: "val" } } }, + assertTraces: 0, + }, + }; + + const result = collectFieldsRequiringJSONObject( + schema, + "Query.travel", + scenarios, + Object.keys(scenarios), + ); + + // legs.c.c1 drills into legs, so legs is NOT a leaf + assert.equal(result.size, 0, "no fields should need JSONObject"); + }); + }); +}); diff --git a/packages/bridge/test/utils/regression.ts b/packages/bridge/test/utils/regression.ts index 13dd4365..427cf2bb 100644 --- a/packages/bridge/test/utils/regression.ts +++ b/packages/bridge/test/utils/regression.ts @@ -40,11 +40,15 @@ import { execute as executeGraphQL, getNamedType, isObjectType, + isScalarType, parse as parseGraphQL, print as printGraphQL, visit, + type FieldDefinitionNode, + type GraphQLObjectType, type GraphQLOutputType, type GraphQLSchema, + type TypeNode, } from "graphql"; import type { Bridge } from "@stackables/bridge-core"; import { omitLoc } from "./parse-test-utils.ts"; @@ -172,6 +176,35 @@ function buildSelectionTreeFromType( return tree; } +/** + * Like `buildSelectionTreeFromType` but only includes immediate scalar + * fields — object-typed children are skipped. Used for wildcard (`*`) + * expansion where `field.*` means "select only scalar sub-fields". + */ +function buildScalarOnlySelectionTreeFromType( + type: GraphQLOutputType, +): SelectionTree | null { + const unwrapped = unwrapOutputType(type); + if (unwrapped instanceof GraphQLList) { + return buildScalarOnlySelectionTreeFromType(unwrapped.ofType); + } + + const named = getNamedType(unwrapped); + if (!isObjectType(named)) { + return null; + } + + const tree: SelectionTree = {}; + for (const [fieldName, fieldDef] of Object.entries(named.getFields())) { + const fieldNamedType = getNamedType(fieldDef.type); + if (isScalarType(fieldNamedType)) { + tree[fieldName] = {}; + } + } + + return Object.keys(tree).length > 0 ? tree : null; +} + function buildSelectionTreeForValue( value: unknown, type: GraphQLOutputType, @@ -217,6 +250,46 @@ function buildSelectionTreeForValue( return tree; } +/** + * Expand wildcard (`*`) entries in a selection tree by looking up + * **scalar-only** sub-fields from the GraphQL schema type. + * Bare leaf selections on object-typed fields are left unchanged + * because they require JSONObject schema replacement instead. + * + * - `{ legs: { "*": {} } }` → `{ legs: { a: {}, b: {} } }` (scalars only) + * - `{ legs: {} }` where `legs` is an object type → left as `{ legs: {} }` + */ +function resolveSelectionsAgainstSchema( + tree: SelectionTree, + type: GraphQLOutputType, +): SelectionTree { + const named = getNamedType(type); + if (!isObjectType(named)) return tree; + + const result: SelectionTree = {}; + for (const [key, child] of Object.entries(tree)) { + const fieldDef = named.getFields()[key]; + if (!fieldDef) { + result[key] = child; + continue; + } + + if ("*" in child) { + // Expand wildcard to scalar-only immediate sub-fields + const expanded = buildScalarOnlySelectionTreeFromType(fieldDef.type); + result[key] = expanded ?? {}; + } else if (Object.keys(child).length > 0) { + result[key] = resolveSelectionsAgainstSchema(child, fieldDef.type); + } else { + // Bare leaf — keep as-is; JSONObject schema replacement handles + // object-typed fields selected without sub-field paths. + result[key] = child; + } + } + + return result; +} + function renderSelectionTree(tree: SelectionTree | null): string { if (!tree || Object.keys(tree).length === 0) { return ""; @@ -234,6 +307,143 @@ function renderSelectionTree(tree: SelectionTree | null): string { return `{ ${body} }`; } +// ── JSONObject replacement for bare-leaf object field selections ──────────── + +/** + * Walk a GraphQL AST type node and replace its innermost NamedType name. + * Preserves NonNull and List wrappers. + */ +function replaceNamedTypeNode(typeNode: TypeNode, newName: string): TypeNode { + switch (typeNode.kind) { + case "NamedType": + return { ...typeNode, name: { ...typeNode.name, value: newName } }; + case "NonNullType": + return { + ...typeNode, + type: replaceNamedTypeNode(typeNode.type, newName) as + typeof typeNode.type, + }; + case "ListType": + return { + ...typeNode, + type: replaceNamedTypeNode(typeNode.type, newName) as + typeof typeNode.type, + }; + default: + return typeNode; + } +} + +/** + * Scan scenarios for leaf field-paths that correspond to object-typed fields + * in the schema (not wildcards). Returns a map of `typeName → Set` + * identifying fields whose types should be replaced with `JSONObject`. + * + * A bare path like `"legs"` is a *full selector* — it cascades to the entire + * sub-object. The only way to represent this in GraphQL is to make the + * field's return type a `JSONObject` scalar so no sub-field selection is + * required in the query. + */ +function collectFieldsRequiringJSONObject( + schema: GraphQLSchema, + operation: string, + scenarios: Record, + scenarioNames: string[], +): Map> { + const result = new Map>(); + + // Collect all non-wildcard leaf paths from scenarios with explicit fields + const allPaths = new Set(); + for (const name of scenarioNames) { + const scenario = scenarios[name]!; + if (scenario.disable?.includes("graphql") || !scenario.fields) continue; + for (const field of scenario.fields) { + if (!field.endsWith(".*")) { + allPaths.add(field); + } + } + } + + // A path is a leaf if no other path drills into it + const leafPaths = [...allPaths].filter( + (path) => ![...allPaths].some((other) => other.startsWith(path + ".")), + ); + + if (leafPaths.length === 0) return result; + + const { field: rootFieldDef } = getOperationField(schema, operation); + + for (const leafPath of leafPaths) { + const segments = leafPath.split("."); + let currentType: GraphQLObjectType = getNamedType( + rootFieldDef.type, + ) as GraphQLObjectType; + if (!isObjectType(currentType)) continue; + + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]!; + const fieldDef = currentType.getFields()[seg]; + if (!fieldDef) break; + + if (i === segments.length - 1) { + // This is the leaf segment — check if it has an object type + const fieldNamedType = getNamedType(fieldDef.type); + if (isObjectType(fieldNamedType)) { + const typeFields = result.get(currentType.name) ?? new Set(); + typeFields.add(seg); + result.set(currentType.name, typeFields); + } + } else { + const nextType = getNamedType(fieldDef.type); + if (!isObjectType(nextType)) break; + currentType = nextType; + } + } + } + + return result; +} + +/** + * Rewrite an SDL string so that the listed fields are typed as `JSONObject` + * instead of their original complex type. + */ +function replaceFieldTypesWithJSONObject( + typeDefs: string, + fieldsToReplace: Map>, +): string { + const ast = parseGraphQL(typeDefs); + let currentTypeName = ""; + + const modifiedAst = visit(ast, { + ObjectTypeDefinition: { + enter(node) { + currentTypeName = node.name.value; + }, + leave() { + currentTypeName = ""; + }, + }, + FieldDefinition(node): FieldDefinitionNode | undefined { + const fields = fieldsToReplace.get(currentTypeName); + if (!fields?.has(node.name.value)) return undefined; + return { + ...node, + type: replaceNamedTypeNode(node.type, "JSONObject"), + }; + }, + }); + + let result = printGraphQL(modifiedAst); + if ( + fieldsToReplace.size > 0 && + !result.includes("scalar JSONObject") + ) { + result = `scalar JSONObject\n\n${result}`; + } + return result; +} + function ensureExecutableSDL(typeDefs: string): string { return typeDefs.includes("type Query {") ? typeDefs @@ -372,6 +582,17 @@ function mergeObservedSelection( for (const selectedPath of selectedPaths) { const path = selectedPath.split(".").filter(Boolean); + + // Handle wildcard: "legs.*" means "select all children of legs" + if (path.length > 1 && path[path.length - 1] === "*") { + const parentPath = path.slice(0, -1); + const parentValue = getObservationPath(value, parentPath); + if (parentValue !== undefined) { + setObservationPath(merged, parentPath, parentValue); + } + continue; + } + const selectedValue = getObservationPath(value, path); if (selectedValue !== undefined) { setObservationPath(merged, path, selectedValue); @@ -428,12 +649,21 @@ function buildGraphQLOperationSource( ? `(${field.args.map((arg) => `${arg.name}: $${arg.name}`).join(", ")})` : ""; - const selectionTree = orderSelectionTree( + let selectionTree = orderSelectionTree( requestedFields?.length ? buildSelectionTreeFromPaths(requestedFields) : buildSelectionTreeForValue(expectedData, field.type), preferredFieldOrder, ); + + // When explicit requestedFields are provided, resolve wildcards + // (e.g. "legs.*") and bare leaf selections on object-typed fields + // (e.g. "legs" where legs has sub-fields) against the schema type so + // the resulting GraphQL query includes the required sub-field selections. + if (selectionTree && requestedFields?.length) { + selectionTree = resolveSelectionsAgainstSchema(selectionTree, field.type); + } + const selection = renderSelectionTree(selectionTree); const operationKeyword = rootTypeName === "Mutation" ? "mutation" : "query"; @@ -1063,6 +1293,7 @@ export function regressionTest(name: string, data: RegressionTest) { describe("graphql replay", () => { let rawSchema!: GraphQLSchema; let replayExemplar: Record = {}; + let inferredSDL = ""; before(async () => { await runtimeCollectionDone; @@ -1130,11 +1361,11 @@ export function regressionTest(name: string, data: RegressionTest) { `Cannot infer GraphQL schema for ${operation} without at least one successful scenario`, ); - const replaySDL = relaxInputNullabilityInSDL( + inferredSDL = relaxInputNullabilityInSDL( ensureExecutableSDLForOperation(observer.toSDL(), operation), ); - rawSchema = buildGraphQLSchema(replaySDL); + rawSchema = buildGraphQLSchema(inferredSDL); }); for (const scenarioName of scenarioNames) { @@ -1170,14 +1401,36 @@ export function regressionTest(name: string, data: RegressionTest) { } context.__bridgeSignal = ac.signal; - const transformedSchema = bridgeTransform(rawSchema, document, { + // Per-scenario schema: when a scenario selects an + // object-typed field as a bare leaf (e.g. fields: ["legs"]), + // build a modified schema where that field is typed as + // JSONObject so the query doesn't need sub-field selection. + const scenarioScenarios: Record = { + [scenarioName]: scenario, + }; + const fieldsToReplace = collectFieldsRequiringJSONObject( + rawSchema, + operation, + scenarioScenarios, + [scenarioName], + ); + let querySchema = rawSchema; + if (fieldsToReplace.size > 0) { + const modifiedSDL = replaceFieldTypesWithJSONObject( + inferredSDL, + fieldsToReplace, + ); + querySchema = buildGraphQLSchema(modifiedSDL); + } + + const transformedSchema = bridgeTransform(querySchema, document, { tools, signalMapper: (ctx) => ctx.__bridgeSignal, toolTimeoutMs: data.toolTimeoutMs ?? 5_000, trace: "full", }); const source = buildGraphQLOperationSource( - rawSchema, + querySchema, operation, replayExpectedData, scenario.fields, @@ -1187,7 +1440,7 @@ export function regressionTest(name: string, data: RegressionTest) { schema: transformedSchema, document: parseGraphQL(source), variableValues: pickDeclaredVariables( - rawSchema, + querySchema, operation, scenario.input, ), @@ -1303,3 +1556,13 @@ export function regressionTest(name: string, data: RegressionTest) { } }); } + +// ── Exported helpers for harness unit tests ───────────────────────────────── + +export { + buildSelectionTreeFromPaths, + buildGraphQLOperationSource, + buildGraphQLSchema, + collectFieldsRequiringJSONObject, + replaceFieldTypesWithJSONObject, +};