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
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,20 @@ These are the declared lossy remainder, each applying in one named direction.

### Gating with `when`

Every rule may carry `when`, a trilean predicate tree, evaluated against the governing document: the source while encoding and the target while decoding. References are paths in the same grammar, without wildcards. A definite `true` applies the rule. A definite `false` skips it. An indeterminate result, where the data needed to decide is absent, also skips it: that is the one deliberate non-throwing branch in the format. Gating a rule on a presence or value predicate is how an optional native field is expressed.
Every rule may carry `when`, a trilean predicate tree, evaluated against the governing document: the source while encoding and the target while decoding. References are paths in the same grammar. A definite `true` applies the rule. A definite `false` skips it. An indeterminate result, where the data needed to decide is absent, also skips it: that is the one deliberate non-throwing branch in the format. Gating a rule on a presence or value predicate is how an optional native field is expressed.

A two-way rule that iterates an array can be gated per element. A reference with wildcards names a field of the element the rule is visiting, written against the rule's `from` side: on a rule over `items[].radius`, `items[].kind` is the `kind` of the same item. The reference's part through its last wildcard must be the array the rule's `from` iterates at that depth, and it may have fewer wildcards than the rule, gating every inner element on a field of the outer one. While decoding, that part is rebased onto the `to` side's array, so `items[].kind` reads `shapes[i].kind` when the rule maps `items[]` to `shapes[]`; the field below it has to keep its name in both shapes, as with any gate. A gated element is skipped before anything below it is read, so an element that lacks the rule's path is skipped rather than absent. Collection references bind the same way. One-way rules visit no element, so their references have no wildcards.

```ts
{
op: "move",
from: "items[].radius",
to: "shapes[].r",
when: { kind: "textCompare", op: "equals", left: { kind: "reference", key: "items[].kind" }, right: { kind: "textLiteral", value: "circle" } },
}
```

An element that every rule writing into it skips leaves a gap in the output array, which is a `RuleApplicationError` naming the element: map at least one field of every element unconditionally.

### Execution

Expand Down Expand Up @@ -242,7 +255,6 @@ Each of these is a deliberate omission, listed so it is not mistaken for an over
- Lookup against external tables.
- Aggregates and folds over arrays.
- Array filter and sort.
- Per-element `when` gating inside a wildcard.
- Regex split.
- One rule writing several targets.
- Further `format` kinds.
Expand Down
21 changes: 20 additions & 1 deletion src/corpus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const whenQtyIsBig: When = { kind: "compare", op: "gt", left: { kind: "reference
/**
* Samples carry only mapped fields (the crosswalk is a total mapping: an unmapped field does not survive encode, so a sample carrying one would fail its own round trip) and use the canonical ISO form decode emits, since any other ISO spelling names the same instant but not the same string.
*
* The deterministic corpus: one cell per op-table row (move, scale, enum, format isoDateTime, format numberText, split, drop, default encode-direction, default decode-direction), plus a wildcard-arity-one cell, a when-applied cell, a when-skipped cell, and one cell per path shape beyond records and one wildcard (nesting and flattening, an array of arrays, fixed indices, quoted keys). Scale factors are integers by design: multiply-then-divide is bit-identical for them (a non-integer factor is a documented round-trip caveat, not a corpus case).
* The deterministic corpus: one cell per op-table row (move, scale, enum, format isoDateTime, format numberText, split, drop, default encode-direction, default decode-direction), plus a wildcard-arity-one cell, a when-applied cell, a when-skipped cell, a per-element when cell, and one cell per path shape beyond records and one wildcard (nesting and flattening, an array of arrays, fixed indices, quoted keys). Scale factors are integers by design: multiply-then-divide is bit-identical for them (a non-integer factor is a documented round-trip caveat, not a corpus case).
*/
export const CORPUS: readonly CorpusCell[] = [
{
Expand Down Expand Up @@ -125,6 +125,24 @@ export const CORPUS: readonly CorpusCell[] = [
},
samples: [{ qty: 1 }],
},
{
name: "when per element",
crosswalk: {
source: "native",
target: "shared",
rules: [
{ op: "move", from: "items[].kind", to: "shapes[].kind" },
{
op: "move",
from: "items[].radius",
to: "shapes[].r",
when: { kind: "textCompare", op: "equals", left: { kind: "reference", key: "items[].kind" }, right: { kind: "textLiteral", value: "circle" } },
},
],
oneWay: [],
},
samples: [{ items: [{ kind: "circle", radius: 2 }, { kind: "square" }] }, { items: [] }],
},
{
name: "nest and flatten",
crosswalk: {
Expand Down Expand Up @@ -178,6 +196,7 @@ export const REQUIRED_ROWS: readonly string[] = [
"wildcard arity one",
"when applied",
"when skipped",
"when per element",
"nest and flatten",
"array of arrays",
"fixed index",
Expand Down
36 changes: 29 additions & 7 deletions src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import { moveValue } from "./ops/move";
import { applyOneWayRule } from "./ops/one-way";
import { scaleDecode, scaleEncode } from "./ops/scale";
import { splitDecode, splitEncode } from "./ops/split";
import { type JsonValue, JsonValueSchema } from "trilean";
import { bindLocus, ensureArray, findHole, formatPath, parsePathMemo, readLocus, wildcardIndexes, writeMatches } from "./paths";
import { type JsonValue, JsonValueSchema, type PredicateNode } from "trilean";
import { bindLocus, type ElementGate, ensureArray, findHole, formatPath, type Locus, parsePathMemo, readLocus, wildcardIndexes, writeMatches } from "./paths";
import { validateCrosswalk } from "./validate";
import { evaluateWhen } from "./when";
import { evaluateWhen, whenArity } from "./when";

/**
* Encodes a native document into the shared shape. Every rule's `from` is read from `doc` exactly as given and every `to` is written into a fresh output, so the input is never mutated and rule order carries no meaning (validateCrosswalk, run first, enforces the path uniqueness that makes this safe). A rule's `when` is evaluated against `doc`: definite true applies, definite false or indeterminate skips. Two-way rules write first, then the output's absent paths gain any due `default` values.
Expand Down Expand Up @@ -45,14 +45,15 @@ function run(crosswalk: Crosswalk, doc: unknown, direction: "encode" | "decode")
}
const target: Record<string, unknown> = {};
crosswalk.rules.forEach((rule, index) => {
if (rule.when !== undefined && !evaluateWhen(rule.when, doc)) {
return;
}
const fromPath = direction === "encode" ? rule.from : rule.to;
const toPath = direction === "encode" ? rule.to : rule.from;
const fromLocus = parsePathMemo(fromPath);
const toLocus = parsePathMemo(toPath);
const reading = readLocus(doc, fromLocus);
const gate = rule.when === undefined ? undefined : elementGate(rule.when, doc, fromLocus);
if (gate === false) {
return;
}
const reading = readLocus(doc, fromLocus, gate);
if (reading === undefined) {
throw new RuleApplicationError(index, fromPath, "the path is absent from the document");
}
Expand Down Expand Up @@ -88,6 +89,27 @@ function run(crosswalk: Crosswalk, doc: unknown, direction: "encode" | "decode")
return parseJsonValue(target);
}

/**
* How a rule's `when` applies: a gate with no wildcard references is decided once for the whole rule (`false` skips it, `undefined` lets it run), and one whose references carry wildcards becomes a per-element gate over the read side, evaluated once per element at the depth its references reach. `governing` is the rule's read-side path in this direction, so the gate's references are rebased onto the arrays it iterates.
*/
function elementGate(when: PredicateNode, doc: unknown, governing: Locus): ElementGate | false | undefined {
const arity = whenArity(when);
if (arity === 0) {
return evaluateWhen(when, doc) ? undefined : false;
}
const wildcardPositions = governing.flatMap((step, position) => (step.kind === "wildcard" ? [position] : []));
const arrayAt = (wildcard: number): Locus => {
const position = wildcardPositions[wildcard];
if (position === undefined) {
throw new Error(`the rule's path has no wildcard ${String(wildcard)}: validateCrosswalk bounds a reference's wildcards by the rule's`);
}

return governing.slice(0, position + 1);
};

return { arity, admit: (indexes) => evaluateWhen(when, doc, { indexes, arrayAt }) };
}

/** Applies one two-way rule's operation to one value in the given direction, turning the operation's own thrown errors into RuleApplicationErrors that name the rule and path. */
function applyOp(rule: TwoWayRule, direction: "encode" | "decode", value: unknown, where: { readonly index: number; readonly path: string }): unknown {
try {
Expand Down
61 changes: 61 additions & 0 deletions src/executor.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,3 +239,64 @@ describe("paths beyond records and one wildcard", () => {
}
});
});

describe("per-element when", () => {
const isCircle = { kind: "textCompare", op: "equals", left: { kind: "reference", key: "items[].kind" }, right: { kind: "textLiteral", value: "circle" } };
const shapes = crosswalk({
source: "n",
target: "s",
rules: [
{ op: "move", from: "items[].kind", to: "shapes[].kind" },
{ op: "move", from: "items[].radius", to: "shapes[].r", when: isCircle },
],
oneWay: [],
});
const native = { items: [{ kind: "circle", radius: 2 }, { kind: "square" }, { kind: "circle", radius: 1 }] };

it("applies a rule only to the elements whose own field passes, skipping elements that lack the path", () => {
expect(encode(shapes, native)).toEqual({ shapes: [{ kind: "circle", r: 2 }, { kind: "square" }, { kind: "circle", r: 1 }] });
});

it("rebases the reference onto the target array while decoding", () => {
expect(decode(shapes, encode(shapes, native))).toEqual(native);
});

it("gates an inner wildcard on a field of the outer element", () => {
const priced = { kind: "exists", operand: { kind: "reference", key: "orders[].currency" } };
const cw = crosswalk({
source: "n",
target: "s",
rules: [
{ op: "move", from: "orders[].id", to: "sales[].id" },
{ op: "move", from: "orders[].currency", to: "sales[].currency", when: priced },
{ op: "scale", from: "orders[].lines[].pence", to: "sales[].lines[].pounds", factor: 100, when: priced },
],
oneWay: [],
});
const orders = { orders: [{ id: "a", currency: "GBP", lines: [{ pence: 1 }] }, { id: "b" }] };
const shared = encode(cw, orders);
expect(shared).toEqual({ sales: [{ id: "a", currency: "GBP", lines: [{ pounds: 100 }] }, { id: "b" }] });
expect(decode(cw, shared)).toEqual(orders);
});

it("binds a collection reference to the visited element", () => {
const anyHeavy = { kind: "some", collection: "boxes[].parts", item: { kind: "compare", op: "gt", left: { kind: "reference", key: "kg" }, right: { kind: "numberLiteral", value: 1 } } };
const cw = crosswalk({
source: "n",
target: "s",
rules: [
{ op: "move", from: "boxes[].parts", to: "crates[].parts" },
{ op: "move", from: "boxes[].label", to: "crates[].warning", when: anyHeavy },
],
oneWay: [],
});
const boxes = { boxes: [{ parts: [{ kg: 2 }], label: "heavy" }, { parts: [{ kg: 1 }], label: "light" }] };
expect(encode(cw, boxes)).toEqual({ crates: [{ parts: [{ kg: 2 }], warning: "heavy" }, { parts: [{ kg: 1 }] }] });
});

it("throws naming the gap when a gated rule is the only writer of an element", () => {
const cw = crosswalk({ source: "n", target: "s", rules: [{ op: "move", from: "items[].radius", to: "radii[]", when: isCircle }], oneWay: [] });
expect(() => encode(cw, { items: [{ kind: "square" }, { kind: "circle", radius: 1 }] })).toThrow("radii[0]: no rule wrote this array element");
});

});
11 changes: 9 additions & 2 deletions src/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,12 @@ export interface EmptySite {
readonly wildcard: number;
}

/** A per-element filter for {@link readLocus}: which elements, identified by their indexes at the first `arity` wildcards, a gated rule visits. */
export interface ElementGate {
readonly arity: number;
readonly admit: (indexes: readonly number[]) => boolean;
}

/** Everything one locus addresses in a document: the matches in index order, and every empty array a wildcard step met on the way. */
export interface LocusReading {
readonly matches: readonly PathMatch[];
Expand All @@ -183,9 +189,10 @@ export interface LocusReading {
* Reads every location a locus addresses in a document. A wildcard step yields one match per element of the array it reaches, so a locus with wildcard arity `a` yields exactly one match per `a`-dimensional index combination of the arrays it traverses; a fixed index step addresses one element.
* @param root - The document to read from.
* @param locus - The parsed path to read.
* @param gate - Optional per-element admission: once the first `arity` wildcards are expanded, each element is kept only if `admit` returns true for its indexes at those wildcards, and nothing below a refused element is read (so an element that lacks the rest of the path is skipped, not absent). Empty arrays met at or above that depth are still reported.
* @returns The reading, or `undefined` when any step of the path is absent from the document: a missing key, a key step meeting a non-record, an index step meeting a non-array or an index past the array's end, or a wildcard meeting a non-array (a wildcard over a missing array is absence too, not an empty list).
*/
export function readLocus(root: unknown, locus: Locus): LocusReading | undefined {
export function readLocus(root: unknown, locus: Locus, gate?: ElementGate): LocusReading | undefined {
let frontier: readonly PathMatch[] = [{ trail: [], value: root }];
const empties: EmptySite[] = [];
let wildcardOrdinal = 0;
Expand Down Expand Up @@ -220,7 +227,7 @@ export function readLocus(root: unknown, locus: Locus): LocusReading | undefined
if (step.kind === "wildcard") {
wildcardOrdinal += 1;
}
frontier = next;
frontier = gate !== undefined && step.kind === "wildcard" && wildcardOrdinal === gate.arity ? next.filter((site) => gate.admit(wildcardIndexes(locus, site.trail))) : next;
}

return { matches: frontier, empties };
Expand Down
Loading