Compare commits

..
23 Commits
Author SHA1 Message Date
hypercross 7802af577c chore: bump version to 2.0.0 2026-08-06 11:08:30 +08:00
hypercross 1a741b09a8 fix(esbuild): correct exclude pattern logic in csvLoader 2026-08-06 10:52:01 +08:00
hypercross 6da2815301 refactor: decouple table loading from lookup generation
Extract `loadReferenceTableData` to separate the raw file loading
logic from the primary key lookup construction. This prevents
unnecessary Map creation during reverse reference resolution.
2026-08-06 10:42:10 +08:00
hypercross 641af7341a perf(csv-loader): optimize reverse reference resolution
Implement a reverse lookup cache to avoid re-filtering the entire
referenced table for every row during reverse reference resolution.
This improves performance from O(N*M) to O(N+M) where N is the number
of rows in the current table and M is the number of rows in the
referenced table.

Also update documentation to reflect the new union resolution
behavior and migration notes for version 2.0.0.
2026-08-06 10:27:18 +08:00
hypercross 37e3514c0c fix(csv-loader): escape quotes in schema rows
Implement loader-side escaping for the schema row to prevent
`csv-parse` from misinterpreting double-quoted string literals (e.g.,
`"active" | "inactive"`) as field delimiters.
2026-08-06 10:25:24 +08:00
hypercross cdff31c126 docs: update union member resolution documentation
Refactor union resolution to explicitly try reference members before
non-reference members. This replaces the previous error-message-based
fallback with a deterministic, structural approach.

- Update `parseValueWithReferences` and `resolveNestedReferences` to
  partition members by reference presence.
- Update documentation in `README.md`, `AGENTS.md`, and
  `syntax-rework-plan.md` to reflect this behavior.
2026-08-06 09:32:10 +08:00
hypercross c969c7f6fc refactor: enforce mandatory brackets for composite values
Implements Phase 1 and 2 of the syntax rework plan:
- Brackets `[]` are now mandatory for all tuple and array values.
- Removed the `[Type][]` array syntax; arrays now only use `Type[]`.
- `[single]` is now strictly a 1-tuple rather than an array.
- Updated documentation and test fixtures to reflect these breaking
  changes.
2026-08-06 09:19:39 +08:00
hypercross 7db0742f50 docs: add syntax rework plan 2026-08-06 09:05:09 +08:00
hypercross a4e17a3c0b refactor: use browser-compatible csv-parse entry point
Update the `csv-parse` import to use the browser ESM sync version
and remove unused `fs` and `path` imports in `loader.ts`.
2026-08-06 08:40:37 +08:00
hypercross a3e0e953ff docs: update README and loader documentation 2026-06-13 10:24:42 +08:00
hypercross ea98399c24 refactor: rename package from inline-schema to typed-csv 2026-06-13 10:22:18 +08:00
hypercross 89be2783d1 fix(csv-loader): use commas in generated type declarations
Update `type-gen.ts` to replace semicolons with commas in the
generated TypeScript type definitions to ensure valid syntax.
2026-04-22 19:23:49 +08:00
hypercross eef3dbfac8 feat(csv-loader): resolve @table references in type declarations
Update type generation to transform @table references into capitalized
type names when using custom schema strings. This ensures that
generated TypeScript types correctly reference other CSV modules.
2026-04-22 18:15:55 +08:00
hypercross e0317946d5 feat(csv-loader): expand custom type references in schemas
Allow custom type names to be expanded before parsing the schema
string. This enables using declared types within complex structures
like tuples or arrays. The original schema string is preserved for
type generation to ensure the output uses the named type rather than
the expanded inline definition.
2026-04-22 17:58:36 +08:00
hypercross 46504a53dd feat(csv-loader): preserve original schema strings in type generation
Store the original schema string during CSV parsing to prevent
unnecessary expansion of type name references in the generated
TypeScript definitions. This ensures that declared types reference
each other by name rather than inlining their full definitions.
2026-04-22 17:08:06 +08:00
hypercross ea362d4229 test(csv-loader): add tests for custom type expansion in tuples and
arrays
2026-04-22 16:43:42 +08:00
hypercross f101c12097 feat(csv-loader): export generated type definitions 2026-04-22 16:39:41 +08:00
hypercross f03226221d test: add support for array suffixes in type expansion
Update `expandSchemaInString` to handle the `[]` suffix, allowing
type aliases to represent arrays (e.g., `IntentEffects[]`). Added a
fixture and test case to verify parsing of tuple arrays and type
aliases.
2026-04-22 16:37:54 +08:00
hypercross 1061e12c81 feat: enrich ParseError with schema and value context
Include the schema string and the input value in `ParseError` to
provide more helpful debugging information when parsing fails.
2026-04-22 16:14:23 +08:00
hypercross b5be558b57 fix(parser): fix empty array and nested bracket parsing
Improve the `ValueParser` to correctly disambiguate between empty
arrays `[]` and the start of nested structures. This ensures that
the parser can distinguish between an array containing a single
element that starts with a bracket and an actual empty array.

Update tests to reflect that parsed tuples are returned as arrays
rather than objects.
2026-04-22 00:28:10 +08:00
hypercross 585f33b856 test: add encounter fixture and complex schema test case 2026-04-22 00:24:25 +08:00
hypercross 721544e7b2 refactor: update CSV comment syntax for declarations
Update the syntax for type declarations and reverse reference
declarations in CSV files to be more explicit.

- Change type declarations from `# TypeName := schema` to
  `# type TypeName = schema`
- Change reverse reference declarations from `# fieldName :=
  ~table(key)`
  to `# inject fieldName = ~table(key)`
2026-04-21 14:36:02 +08:00
hypercross e5332f506d test: update csvToModule to mutate rows instead of returning new objects 2026-04-21 14:24:24 +08:00
25 changed files with 875 additions and 291 deletions
+4 -3
View File
@@ -13,12 +13,12 @@ No linter or formatter is configured. No CI pipeline exists.
Single-package TypeScript library with two runtime entry points: Single-package TypeScript library with two runtime entry points:
- **`inline-schema`** (`src/index.ts`) — core schema parser, value parser, and validator - **`typed-csv`** (`src/index.ts`) — core schema parser, value parser, and validator
- `src/parser.ts` — `parseSchema()` turns schema strings into AST (`Schema` type) - `src/parser.ts` — `parseSchema()` turns schema strings into AST (`Schema` type)
- `src/validator.ts` — `parseValue()` and `createValidator()` operate on the AST - `src/validator.ts` — `parseValue()` and `createValidator()` operate on the AST
- `src/types.ts` — union type `Schema = Primitive | Tuple | Array | Reference | StringLiteral | Union` - `src/types.ts` — union type `Schema = Primitive | Tuple | Array | Reference | StringLiteral | Union`
- **`inline-schema/csv-loader`** (`src/csv-loader/loader.ts`) — CSV loader with `@table` reference resolution - **`typed-csv/csv-loader`** (`src/csv-loader/loader.ts`) — CSV loader with `@table` reference resolution
- `loader.ts` — `parseCsv()` (eager resolution) and `csvToModule()` (accessor-based output with lazy resolution); `resolveReferences: false` mode stores IDs instead of resolved objects - `loader.ts` — `parseCsv()` (eager resolution) and `csvToModule()` (accessor-based output with lazy resolution); `resolveReferences: false` mode stores IDs instead of resolved objects
- `webpack.ts`, `rollup.ts`, `esbuild.ts` — bundler plugin wrappers around `csvToModule` - `webpack.ts`, `rollup.ts`, `esbuild.ts` — bundler plugin wrappers around `csvToModule`
@@ -28,6 +28,7 @@ Build produces separate bundles per entry point (see `tsup.config.ts`). The csv-
- Schema syntax uses **semicolons** (`;`) as separators, not commas - Schema syntax uses **semicolons** (`;`) as separators, not commas
- Unknown identifiers throw a `ParseError` — only recognized keywords (`string`, `number`, `int`, `float`, `boolean`) and string literals (`"on"`, `'off'`) are valid types - Unknown identifiers throw a `ParseError` — only recognized keywords (`string`, `number`, `int`, `float`, `boolean`) and string literals (`"on"`, `'off'`) are valid types
- 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
- `@tablename` / `@tablename[]` are reference schemas resolved at CSV load time - `@tablename` / `@tablename[]` are reference schemas resolved at CSV load time
- `csvToModule()` emits accessor functions (`getData()`) for tables with references, and static JSON for tables without; bundler loaders all use `csvToModule` - `csvToModule()` emits accessor functions (`getData()`) for tables with references, and static JSON for tables without; bundler loaders all use `csvToModule`
- `parseCsv({ resolveReferences: false })` stores reference IDs instead of resolved objects — used by `csvToModule` to emit import-based lazy resolution - `parseCsv({ resolveReferences: false })` stores reference IDs instead of resolved objects — used by `csvToModule` to emit import-based lazy resolution
@@ -37,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. - **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. - **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. - **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`. - **Module imports use `.js` extension** — source files import from `../index.js` etc. (ESM convention), not `../index.ts`.
+52 -31
View File
@@ -1,11 +1,11 @@
# inline-schema # typed-csv
A TypeScript library for parsing and validating inline schemas with a TypeScript-like syntax using `;` instead of `,`. A TypeScript library for typed CSV data with inline schema validation using a TypeScript-like syntax with `;` instead of `,`.
## Installation ## Installation
```bash ```bash
npm install inline-schema npm install typed-csv
``` ```
## Usage ## Usage
@@ -13,7 +13,7 @@ npm install inline-schema
### Basic Example ### Basic Example
```typescript ```typescript
import { defineSchema } from 'inline-schema'; import { defineSchema } from 'typed-csv';
// Define a schema // Define a schema
const stringSchema = defineSchema('string'); const stringSchema = defineSchema('string');
@@ -35,14 +35,9 @@ numberSchema.validator(name); // false
```typescript ```typescript
const tupleSchema = defineSchema('[string; number; boolean]'); const tupleSchema = defineSchema('[string; number; boolean]');
// With brackets
const value1 = tupleSchema.parse('[hello; 42; true]'); const value1 = tupleSchema.parse('[hello; 42; true]');
// ["hello", 42, true] // ["hello", 42, true]
// Without brackets (outermost brackets are optional)
const value2 = tupleSchema.parse('hello; 42; true');
// ["hello", 42, true]
tupleSchema.validator(value1); // true tupleSchema.validator(value1); // true
tupleSchema.validator(['a', 'b', true]); // false (second element should be number) tupleSchema.validator(['a', 'b', true]); // false (second element should be number)
``` ```
@@ -50,16 +45,11 @@ tupleSchema.validator(['a', 'b', true]); // false (second element should be num
### Arrays ### Arrays
```typescript ```typescript
// Array syntax: Type[] or [Type][] // Array syntax: Type[]
const stringArray = defineSchema('string[]'); const stringArray = defineSchema('string[]');
const numberArray = defineSchema('[number][]'); const numberArray = defineSchema('number[]');
// With brackets const names = stringArray.parse('[alice; bob; charlie]');
const names1 = stringArray.parse('[alice; bob; charlie]');
// ["alice", "bob", "charlie"]
// Without brackets (outermost brackets are optional)
const names2 = stringArray.parse('alice; bob; charlie');
// ["alice", "bob", "charlie"] // ["alice", "bob", "charlie"]
const numbers = numberArray.parse('[1; 2; 3; 4; 5]'); const numbers = numberArray.parse('[1; 2; 3; 4; 5]');
@@ -71,12 +61,7 @@ const numbers = numberArray.parse('[1; 2; 3; 4; 5]');
```typescript ```typescript
const schema = defineSchema('[string; number][]'); const schema = defineSchema('[string; number][]');
// With outer brackets const data = schema.parse('[[a; 1]; [b; 2]; [c; 3]]');
const data1 = schema.parse('[[a; 1]; [b; 2]; [c; 3]]');
// [["a", 1], ["b", 2], ["c", 3]]
// Without outer brackets
const data2 = schema.parse('[a; 1]; [b; 2]; [c; 3]');
// [["a", 1], ["b", 2], ["c", 3]] // [["a", 1], ["b", 2], ["c", 3]]
``` ```
@@ -128,27 +113,63 @@ Parses a value string according to the given schema.
Creates a validation function for the given schema. Creates a validation function for the given schema.
### `schemaToTypeString(schema: Schema): string`
Converts a schema AST back to a human-readable type string.
### `ParseError`
Error class thrown for invalid schema syntax.
### Types
The following TypeScript types are exported:
- `Schema` — union of all schema AST node types
- `ParsedSchema` — the return type of `defineSchema()`
- `PrimitiveSchema`, `TupleSchema`, `ArraySchema` — AST node types
- `ReferenceSchema`, `ReverseReferenceSchema` — reference AST node types
- `StringLiteralSchema`, `UnionSchema` — literal and union AST node types
## Schema Syntax ## Schema Syntax
| Type | Schema | Example Value | | Type | Schema | Example Value |
|------|--------|---------------| |------|--------|---------------|
| String | `string` or `identifier` | `hello` | | String | `string` or `identifier` | `hello` |
| Number | `number` | `42` | | Int | `int` | `42` |
| Float | `float` | `3.14` |
| Number | `number` | `42` or `3.14` |
> **Note:** `int`, `float`, and `number` all collapse to the `number` type in generated TypeScript declarations (`schemaToTypeString`). The distinction is preserved at parse time — `int` rejects non-integer values while `float`/`number` accept them.
| Boolean | `boolean` | `true` or `false` | | Boolean | `boolean` | `true` or `false` |
| Tuple | `[Type1; Type2; ...]` | `[hello; 42; true]` or `hello; 42; true` | | Tuple | `[Type1; Type2; ...]` | `[hello; 42; true]` |
| Array | `Type[]` or `[Type][]` | `[1; 2; 3]` or `1; 2; 3` | | Array | `Type[]` | `[1; 2; 3]` |
| Array of Tuples | `[Type1; Type2][]` | `[[a; 1]; [b; 2]]` or `[a; 1]; [b; 2]` | | Array of Tuples | `[Type1; Type2][]` | `[[a; 1]; [b; 2]]` |
| 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) |
## Notes ## Notes
- Semicolons `;` are used as separators instead of commas `,` - Semicolons `;` are used as separators instead of commas `,`
- Outermost brackets `[]` are optional for tuple and array values - 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: `\;`, `\[`, `\]`, `\\` - Special characters can be escaped with backslash: `\;`, `\[`, `\]`, `\\`
- Empty arrays/tuples are not allowed - Empty arrays/tuples are not allowed
- In CSV schema rows, double-quoted string literals like `"active" | "inactive"` are handled automatically by the loader; avoid commas inside string literals (use single-quoted literals like `'a,b'` for those)
- For CSV loading with reference resolution, see [csv-loader.md](./csv-loader.md)
## CSV Loader ## Migration from 1.x
For loading CSV files with schema validation in rspack, see [csv-loader.md](./csv-loader.md). Version 2.0.0 introduces breaking changes to the schema DSL:
1. **Composite values must be fully bracketed.** Tuple and array values now always require `[]` — `[a; 1]; [b; 2]` is no longer accepted; write `[[a; 1]; [b; 2]]`.
2. **The `[Type][]` array form is removed.** Use `Type[]` (e.g. `[string; number][]` instead of `[[string; number]][]`).
3. **`[single]` is now a 1-tuple, not an array.** Use `Type[]` for single-element arrays.
4. **Union resolution prefers reference members.** In a union containing references, reference members are tried before non-reference members regardless of author order (see the notes above).
5. **Schema cells may be fully quoted.** Double-quoted string literals like `"active" | "inactive"` in a CSV schema row are handled automatically by the loader.
```javascript ```javascript
// rspack.config.js // rspack.config.js
@@ -157,7 +178,7 @@ module.exports = {
rules: [ rules: [
{ {
test: /\.schema\.csv$/, test: /\.schema\.csv$/,
use: 'inline-schema/csv-loader', use: 'typed-csv/csv-loader',
}, },
], ],
}, },
+19 -9
View File
@@ -1,18 +1,18 @@
# inline-schema/csv-loader # typed-csv/csv-loader
A rspack/rollup loader for CSV files that uses inline-schema for type validation. A bundler loader (rspack/webpack/rollup/esbuild) for CSV files that uses typed-csv for type validation and cross-table reference resolution.
## Installation ## Installation
```bash ```bash
npm install inline-schema npm install typed-csv
``` ```
## Usage ## Usage
The loader expects: The loader expects:
- **First row**: Property names (headers) - **First row**: Property names (headers)
- **Second row**: Inline-schema definitions for each property - **Second row**: typed-csv schema definitions for each property
- **Remaining rows**: Data values - **Remaining rows**: Data values
### Example CSV ### Example CSV
@@ -35,7 +35,7 @@ module.exports = {
{ {
test: /\.schema\.csv$/, test: /\.schema\.csv$/,
use: { use: {
loader: 'inline-schema/csv-loader', loader: 'typed-csv/csv-loader',
options: { options: {
delimiter: ',', delimiter: ',',
quote: '"', quote: '"',
@@ -60,7 +60,7 @@ module.exports = {
```typescript ```typescript
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import { csvLoader } from 'inline-schema/csv-loader/rollup'; import { csvLoader } from 'typed-csv/csv-loader/rollup';
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
@@ -85,7 +85,7 @@ export default defineConfig({
```typescript ```typescript
import { defineConfig } from 'tsup'; import { defineConfig } from 'tsup';
import { csvLoader } from 'inline-schema/csv-loader/rollup'; import { csvLoader } from 'typed-csv/csv-loader/rollup';
export default defineConfig({ export default defineConfig({
entry: ['src/index.ts'], entry: ['src/index.ts'],
@@ -139,16 +139,26 @@ import data from './data.csv';
## Schema Syntax ## Schema Syntax
Uses [inline-schema](https://github.com/your-repo/inline-schema) syntax: Uses [typed-csv](https://github.com/your-repo/typed-csv) syntax:
| Type | Schema | Example | | Type | Schema | Example |
|------|--------|---------| |------|--------|---------|
| String | `string` | `hello` | | String | `string` | `hello` |
| Number | `number` | `42` | | Number | `number` | `42` |
| Boolean | `boolean` | `true` | | Boolean | `boolean` | `true` |
| Array | `string[]` or `[string][]` | `[a; b; c]` | | Array | `string[]` | `[a; b; c]` |
| Tuple | `[string; number]` | `[hello; 42]` | | Tuple | `[string; number]` | `[hello; 42]` |
> **Note:** `int` and `float` are accepted in schemas and validated at parse time, but both collapse to `number` in generated type declarations.
## Migration from 1.x
- Composite values must be fully bracketed: `[[a; 1]; [b; 2]]`, not `[a; 1]; [b; 2]`.
- The `[Type][]` array form is removed — use `Type[]`.
- `[single]` is a 1-tuple, not an array.
- In unions containing references, reference members are tried first.
- Double-quoted string literals (`"active" | "inactive"`) in a schema row are handled automatically.
## License ## License
ISC ISC
+106
View File
@@ -0,0 +1,106 @@
# Implementation Plan: Syntax Clarity & Parser Robustness
Status: **In progress — Phases 1, 2, 3 & 4 applied** (Phases 5–6 not yet done)
## Goals
1. Eliminate the backtracking heuristics in the value parser by making the value grammar context-free.
2. Remove redundant/ambiguous syntax forms.
3. Make union resolution deterministic and structural (not error-message-driven).
4. Address the `csv-parse` quote conflict.
5. Keep the `;` separator (forced by CSV constraints — not the source of ambiguity).
All changes are **breaking** to the DSL → bump to `2.0.0`, update README + `csv-loader.md`, add a migration note.
---
## Phase 1 — Mandatory brackets for composite values ✅ Applied
**Files:** `src/value-parser.ts`, `src/index.test.ts`, `src/csv-loader/reference-resolver.ts`
- Removed the `allowOmitBrackets` parameter from `parseValue`, `parseTupleValue`, `parseArrayValue`.
- Deleted the `elementIsTupleOrArray` disambiguation block and all `savedPos` restore logic in `parseArrayValue`.
- Dropped the `allowOmitBrackets = schema.type === "tuple" || "array"` special case in top-level `parseValue` — brackets are always required.
- Array references (`@table[]` values) now also require brackets, for consistency.
- Values must now be fully bracketed: `[a; 1]; [b; 2]` (no more `[a; 1]; [b; 2]` without outer brackets).
**Decision:** Full mandatory brackets (no carve-out).
---
## Phase 2 — Single array form `Type[]` ✅ Applied
**Files:** `src/parser.ts`, `src/index.test.ts`
- Removed the `[Type][]` array syntax from `parseSchemaInternal`. Kept only `Type[]`.
- `[string]` is now a **1-tuple** (previously it collapsed to an array). The tuple branch always returns `{ type: "tuple", elements }`.
- Updated `schemaToTypeString` in `src/type-utils.ts` — the `array` case no longer special-cases tuple elements; arrays are always `elementType[]` (kept the `(union)[]` paren wrapping).
**Tests updated:** `index.test.ts` bracket-optional tests, `encounter.csv` / `enemy_intents.csv` fixtures, and `parseCsv-typeDeclarations.test.ts` array-of-tuple values.
---
## Phase 3 — Deterministic union resolution ✅ Applied
**Files:** `src/csv-loader/reference-resolver.ts`, `src/csv-loader/module-gen.ts`
- **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.
**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.
---
## Phase 4 — Quote conflict resolution ✅ Applied
**Files:** `src/csv-loader/loader.ts`, `src/csv-loader/tests/parseCsv-basic.test.ts`, README, `csv-loader.md`
- Added `escapeSchemaRowQuotes()` in `loader.ts`: the schema row (2nd non-empty line) is split on the delimiter, and any cell containing the quote char has its inner quotes escaped and is wrapped in quotes before `csv-parse` runs.
- This lets users write `"active" | "inactive"` naturally in a schema row — previously csv-parse threw `Invalid Closing Quote`.
- Single-quoted literals (`'on' | 'off'`) are untouched (no `"` present).
- Added a test in `parseCsv-basic.test.ts`.
**Decision:** Implemented option (b) — loader-side escaping of the schema row. This fixes the root cause (schema cells containing `"` break csv-parse) without redesigning the literal syntax.
**Known limitation (pre-existing, out of scope):** a comma inside a string literal (e.g. `"a,b"`) is ambiguous with the CSV delimiter and still fails — use single-quoted literals (`'a,b'`) for those, or a non-comma delimiter.
---
## Phase 5 — Cleanup & consistency
**Files:** `src/csv-loader/reference-resolver.ts`, `src/csv-loader/loader.ts`, `src/csv-loader/module-gen.ts`
- **Reverse-reference performance:** `resolveReverseReference` does `refTable.filter(...)` per row. Build a `Map<fk, rows[]>` lookup once per referenced table (mirroring what `module-gen.ts` already generates) and reuse it. Cache the lookup alongside the parsed table in `referenceTableCache`.
- **Remove the `,` stop-character in `parseReferenceValue`** (value parser has a `,` stop char the schema parser doesn't — drift). Make reference ID parsing consistent with the rest of the value grammar.
- **`int`/`float`/`number` → `number`:** document the collapse in README (type-level collapse is intentional; parse-time distinction remains).
---
## Phase 6 — Docs & migration
**Files:** `README.md`, `csv-loader.md`, `AGENTS.md`
- Update all syntax tables and examples for mandatory brackets + single array form.
- Add a **Migration section** listing the breaking changes:
1. Composite values must be fully bracketed.
2. `[Type][]` array form removed — use `Type[]`.
3. `[single]` is now a 1-tuple, not an array.
4. Union resolution now prefers reference members (tried before non-reference members).
5. (If Phase 4b) schema cells may be fully quoted.
- Update `AGENTS.md` gotchas: union ordering rule, quote handling.
---
## Validation
- `npm run typecheck`
- `npm run test` (update `src/index.test.ts`, `src/csv-loader/*.test.ts` first)
- Manually verify the integration fixture (`user_rev.csv` / `order_rev.csv`) still resolves.
---
## Suggested execution order
Phases 1 → 2 are tightly coupled (both touch bracket parsing) — do them together. Phase 3 is independent. Phase 4 is independent. Phases 5–6 are cleanup/docs and can go last.
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "inline-schema", "name": "typed-csv",
"version": "1.0.0", "version": "2.0.0",
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.mjs", "module": "./dist/index.mjs",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -49,7 +49,7 @@
], ],
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"description": "A TypeScript library for parsing and validating inline schemas", "description": "A TypeScript library for typed CSV data with inline schema validation",
"dependencies": { "dependencies": {
"csv-parse": "^5.5.6" "csv-parse": "^5.5.6"
}, },
+3 -3
View File
@@ -39,7 +39,7 @@ function matchesPattern(
} }
/** /**
* Esbuild plugin for loading CSV files with inline-schema validation. * Esbuild plugin for loading CSV files with typed-csv validation.
*/ */
export function csvLoader(options: CsvEsbuildOptions = {}): Plugin { export function csvLoader(options: CsvEsbuildOptions = {}): Plugin {
const { const {
@@ -54,12 +54,12 @@ export function csvLoader(options: CsvEsbuildOptions = {}): Plugin {
const includeFilter = createFilter(include); const includeFilter = createFilter(include);
return { return {
name: "inline-schema-csv", name: "typed-csv",
setup(build) { setup(build) {
build.onLoad({ filter: includeFilter }, async (args) => { build.onLoad({ filter: includeFilter }, async (args) => {
// Check exclude pattern // Check exclude pattern
if (exclude && !matchesPattern(args.path, exclude)) { if (exclude && matchesPattern(args.path, exclude)) {
return null; return null;
} }
+7
View File
@@ -0,0 +1,7 @@
# type EncounterType = 'minion'|'elite'|'event'|'shop'|'camp'|'curio'
# type EnemyList = [name: string; hp: int; effects: [effect: string;stacks: int][]][]
id,type,name,description,enemies,dialogue
string,EncounterType,string,string,EnemyList,string
cactus_pair,minion,仙人掌怪,概念:防+强化。【尖刺X】:对攻击者造成X点伤害。,[[仙人掌怪;12;[]];[仙人掌怪;12;[]]],
snake_pair,minion,蛇,概念:攻+强化。给玩家塞入蛇毒牌(1费:打出时移除此牌。弃掉时受到3点伤害)。,[[蛇;10;[[poison;2];[quick;1]]]],
1 # type EncounterType = 'minion'|'elite'|'event'|'shop'|'camp'|'curio'
2 # type EnemyList = [name: string; hp: int; effects: [effect: string;stacks: int][]][]
3 id,type,name,description,enemies,dialogue
4 string,EncounterType,string,string,EnemyList,string
5 cactus_pair,minion,仙人掌怪,概念:防+强化。【尖刺X】:对攻击者造成X点伤害。,[[仙人掌怪;12;[]];[仙人掌怪;12;[]]],
6 snake_pair,minion,蛇,概念:攻+强化。给玩家塞入蛇毒牌(1费:打出时移除此牌。弃掉时受到3点伤害)。,[[蛇;10;[[poison;2];[quick;1]]]],
@@ -0,0 +1,7 @@
# type IntentEffectTarget = 'user' | 'eachEnemy' | 'randomEnemy' | 'player'
# type IntentEffect = [IntentEffectTarget; string; number]
# type IntentEffects = IntentEffect[]
id,enemy,initialIntent,nextIntents,brokenIntent,effects
string,string,boolean,string[],string[],IntentEffects
仙人掌怪-boost,仙人掌怪,true,[仙人掌怪-boost;仙人掌怪-defend],,[[user;spike;1];[user;defend;4]]
1 # type IntentEffectTarget = 'user' | 'eachEnemy' | 'randomEnemy' | 'player'
2 # type IntentEffect = [IntentEffectTarget; string; number]
3 # type IntentEffects = IntentEffect[]
4 id,enemy,initialIntent,nextIntents,brokenIntent,effects
5 string,string,boolean,string[],string[],IntentEffects
6 仙人掌怪-boost,仙人掌怪,true,[仙人掌怪-boost;仙人掌怪-defend],,[[user;spike;1];[user;defend;4]]
+1 -1
View File
@@ -1,4 +1,4 @@
# orders := ~order_rev(user) # inject orders = ~order_rev(user)
id,name id,name
string,string string,string
u01,Alice u01,Alice
1 # orders := ~order_rev(user) # inject orders = ~order_rev(user)
2 id,name id,name
3 string,string string,string
4 u01,Alice u01,Alice
+84 -8
View File
@@ -1,4 +1,4 @@
import { parse } from "csv-parse/sync"; import { parse } from "csv-parse/browser/esm/sync";
import { import {
parseSchema, parseSchema,
createValidator, createValidator,
@@ -18,7 +18,6 @@ import type {
ReverseReferenceDeclaration, ReverseReferenceDeclaration,
TypeDeclaration, TypeDeclaration,
} from "./types.js"; } from "./types.js";
import { ParseError } from "../parser.js";
import { import {
hasNestedReferences, hasNestedReferences,
loadReferenceTable, loadReferenceTable,
@@ -33,9 +32,54 @@ import {
parseReferenceValue, parseReferenceValue,
} from "./reference-resolver.js"; } from "./reference-resolver.js";
import { generateTypeDefinition } from "./type-gen.js"; import { generateTypeDefinition } from "./type-gen.js";
/**
* Escape double-quote characters in the schema row so csv-parse doesn't
* misinterpret them as CSV field delimiters.
*
* The schema row is the 2nd non-empty line. Cells that contain the quote
* character (e.g. `"active" | "inactive"`) have their inner quotes escaped
* and are wrapped in quotes, so csv-parse treats each as a single field.
*/
function escapeSchemaRowQuotes(
content: string,
delimiter: string,
quote: string,
escape: string,
): string {
if (!quote || !content.includes(quote)) {
return content;
}
const lines = content.split(/\r?\n/);
let schemaRowIndex = -1;
let nonEmptyCount = 0;
for (let i = 0; i < lines.length; i++) {
if (lines[i].trim() === "") continue;
nonEmptyCount++;
if (nonEmptyCount === 2) {
schemaRowIndex = i;
break;
}
}
if (schemaRowIndex === -1) {
return content;
}
const cells = lines[schemaRowIndex].split(delimiter);
const transformed = cells
.map((cell) => {
if (!cell.includes(quote)) {
return cell;
}
const escaped = cell.split(quote).join(escape + quote);
return quote + escaped + quote;
})
.join(delimiter);
lines[schemaRowIndex] = transformed;
return lines.join("\n");
}
import { csvToModule } from "./module-gen.js"; import { csvToModule } from "./module-gen.js";
import * as fs from "fs";
import * as path from "path";
import { import {
parseTypeDeclaration, parseTypeDeclaration,
parseReverseReferenceDeclaration, parseReverseReferenceDeclaration,
@@ -98,6 +142,15 @@ export function parseCsv(
filteredContent = nonCommentLines.join("\n"); filteredContent = nonCommentLines.join("\n");
} }
// Escape double-quotes in the schema row so csv-parse doesn't treat them
// as CSV field delimiters (e.g. `"active" | "inactive"` in a schema cell).
filteredContent = escapeSchemaRowQuotes(
filteredContent,
delimiter,
quote,
escape,
);
const records = parse(filteredContent, { const records = parse(filteredContent, {
delimiter, delimiter,
quote, quote,
@@ -160,7 +213,11 @@ export function parseCsv(
} }
// Parse type declarations with expansion of type name references // Parse type declarations with expansion of type name references
const typeDeclarationsParsed: { name: string; schema: Schema }[] = []; const typeDeclarationsParsed: {
name: string;
schema: Schema;
schemaString: string;
}[] = [];
for (const decl of typeDeclarationsRaw) { for (const decl of typeDeclarationsRaw) {
// Expand any type name references before parsing // Expand any type name references before parsing
const expandedSchema = expandSchemaString( const expandedSchema = expandSchemaString(
@@ -168,7 +225,11 @@ export function parseCsv(
declaredSchemaStrings, declaredSchemaStrings,
); );
const schema = parseSchema(expandedSchema.trim()); const schema = parseSchema(expandedSchema.trim());
typeDeclarationsParsed.push({ name: decl.typeName, schema }); typeDeclarationsParsed.push({
name: decl.typeName,
schema,
schemaString: decl.schemaString,
});
} }
// Build declared types map // Build declared types map
@@ -181,7 +242,11 @@ export function parseCsv(
const typeDeclarations: TypeDeclaration[] = []; const typeDeclarations: TypeDeclaration[] = [];
for (const decl of typeDeclarationsParsed) { for (const decl of typeDeclarationsParsed) {
const resolvedSchema = resolveTypeReferences(decl.schema, declaredTypes); const resolvedSchema = resolveTypeReferences(decl.schema, declaredTypes);
typeDeclarations.push({ name: decl.name, schema: resolvedSchema }); typeDeclarations.push({
name: decl.name,
schema: resolvedSchema,
schemaString: decl.schemaString,
});
} }
// Update declaredTypes with resolved schemas for column schema lookup // Update declaredTypes with resolved schemas for column schema lookup
@@ -197,11 +262,21 @@ export function parseCsv(
// Check if schema string matches a declared type name // Check if schema string matches a declared type name
let schema: Schema; let schema: Schema;
let declaredTypeName: string | undefined; let declaredTypeName: string | undefined;
let columnSchemaString: string | undefined;
if (declaredTypes.has(schemaString)) { if (declaredTypes.has(schemaString)) {
schema = declaredTypes.get(schemaString)!; schema = declaredTypes.get(schemaString)!;
declaredTypeName = schemaString; declaredTypeName = schemaString;
} else { } else {
schema = parseSchema(schemaString); // Expand any custom type name references before parsing
const expandedSchema = expandSchemaString(
schemaString,
declaredSchemaStrings,
);
schema = parseSchema(expandedSchema.trim());
// Only preserve the original schema string if expansion actually changed it
if (expandedSchema !== schemaString) {
columnSchemaString = schemaString;
}
} }
const config: PropertyConfig = { const config: PropertyConfig = {
@@ -210,6 +285,7 @@ export function parseCsv(
validator: createValidator(schema), validator: createValidator(schema),
parser: (valueString: string) => parseValue(schema, valueString), parser: (valueString: string) => parseValue(schema, valueString),
declaredTypeName, declaredTypeName,
schemaString: columnSchemaString,
}; };
if (schema.type === "reference") { if (schema.type === "reference") {
+15 -15
View File
@@ -104,7 +104,7 @@ describe("csvToModule - circular reference support", () => {
expect(result.js).toContain("_self_refLookup"); expect(result.js).toContain("_self_refLookup");
expect(result.js).toContain("_self_refLookup = new Map(_raw.map"); expect(result.js).toContain("_self_refLookup = new Map(_raw.map");
expect(result.js).toContain( expect(result.js).toContain(
"parent: _self_refLookup.get(String(row.parent))", "row.parent = _self_refLookup.get(String(row.parent));",
); );
}); });
@@ -119,7 +119,7 @@ describe("csvToModule - circular reference support", () => {
expect(result.js).toContain("import _circular_b from './circular_b.csv'"); expect(result.js).toContain("import _circular_b from './circular_b.csv'");
expect(result.js).toContain("export default function getData()"); expect(result.js).toContain("export default function getData()");
expect(result.js).toContain("_circular_bLookup"); expect(result.js).toContain("_circular_bLookup");
expect(result.js).toContain("related:"); expect(result.js).toContain("row.related =");
}); });
it("should emit accessor for self-referencing table with nested reference in tuple", () => { it("should emit accessor for self-referencing table with nested reference in tuple", () => {
@@ -137,7 +137,7 @@ describe("csvToModule - circular reference support", () => {
expect(result.js).not.toContain("import _self_ref from './self_ref.csv'"); expect(result.js).not.toContain("import _self_ref from './self_ref.csv'");
expect(result.js).toContain("_self_refLookup"); expect(result.js).toContain("_self_refLookup");
expect(result.js).toContain("export default function getData()"); expect(result.js).toContain("export default function getData()");
expect(result.js).toContain("parent_info:"); expect(result.js).toContain("row.parent_info =");
expect(result.js).toContain( expect(result.js).toContain(
"_self_refLookup.get(String(row.parent_info[0]))", "_self_refLookup.get(String(row.parent_info[0]))",
); );
@@ -159,7 +159,7 @@ describe("csvToModule - circular reference support", () => {
expect(result.js).not.toContain("import _self_ref from './self_ref.csv'"); expect(result.js).not.toContain("import _self_ref from './self_ref.csv'");
expect(result.js).toContain("_self_refLookup"); expect(result.js).toContain("_self_refLookup");
expect(result.js).toContain("export default function getData()"); expect(result.js).toContain("export default function getData()");
expect(result.js).toContain("ref_or_val:"); expect(result.js).toContain("row.ref_or_val =");
expect(result.js).toContain( expect(result.js).toContain(
"_self_refLookup.get(String(row.ref_or_val)) ?? row.ref_or_val", "_self_refLookup.get(String(row.ref_or_val)) ?? row.ref_or_val",
); );
@@ -180,7 +180,7 @@ describe("csvToModule - circular reference support", () => {
expect(result.js).not.toContain("import _self_ref from './self_ref.csv'"); expect(result.js).not.toContain("import _self_ref from './self_ref.csv'");
expect(result.js).toContain("_self_refLookup"); expect(result.js).toContain("_self_refLookup");
expect(result.js).toContain("export default function getData()"); expect(result.js).toContain("export default function getData()");
expect(result.js).toContain("children:"); expect(result.js).toContain("row.children =");
}); });
it("should generate correct type definition for self-referencing table using local singular type", () => { it("should generate correct type definition for self-referencing table using local singular type", () => {
@@ -247,7 +247,7 @@ describe("csvToModule - reverse reference output", () => {
const csv = [ const csv = [
"id,name", "id,name",
"string,string", "string,string",
"# orders := ~orders(customer)", "# inject orders = ~orders(customer)",
"1,Alice", "1,Alice",
].join("\n"); ].join("\n");
@@ -255,7 +255,7 @@ describe("csvToModule - reverse reference output", () => {
expect(result.js).toContain("import _orders from './orders.csv'"); expect(result.js).toContain("import _orders from './orders.csv'");
expect(result.js).toContain("_ordersBy_customer"); expect(result.js).toContain("_ordersBy_customer");
expect(result.js).toContain("orders:"); expect(result.js).toContain("row.orders =");
expect(result.js).toContain("_ordersBy_customer.get(String(row.id))"); expect(result.js).toContain("_ordersBy_customer.get(String(row.id))");
}); });
@@ -263,7 +263,7 @@ describe("csvToModule - reverse reference output", () => {
const csv = [ const csv = [
"id,name", "id,name",
"string,string", "string,string",
"# orders := ~orders(customer)?", "# inject orders = ~orders(customer)?",
"1,Alice", "1,Alice",
].join("\n"); ].join("\n");
@@ -278,7 +278,7 @@ describe("csvToModule - reverse reference output", () => {
const csv = [ const csv = [
"id,name", "id,name",
"string,string", "string,string",
"# orders := ~orders(customer)", "# inject orders = ~orders(customer)",
"1,Alice", "1,Alice",
].join("\n"); ].join("\n");
@@ -291,7 +291,7 @@ describe("csvToModule - reverse reference output", () => {
const csv = [ const csv = [
"id,name", "id,name",
"string,string", "string,string",
"# children := ~self_ref(parent)", "# inject children = ~self_ref(parent)",
"1,Root", "1,Root",
"2,Child", "2,Child",
].join("\n"); ].join("\n");
@@ -304,14 +304,14 @@ describe("csvToModule - reverse reference output", () => {
expect(result.js).not.toContain("import _self_ref from './self_ref.csv'"); expect(result.js).not.toContain("import _self_ref from './self_ref.csv'");
expect(result.js).toContain("_self_refBy_parent"); expect(result.js).toContain("_self_refBy_parent");
expect(result.js).toContain("for (const r of _raw)"); expect(result.js).toContain("for (const r of _raw)");
expect(result.js).toContain("children:"); expect(result.js).toContain("row.children =");
}); });
it("should generate correct type definition for reverse references", () => { it("should generate correct type definition for reverse references", () => {
const csv = [ const csv = [
"id,name", "id,name",
"string,string", "string,string",
"# orders := ~orders(customer)", "# inject orders = ~orders(customer)",
"1,Alice", "1,Alice",
].join("\n"); ].join("\n");
@@ -329,7 +329,7 @@ describe("csvToModule - reverse reference output", () => {
const csv = [ const csv = [
"id,name", "id,name",
"string,string", "string,string",
"# orders := ~orders(customer)?", "# inject orders = ~orders(customer)?",
"1,Alice", "1,Alice",
].join("\n"); ].join("\n");
@@ -346,7 +346,7 @@ describe("csvToModule - reverse reference output", () => {
const csv = [ const csv = [
"id,name,manager", "id,name,manager",
"string,string,@users", "string,string,@users",
"# reports := ~users(manager)", "# inject reports = ~users(manager)",
"1,Alice,2", "1,Alice,2",
"2,Bob,", "2,Bob,",
].join("\n"); ].join("\n");
@@ -365,7 +365,7 @@ describe("csvToModule - reverse reference output", () => {
const csv = [ const csv = [
"id,name,creator", "id,name,creator",
"string,string,@users", "string,string,@users",
"# reviews := ~users(reviewer)", "# inject reviews = ~users(reviewer)",
"1,Doc,1", "1,Doc,1",
].join("\n"); ].join("\n");
+14 -11
View File
@@ -186,7 +186,7 @@ export function csvToModule(
const reverseLookupVar = (tableName: string, foreignKey: string) => const reverseLookupVar = (tableName: string, foreignKey: string) =>
reverseLookupVarMap.get(`${tableName}:${foreignKey}`)!; reverseLookupVarMap.get(`${tableName}:${foreignKey}`)!;
const rowResolvers: string[] = []; const rowResolvers: { name: string; code: string }[] = [];
for (const config of result.propertyConfigs) { for (const config of result.propertyConfigs) {
if (config.isReverseReference) { if (config.isReverseReference) {
// Reverse reference resolution // Reverse reference resolution
@@ -196,13 +196,15 @@ export function csvToModule(
if (decl) { if (decl) {
const revLookup = reverseLookupVar(decl.tableName, decl.foreignKey); const revLookup = reverseLookupVar(decl.tableName, decl.foreignKey);
if (decl.isOptional) { if (decl.isOptional) {
rowResolvers.push( rowResolvers.push({
` ${config.name}: (${revLookup}.get(String(row.${defaultPrimaryKey})) || null),`, name: config.name,
); code: `(${revLookup}.get(String(row.${defaultPrimaryKey})) || null)`,
});
} else { } else {
rowResolvers.push( rowResolvers.push({
` ${config.name}: (${revLookup}.get(String(row.${defaultPrimaryKey})) || []),`, name: config.name,
); code: `(${revLookup}.get(String(row.${defaultPrimaryKey})) || [])`,
});
} }
} }
} else if (hasNestedReferences(config.schema)) { } else if (hasNestedReferences(config.schema)) {
@@ -213,7 +215,7 @@ export function csvToModule(
defaultPrimaryKey, defaultPrimaryKey,
reverseLookupVar, reverseLookupVar,
); );
rowResolvers.push(` ${config.name}: ${resolveCode},`); rowResolvers.push({ name: config.name, code: resolveCode });
} }
} }
@@ -233,9 +235,10 @@ export function csvToModule(
...reverseLookupInits.map((l) => ` ${l}`), ...reverseLookupInits.map((l) => ` ${l}`),
...(rowResolvers.length > 0 ...(rowResolvers.length > 0
? [ ? [
" _resolved = _raw.map(row => (", " _resolved = _raw.map(row => {",
...rowResolvers.map((r) => ` row${r.slice(1)}`), ...rowResolvers.map((r) => ` row.${r.name} = ${r.code};`),
" row));", " return row;",
" });",
] ]
: []), : []),
" return _resolved;", " return _resolved;",
+115 -58
View File
@@ -12,6 +12,16 @@ import { parseCsv } from "./loader.js";
/** Cache for loaded referenced tables */ /** Cache for loaded referenced tables */
const referenceTableCache = new Map<string, Record<string, unknown>[]>(); const referenceTableCache = new Map<string, Record<string, unknown>[]>();
/**
* Cache for reverse-reference lookups: filePath -> (foreignKey -> (fkValue -> rows[])).
* Built once per referenced table + foreign key, mirroring what module-gen emits,
* so resolveReverseReference doesn't re-filter the whole table per row.
*/
const reverseLookupCache = new Map<
string,
Map<string, Map<string, Record<string, unknown>[]>>
>();
/** Set of file paths currently being loaded (to detect circular references) */ /** Set of file paths currently being loaded (to detect circular references) */
const loadingFiles = new Set<string>(); const loadingFiles = new Set<string>();
@@ -31,14 +41,13 @@ export function hasNestedReferences(schema: Schema): boolean {
} }
} }
export function loadReferenceTable( function loadReferenceTableData(
schema: ReferenceSchema | ReverseReferenceSchema, schema: ReferenceSchema | ReverseReferenceSchema,
refBaseDir: string | undefined, refBaseDir: string | undefined,
defaultPrimaryKey: string,
currentFilePath: string | undefined, currentFilePath: string | undefined,
): { ): {
lookup: Map<string, Record<string, unknown>>;
refTable: Record<string, unknown>[]; refTable: Record<string, unknown>[];
refFilePath: string;
} { } {
const baseDir = const baseDir =
refBaseDir || refBaseDir ||
@@ -75,6 +84,30 @@ export function loadReferenceTable(
} }
} }
return { refTable, refFilePath };
}
/**
* Load a referenced table and build a forward lookup keyed by primary key.
* Only call this when forward resolution is actually needed; the reverse
* path uses `loadReferenceTableData` directly to avoid building a throwaway map.
*/
export function loadReferenceTable(
schema: ReferenceSchema | ReverseReferenceSchema,
refBaseDir: string | undefined,
defaultPrimaryKey: string,
currentFilePath: string | undefined,
): {
lookup: Map<string, Record<string, unknown>>;
refTable: Record<string, unknown>[];
refFilePath: string;
} {
const { refTable, refFilePath } = loadReferenceTableData(
schema,
refBaseDir,
currentFilePath,
);
const lookup = new Map<string, Record<string, unknown>>(); const lookup = new Map<string, Record<string, unknown>>();
refTable.forEach((row) => { refTable.forEach((row) => {
const pkValue = row[defaultPrimaryKey]; const pkValue = row[defaultPrimaryKey];
@@ -83,7 +116,50 @@ export function loadReferenceTable(
} }
}); });
return { lookup, refTable }; return { lookup, refTable, refFilePath };
}
/**
* Build (and cache) a reverse lookup for a referenced table + foreign key:
* a map from the foreign-key value to the rows that reference it.
*/
function getReverseLookup(
schema: ReverseReferenceSchema,
refBaseDir: string | undefined,
defaultPrimaryKey: string,
currentFilePath: string | undefined,
): Map<string, Record<string, unknown>[]> {
const { refTable, refFilePath } = loadReferenceTableData(
schema,
refBaseDir,
currentFilePath,
);
let byForeignKey = reverseLookupCache.get(refFilePath);
if (!byForeignKey) {
byForeignKey = new Map();
reverseLookupCache.set(refFilePath, byForeignKey);
}
const cached = byForeignKey.get(schema.foreignKey);
if (cached) return cached;
const lookup = new Map<string, Record<string, unknown>[]>();
for (const row of refTable) {
const fkValue = row[schema.foreignKey];
const fkStr =
fkValue !== null && fkValue !== undefined && typeof fkValue === "object"
? String((fkValue as Record<string, unknown>)[defaultPrimaryKey])
: String(fkValue);
const bucket = lookup.get(fkStr);
if (bucket) {
bucket.push(row);
} else {
lookup.set(fkStr, [row]);
}
}
byForeignKey.set(schema.foreignKey, lookup);
return lookup;
} }
export function resolveReferenceId( export function resolveReferenceId(
@@ -299,39 +375,27 @@ export function parseValueWithReferences(
); );
} }
case "union": { 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[] = []; const errors: Error[] = [];
for (const member of schema.members) { for (const member of [...refMembers, ...nonRefMembers]) {
if (hasNestedReferences(member)) { try {
try { const parsed = parseValue(member, valueString);
const parsed = parseValue(member, valueString); return resolveNestedReferences(
return resolveNestedReferences( parsed,
parsed, member,
member, refBaseDir,
refBaseDir, defaultPrimaryKey,
defaultPrimaryKey, currentFilePath,
currentFilePath, currentRowPk,
currentRowPk, );
); } catch (e) {
} catch (e) { errors.push(e instanceof Error ? e : new Error(String(e)));
errors.push(e instanceof Error ? e : new Error(String(e)));
}
} }
} }
if ( throw errors[0] ?? new Error("Value does not match any union member");
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);
} }
default: default:
return parseValue(schema, valueString); return parseValue(schema, valueString);
@@ -345,21 +409,13 @@ export function resolveReverseReference(
defaultPrimaryKey: string, defaultPrimaryKey: string,
currentFilePath: string | undefined, currentFilePath: string | undefined,
): Record<string, unknown>[] { ): Record<string, unknown>[] {
const { refTable } = loadReferenceTable( const lookup = getReverseLookup(
schema, schema,
refBaseDir, refBaseDir,
defaultPrimaryKey, defaultPrimaryKey,
currentFilePath, currentFilePath,
); );
const pkStr = String(pkValue); return lookup.get(String(pkValue)) ?? [];
return refTable.filter((row) => {
const fkValue = row[schema.foreignKey];
const fkStr =
fkValue !== null && fkValue !== undefined && typeof fkValue === "object"
? String((fkValue as Record<string, unknown>)[defaultPrimaryKey])
: String(fkValue);
return fkStr === pkStr;
});
} }
export function resolveNestedReferences( export function resolveNestedReferences(
@@ -425,21 +481,22 @@ export function resolveNestedReferences(
); );
} }
case "union": { 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[] = []; const errors: Error[] = [];
for (const member of schema.members) { for (const member of [...refMembers, ...nonRefMembers]) {
if (hasNestedReferences(member)) { try {
try { return resolveNestedReferences(
return resolveNestedReferences( value,
value, member,
member, refBaseDir,
refBaseDir, defaultPrimaryKey,
defaultPrimaryKey, currentFilePath,
currentFilePath, currentRowPk,
currentRowPk, );
); } catch (e) {
} catch (e) { errors.push(e instanceof Error ? e : new Error(String(e)));
errors.push(e instanceof Error ? e : new Error(String(e)));
}
} }
} }
if (errors.length > 0) { if (errors.length > 0) {
+2 -2
View File
@@ -53,7 +53,7 @@ interface RollupPlugin {
} }
/** /**
* Rollup plugin for loading CSV files with inline-schema validation. * Rollup plugin for loading CSV files with typed-csv validation.
* Works with both Vite and Tsup (esbuild). * Works with both Vite and Tsup (esbuild).
*/ */
export function csvLoader(options: CsvRollupOptions = {}): RollupPlugin { export function csvLoader(options: CsvRollupOptions = {}): RollupPlugin {
@@ -67,7 +67,7 @@ export function csvLoader(options: CsvRollupOptions = {}): RollupPlugin {
} = options; } = options;
return { return {
name: "inline-schema-csv", name: "typed-csv",
transform(code: string, id: string) { transform(code: string, id: string) {
// Check if file matches the include/exclude patterns // Check if file matches the include/exclude patterns
@@ -65,6 +65,21 @@ describe("parseCsv - basic parsing", () => {
expect(result.data[1]).toEqual({ name: "Bob", status: "off" }); expect(result.data[1]).toEqual({ name: "Bob", status: "off" });
}); });
it("should parse CSV with double-quoted string literal columns", () => {
const csv = [
"name,status",
'string,"active" | "inactive"',
"Alice,active",
"Bob,inactive",
].join("\n");
const result = parseCsv(csv, { emitTypes: false });
expect(result.data).toHaveLength(2);
expect(result.data[0]).toEqual({ name: "Alice", status: "active" });
expect(result.data[1]).toEqual({ name: "Bob", status: "inactive" });
});
it("should parse CSV with array columns", () => { it("should parse CSV with array columns", () => {
const csv = [ const csv = [
"name,tags", "name,tags",
@@ -1,7 +1,7 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { parseCsv } from "../loader"; import { parseCsv } from "../loader";
import * as path from "path"; import * as path from "path";
import { fixturesDir } from "../test-utils"; import { fixturesDir, readFixture } from "../test-utils";
describe("parseCsv - references in combinatory schemas", () => { describe("parseCsv - references in combinatory schemas", () => {
it("should resolve reference inside a tuple", () => { it("should resolve reference inside a tuple", () => {
@@ -161,4 +161,74 @@ describe("parseCsv - references in combinatory schemas", () => {
}); });
expect(details[1]).toBe(5); expect(details[1]).toBe(5);
}); });
it("should parse encounter fixture with custom type aliases and nested tuples/arrays", () => {
const encounterCsv = readFixture("encounter.csv");
const result = parseCsv(encounterCsv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "encounter.csv"),
});
expect(result.data).toHaveLength(2);
// First row: cactus_pair
expect(result.data[0]).toMatchObject({
id: "cactus_pair",
type: "minion",
name: "仙人掌怪",
description: "概念:防+强化。【尖刺X】:对攻击者造成X点伤害。",
dialogue: "",
});
const cactusEnemies = result.data[0].enemies as unknown[];
expect(cactusEnemies).toHaveLength(2);
expect(cactusEnemies[0]).toEqual(["仙人掌怪", 12, []]);
expect(cactusEnemies[1]).toEqual(["仙人掌怪", 12, []]);
// Second row: snake_pair
expect(result.data[1]).toMatchObject({
id: "snake_pair",
type: "minion",
name: "蛇",
description:
"概念:攻+强化。给玩家塞入蛇毒牌(1费:打出时移除此牌。弃掉时受到3点伤害)。",
dialogue: "",
});
const snakeEnemies = result.data[1].enemies as unknown[];
expect(snakeEnemies).toHaveLength(1);
expect(snakeEnemies[0]).toEqual([
"蛇",
10,
[
["poison", 2],
["quick", 1],
],
]);
});
it("should parse enemy_intents fixture with tuple arrays and type aliases", () => {
const enemyIntentsCsv = readFixture("enemy_intents.csv");
const result = parseCsv(enemyIntentsCsv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "enemy_intents.csv"),
});
expect(result.data).toHaveLength(1);
expect(result.data[0]).toMatchObject({
id: "仙人掌怪-boost",
enemy: "仙人掌怪",
initialIntent: true,
nextIntents: ["仙人掌怪-boost", "仙人掌怪-defend"],
brokenIntent: [],
});
const effects = result.data[0].effects as unknown[][];
expect(effects).toHaveLength(2);
expect(effects[0]).toEqual(["user", "spike", 1]);
expect(effects[1]).toEqual(["user", "defend", 4]);
});
}); });
@@ -20,7 +20,7 @@ describe("parseCsv - reverse reference resolution", () => {
const csv = [ const csv = [
"id,name", "id,name",
"string,string", "string,string",
"# orders := ~rev_orders(customer)", "# inject orders = ~rev_orders(customer)",
"1,Alice", "1,Alice",
"2,Bob", "2,Bob",
].join("\n"); ].join("\n");
@@ -43,6 +43,40 @@ describe("parseCsv - reverse reference resolution", () => {
} }
}); });
it("should resolve reverse reference when the foreign key is an object reference", () => {
// The referenced table's foreign key column is itself a reference, so the
// stored value is an object and the reverse lookup must key on its primary key.
const ordersCsvPath = path.join(fixturesDir, "rev_orders.csv");
const ordersContent = [
"id,customer,total",
"string,@users,number",
"1,1,100",
"2,1,50",
].join("\n");
fs.writeFileSync(ordersCsvPath, ordersContent);
try {
const csv = [
"id,name",
"string,string",
"# inject orders = ~rev_orders(customer)",
"1,Alice",
].join("\n");
const result = parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "test.csv"),
});
const orders = result.data[0].orders as Record<string, unknown>[];
expect(orders).toHaveLength(2);
expect(orders[0].id).toBe("1");
expect(orders[1].id).toBe("2");
} finally {
fs.unlinkSync(ordersCsvPath);
}
});
it("should return empty array for reverse reference with no matches", () => { it("should return empty array for reverse reference with no matches", () => {
const ordersCsvPath = path.join(fixturesDir, "rev_orders.csv"); const ordersCsvPath = path.join(fixturesDir, "rev_orders.csv");
const ordersContent = [ const ordersContent = [
@@ -57,7 +91,7 @@ describe("parseCsv - reverse reference resolution", () => {
const csv = [ const csv = [
"id,name", "id,name",
"string,string", "string,string",
"# orders := ~rev_orders(customer)", "# inject orders = ~rev_orders(customer)",
"1,Alice", "1,Alice",
"99,Nobody", "99,Nobody",
].join("\n"); ].join("\n");
@@ -79,7 +113,7 @@ describe("parseCsv - reverse reference resolution", () => {
const csv = [ const csv = [
"id,name", "id,name",
"string,string", "string,string",
"# orders := ~orders(customer)?", "# inject orders = ~orders(customer)?",
"99,Nobody", "99,Nobody",
].join("\n"); ].join("\n");
@@ -96,7 +130,7 @@ describe("parseCsv - reverse reference resolution", () => {
const csv = [ const csv = [
"id,name", "id,name",
"string,string", "string,string",
"# orders := ~orders(customer)", "# inject orders = ~orders(customer)",
"1,Alice", "1,Alice",
].join("\n"); ].join("\n");
@@ -124,7 +158,7 @@ describe("parseCsv - reverse reference resolution", () => {
const csv = [ const csv = [
"id,name", "id,name",
"string,string", "string,string",
"# orders := ~orders(customer)", "# inject orders = ~orders(customer)",
"1,Alice", "1,Alice",
].join("\n"); ].join("\n");
@@ -140,8 +174,8 @@ describe("parseCsv - reverse reference resolution", () => {
const csv = [ const csv = [
"id,name", "id,name",
"string,string", "string,string",
"# orders := ~orders(customer)", "# inject orders = ~orders(customer)",
"# parts := ~parts(user)", "# inject parts = ~parts(user)",
"1,Alice", "1,Alice",
].join("\n"); ].join("\n");
@@ -162,7 +196,7 @@ describe("parseCsv - reverse reference resolution", () => {
"id,name", "id,name",
"string,string", "string,string",
"# This is just a comment", "# This is just a comment",
"# orders := ~orders(customer)", "# inject orders = ~orders(customer)",
"1,Alice", "1,Alice",
].join("\n"); ].join("\n");
@@ -190,7 +224,7 @@ describe("parseCsv - reverse reference resolution", () => {
const csv = [ const csv = [
"# id: id of user", "# id: id of user",
"# orders: list of related orders", "# orders: list of related orders",
"# orders := ~order(user)", "# inject orders = ~order(user)",
"id,name", "id,name",
"string,string", "string,string",
"u01,Alice", "u01,Alice",
@@ -225,7 +259,7 @@ describe("parseCsv - reverse reference with resolveReferences: false", () => {
const csv = [ const csv = [
"id,name", "id,name",
"string,string", "string,string",
"# orders := ~orders(customer)", "# inject orders = ~orders(customer)",
"1,Alice", "1,Alice",
].join("\n"); ].join("\n");
@@ -255,7 +289,7 @@ describe("parseCsv - reverse reference with resolveReferences: false", () => {
const csv = [ const csv = [
"id,name", "id,name",
"string,string", "string,string",
"# nonexistent := ~nonexistent(some_key)", "# inject nonexistent = ~nonexistent(some_key)",
"1,Alice", "1,Alice",
].join("\n"); ].join("\n");
@@ -6,7 +6,7 @@ import { fixturesDir } from "../test-utils";
describe("parseCsv - type declarations", () => { describe("parseCsv - type declarations", () => {
it("should parse type declaration from comment line", () => { it("should parse type declaration from comment line", () => {
const csv = [ const csv = [
"# Trigger := 'onPlay' | 'onDraw' | 'onDiscard'", "# type Trigger = 'onPlay' | 'onDraw' | 'onDiscard'",
"id,trigger", "id,trigger",
"string,Trigger", "string,Trigger",
"attack,onPlay", "attack,onPlay",
@@ -30,7 +30,7 @@ describe("parseCsv - type declarations", () => {
it("should use declared type as column schema", () => { it("should use declared type as column schema", () => {
const csv = [ const csv = [
"# Trigger := 'onPlay' | 'onDraw' | 'onDiscard'", "# type Trigger = 'onPlay' | 'onDraw' | 'onDiscard'",
"id,trigger", "id,trigger",
"string,Trigger", "string,Trigger",
"attack,onPlay", "attack,onPlay",
@@ -44,7 +44,7 @@ describe("parseCsv - type declarations", () => {
it("should include declared types in type definition", () => { it("should include declared types in type definition", () => {
const csv = [ const csv = [
"# Trigger := 'onPlay' | 'onDraw' | 'onDiscard'", "# type Trigger = 'onPlay' | 'onDraw' | 'onDiscard'",
"id,trigger", "id,trigger",
"string,Trigger", "string,Trigger",
"attack,onPlay", "attack,onPlay",
@@ -56,16 +56,16 @@ describe("parseCsv - type declarations", () => {
currentFilePath: path.join(fixturesDir, "card.csv"), currentFilePath: path.join(fixturesDir, "card.csv"),
}); });
expect(result.typeDefinition).toContain("type Trigger ="); expect(result.typeDefinition).toContain("export type Trigger =");
expect(result.typeDefinition).toContain( expect(result.typeDefinition).toContain(
'"onPlay" | "onDraw" | "onDiscard"', "'onPlay' | 'onDraw' | 'onDiscard'",
); );
}); });
it("should parse multiple type declarations", () => { it("should parse multiple type declarations", () => {
const csv = [ const csv = [
"# Trigger := 'onPlay' | 'onDraw'", "# type Trigger = 'onPlay' | 'onDraw'",
"# Status := 'active' | 'inactive'", "# type Status = 'active' | 'inactive'",
"id,trigger,status", "id,trigger,status",
"string,Trigger,Status", "string,Trigger,Status",
"attack,onPlay,active", "attack,onPlay,active",
@@ -80,8 +80,8 @@ describe("parseCsv - type declarations", () => {
it("should include multiple type declarations in type definition", () => { it("should include multiple type declarations in type definition", () => {
const csv = [ const csv = [
"# Trigger := 'onPlay' | 'onDraw'", "# type Trigger = 'onPlay' | 'onDraw'",
"# Status := 'active' | 'inactive'", "# type Status = 'active' | 'inactive'",
"id,trigger,status", "id,trigger,status",
"string,Trigger,Status", "string,Trigger,Status",
"attack,onPlay,active", "attack,onPlay,active",
@@ -93,14 +93,14 @@ describe("parseCsv - type declarations", () => {
currentFilePath: path.join(fixturesDir, "card.csv"), currentFilePath: path.join(fixturesDir, "card.csv"),
}); });
expect(result.typeDefinition).toContain("type Trigger ="); expect(result.typeDefinition).toContain("export type Trigger =");
expect(result.typeDefinition).toContain("type Status ="); expect(result.typeDefinition).toContain("export type Status =");
}); });
it("should ignore comment lines that are not type declarations", () => { it("should ignore comment lines that are not type declarations", () => {
const csv = [ const csv = [
"# This is just a comment", "# This is just a comment",
"# Trigger := 'onPlay' | 'onDraw'", "# type Trigger = 'onPlay' | 'onDraw'",
"id,trigger", "id,trigger",
"string,Trigger", "string,Trigger",
"attack,onPlay", "attack,onPlay",
@@ -114,7 +114,7 @@ describe("parseCsv - type declarations", () => {
it("should handle type declaration with array schema", () => { it("should handle type declaration with array schema", () => {
const csv = [ const csv = [
"# Tags := string[]", "# type Tags = string[]",
"id,tags", "id,tags",
"string,Tags", "string,Tags",
"1,[dev; admin; user]", "1,[dev; admin; user]",
@@ -129,7 +129,7 @@ describe("parseCsv - type declarations", () => {
it("should include type declaration before table type in output", () => { it("should include type declaration before table type in output", () => {
const csv = [ const csv = [
"# Status := 'active' | 'inactive'", "# type Status = 'active' | 'inactive'",
"id,status", "id,status",
"string,Status", "string,Status",
"1,active", "1,active",
@@ -150,7 +150,7 @@ describe("parseCsv - type declarations", () => {
it("should work with type declarations alongside reverse references", () => { it("should work with type declarations alongside reverse references", () => {
const csv = [ const csv = [
"# Trigger := 'onPlay' | 'onDraw'", "# type Trigger = 'onPlay' | 'onDraw'",
"id,trigger", "id,trigger",
"string,Trigger", "string,Trigger",
"attack,onPlay", "attack,onPlay",
@@ -164,8 +164,8 @@ describe("parseCsv - type declarations", () => {
it("should resolve type references inside tuple type declarations", () => { it("should resolve type references inside tuple type declarations", () => {
const csv = [ const csv = [
"# Trigger := 'onPlay' | 'onDraw' | 'onDiscard'", "# type Trigger = 'onPlay' | 'onDraw' | 'onDiscard'",
"# Effect := [Trigger; @effect; int]", "# type Effect = [Trigger; @effect; int]",
"id,trigger", "id,trigger",
"string,Trigger", "string,Trigger",
"attack,onPlay", "attack,onPlay",
@@ -182,8 +182,8 @@ describe("parseCsv - type declarations", () => {
it("should include resolved type references in generated type definition", () => { it("should include resolved type references in generated type definition", () => {
const csv = [ const csv = [
"# Trigger := 'onPlay' | 'onDraw' | 'onDiscard'", "# type Trigger = 'onPlay' | 'onDraw' | 'onDiscard'",
"# Effect := [Trigger; @effect; int]", "# type Effect = [Trigger; @effect; int]",
"id,trigger", "id,trigger",
"string,Trigger", "string,Trigger",
"attack,onPlay", "attack,onPlay",
@@ -196,17 +196,125 @@ describe("parseCsv - type declarations", () => {
}); });
// Both type declarations should appear // Both type declarations should appear
expect(result.typeDefinition).toContain("type Trigger ="); expect(result.typeDefinition).toContain("export type Trigger =");
expect(result.typeDefinition).toContain("type Effect ="); expect(result.typeDefinition).toContain("export type Effect =");
// Column should use the declared type name, not expanded union // Column should use the declared type name, not expanded union
expect(result.typeDefinition).toContain("readonly trigger: Trigger;"); expect(result.typeDefinition).toContain("readonly trigger: Trigger;");
}); });
it("should expand custom type names inside tuples and arrays", () => {
const csv = [
"# type IntentEffectTarget = 'user' | 'eachEnemy' | 'randomEnemy' | 'player'",
"# type IntentEffect = [IntentEffectTarget; string; int]",
"# type IntentEffects = IntentEffect[]",
"id,effects",
"string,IntentEffects",
"boost,[[user;spike;1];[user;defend;4]]",
].join("\n");
const result = parseCsv(csv, { emitTypes: false });
expect(result.typeDeclarations).toHaveLength(3);
const intentEffect = result.typeDeclarations.find(
(d) => d.name === "IntentEffect",
)!;
expect(intentEffect.schema.type).toBe("tuple");
// First element should be resolved to union, not a string "IntentEffectTarget"
const firstEl = (
intentEffect.schema as { elements: { schema: { type: string } }[] }
).elements[0].schema;
expect(firstEl.type).toBe("union");
});
it("should use declared type names in generated type definition for tuple arrays", () => {
const csv = [
"# type IntentEffectTarget = 'user' | 'eachEnemy' | 'randomEnemy' | 'player'",
"# type IntentEffect = [IntentEffectTarget; string; int]",
"# type IntentEffects = IntentEffect[]",
"id,effects",
"string,IntentEffects",
"boost,[[user;spike;1];[user;defend;4]]",
].join("\n");
const result = parseCsv(csv, {
emitTypes: true,
resourceName: "intent",
currentFilePath: path.join(fixturesDir, "intent.csv"),
});
expect(result.typeDefinition).toContain("export type IntentEffectTarget =");
expect(result.typeDefinition).toContain("export type IntentEffect =");
expect(result.typeDefinition).toContain("export type IntentEffects =");
// IntentEffect should reference IntentEffectTarget, not expand it
expect(result.typeDefinition).toContain(
"[IntentEffectTarget, string, int]",
);
// IntentEffects should reference IntentEffect, not expand it
expect(result.typeDefinition).toContain("IntentEffect[]");
// Column should reference IntentEffects, not inline expansion
expect(result.typeDefinition).toContain("readonly effects: IntentEffects;");
});
it("should handle custom type inside a tuple used as an array element", () => {
const csv = [
'# type Type = "apple" | "orange"',
"id,items",
"string,[Type; int][]",
"001,[[apple;2];[orange;3]]",
].join("\n");
const result = parseCsv(csv, { emitTypes: false });
expect(result.data).toHaveLength(1);
expect(result.data[0]).toEqual({
id: "001",
items: [
["apple", 2],
["orange", 3],
],
});
const typeResult = parseCsv(csv, {
emitTypes: true,
resourceName: "item",
currentFilePath: path.join(fixturesDir, "item.csv"),
});
expect(typeResult.typeDefinition).toContain("export type Type =");
expect(typeResult.typeDefinition).toContain("[Type, int][]");
expect(typeResult.typeDefinition).toContain(
"readonly items: [Type, int][];",
);
});
it("should resolve @table references inside declared types to capitalized type names", () => {
const csv = [
"# type Entry = [@user]",
"id,entry",
"string,Entry",
"1,[1]",
].join("\n");
const result = parseCsv(csv, {
emitTypes: true,
resourceName: "entries",
currentFilePath: path.join(fixturesDir, "entries.csv"),
resolveReferences: false,
});
expect(result.typeDefinition).toContain("export type Entry = [User]");
expect(result.typeDefinition).toContain("readonly entry: Entry;");
// It should also import the User type
expect(result.typeDefinition).toContain(
"import type { User } from './user.csv'",
);
});
}); });
describe("parseCsv - type declarations with resolveReferences: false", () => { describe("parseCsv - type declarations with resolveReferences: false", () => {
it("should populate typeDeclarations even when resolveReferences is false", () => { it("should populate typeDeclarations even when resolveReferences is false", () => {
const csv = [ const csv = [
"# Trigger := 'onPlay' | 'onDraw'", "# type Trigger = 'onPlay' | 'onDraw'",
"id,trigger", "id,trigger",
"string,Trigger", "string,Trigger",
"attack,onPlay", "attack,onPlay",
+15 -10
View File
@@ -1,7 +1,4 @@
import type { import type { Schema, ReverseReferenceSchema } from "../types.js";
Schema,
ReverseReferenceSchema,
} from "../types.js";
import { parseSchema } from "../parser.js"; import { parseSchema } from "../parser.js";
export interface ReverseReferenceDeclaration { export interface ReverseReferenceDeclaration {
@@ -19,7 +16,7 @@ export interface TypeDeclaration {
/** /**
* Parse a type declaration from a comment line. * Parse a type declaration from a comment line.
* Format: # TypeName := schema * Format: # type TypeName = schema
* Returns null if the line is not a type declaration. * Returns null if the line is not a type declaration.
*/ */
export function parseTypeDeclaration( export function parseTypeDeclaration(
@@ -32,8 +29,8 @@ export function parseTypeDeclaration(
const content = trimmed.slice(commentChar.length).trim(); const content = trimmed.slice(commentChar.length).trim();
// Match pattern: TypeName := schema // Match pattern: type TypeName = schema
const match = content.match(/^([A-Z][a-zA-Z0-9]*)\s*:=\s*(.+)$/); const match = content.match(/^type\s+([A-Z][a-zA-Z0-9]*)\s*=\s*(.+)$/);
if (!match) return null; if (!match) return null;
const [, typeName, schemaString] = match; const [, typeName, schemaString] = match;
@@ -88,6 +85,14 @@ function expandSchemaInString(
return expanded; return expanded;
} }
// Handle array suffix: TypeName[] or [tuple][]
const trimmed = schemaString.trim();
if (trimmed.endsWith("[]")) {
const inner = trimmed.slice(0, -2);
const expandedInner = expandSchemaInString(inner, declaredTypes);
return `${expandedInner}[]`;
}
// Handle union types (recursively expand each member) // Handle union types (recursively expand each member)
if (schemaString.includes("|")) { if (schemaString.includes("|")) {
// Split by | but respect quotes // Split by | but respect quotes
@@ -214,7 +219,7 @@ export function resolveTypeDeclarationSchema(
/** /**
* Parse a reverse reference declaration from a comment line. * Parse a reverse reference declaration from a comment line.
* Format: # fieldName := ~tableName(foreignKey) * Format: # inject fieldName = ~tableName(foreignKey)
* Returns null if the line is not a reverse reference declaration. * Returns null if the line is not a reverse reference declaration.
*/ */
export function parseReverseReferenceDeclaration( export function parseReverseReferenceDeclaration(
@@ -227,8 +232,8 @@ export function parseReverseReferenceDeclaration(
const content = trimmed.slice(commentChar.length).trim(); const content = trimmed.slice(commentChar.length).trim();
// Match pattern: fieldName := ~tableName(foreignKey) // Match pattern: inject fieldName = ~tableName(foreignKey)
const match = content.match(/^(\w+)\s*:=\s*~(\w+)\((\w+)\)(\?)?$/); const match = content.match(/^inject\s+(\w+)\s*=\s*~(\w+)\((\w+)\)(\?)?$/);
if (!match) return null; if (!match) return null;
const [, fieldName, tableName, foreignKey, optionalMark] = match; const [, fieldName, tableName, foreignKey, optionalMark] = match;
+18 -2
View File
@@ -2,6 +2,20 @@ import * as path from "path";
import { schemaToTypeString } from "../index.js"; import { schemaToTypeString } from "../index.js";
import type { PropertyConfig, TypeDeclaration } from "./types.js"; import type { PropertyConfig, TypeDeclaration } from "./types.js";
function resolveReferencesInSchemaString(
schemaString: string,
resourceNames: Map<string, string>,
): string {
return schemaString
.replace(/@([a-zA-Z0-9\-_]+)(\[\])?/g, (_match, tableName, arraySuffix) => {
const typeName =
resourceNames.get(tableName) ||
tableName.charAt(0).toUpperCase() + tableName.slice(1);
return arraySuffix ? `${typeName}[]` : typeName;
})
.replace(/; ?/g, ", ");
}
/** /**
* Generate TypeScript interface for the CSV data * Generate TypeScript interface for the CSV data
*/ */
@@ -51,7 +65,7 @@ export function generateTypeDefinition(
? typeDeclarations ? typeDeclarations
.map( .map(
(decl) => (decl) =>
`type ${decl.name} = ${schemaToTypeString(decl.schema, resourceNames)};`, `export type ${decl.name} = ${decl.schemaString ? resolveReferencesInSchemaString(decl.schemaString, resourceNames) : schemaToTypeString(decl.schema, resourceNames)};`,
) )
.join("\n") + "\n\n" .join("\n") + "\n\n"
: ""; : "";
@@ -60,7 +74,9 @@ export function generateTypeDefinition(
.map((config) => { .map((config) => {
const typeStr = config.declaredTypeName const typeStr = config.declaredTypeName
? config.declaredTypeName ? config.declaredTypeName
: schemaToTypeString(config.schema, resourceNames); : config.schemaString
? resolveReferencesInSchemaString(config.schemaString, resourceNames)
: schemaToTypeString(config.schema, resourceNames);
return ` readonly ${config.name}: ${typeStr};`; return ` readonly ${config.name}: ${typeStr};`;
}) })
.join("\n"); .join("\n");
+4
View File
@@ -78,6 +78,8 @@ export interface PropertyConfig {
reverseReferenceForeignKey?: string; reverseReferenceForeignKey?: string;
/** When a column uses a declared type name, this stores that name */ /** When a column uses a declared type name, this stores that name */
declaredTypeName?: string; declaredTypeName?: string;
/** The original schema string for the column (preserves type name references for output) */
schemaString?: string;
} }
/** Parsed reverse reference declaration from a comment line */ /** Parsed reverse reference declaration from a comment line */
@@ -98,6 +100,8 @@ export interface ReverseReferenceDeclaration {
export interface TypeDeclaration { export interface TypeDeclaration {
/** Name of the type being defined */ /** Name of the type being defined */
name: string; name: string;
/** The original schema string (preserves type name references for output) */
schemaString: string;
/** The parsed schema for this type */ /** The parsed schema for this type */
schema: Schema; schema: Schema;
} }
+13 -9
View File
@@ -174,10 +174,11 @@ describe("Tuples", () => {
expect(schema.validator(["hello", "42"])).toBe(false); expect(schema.validator(["hello", "42"])).toBe(false);
}); });
it("should parse tuple without brackets", () => { it("should require brackets for tuple values", () => {
const schema = defineSchema("[string; number]"); const schema = defineSchema("[string; number]");
const value = schema.parse("hello; 42"); const value = schema.parse("[hello; 42]");
expect(value).toEqual(["hello", 42]); expect(value).toEqual(["hello", 42]);
expect(() => schema.parse("hello; 42")).toThrow(ParseError);
}); });
it("should parse named tuple", () => { it("should parse named tuple", () => {
@@ -212,10 +213,11 @@ describe("Arrays", () => {
expect(schema.validator(["hello", 42])).toBe(false); expect(schema.validator(["hello", 42])).toBe(false);
}); });
it("should parse array without brackets", () => { it("should require brackets for array values", () => {
const schema = defineSchema("string[]"); const schema = defineSchema("string[]");
const value = schema.parse("hello; world; test"); const value = schema.parse("[hello; world; test]");
expect(value).toEqual(["hello", "world", "test"]); expect(value).toEqual(["hello", "world", "test"]);
expect(() => schema.parse("hello; world; test")).toThrow(ParseError);
}); });
it("should parse array of numbers", () => { it("should parse array of numbers", () => {
@@ -246,14 +248,15 @@ describe("Arrays", () => {
]); ]);
}); });
it("should parse array of tuples without outer brackets", () => { it("should require outer brackets for array of tuples", () => {
const schema = defineSchema("[string; number][]"); const schema = defineSchema("[string; number][]");
const value = schema.parse("[a; 1]; [b; 2]; [c; 3]"); const value = schema.parse("[[a; 1]; [b; 2]; [c; 3]]");
expect(value).toEqual([ expect(value).toEqual([
["a", 1], ["a", 1],
["b", 2], ["b", 2],
["c", 3], ["c", 3],
]); ]);
expect(() => schema.parse("[a; 1]; [b; 2]; [c; 3]")).toThrow(ParseError);
}); });
it("should parse array of unions", () => { it("should parse array of unions", () => {
@@ -283,7 +286,7 @@ describe("Escaping", () => {
it("should handle escaped semicolon in tuple", () => { it("should handle escaped semicolon in tuple", () => {
const schema = defineSchema("[string; string]"); const schema = defineSchema("[string; string]");
const value = schema.parse("hello\\;world; test"); const value = schema.parse("[hello\\;world; test]");
expect(value).toEqual(["hello;world", "test"]); expect(value).toEqual(["hello;world", "test"]);
}); });
}); });
@@ -682,14 +685,15 @@ describe("Reference value parsing (parseValue)", () => {
expect(result).toEqual(["1", "2", "3"]); expect(result).toEqual(["1", "2", "3"]);
}); });
it("should parse array reference IDs without brackets", () => { it("should require brackets for array reference IDs", () => {
const schema: import("./types").ReferenceSchema = { const schema: import("./types").ReferenceSchema = {
type: "reference", type: "reference",
tableName: "users", tableName: "users",
isArray: true, isArray: true,
}; };
const result = parseValue(schema, "1; 2; 3"); const result = parseValue(schema, "[1; 2; 3]");
expect(result).toEqual(["1", "2", "3"]); expect(result).toEqual(["1", "2", "3"]);
expect(() => parseValue(schema, "1; 2; 3")).toThrow(ParseError);
}); });
it("should parse empty array reference", () => { it("should parse empty array reference", () => {
+14 -17
View File
@@ -14,10 +14,20 @@ export class ParseError extends Error {
constructor( constructor(
message: string, message: string,
public position?: number, public position?: number,
public schema?: string,
public value?: string,
) { ) {
super( let fullMessage = message;
position !== undefined ? `${message} at position ${position}` : message, if (position !== undefined) {
); fullMessage += ` at position ${position}`;
}
if (schema !== undefined) {
fullMessage += `. Schema: ${schema}`;
}
if (value !== undefined) {
fullMessage += `. Value: ${value}`;
}
super(fullMessage);
this.name = "ParseError"; this.name = "ParseError";
} }
} }
@@ -240,20 +250,7 @@ class Parser {
throw new ParseError("Expected ]", this.pos); throw new ParseError("Expected ]", this.pos);
} }
if (this.consumeStr("[")) { // [t] is always a 1-tuple (the [Type][] array form was removed)
this.skipWhitespace();
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
if (elements.length === 1 && !elements[0].name) {
return { type: "array", element: elements[0].schema };
}
return { type: "array", element: { type: "tuple", elements } };
}
if (elements.length === 1 && !elements[0].name) {
return { type: "array", element: elements[0].schema };
}
return { type: "tuple", elements }; return { type: "tuple", elements };
} }
-7
View File
@@ -39,13 +39,6 @@ export function schemaToTypeString(
return schema.isOptional ? `${baseType} | null` : baseType; return schema.isOptional ? `${baseType} | null` : baseType;
} }
case "array": case "array":
if (schema.element.type === "tuple") {
const tupleElements = schema.element.elements.map((el) => {
const typeStr = schemaToTypeString(el.schema, resourceNames);
return el.name ? `${el.name}: ${typeStr}` : typeStr;
});
return `[${tupleElements.join(", ")}][]`;
}
const elementType = schemaToTypeString(schema.element, resourceNames); const elementType = schemaToTypeString(schema.element, resourceNames);
if (schema.element.type === "union") { if (schema.element.type === "union") {
return `(${elementType})[]`; return `(${elementType})[]`;
+118 -68
View File
@@ -7,13 +7,16 @@ import type {
UnionSchema, UnionSchema,
} from "./types"; } from "./types";
import { ParseError } from "./parser"; import { ParseError } from "./parser";
import { schemaToTypeString } from "./type-utils";
class ValueParser { class ValueParser {
private input: string; private input: string;
private schemaString: string;
private pos: number = 0; private pos: number = 0;
constructor(input: string) { constructor(input: string, schemaString: string) {
this.input = input; this.input = input;
this.schemaString = schemaString;
} }
private peek(): string { private peek(): string {
@@ -38,7 +41,7 @@ class ValueParser {
return false; return false;
} }
parseValue(schema: Schema, allowOmitBrackets: boolean = false): unknown { parseValue(schema: Schema): unknown {
this.skipWhitespace(); this.skipWhitespace();
switch (schema.type) { switch (schema.type) {
@@ -57,9 +60,9 @@ class ValueParser {
case "union": case "union":
return this.parseUnionValue(schema); return this.parseUnionValue(schema);
case "tuple": case "tuple":
return this.parseTupleValue(schema, allowOmitBrackets); return this.parseTupleValue(schema);
case "array": case "array":
return this.parseArrayValue(schema, allowOmitBrackets); return this.parseArrayValue(schema);
case "reference": case "reference":
// Reference values are parsed as strings (IDs) initially, resolved later // Reference values are parsed as strings (IDs) initially, resolved later
return this.parseReferenceValue(schema); return this.parseReferenceValue(schema);
@@ -71,6 +74,8 @@ class ValueParser {
throw new ParseError( throw new ParseError(
`Unknown schema type: ${(schema as { type: string }).type}`, `Unknown schema type: ${(schema as { type: string }).type}`,
this.pos, this.pos,
this.schemaString,
this.input,
); );
} }
} }
@@ -109,7 +114,12 @@ class ValueParser {
} }
const num = parseFloat(numStr); const num = parseFloat(numStr);
if (isNaN(num)) { if (isNaN(num)) {
throw new ParseError("Invalid number", this.pos - numStr.length); throw new ParseError(
"Invalid number",
this.pos - numStr.length,
this.schemaString,
this.input,
);
} }
return num; return num;
} }
@@ -121,10 +131,20 @@ class ValueParser {
} }
const num = parseFloat(numStr); const num = parseFloat(numStr);
if (isNaN(num)) { if (isNaN(num)) {
throw new ParseError("Invalid number", this.pos - numStr.length); throw new ParseError(
"Invalid number",
this.pos - numStr.length,
this.schemaString,
this.input,
);
} }
if (!Number.isInteger(num)) { if (!Number.isInteger(num)) {
throw new ParseError("Expected integer value", this.pos - numStr.length); throw new ParseError(
"Expected integer value",
this.pos - numStr.length,
this.schemaString,
this.input,
);
} }
return num; return num;
} }
@@ -140,7 +160,12 @@ class ValueParser {
if (this.consumeStr("false")) { if (this.consumeStr("false")) {
return false; return false;
} }
throw new ParseError("Expected true or false", this.pos); throw new ParseError(
"Expected true or false",
this.pos,
this.schemaString,
this.input,
);
} }
private parseStringLiteralValue(schema: StringLiteralSchema): string { private parseStringLiteralValue(schema: StringLiteralSchema): string {
@@ -174,6 +199,8 @@ class ValueParser {
throw new ParseError( throw new ParseError(
`Invalid value '"${value}"'. Expected '"${schema.value}"'`, `Invalid value '"${value}"'. Expected '"${schema.value}"'`,
this.pos, this.pos,
this.schemaString,
this.input,
); );
} }
@@ -183,7 +210,12 @@ class ValueParser {
} }
} }
throw new ParseError("Unterminated string literal", this.pos); throw new ParseError(
"Unterminated string literal",
this.pos,
this.schemaString,
this.input,
);
} else { } else {
// 不带引号的字符串,像普通字符串一样解析 // 不带引号的字符串,像普通字符串一样解析
let value = ""; let value = "";
@@ -201,6 +233,8 @@ class ValueParser {
throw new ParseError( throw new ParseError(
`Invalid value '${value}'. Expected '${schema.value}'`, `Invalid value '${value}'. Expected '${schema.value}'`,
this.pos - value.length, this.pos - value.length,
this.schemaString,
this.input,
); );
} }
@@ -216,7 +250,7 @@ class ValueParser {
for (let i = 0; i < schema.members.length; i++) { for (let i = 0; i < schema.members.length; i++) {
this.pos = savedPos; this.pos = savedPos;
try { try {
return this.parseValue(schema.members[i], false); return this.parseValue(schema.members[i]);
} catch (e) { } catch (e) {
errors.push(e as Error); errors.push(e as Error);
// Continue to next member // Continue to next member
@@ -227,25 +261,24 @@ class ValueParser {
throw new ParseError( throw new ParseError(
`Value does not match any union member. Tried ${schema.members.length} alternatives.`, `Value does not match any union member. Tried ${schema.members.length} alternatives.`,
this.pos, this.pos,
this.schemaString,
this.input,
); );
} }
private parseTupleValue( private parseTupleValue(schema: TupleSchema): unknown[] {
schema: TupleSchema, if (!this.consumeStr("[")) {
allowOmitBrackets: boolean, throw new ParseError(
): unknown[] { "Expected [",
let hasOpenBracket = false; this.pos,
this.schemaString,
if (this.peek() === "[") { this.input,
this.consume(); );
hasOpenBracket = true;
} else if (!allowOmitBrackets) {
throw new ParseError("Expected [", this.pos);
} }
this.skipWhitespace(); this.skipWhitespace();
if (this.peek() === "]" && hasOpenBracket) { if (this.peek() === "]") {
this.consume(); this.consume();
return []; return [];
} }
@@ -267,56 +300,52 @@ class ValueParser {
} }
} }
result.push(this.parseValue(elementSchema.schema, false)); result.push(this.parseValue(elementSchema.schema));
this.skipWhitespace(); this.skipWhitespace();
if (i < schema.elements.length - 1) { if (i < schema.elements.length - 1) {
if (!this.consumeStr(";")) { if (!this.consumeStr(";")) {
throw new ParseError("Expected ;", this.pos); throw new ParseError(
"Expected ;",
this.pos,
this.schemaString,
this.input,
);
} }
} }
} }
this.skipWhitespace(); this.skipWhitespace();
if (hasOpenBracket) { if (!this.consumeStr("]")) {
if (!this.consumeStr("]")) { throw new ParseError(
throw new ParseError("Expected ]", this.pos); "Expected ]",
} this.pos,
this.schemaString,
this.input,
);
} }
return result; return result;
} }
private parseArrayValue( private parseArrayValue(schema: ArraySchema): unknown[] {
schema: ArraySchema,
allowOmitBrackets: boolean,
): unknown[] {
let hasOpenBracket = false;
const elementIsTupleOrArray =
schema.element.type === "tuple" || schema.element.type === "array";
if (this.pos >= this.input.length || !this.input.trim()) { if (this.pos >= this.input.length || !this.input.trim()) {
return []; return [];
} }
if (this.peek() === "[") { if (!this.consumeStr("[")) {
if (!elementIsTupleOrArray) { throw new ParseError(
this.consume(); "Expected [",
hasOpenBracket = true; this.pos,
} else if (this.input[this.pos + 1] === "[") { this.schemaString,
this.consume(); this.input,
hasOpenBracket = true; );
}
}
if (!hasOpenBracket && !allowOmitBrackets && !elementIsTupleOrArray) {
throw new ParseError("Expected [", this.pos);
} }
this.skipWhitespace(); this.skipWhitespace();
if (this.peek() === "]" && hasOpenBracket) { if (this.peek() === "]") {
this.consume(); this.consume();
return []; return [];
} }
@@ -324,7 +353,8 @@ class ValueParser {
const result: unknown[] = []; const result: unknown[] = [];
while (true) { while (true) {
this.skipWhitespace(); this.skipWhitespace();
result.push(this.parseValue(schema.element, false));
result.push(this.parseValue(schema.element));
this.skipWhitespace(); this.skipWhitespace();
if (!this.consumeStr(";")) { if (!this.consumeStr(";")) {
@@ -334,10 +364,13 @@ class ValueParser {
this.skipWhitespace(); this.skipWhitespace();
if (hasOpenBracket) { if (!this.consumeStr("]")) {
if (!this.consumeStr("]")) { throw new ParseError(
throw new ParseError("Expected ]", this.pos); "Expected ]",
} this.pos,
this.schemaString,
this.input,
);
} }
return result; return result;
@@ -355,15 +388,18 @@ class ValueParser {
if (schema.isArray) { if (schema.isArray) {
// Parse array of IDs: [id1; id2; id3] // Parse array of IDs: [id1; id2; id3]
let hasOpenBracket = false; if (!this.consumeStr("[")) {
if (this.peek() === "[") { throw new ParseError(
this.consume(); "Expected [",
hasOpenBracket = true; this.pos,
this.schemaString,
this.input,
);
} }
this.skipWhitespace(); this.skipWhitespace();
if (this.peek() === "]" && hasOpenBracket) { if (this.peek() === "]") {
this.consume(); this.consume();
return []; return [];
} }
@@ -388,10 +424,15 @@ class ValueParser {
} }
} }
if (hasOpenBracket) { this.skipWhitespace();
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos); if (!this.consumeStr("]")) {
} throw new ParseError(
"Expected ]",
this.pos,
this.schemaString,
this.input,
);
} }
return ids; return ids;
@@ -418,13 +459,22 @@ class ValueParser {
} }
} }
export function parseValue(schema: Schema, valueString: string): unknown { export function parseValue(
const parser = new ValueParser(valueString.trim()); schema: Schema,
const allowOmitBrackets = schema.type === "tuple" || schema.type === "array"; valueString: string,
const value = parser.parseValue(schema, allowOmitBrackets); schemaString?: string,
): unknown {
const sStr = schemaString || schemaToTypeString(schema);
const parser = new ValueParser(valueString.trim(), sStr);
const value = parser.parseValue(schema);
if (parser.getPosition() < parser.getInputLength()) { if (parser.getPosition() < parser.getInputLength()) {
throw new ParseError("Unexpected input after value", parser.getPosition()); throw new ParseError(
"Unexpected input after value",
parser.getPosition(),
sStr,
valueString.trim(),
);
} }
return value; return value;