diff --git a/README.md b/README.md index fb30671..05d0b40 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,7 @@ These are the declared lossy remainder, each applying in one named direction. | --- | --- | --- | | `drop` | `from`, `direction: "encode"` | states that `from` is deliberately not carried across. Nothing is written, so the path is simply absent from the output | | `default` | `to`, `value`, `direction`: `"encode"` or `"decode"` | inserts `value` at `to` when running in that direction and `to` is still absent from the output. `to` has no wildcard | +| `aggregate` | `fn`: `count`, `sum`, `min` or `max`; `from`, `to`, `direction` | when running in that direction, reads the elements `from` reaches in the input and writes their count, or the sum, minimum or maximum of their numbers, at `to`. `to` has fewer wildcards than `from`, and the elements are grouped by the indexes at the wildcards `to` keeps, so `orders[].lines[].qty` into `sales[].units` writes one total per order. An empty group counts and sums to 0; `min` and `max` of an empty group throw | ### Gating with `when` @@ -243,7 +244,7 @@ The errors crossmap defines all extend `CrossmapError`: `CrosswalkSyntaxError` f ## Round trips -`assertRoundTrip(crosswalk, samples)` encodes and decodes each sample and deep-compares the result with it, leaving out the paths one-way rules touch: a dropped path cannot survive, and a decode-side default inserts a path the sample never had. It throws on the first difference. Its limits follow from the format: +`assertRoundTrip(crosswalk, samples)` encodes and decodes each sample and deep-compares the result with it, leaving out the paths one-way rules touch: a dropped path cannot survive, and a decode-direction default or aggregate inserts a path the sample never had. It throws on the first difference. Its limits follow from the format: - A crosswalk is a total mapping over the document. A field no rule maps does not survive encode, so a sample carrying one fails its own round trip. - A `scale` with a non-integer factor can fail bit-identity: multiplying and then dividing is exact for integers and powers of two, and can differ in the last bit for factors such as `0.1`. Decode divides and does not multiply by a reciprocal, which is the more accurate route. @@ -261,7 +262,6 @@ Each of these is a deliberate omission, listed so it is not mistaken for an over - `nest` and `unnest`: a `move` whose `to` is deeper nests, one whose `to` is shallower flattens, and a `move` of a whole record carries every field in it, so a dedicated operation would add nothing the format cannot already say. - Lookup against external tables. -- Aggregates and folds over arrays. - Array filter and sort. - Regex split. - Further `format` kinds. diff --git a/src/corpus.ts b/src/corpus.ts index ec4a3b7..0a201a4 100644 --- a/src/corpus.ts +++ b/src/corpus.ts @@ -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, template, copy, 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). + * The deterministic corpus: one cell per op-table row (move, scale, enum, format isoDateTime, format numberText, split, template, copy, drop, default encode-direction, default decode-direction, aggregate), 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[] = [ { @@ -114,6 +114,19 @@ export const CORPUS: readonly CorpusCell[] = [ crosswalk: { source: "native", target: "shared", rules: [{ op: "copy", from: "items[].id", to: ["nodes[].id", "labels[].text"] }], oneWay: [] }, samples: [{ items: [{ id: "a" }, { id: { nested: [1] } }] }, { items: [] }], }, + { + name: "aggregate", + crosswalk: { + source: "native", + target: "shared", + rules: [{ op: "move", from: "orders[].lines[].qty", to: "sales[].lines[].qty" }], + oneWay: [ + { op: "aggregate", fn: "sum", from: "orders[].lines[].qty", to: "sales[].units", direction: "encode" }, + { op: "aggregate", fn: "max", from: "sales[].lines[].qty", to: "largestLine", direction: "decode" }, + ], + }, + samples: [{ orders: [{ lines: [{ qty: 1 }, { qty: 2 }] }, { lines: [{ qty: 0 }] }] }], + }, { name: "when applied", crosswalk: { @@ -210,6 +223,7 @@ export const REQUIRED_ROWS: readonly string[] = [ "drop", "default encode-direction", "default decode-direction", + "aggregate", "wildcard arity one", "when applied", "when skipped", diff --git a/src/crosswalk.ts b/src/crosswalk.ts index cd76392..b2408ed 100644 --- a/src/crosswalk.ts +++ b/src/crosswalk.ts @@ -147,8 +147,24 @@ export const DefaultRuleSchema = z .strict(); export type DefaultRule = z.infer; +export const AggregateFunctionSchema = z.enum(["count", "sum", "min", "max"]); +export type AggregateFunction = z.infer; + +/** A value derived from the elements a wildcard path reaches, written in one direction: `count` of the matches, or their `sum`, `min` or `max` (numbers). The elements it combines cannot be recovered from it, which is why it is one-way. `to` has fewer wildcards than `from`; with `k` of them, the matches are grouped by their first `k` indexes, one result per group (a total per order, say). */ +export const AggregateRuleSchema = z + .object({ + op: z.literal("aggregate"), + fn: AggregateFunctionSchema, + from: PathField, + to: PathField, + direction: z.enum(["encode", "decode"]), + when: PredicateNodeSchema.optional(), + }) + .strict(); +export type AggregateRule = z.infer; + /** A one-way rule: explicitly lossy, applying in one direction only. */ -export const OneWayRuleSchema = z.discriminatedUnion("op", [DropRuleSchema, DefaultRuleSchema]); +export const OneWayRuleSchema = z.discriminatedUnion("op", [DropRuleSchema, DefaultRuleSchema, AggregateRuleSchema]); export type OneWayRule = z.infer; /** diff --git a/src/executor.ts b/src/executor.ts index c888135..5fc709d 100644 --- a/src/executor.ts +++ b/src/executor.ts @@ -55,9 +55,9 @@ function run(crosswalk: Crosswalk, doc: unknown, direction: "encode" | "decode") return; } try { - applyOneWayRule(rule, direction, target); + applyOneWayRule(rule, direction, doc, target); } catch (error) { - throw new RuleApplicationError(crosswalk.rules.length + index, rule.op === "drop" ? rule.from : rule.to, errorMessage(error)); + throw new RuleApplicationError(crosswalk.rules.length + index, rule.op === "default" ? rule.to : rule.from, errorMessage(error)); } }); const hole = findHole(target); diff --git a/src/executor.unit.test.ts b/src/executor.unit.test.ts index 620c22d..62cb7a2 100644 --- a/src/executor.unit.test.ts +++ b/src/executor.unit.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { type Crosswalk, parseCrosswalk } from "./crosswalk"; import { CrosswalkStructureError, RuleApplicationError } from "./errors"; import { decode, encode } from "./executor"; +import { assertRoundTrip } from "./roundtrip"; const crosswalk = (doc: unknown): Crosswalk => parseCrosswalk(doc); @@ -339,3 +340,38 @@ describe("template and copy", () => { expect(() => decode(cw, { nodes: [{ id: "a" }], labels: [] })).toThrow(/addresses different elements/); }); }); + +describe("aggregate", () => { + it("adds derived totals on encode and leaves them out of decode", () => { + const cw = crosswalk({ + source: "n", + target: "s", + rules: [{ op: "move", from: "orders[].lines[].qty", to: "sales[].lines[].qty" }], + oneWay: [ + { op: "aggregate", fn: "sum", from: "orders[].lines[].qty", to: "sales[].units", direction: "encode" }, + { op: "aggregate", fn: "count", from: "orders[]", to: "orderCount", direction: "encode" }, + ], + }); + const native = { orders: [{ lines: [{ qty: 1 }, { qty: 2 }] }, { lines: [] }] }; + const shared = encode(cw, native); + expect(shared).toEqual({ sales: [{ lines: [{ qty: 1 }, { qty: 2 }], units: 3 }, { lines: [], units: 0 }], orderCount: 2 }); + expect(decode(cw, shared)).toEqual(native); + assertRoundTrip(cw, [native]); + }); + + it("writes a decode-direction aggregate into the source shape, excluded from the round trip", () => { + const cw = crosswalk({ + source: "n", + target: "s", + rules: [{ op: "move", from: "tags[]", to: "labels[]" }], + oneWay: [{ op: "aggregate", fn: "count", from: "labels[]", to: "tagCount", direction: "decode" }], + }); + expect(decode(cw, { labels: ["a", "b"] })).toEqual({ tags: ["a", "b"], tagCount: 2 }); + assertRoundTrip(cw, [{ tags: ["a"] }]); + }); + + it("throws RuleApplicationError for a value that does not fit", () => { + const cw = crosswalk({ source: "n", target: "s", rules: [], oneWay: [{ op: "aggregate", fn: "sum", from: "xs[]", to: "total", direction: "encode" }] }); + expect(() => encode(cw, { xs: ["1"] })).toThrow(RuleApplicationError); + }); +}); diff --git a/src/index.ts b/src/index.ts index 15f8964..9ea2ce1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,7 @@ -export { CrosswalkSchema, FormatKindSchema, parseCrosswalk } from "./crosswalk"; +export { AggregateFunctionSchema, CrosswalkSchema, FormatKindSchema, parseCrosswalk } from "./crosswalk"; export type { + AggregateFunction, + AggregateRule, CopyRule, Crosswalk, DefaultRule, diff --git a/src/ops/aggregate.ts b/src/ops/aggregate.ts new file mode 100644 index 0000000..f6699b5 --- /dev/null +++ b/src/ops/aggregate.ts @@ -0,0 +1,84 @@ +import type { AggregateFunction, AggregateRule } from "../crosswalk"; +import { bindLocus, type Locus, parsePathMemo, readLocus, type Trail, wildcardCount, wildcardIndexes } from "../paths"; + +/** + * Computes an aggregate rule's results against the governing input document: the matches of `from` grouped by their indexes at the first `k` wildcards, where `k` is the wildcard count of `to`, and one value per group written at `to` bound to those indexes. Every group is enumerated from the arrays themselves, so a group with no elements (an empty inner array) still gets its result. + * @param rule - The rule; validateCrosswalk has checked that `to` has fewer wildcards than `from`. + * @param doc - The governing input document (the source while encoding, the target while decoding). + * @returns One write per group. + * @throws Error when `from` is absent from the document, a value does not fit the function, or `min` or `max` meets a group with no values. + */ +export function aggregateWrites(rule: AggregateRule, doc: unknown): readonly { readonly trail: Trail; readonly value: number }[] { + const from = parsePathMemo(rule.from); + const to = parsePathMemo(rule.to); + const groupDepth = wildcardCount(to); + const reading = readLocus(doc, from); + const groups = readLocus(doc, prefixThroughWildcard(from, groupDepth)); + if (reading === undefined || groups === undefined) { + throw new Error("the path is absent from the document"); + } + const groupValues = groups.matches.map((group) => ({ indexes: wildcardIndexes(from, group.trail), values: new Array() })); + const byKey = new Map(groupValues.map((group) => [group.indexes.join(","), group.values])); + for (const match of reading.matches) { + const key = wildcardIndexes(from, match.trail).slice(0, groupDepth).join(","); + const bucket = byKey.get(key); + // Every match descends from an element of the group array, which the second reading enumerated, so a miss means the two readings disagree: a bug here, not a data condition. + if (bucket === undefined) { + throw new Error(`a match of ${rule.from} belongs to no group at [${key}]`); + } + bucket.push(match.value); + } + + return groupValues.map((group) => ({ trail: bindLocus(to, group.indexes), value: combine(rule.fn, group.values) })); +} + +/** The prefix of a locus through its n-th wildcard, the array whose elements form the groups; the whole document (the empty prefix) for n of zero. */ +function prefixThroughWildcard(locus: Locus, wildcards: number): Locus { + if (wildcards === 0) { + return []; + } + let seen = 0; + const end = locus.findIndex((step) => { + if (step.kind === "wildcard") { + seen += 1; + } + + return seen === wildcards; + }); + + return locus.slice(0, end + 1); +} + +/** + * Combines one group's values. + * @param fn - The aggregate function. + * @param values - The group's values in index order. + * @returns The count, or the sum, minimum or maximum of finite numbers. + * @throws Error for a value that is not a finite number (except under `count`, which counts any value), a sum that overflows, or `min` or `max` of no values, which has no answer. + */ +export function combine(fn: AggregateFunction, values: readonly unknown[]): number { + if (fn === "count") { + return values.length; + } + const numbers = values.map((value) => { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`${fn} applies to finite numbers, found: ${JSON.stringify(value)}`); + } + + return value; + }); + if (fn === "sum") { + const total = numbers.reduce((sum, value) => sum + value, 0); + if (!Number.isFinite(total)) { + throw new Error("the sum overflows to a non-finite value"); + } + + return total; + } + if (numbers.length === 0) { + throw new Error(`${fn} of no values has no answer: gate the rule with when, or use count`); + } + + // A reduce rather than Math.min(...numbers): spreading a large group as arguments overflows the call stack. + return numbers.reduce((best, value) => (fn === "min" ? Math.min(best, value) : Math.max(best, value))); +} diff --git a/src/ops/aggregate.unit.test.ts b/src/ops/aggregate.unit.test.ts new file mode 100644 index 0000000..6624291 --- /dev/null +++ b/src/ops/aggregate.unit.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import type { AggregateFunction, AggregateRule } from "../crosswalk"; +import { aggregateWrites, combine } from "./aggregate"; + +/** Named figures, so the cases assert against names rather than bare literals. */ +const figures = { small: 2, large: 7, total: 9, negative: -3, three: 3 }; + +const cases: readonly (readonly [AggregateFunction, readonly unknown[], number])[] = [ + ["count", [figures.small, "x", null], figures.three], + ["sum", [figures.small, figures.large], figures.total], + ["min", [figures.large, figures.negative, figures.small], figures.negative], + ["max", [figures.small, figures.large], figures.large], + ["count", [], 0], + ["sum", [], 0], +]; +const emptyless: readonly AggregateFunction[] = ["min", "max"]; + +describe("combine", () => { + it.each(cases)("%s of %j is %j", (fn, values, expected) => { + expect(combine(fn, values)).toBe(expected); + }); + + it.each(emptyless)("%s of no values throws", (fn) => { + expect(() => combine(fn, [])).toThrow(/has no answer/); + }); + + it("refuses a value that is not a finite number", () => { + expect(() => combine("sum", [1, "2"])).toThrow(/finite numbers/); + expect(() => combine("max", [Number.NaN])).toThrow(/finite numbers/); + }); + + it("refuses a sum that overflows", () => { + expect(() => combine("sum", [Number.MAX_VALUE, Number.MAX_VALUE])).toThrow(/overflows/); + }); +}); + +describe("aggregateWrites", () => { + it("writes one total for the whole array", () => { + const rule: AggregateRule = { op: "aggregate", fn: "count", from: "items[]", to: "itemCount", direction: "encode" }; + expect(aggregateWrites(rule, { items: ["a", "b"] })).toEqual([{ trail: [{ kind: "key", key: "itemCount" }], value: 2 }]); + }); + + it("writes one total per outer element, including elements whose inner array is empty", () => { + const rule: AggregateRule = { op: "aggregate", fn: "sum", from: "orders[].lines[].qty", to: "sales[].units", direction: "encode" }; + const doc = { orders: [{ lines: [{ qty: figures.small }, { qty: figures.large }] }, { lines: [] }] }; + expect(aggregateWrites(rule, doc).map((write) => write.value)).toEqual([figures.total, 0]); + }); + + it("throws when the path is absent", () => { + const rule: AggregateRule = { op: "aggregate", fn: "count", from: "items[]", to: "n", direction: "encode" }; + expect(() => aggregateWrites(rule, {})).toThrow(/absent/); + }); +}); diff --git a/src/ops/one-way.ts b/src/ops/one-way.ts index 8498417..7b56eb4 100644 --- a/src/ops/one-way.ts +++ b/src/ops/one-way.ts @@ -1,23 +1,26 @@ import type { OneWayRule } from "../crosswalk"; import { bindLocus, parsePathMemo, readTrail, writeMatches } from "../paths"; +import { aggregateWrites } from "./aggregate"; /** - * Applies a one-way rule to the output document under construction, after every two-way rule has written. `drop` declares that its `from` is deliberately not carried across: in the executor's fresh output a path no rule writes is already absent, so the declaration is what the format and assertRoundTrip's exclusion list consume, and there is nothing to do. `default` inserts its value only when the executor is running the direction it names and its `to` is still absent from the output. + * Applies a one-way rule to the output document under construction, after every two-way rule has written. `aggregate` writes its derived values, read from the input document, when the executor is running the direction it names. `drop` declares that its `from` is deliberately not carried across: in the executor's fresh output a path no rule writes is already absent, so the declaration is what the format and assertRoundTrip's exclusion list consume, and there is nothing to do. `default` inserts its value only when the executor is running the direction it names and its `to` is still absent from the output. * @param rule - The one-way rule to apply. * @param direction - The direction the executor is running. + * @param doc - The input document of this run, which an aggregate reads. * @param target - The output document under construction. */ -export function applyOneWayRule( - rule: OneWayRule, - direction: "encode" | "decode", - target: Record, -): void { +export function applyOneWayRule(rule: OneWayRule, direction: "encode" | "decode", doc: unknown, target: Record): void { if (rule.op === "drop") { return; } if (rule.direction !== direction) { return; } + if (rule.op === "aggregate") { + writeMatches(target, aggregateWrites(rule, doc)); + + return; + } // validateCrosswalk rejects a wildcard in a default's `to`, so binding with no indexes yields its one concrete trail (bindLocus throws if a wildcard were there). const trail = bindLocus(parsePathMemo(rule.to), []); if (readTrail(target, trail).found) { diff --git a/src/ops/one-way.unit.test.ts b/src/ops/one-way.unit.test.ts index ed1a1b4..6d5c570 100644 --- a/src/ops/one-way.unit.test.ts +++ b/src/ops/one-way.unit.test.ts @@ -6,36 +6,36 @@ describe("applyOneWayRule", () => { it("inserts an encode-direction default when its target is absent", () => { const rule: DefaultRule = { op: "default", to: "locale", value: "en-GB", direction: "encode" }; const target: Record = {}; - applyOneWayRule(rule, "encode", target); + applyOneWayRule(rule, "encode", {}, target); expect(target).toEqual({ locale: "en-GB" }); }); it("leaves an existing value in place", () => { const rule: DefaultRule = { op: "default", to: "locale", value: "en-GB", direction: "encode" }; const target: Record = { locale: "cy" }; - applyOneWayRule(rule, "encode", target); + applyOneWayRule(rule, "encode", {}, target); expect(target).toEqual({ locale: "cy" }); }); it("does nothing when running the other direction", () => { const rule: DefaultRule = { op: "default", to: "locale", value: "en-GB", direction: "decode" }; const target: Record = {}; - applyOneWayRule(rule, "encode", target); + applyOneWayRule(rule, "encode", {}, target); expect(target).toEqual({}); }); it("creates intermediate records for a nested target", () => { const rule: DefaultRule = { op: "default", to: "delivery.locale", value: "en-GB", direction: "encode" }; const target: Record = {}; - applyOneWayRule(rule, "encode", target); + applyOneWayRule(rule, "encode", {}, target); expect(target).toEqual({ delivery: { locale: "en-GB" } }); }); it("drop is a declared no-op in the fresh-output executor", () => { const rule: DropRule = { op: "drop", from: "internal", direction: "encode" }; const target: Record = {}; - applyOneWayRule(rule, "encode", target); - applyOneWayRule(rule, "decode", target); + applyOneWayRule(rule, "encode", {}, target); + applyOneWayRule(rule, "decode", {}, target); expect(target).toEqual({}); }); }); diff --git a/src/roundtrip.ts b/src/roundtrip.ts index 9d623ac..4355dc8 100644 --- a/src/roundtrip.ts +++ b/src/roundtrip.ts @@ -6,7 +6,7 @@ import { setOwn } from "./own"; import { validateCrosswalk } from "./validate"; /** - * Checks that decode inverts encode over every sample, on the paths the crosswalk promises to preserve. The paths one-way rules touch are excluded by construction (a dropped path cannot survive; a decode-side default inserts a path the sample never had), then each sample's round trip is deep-compared with Object.is at the leaves; the first difference throws. + * Checks that decode inverts encode over every sample, on the paths the crosswalk promises to preserve. The paths one-way rules touch are excluded by construction (a dropped path cannot survive; a decode-direction default or aggregate inserts a path the sample never had), then each sample's round trip is deep-compared with Object.is at the leaves; the first difference throws. * * What it proves and what it cannot: a `scale` rule with a non-integer factor may fail bit-identity under multiply-then-divide (integer or power-of-two factors are exact), a `when` must be written against fields the rule does not itself move (this check is the empirical test of that, static validation cannot decide it), and a sample that populates an encode-side default's `to` path will differ from its round trip (the default only inserts into an absent target), so samples leave those paths unset. * @param crosswalk - The parsed crosswalk. diff --git a/src/validate.ts b/src/validate.ts index bdfd950..56431bd 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -1,4 +1,4 @@ -import type { Crosswalk, OneWayRule } from "./crosswalk"; +import type { AggregateRule, Crosswalk, OneWayRule } from "./crosswalk"; import type { CrosswalkIssue } from "./errors"; import { canonicalPath, formatPath, type Locus, PATH_REGEX, parsePathMemo, wildcardCount } from "./paths"; import { type RuleSides, ruleSides } from "./sides"; @@ -23,7 +23,17 @@ export function validateCrosswalk(crosswalk: Crosswalk): readonly CrosswalkIssue ...crosswalk.oneWay.flatMap((rule, index): readonly CrosswalkIssue[] => { const oneWayIndex = ruleCount + index; - return rule.op === "default" ? [...checkWhenPaths(rule.when, oneWayIndex, undefined), ...checkDefaultTo(rule, oneWayIndex)] : checkWhenPaths(rule.when, oneWayIndex, undefined); + const when = checkWhenPaths(rule.when, oneWayIndex, undefined); + switch (rule.op) { + case "default": + return [...when, ...checkDefaultTo(rule, oneWayIndex)]; + case "aggregate": + return [...when, ...checkAggregateWildcards(rule, oneWayIndex)]; + case "drop": + return when; + default: + return unhandledOneWay(); + } }), ...checkPathUniqueness(crosswalk), ...checkWildcardRoots(crosswalk), @@ -124,6 +134,22 @@ function checkDefaultTo(rule: OneWayRule & { op: "default" }, index: number): re ]; } +/** An aggregate combines the elements a wildcard reaches, so its `from` has at least one wildcard, and its `to` has fewer: one result per group of elements sharing their indexes at the wildcards `to` keeps. */ +function checkAggregateWildcards(rule: AggregateRule, index: number): readonly CrosswalkIssue[] { + const fromWildcards = wildcardCount(parsePathMemo(rule.from)); + const toWildcards = wildcardCount(parsePathMemo(rule.to)); + if (fromWildcards > toWildcards) { + return []; + } + + return [{ ruleIndex: index, message: `an aggregate's from (${rule.from}) needs more wildcards than its to (${rule.to}): it combines the elements of the wildcards to drops` }]; +} + +/** Unreachable for any rule that passed the schema: the default arm exists so the switch is total. */ +function unhandledOneWay(): never { + throw new Error("no structural checks for the one-way rule's op: the schema and validateCrosswalk disagree"); +} + /** The issue index of a defect that lies between rules rather than in one (CrosswalkIssue's documented negative index). */ const BETWEEN_RULES = -1; @@ -147,9 +173,11 @@ function checkPathUniqueness(crosswalk: Crosswalk): readonly CrosswalkIssue[] { crosswalk.oneWay.forEach((rule, index) => { if (rule.op === "drop") { notePath(fromPaths, rule.from, ruleCount + index); - } else { - notePath(toPaths, rule.to, ruleCount + index); + + return; } + // A decode-direction write lands in the source-shape document, where the two-way rules' from paths live, so it collides with those rather than with to paths. + notePath(rule.direction === "encode" ? toPaths : fromPaths, rule.to, ruleCount + index); }); const issues: CrosswalkIssue[] = []; for (const [path, index] of toPaths) { @@ -159,7 +187,7 @@ function checkPathUniqueness(crosswalk: Crosswalk): readonly CrosswalkIssue[] { } for (const [path, index] of fromPaths) { if (index === DUPLICATE) { - issues.push({ ruleIndex: BETWEEN_RULES, message: `two rules read the same from path: ${path}` }); + issues.push({ ruleIndex: BETWEEN_RULES, message: `two rules use the same from path: ${path}` }); } } @@ -197,6 +225,22 @@ function checkWildcardRoots(crosswalk: Crosswalk): readonly CrosswalkIssue[] { feeders.set(toArray, fromArray); }); }); + crosswalk.oneWay.forEach((rule, oneWayPosition) => { + if (rule.op !== "aggregate") { + return; + } + // An aggregate writes its results into the arrays `to` keeps, which must be the counterparts of the arrays `from` groups by. Encoding, `to` is on the target side; decoding, `from` is, so the pair is read the other way round. + const toArrays = wildcardArrays(parsePathMemo(rule.to)); + const fromArrays = wildcardArrays(parsePathMemo(rule.from)).slice(0, toArrays.length); + const [targetArrays, sourceArrays] = rule.direction === "encode" ? [toArrays, fromArrays] : [fromArrays, toArrays]; + targetArrays.forEach((targetArray, depth) => { + const sourceArray = sourceArrays[depth]; + const existing = feeders.get(targetArray); + if (sourceArray !== undefined && existing !== undefined && existing !== sourceArray) { + issues.push({ ruleIndex: crosswalk.rules.length + oneWayPosition, message: `an aggregate groups ${sourceArray} into ${targetArray}, which ${existing} feeds: the results would land in the wrong elements` }); + } + }); + }); return issues; } @@ -215,8 +259,9 @@ function wildcardArrays(locus: Locus): readonly string[] { /** An array is addressed by position or by wildcard on one side, never both: a fixed index into an array that a wildcard also iterates makes the array's length depend on two rules, so an empty source array would encode to a one-element target that decode cannot read back, and reading one element both ways would write it twice on the way back. Each side (every `from` path, and every `to` path including a default's) is checked on its own, since the two sides are different documents. */ function checkIndexWildcardMix(crosswalk: Crosswalk): readonly CrosswalkIssue[] { - const fromSide = crosswalk.rules.flatMap((rule) => ruleSides(rule).from); - const toSide = [...crosswalk.rules.flatMap((rule) => ruleSides(rule).to), ...crosswalk.oneWay.flatMap((rule) => (rule.op === "default" ? [rule.to] : []))]; + const oneWayWrites = (direction: "encode" | "decode"): readonly string[] => crosswalk.oneWay.flatMap((rule) => (rule.op !== "drop" && rule.direction === direction ? [rule.to] : [])); + const fromSide = [...crosswalk.rules.flatMap((rule) => ruleSides(rule).from), ...oneWayWrites("decode")]; + const toSide = [...crosswalk.rules.flatMap((rule) => ruleSides(rule).to), ...oneWayWrites("encode")]; return [...mixedArrays(fromSide), ...mixedArrays(toSide)].map((array) => ({ ruleIndex: BETWEEN_RULES, diff --git a/src/validate.unit.test.ts b/src/validate.unit.test.ts index 33e6c3a..a87351d 100644 --- a/src/validate.unit.test.ts +++ b/src/validate.unit.test.ts @@ -187,6 +187,23 @@ describe("validateCrosswalk on multi-path rules", () => { }); it("counts every template field as a read", () => { - expect(issuesOf([{ op: "template", from: { a: "a", b: "b" }, to: "t", template: "{a}/{b}" }, { op: "move", from: "b", to: "u" }])).toEqual(["two rules read the same from path: b"]); + expect(issuesOf([{ op: "template", from: { a: "a", b: "b" }, to: "t", template: "{a}/{b}" }, { op: "move", from: "b", to: "u" }])).toEqual(["two rules use the same from path: b"]); + }); +}); + +describe("validateCrosswalk on aggregates", () => { + const issuesOf = (rules: readonly unknown[], oneWay: readonly unknown[]) => validateCrosswalk(crosswalk({ source: "a", target: "b", rules, oneWay })).map((issue) => issue.message); + + it("needs more wildcards in from than in to", () => { + expect(issuesOf([], [{ op: "aggregate", fn: "count", from: "n", to: "c", direction: "encode" }])).toEqual(["an aggregate's from (n) needs more wildcards than its to (c): it combines the elements of the wildcards to drops"]); + }); + + it("rejects grouping into a target array another source array feeds", () => { + expect(issuesOf([{ op: "move", from: "orders[].id", to: "sales[].id" }], [{ op: "aggregate", fn: "count", from: "refunds[].x[]", to: "sales[].n", direction: "encode" }])).toEqual(["an aggregate groups refunds[] into sales[], which orders[] feeds: the results would land in the wrong elements"]); + }); + + it("compares a decode-direction write with the source-side paths, not the target-side ones", () => { + expect(issuesOf([{ op: "move", from: "a", to: "total" }], [{ op: "aggregate", fn: "count", from: "xs[]", to: "total", direction: "decode" }])).toEqual([]); + expect(issuesOf([{ op: "move", from: "total", to: "t" }], [{ op: "default", to: "total", value: 0, direction: "decode" }])).toEqual(["two rules use the same from path: total"]); }); });