Skip to content

Commit 04243e6

Browse files
authored
Object spread bugfixes (#90)
* Spread syntax in playground * fix: object spread functionality in path-scoped blocks
1 parent 517e33b commit 04243e6

File tree

7 files changed

+312
-17
lines changed

7 files changed

+312
-17
lines changed

.changeset/cute-states-wash.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
---
22
"@stackables/bridge-compiler": minor
33
"@stackables/bridge-parser": minor
4+
"@stackables/bridge-core": minor
45
---
56

67
Support object spread in path-scoped scope blocks

packages/bridge-compiler/src/codegen.ts

Lines changed: 82 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1348,13 +1348,24 @@ class CodegenContext {
13481348
const arrayIterators = this.bridge.arrayIterators ?? {};
13491349
const isRootArray = "" in arrayIterators;
13501350

1351-
// Check for root passthrough (wire with empty path) — but not if it's a root array source
1352-
const rootWire = outputWires.find((w) => w.to.path.length === 0);
1353-
if (rootWire && !isRootArray) {
1354-
lines.push(` return ${this.wireToExpr(rootWire)};`);
1351+
// Separate root wires into passthrough vs spread
1352+
const rootWires = outputWires.filter((w) => w.to.path.length === 0);
1353+
const spreadRootWires = rootWires.filter(
1354+
(w) => "from" in w && "spread" in w && w.spread,
1355+
);
1356+
const passthroughRootWire = rootWires.find(
1357+
(w) => !("from" in w && "spread" in w && w.spread),
1358+
);
1359+
1360+
// Passthrough (non-spread root wire) — return directly
1361+
if (passthroughRootWire && !isRootArray) {
1362+
lines.push(` return ${this.wireToExpr(passthroughRootWire)};`);
13551363
return;
13561364
}
13571365

1366+
// Check for root passthrough (wire with empty path) — but not if it's a root array source
1367+
const rootWire = rootWires[0]; // for backwards compat with array handling below
1368+
13581369
// Handle root array output (o <- src.items[] as item { ... })
13591370
if (isRootArray && rootWire) {
13601371
const elemWires = outputWires.filter(
@@ -1461,6 +1472,13 @@ class CodegenContext {
14611472
} else if (arrayFields.has(topField) && w.to.path.length === 1) {
14621473
// Root wire for an array field
14631474
arraySourceWires.set(topField, w);
1475+
} else if (
1476+
"from" in w &&
1477+
"spread" in w &&
1478+
w.spread &&
1479+
w.to.path.length === 0
1480+
) {
1481+
// Spread root wire — handled separately via spreadRootWires
14641482
} else {
14651483
scalarWires.push(w);
14661484
}
@@ -1470,11 +1488,42 @@ class CodegenContext {
14701488
interface TreeNode {
14711489
expr?: string;
14721490
terminal?: boolean;
1491+
spreadExprs?: string[];
14731492
children: Map<string, TreeNode>;
14741493
}
14751494
const tree: TreeNode = { children: new Map() };
14761495

1477-
for (const w of scalarWires) {
1496+
// First pass: handle nested spread wires (spread with path.length > 0)
1497+
const nestedSpreadWires = scalarWires.filter(
1498+
(w) => "from" in w && "spread" in w && w.spread && w.to.path.length > 0,
1499+
);
1500+
const normalScalarWires = scalarWires.filter(
1501+
(w) => !("from" in w && "spread" in w && w.spread),
1502+
);
1503+
1504+
// Add nested spread expressions to tree nodes
1505+
for (const w of nestedSpreadWires) {
1506+
const path = w.to.path;
1507+
let current = tree;
1508+
// Navigate to parent of the target
1509+
for (let i = 0; i < path.length - 1; i++) {
1510+
const seg = path[i]!;
1511+
if (!current.children.has(seg)) {
1512+
current.children.set(seg, { children: new Map() });
1513+
}
1514+
current = current.children.get(seg)!;
1515+
}
1516+
const lastSeg = path[path.length - 1]!;
1517+
if (!current.children.has(lastSeg)) {
1518+
current.children.set(lastSeg, { children: new Map() });
1519+
}
1520+
const node = current.children.get(lastSeg)!;
1521+
// Add spread expression to this node
1522+
if (!node.spreadExprs) node.spreadExprs = [];
1523+
node.spreadExprs.push(this.wireToExpr(w));
1524+
}
1525+
1526+
for (const w of normalScalarWires) {
14781527
const path = w.to.path;
14791528
let current = tree;
14801529
for (let i = 0; i < path.length - 1; i++) {
@@ -1561,7 +1610,9 @@ class CodegenContext {
15611610
}
15621611

15631612
// Serialize the tree to a return statement
1564-
const objStr = this.serializeOutputTree(tree, 4);
1613+
// Include spread expressions at the start if present
1614+
const spreadExprs = spreadRootWires.map((w) => this.wireToExpr(w));
1615+
const objStr = this.serializeOutputTree(tree, 4, spreadExprs);
15651616
lines.push(` return ${objStr};`);
15661617
}
15671618

@@ -1571,15 +1622,37 @@ class CodegenContext {
15711622
children: Map<string, { expr?: string; children: Map<string, any> }>;
15721623
},
15731624
indent: number,
1625+
spreadExprs?: string[],
15741626
): string {
15751627
const pad = " ".repeat(indent);
15761628
const entries: string[] = [];
15771629

1630+
// Add spread expressions first (they come before field overrides)
1631+
if (spreadExprs) {
1632+
for (const expr of spreadExprs) {
1633+
entries.push(`${pad}...${expr}`);
1634+
}
1635+
}
1636+
15781637
for (const [key, child] of node.children) {
1579-
if (child.expr != null && child.children.size === 0) {
1638+
// Check if child has spread expressions
1639+
const childSpreadExprs = (child as { spreadExprs?: string[] })
1640+
.spreadExprs;
1641+
1642+
if (
1643+
child.expr != null &&
1644+
child.children.size === 0 &&
1645+
!childSpreadExprs
1646+
) {
1647+
// Simple leaf with just an expression
15801648
entries.push(`${pad}${JSON.stringify(key)}: ${child.expr}`);
1581-
} else if (child.children.size > 0 && child.expr == null) {
1582-
const nested = this.serializeOutputTree(child, indent + 2);
1649+
} else if (childSpreadExprs || child.children.size > 0) {
1650+
// Nested object: may have spreads, children, or both
1651+
const nested = this.serializeOutputTree(
1652+
child,
1653+
indent + 2,
1654+
childSpreadExprs,
1655+
);
15831656
entries.push(`${pad}${JSON.stringify(key)}: ${nested}`);
15841657
} else {
15851658
// Has both expr and children — use expr (children override handled elsewhere)

packages/bridge-core/src/ExecutionTree.ts

Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -618,7 +618,16 @@ export class ExecutionTree implements TreeContext {
618618
w.to.field === field &&
619619
pathEquals(w.to.path, prefix),
620620
);
621-
if (exactWires.length > 0) {
621+
622+
// Separate spread wires from regular wires
623+
const spreadWires = exactWires.filter(
624+
(w) => "from" in w && "spread" in w && w.spread,
625+
);
626+
const regularWires = exactWires.filter(
627+
(w) => !("from" in w && "spread" in w && w.spread),
628+
);
629+
630+
if (regularWires.length > 0) {
622631
// Check for array mapping: exact wires (the array source) PLUS
623632
// element-level wires deeper than prefix (the field mappings).
624633
// E.g. `o.entries <- src[] as x { .id <- x.item_id }` produces
@@ -639,15 +648,16 @@ export class ExecutionTree implements TreeContext {
639648
if (hasElementWires) {
640649
// Array mapping on a sub-field: resolve the array source,
641650
// create shadow trees, and materialise with field mappings.
642-
const resolved = await this.resolveWires(exactWires);
651+
const resolved = await this.resolveWires(regularWires);
643652
if (!Array.isArray(resolved)) return resolved;
644653
const shadows = this.createShadowArray(resolved);
645654
return this.materializeShadows(shadows, prefix);
646655
}
647656

648-
return this.resolveWires(exactWires);
657+
return this.resolveWires(regularWires);
649658
}
650659

660+
// Collect sub-fields from deeper wires
651661
const subFields = new Set<string>();
652662
for (const wire of bridge.wires) {
653663
const p = wire.to.path;
@@ -661,6 +671,37 @@ export class ExecutionTree implements TreeContext {
661671
subFields.add(p[prefix.length]!);
662672
}
663673
}
674+
675+
// Spread wires: resolve and merge, then overlay sub-field wires
676+
if (spreadWires.length > 0) {
677+
const result: Record<string, unknown> = {};
678+
679+
// First resolve spread sources (in order)
680+
for (const wire of spreadWires) {
681+
const spreadValue = await this.resolveWires([wire]);
682+
if (spreadValue != null && typeof spreadValue === "object") {
683+
Object.assign(result, spreadValue);
684+
}
685+
}
686+
687+
// Then resolve sub-fields and overlay on spread result
688+
const prefixStr = prefix.join(".");
689+
const activeSubFields = this.requestedFields
690+
? [...subFields].filter((sub) => {
691+
const fullPath = prefixStr ? `${prefixStr}.${sub}` : sub;
692+
return matchesRequestedFields(fullPath, this.requestedFields);
693+
})
694+
: [...subFields];
695+
696+
await Promise.all(
697+
activeSubFields.map(async (sub) => {
698+
result[sub] = await this.resolveNestedField([...prefix, sub]);
699+
}),
700+
);
701+
702+
return result;
703+
}
704+
664705
if (subFields.size === 0) return undefined;
665706

666707
// Apply sparse fieldset filter at nested level
@@ -792,8 +833,8 @@ export class ExecutionTree implements TreeContext {
792833

793834
const { type, field } = this.trunk;
794835

795-
// Is there a root-level wire targeting the output with path []?
796-
const hasRootWire = bridge.wires.some(
836+
// Separate root-level wires into passthrough vs spread
837+
const rootWires = bridge.wires.filter(
797838
(w) =>
798839
"from" in w &&
799840
w.to.module === SELF_MODULE &&
@@ -802,6 +843,18 @@ export class ExecutionTree implements TreeContext {
802843
w.to.path.length === 0,
803844
);
804845

846+
// Passthrough wire: root wire without spread flag
847+
const hasPassthroughWire = rootWires.some(
848+
(w) => "from" in w && !("spread" in w && w.spread),
849+
);
850+
851+
// Spread wires: root wires with spread flag
852+
const spreadWires = rootWires.filter(
853+
(w) => "from" in w && "spread" in w && w.spread,
854+
);
855+
856+
const hasRootWire = rootWires.length > 0;
857+
805858
// Array-mapped output (`o <- items[] as x { ... }`) has BOTH a root wire
806859
// AND element-level wires (from.element === true). A plain passthrough
807860
// (`o <- api.user`) only has the root wire.
@@ -827,8 +880,8 @@ export class ExecutionTree implements TreeContext {
827880
return this.materializeShadows(shadows, []);
828881
}
829882

830-
// Whole-object passthrough: `o <- api.user`
831-
if (hasRootWire) {
883+
// Whole-object passthrough: `o <- api.user` (non-spread root wire)
884+
if (hasPassthroughWire) {
832885
const [result] = await Promise.all([
833886
this.pullOutputField([]),
834887
...forcePromises,
@@ -849,7 +902,11 @@ export class ExecutionTree implements TreeContext {
849902
}
850903
}
851904

852-
if (outputFields.size === 0) {
905+
// Spread wires: resolve and merge source objects
906+
// Later field wires will override spread properties
907+
const hasSpreadWires = spreadWires.length > 0;
908+
909+
if (outputFields.size === 0 && !hasSpreadWires) {
853910
throw new Error(
854911
`Bridge "${type}.${field}" has no output wires. ` +
855912
`Ensure at least one wire targets the output (e.g. \`o.field <- ...\`).`,
@@ -861,6 +918,16 @@ export class ExecutionTree implements TreeContext {
861918

862919
const result: Record<string, unknown> = {};
863920

921+
// First resolve spread wires (in order) to build base object
922+
// Each spread source's properties are merged into result
923+
for (const wire of spreadWires) {
924+
const spreadValue = await this.resolveWires([wire]);
925+
if (spreadValue != null && typeof spreadValue === "object") {
926+
Object.assign(result, spreadValue);
927+
}
928+
}
929+
930+
// Then resolve explicit field wires - these override spread properties
864931
await Promise.all([
865932
...[...activeFields].map(async (name) => {
866933
result[name] = await this.resolveNestedField([name]);

packages/bridge-core/src/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,15 @@ export type NodeRef = {
3333
* Pipe wires (`pipe: true`) are generated by the `<- h1:h2:source` shorthand
3434
* and route data through declared tool handles; the serializer collapses them
3535
* back to pipe notation.
36+
* Spread wires (`spread: true`) merge source object properties into the target.
3637
*/
3738
export type Wire =
3839
| {
3940
from: NodeRef;
4041
to: NodeRef;
4142
pipe?: true;
43+
/** When true, this wire merges source properties into target (from `...source` syntax). */
44+
spread?: true;
4245
safe?: true;
4346
falsyFallbackRefs?: NodeRef[];
4447
falsyFallback?: string;

packages/bridge-parser/src/parser/parser.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2221,6 +2221,7 @@ function processElementLines(
22212221
element: true,
22222222
path: elemToPath,
22232223
},
2224+
spread: true as const,
22242225
...(spreadSafe ? { safe: true as const } : {}),
22252226
});
22262227
}
@@ -2368,6 +2369,7 @@ function processElementScopeLines(
23682369
element: true,
23692370
path: spreadToPath,
23702371
},
2372+
spread: true as const,
23712373
...(spreadSafe ? { safe: true as const } : {}),
23722374
});
23732375
}
@@ -4228,6 +4230,7 @@ function buildBridgeBody(
42284230
wires.push({
42294231
from: fromRef,
42304232
to: nestedToRef,
4233+
spread: true as const,
42314234
...(spreadSafe ? { safe: true as const } : {}),
42324235
});
42334236
}
@@ -4880,6 +4883,7 @@ function buildBridgeBody(
48804883
wires.push({
48814884
from: fromRef,
48824885
to: toRef,
4886+
spread: true as const,
48834887
...(spreadSafe ? { safe: true as const } : {}),
48844888
});
48854889
}

0 commit comments

Comments
 (0)