Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion packages/bridge-core/src/ExecutionTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(".");
Expand Down Expand Up @@ -1820,6 +1843,12 @@ export class ExecutionTree implements TreeContext {
if (fieldName !== undefined && fieldName in elementData) {
const value = (elementData as Record<string, any>)[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) => {
Expand All @@ -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;
}
Expand Down
215 changes: 215 additions & 0 deletions packages/bridge-graphql/test/jsonobject-fields.test.ts
Original file line number Diff line number Diff line change
@@ -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" }] },
]);
});
});
4 changes: 2 additions & 2 deletions packages/bridge/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
Loading
Loading