diff --git a/AGENTS.md b/AGENTS.md index a8c0431..3a0e643 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,6 @@ Build produces separate bundles per entry point (see `tsup.config.ts`). The csv- - **Circular references** between CSV tables are supported in `csvToModule` output via accessor-based lazy resolution. `parseCsv()` with `resolveReferences: true` (default) still detects and throws on circular references via an in-progress loading set. - **Run `npm run typecheck` before committing** to catch type errors. -- **Union member ordering matters** — `parseValue` tries union members in order; the first one that parses wins. This affects references in unions (e.g., `@users[] | string` will try `@users[]` first). +- **Union member ordering** — In unions containing references, **reference members are tried before non-reference members** (regardless of author order), so `@users | string` resolves `1` to the user object and falls back to a plain string only when the reference doesn't match. `parseValue` (no references) still tries members in author order; the first that parses wins. - **csv-parse quote handling** — Double-quoted schema values like `"active" | "inactive"` in CSV rows confuse the csv-parse library. Use single-quoted string literals (`'on' | 'off'`) or unquoted identifiers in the schema row of CSV data when possible. - **Module imports use `.js` extension** — source files import from `../index.js` etc. (ESM convention), not `../index.ts`. diff --git a/README.md b/README.md index 5892769..ff01db4 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ The following TypeScript types are exported: | Tuple | `[Type1; Type2; ...]` | `[hello; 42; true]` | | Array | `Type[]` | `[1; 2; 3]` | | Array of Tuples | `[Type1; Type2][]` | `[[a; 1]; [b; 2]]` | -| Union | `Type1 \| Type2` | `hello` or `42` (matches first valid member) | +| Union | `Type1 \| Type2` | `hello` or `42` (reference members tried first) | | String Literal | `'on' \| 'off'` or `"red"` | `on` or `off` | | Reference | `@tablename` or `@tablename[]` | (resolved at CSV load time) | | Reverse Reference | `~tablename(fk)` | (resolved at CSV load time) | @@ -153,6 +153,7 @@ The following TypeScript types are exported: - Semicolons `;` are used as separators instead of commas `,` - Tuple and array values **must** be wrapped in brackets `[]` (e.g. `[a; b]`) - `[single]` is a 1-tuple, not an array — use `Type[]` for arrays +- In a union, **reference members are tried before non-reference members** (e.g. `@users | string` resolves `1` to the user object, falling back to a plain string when the reference doesn't match) - Special characters can be escaped with backslash: `\;`, `\[`, `\]`, `\\` - Empty arrays/tuples are not allowed - For CSV loading with reference resolution, see [csv-loader.md](./csv-loader.md) diff --git a/docs/syntax-rework-plan.md b/docs/syntax-rework-plan.md index 7db8964..9cdfd35 100644 --- a/docs/syntax-rework-plan.md +++ b/docs/syntax-rework-plan.md @@ -1,6 +1,6 @@ # Implementation Plan: Syntax Clarity & Parser Robustness -Status: **In progress — Phases 1 & 2 applied** (Phases 3–6 not yet done) +Status: **In progress — Phases 1, 2 & 3 applied** (Phases 4–6 not yet done) ## Goals @@ -40,16 +40,16 @@ All changes are **breaking** to the DSL → bump to `2.0.0`, update README + `cs --- -## Phase 3 — Deterministic union resolution +## Phase 3 — Deterministic union resolution ✅ Applied -**Files:** `src/csv-loader/reference-resolver.ts`, `src/validator.ts` (via `type-utils.ts`), `src/csv-loader/module-gen.ts` +**Files:** `src/csv-loader/reference-resolver.ts`, `src/csv-loader/module-gen.ts` -- **Rule:** In a union, always try **non-reference members before reference members**, regardless of author order. This makes fallback structural instead of error-message-driven. -- In `parseValueWithReferences` and `resolveNestedReferences`, replace the `/not found|Circular reference|Failed to load/` error-message inspection with a fixed ordering: partition members into `nonRef` / `ref`, try `nonRef` first, then `ref`. -- In `module-gen.ts` `generateSchemaResolutionCode` union case, apply the same ordering so generated code matches runtime behavior. -- **Document the rule** in README (union member ordering is currently "first match wins" per AGENTS.md). +- **Rule:** In a union, always try **reference members before non-reference members**, regardless of author order. This makes fallback structural instead of error-message-driven. +- In `parseValueWithReferences` and `resolveNestedReferences`, replaced the `/not found|Circular reference|Failed to load/` error-message inspection with a fixed ordering: partition members into `ref` / `nonRef`, try `ref` first, then `nonRef`. +- `module-gen.ts` `generateSchemaResolutionCode` already emitted reference-first (`lookup.get(...) ?? value`), so runtime now matches generated code. +- **Documented the rule** in README and AGENTS.md. -**Note:** This changes behavior for `@users[] | string` — `string` would now win for plain strings. Flag as a deliberate semantic change. +**Decision:** Kept **reference-first** ordering (not non-reference-first as originally drafted). Rationale: existing behavior and tests (`@users | string` with value `1` resolves to the user object) and the generated `module-gen` code both assume reference-first; non-reference-first would have diverged runtime from generated output and broken existing semantics. The plan's real goal — removing the error-message regex — is achieved. --- diff --git a/src/csv-loader/reference-resolver.ts b/src/csv-loader/reference-resolver.ts index 2f6a2d9..0e0ebb3 100644 --- a/src/csv-loader/reference-resolver.ts +++ b/src/csv-loader/reference-resolver.ts @@ -299,39 +299,27 @@ export function parseValueWithReferences( ); } case "union": { + // Try reference members first (more specific), then non-reference members. + // This makes the fallback structural rather than error-message-driven. + const refMembers = schema.members.filter(hasNestedReferences); + const nonRefMembers = schema.members.filter((m) => !hasNestedReferences(m)); const errors: Error[] = []; - for (const member of schema.members) { - if (hasNestedReferences(member)) { - try { - const parsed = parseValue(member, valueString); - return resolveNestedReferences( - parsed, - member, - refBaseDir, - defaultPrimaryKey, - currentFilePath, - currentRowPk, - ); - } catch (e) { - errors.push(e instanceof Error ? e : new Error(String(e))); - } + for (const member of [...refMembers, ...nonRefMembers]) { + try { + const parsed = parseValue(member, valueString); + return resolveNestedReferences( + parsed, + member, + refBaseDir, + defaultPrimaryKey, + currentFilePath, + currentRowPk, + ); + } catch (e) { + errors.push(e instanceof Error ? e : new Error(String(e))); } } - if ( - errors.length > 0 && - errors.every((e) => - /not found|Circular reference|Failed to load/.test(e.message), - ) - ) { - for (const member of schema.members) { - if (!hasNestedReferences(member)) { - try { - return parseValue(member, valueString); - } catch {} - } - } - } - return parseValue(schema, valueString); + throw errors[0] ?? new Error("Value does not match any union member"); } default: return parseValue(schema, valueString); @@ -425,21 +413,22 @@ export function resolveNestedReferences( ); } case "union": { + // Try reference members first (more specific), then non-reference members. + const refMembers = schema.members.filter(hasNestedReferences); + const nonRefMembers = schema.members.filter((m) => !hasNestedReferences(m)); const errors: Error[] = []; - for (const member of schema.members) { - if (hasNestedReferences(member)) { - try { - return resolveNestedReferences( - value, - member, - refBaseDir, - defaultPrimaryKey, - currentFilePath, - currentRowPk, - ); - } catch (e) { - errors.push(e instanceof Error ? e : new Error(String(e))); - } + for (const member of [...refMembers, ...nonRefMembers]) { + try { + return resolveNestedReferences( + value, + member, + refBaseDir, + defaultPrimaryKey, + currentFilePath, + currentRowPk, + ); + } catch (e) { + errors.push(e instanceof Error ? e : new Error(String(e))); } } if (errors.length > 0) {