Compare commits

...
37 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
hypercross 55a33b98e5 refactor: modularize validator and type declaration logic 2026-04-21 13:55:47 +08:00
hypercross 53ccac39e6 feat(csv-loader): add support for custom type declarations
Introduce the ability to define reusable types within CSV files using
comment lines with the format `# TypeName := schema`.

- Support parsing type declarations from comments or schema cells
- Enable recursive expansion of type names within schemas
- Integrate declared types into generated TypeScript definitions
- Allow columns to reference declared types by name
2026-04-21 13:47:16 +08:00
hypercross 89ac1619e7 refactor(csv-loader): simplify module generation logic
Update the code generation in `csvToModule` to use a more concise
mapping pattern for row resolvers, removing the unnecessary object
spread and nested braces.
2026-04-20 15:49:41 +08:00
hypercross 3e768f5c83 test: refactor csv-loader tests into separate files
Split the monolithic `src/csv-loader/loader.test.ts` into multiple
specialized test files to improve maintainability and readability:

- `parseCsv-basic.test.ts`: Primitive types, arrays, and tuples
- `parseCsv-caching.test.ts`: Table caching logic
- `parseCsv-circular.test.ts`: Circular reference detection
- `parseCsv-combinators.test.ts`: References in unions and tuples
- `parseCsv-noResolveRefs.
2026-04-20 11:37:56 +08:00
hypercross d0ed1dc92d refactor: use parseValue for reference ID extraction
Replace the custom `ReferenceValueParser` class with the core
`parseValue` function in `reference-resolver.ts`. This simplifies
the logic for parsing reference IDs and ensures consistency with the
rest of the schema parsing engine.
2026-04-20 11:15:51 +08:00
hypercross f66f60aa0e test: add comprehensive tests for csvToModule
- Add unit tests for accessor-based output, circular references,
  and reverse reference resolution in `csvToModule`.
- Extract fixture loading logic into `test-utils.ts`.
- Refactor `loader.test.ts` to use the new test utilities.
2026-04-20 01:29:59 +08:00
hypercross f94e9b68e4 refactor(csv-loader): decouple reference resolution and module
generation

Extract reference resolution logic, type generation, and module
generation into dedicated modules to improve maintainability and
clean up the core loader.
2026-04-20 00:48:01 +08:00
hypercross eeaac92e39 test: add integration test for CSV reference resolution
Add integration tests for `parseCsv` using fixture CSV files
to verify that table references are correctly resolved across
multiple files.
2026-04-20 00:21:41 +08:00
hypercross c3572a5b56 fix(csv-loader): handle object foreign keys in reverse lookups
Update `csvToModule` to check if a foreign key value is an object
containing the default primary key before converting it to a string.
This ensures correct key mapping when references are resolved as
objects rather than primitive IDs.
2026-04-19 23:58:09 +08:00
hyper 5f941aba10 feat(csv-loader): support references in addition to strings for foreignKey reverse ref 2026-04-19 18:58:52 +08:00
hypercross 9a2db5edb6 feat(csv-loader): strip comments before parsing CSV
Pre-filter comment lines from the content before passing it to
`csv-parse`. This prevents quote parsing errors when comment lines
contain double quotes and simplifies the record filtering logic.
2026-04-19 14:40:41 +08:00
hypercross 5a1627c6f1 test: improve reverse reference resolution handling 2026-04-19 14:19:45 +08:00
hypercross be8eb277d4 test(csv-loader): add test for multiple comment lines at the beginning
with reverse references
2026-04-19 14:15:44 +08:00
hypercross 9dd4f60c2d feat(csv-loader): allow configurable comment character
Allow `parseCsv` to accept a custom comment character for parsing
reverse reference declarations, instead of being hardcoded to `#`.
2026-04-19 14:06:11 +08:00
37 changed files with 4263 additions and 3040 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:
- **`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/validator.ts` — `parseValue()` and `createValidator()` operate on the AST
- `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
- `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
- 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
- `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
@@ -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.
- **Run `npm run typecheck` before committing** to catch type errors.
- **Union member ordering matters** — `parseValue` tries union members in order; the first one that parses wins. This affects references in unions (e.g., `@users[] | string` will try `@users[]` first).
- **Union member ordering** — In unions containing references, **reference members are tried before non-reference members** (regardless of author order), so `@users | string` resolves `1` to the user object and falls back to a plain string only when the reference doesn't match. `parseValue` (no references) still tries members in author order; the first that parses wins.
- **csv-parse quote handling** — Double-quoted schema values like `"active" | "inactive"` in CSV rows confuse the csv-parse library. Use single-quoted string literals (`'on' | 'off'`) or unquoted identifiers in the schema row of CSV data when possible.
- **Module imports use `.js` extension** — source files import from `../index.js` etc. (ESM convention), not `../index.ts`.
+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
```bash
npm install inline-schema
npm install typed-csv
```
## Usage
@@ -13,7 +13,7 @@ npm install inline-schema
### Basic Example
```typescript
import { defineSchema } from 'inline-schema';
import { defineSchema } from 'typed-csv';
// Define a schema
const stringSchema = defineSchema('string');
@@ -35,14 +35,9 @@ numberSchema.validator(name); // false
```typescript
const tupleSchema = defineSchema('[string; number; boolean]');
// With brackets
const value1 = tupleSchema.parse('[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(['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
```typescript
// Array syntax: Type[] or [Type][]
// Array syntax: Type[]
const stringArray = defineSchema('string[]');
const numberArray = defineSchema('[number][]');
const numberArray = defineSchema('number[]');
// With brackets
const names1 = stringArray.parse('[alice; bob; charlie]');
// ["alice", "bob", "charlie"]
// Without brackets (outermost brackets are optional)
const names2 = stringArray.parse('alice; bob; charlie');
const names = stringArray.parse('[alice; bob; charlie]');
// ["alice", "bob", "charlie"]
const numbers = numberArray.parse('[1; 2; 3; 4; 5]');
@@ -71,12 +61,7 @@ const numbers = numberArray.parse('[1; 2; 3; 4; 5]');
```typescript
const schema = defineSchema('[string; number][]');
// With outer brackets
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]');
const data = schema.parse('[[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.
### `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
| Type | Schema | Example Value |
|------|--------|---------------|
| 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` |
| Tuple | `[Type1; Type2; ...]` | `[hello; 42; true]` or `hello; 42; true` |
| Array | `Type[]` or `[Type][]` | `[1; 2; 3]` or `1; 2; 3` |
| Array of Tuples | `[Type1; Type2][]` | `[[a; 1]; [b; 2]]` or `[a; 1]; [b; 2]` |
| Tuple | `[Type1; Type2; ...]` | `[hello; 42; true]` |
| Array | `Type[]` | `[1; 2; 3]` |
| Array of Tuples | `[Type1; Type2][]` | `[[a; 1]; [b; 2]]` |
| Union | `Type1 \| Type2` | `hello` or `42` (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
- 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: `\;`, `\[`, `\]`, `\\`
- 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
// rspack.config.js
@@ -157,7 +178,7 @@ module.exports = {
rules: [
{
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
```bash
npm install inline-schema
npm install typed-csv
```
## Usage
The loader expects:
- **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
### Example CSV
@@ -35,7 +35,7 @@ module.exports = {
{
test: /\.schema\.csv$/,
use: {
loader: 'inline-schema/csv-loader',
loader: 'typed-csv/csv-loader',
options: {
delimiter: ',',
quote: '"',
@@ -60,7 +60,7 @@ module.exports = {
```typescript
import { defineConfig } from 'vite';
import { csvLoader } from 'inline-schema/csv-loader/rollup';
import { csvLoader } from 'typed-csv/csv-loader/rollup';
export default defineConfig({
plugins: [
@@ -85,7 +85,7 @@ export default defineConfig({
```typescript
import { defineConfig } from 'tsup';
import { csvLoader } from 'inline-schema/csv-loader/rollup';
import { csvLoader } from 'typed-csv/csv-loader/rollup';
export default defineConfig({
entry: ['src/index.ts'],
@@ -139,16 +139,26 @@ import data from './data.csv';
## 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 |
|------|--------|---------|
| String | `string` | `hello` |
| Number | `number` | `42` |
| Boolean | `boolean` | `true` |
| Array | `string[]` or `[string][]` | `[a; b; c]` |
| Array | `string[]` | `[a; b; c]` |
| 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
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",
"version": "1.0.0",
"name": "typed-csv",
"version": "2.0.0",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
@@ -49,7 +49,7 @@
],
"author": "",
"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": {
"csv-parse": "^5.5.6"
},
+19 -17
View File
@@ -1,8 +1,8 @@
import type { CsvLoaderOptions } from './loader.js';
import { csvToModule } from './loader.js';
import * as path from 'path';
import * as fs from 'fs';
import type { Plugin, OnLoadResult } from 'esbuild';
import * as path from "path";
import * as fs from "fs";
import type { Plugin, OnLoadResult } from "esbuild";
import { csvToModule } from "./module-gen";
import { CsvLoaderOptions } from "./types";
export interface CsvEsbuildOptions extends CsvLoaderOptions {
/** Include pattern for CSV files (default: /\.csv$/) */
@@ -16,7 +16,7 @@ export interface CsvEsbuildOptions extends CsvLoaderOptions {
}
function createFilter(
pattern: RegExp | string | Array<RegExp | string>
pattern: RegExp | string | Array<RegExp | string>,
): RegExp {
if (pattern instanceof RegExp) return pattern;
if (Array.isArray(pattern)) {
@@ -28,7 +28,7 @@ function createFilter(
function matchesPattern(
id: string,
pattern: RegExp | string | Array<RegExp | string> | undefined
pattern: RegExp | string | Array<RegExp | string> | undefined,
): boolean {
if (!pattern) return true;
const patterns = Array.isArray(pattern) ? pattern : [pattern];
@@ -39,14 +39,14 @@ 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 {
const {
include = /\.csv$/,
exclude,
emitTypes = true,
typesOutputDir = '',
typesOutputDir = "",
writeToDisk = false,
...parseOptions
} = options;
@@ -54,19 +54,19 @@ export function csvLoader(options: CsvEsbuildOptions = {}): Plugin {
const includeFilter = createFilter(include);
return {
name: 'inline-schema-csv',
name: "typed-csv",
setup(build) {
build.onLoad({ filter: includeFilter }, async (args) => {
// Check exclude pattern
if (exclude && !matchesPattern(args.path, exclude)) {
if (exclude && matchesPattern(args.path, exclude)) {
return null;
}
// Read the file content
let content: string;
try {
content = await fs.promises.readFile(args.path, 'utf-8');
content = await fs.promises.readFile(args.path, "utf-8");
} catch (error) {
return {
errors: [{ text: `Failed to read file: ${args.path}` }],
@@ -74,9 +74,11 @@ export function csvLoader(options: CsvEsbuildOptions = {}): Plugin {
}
// Infer resource name from filename
const fileName = path.basename(args.path, '.csv').split('.')[0];
const fileName = path.basename(args.path, ".csv").split(".")[0];
const resourceName = fileName
.replace(/[-_\s]+(.)?/g, (_, char) => char ? char.toUpperCase() : '')
.replace(/[-_\s]+(.)?/g, (_, char) =>
char ? char.toUpperCase() : "",
)
.replace(/^(.)/, (_, char) => char.toUpperCase());
const result = csvToModule(content, {
@@ -91,8 +93,8 @@ export function csvLoader(options: CsvEsbuildOptions = {}): Plugin {
// Emit type definition file if enabled
if (emitTypes && result.dts) {
const dtsPath = typesOutputDir
? path.join(typesOutputDir, path.basename(args.path) + '.d.ts')
: args.path + '.d.ts';
? path.join(typesOutputDir, path.basename(args.path) + ".d.ts")
: args.path + ".d.ts";
if (writeToDisk) {
const absolutePath = path.isAbsolute(dtsPath)
@@ -105,7 +107,7 @@ export function csvLoader(options: CsvEsbuildOptions = {}): Plugin {
return {
contents: result.js,
loader: 'js' as const,
loader: "js" as const,
};
});
},
+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]]
+3
View File
@@ -0,0 +1,3 @@
id,user,total
string,string,int
o01,u01,100
1 id user total
2 string string int
3 o01 u01 100
+5
View File
@@ -0,0 +1,5 @@
# inject orders = ~order_rev(user)
id,name
string,string
u01,Alice
u02,Bob
1 # inject orders = ~order_rev(user)
2 id,name
3 string,string
4 u01,Alice
5 u02,Bob
+31
View File
@@ -0,0 +1,31 @@
import { describe, it, expect } from "vitest";
import { parseCsv } from "./loader";
import * as path from "path";
import * as fs from "fs";
const fixturesDir = path.join(__dirname, "fixtures");
describe("parseCsv - integration: loading two CSV tables from fixtures", () => {
it("should load user_rev and order_rev tables and resolve orders array with correct length", () => {
const userCsv = fs.readFileSync(
path.join(fixturesDir, "user_rev.csv"),
"utf-8",
);
const result = parseCsv(userCsv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "user_rev.csv"),
});
expect(result.data).toHaveLength(2);
// Alice (u01) should have 1 order
const aliceOrders = result.data[0].orders as Record<string, unknown>[];
expect(aliceOrders).toHaveLength(1);
expect(aliceOrders[0].id).toBe("o01");
// Bob (u02) should have 0 orders
const bobOrders = result.data[1].orders as Record<string, unknown>[];
expect(bobOrders).toHaveLength(0);
});
});
File diff suppressed because it is too large Load Diff
+208 -1017
View File
File diff suppressed because it is too large Load Diff
+381
View File
@@ -0,0 +1,381 @@
import { describe, it, expect } from "vitest";
import { csvToModule } from "./module-gen";
import * as path from "path";
import { fixturesDir, readFixture } from "./test-utils";
describe("csvToModule - accessor-based output", () => {
it("should emit accessor function for tables without references", () => {
const csv = ["name,age", "string,number", "Alice,30"].join("\n");
const result = csvToModule(csv, { emitTypes: false });
expect(result.js).toContain("export default function getData()");
expect(result.js).not.toContain("import ");
expect(result.js).not.toContain("Lookup");
});
it("should emit accessor function for tables with references", () => {
const csv = ["id,customer", "string,@users", "1,1"].join("\n");
const result = csvToModule(csv, { emitTypes: false });
expect(result.js).toContain("import _users from './users.csv'");
expect(result.js).toContain("export default function getData()");
expect(result.js).toContain("_usersLookup");
expect(result.js).toContain("_resolved = _raw;");
});
it("should emit accessor function for tables with array references", () => {
const csv = ["id,items", "string,@parts[]", "1,[1; 2]"].join("\n");
const result = csvToModule(csv, { emitTypes: false });
expect(result.js).toContain("import _parts from './parts.csv'");
expect(result.js).toContain("_partsLookup");
expect(result.js).toContain(".map(id =>");
});
it("should emit multiple imports for multiple reference tables", () => {
const csv = [
"id,customer,items",
"string,@users,@parts[]",
"1,1,[1; 2]",
].join("\n");
const result = csvToModule(csv, { emitTypes: false });
expect(result.js).toContain("import _users from './users.csv'");
expect(result.js).toContain("import _parts from './parts.csv'");
expect(result.js).toContain("_usersLookup");
expect(result.js).toContain("_partsLookup");
});
it("should generate function type in dts for tables with references", () => {
const csv = ["id,customer", "string,@users", "1,1"].join("\n");
const result = csvToModule(csv, {
emitTypes: true,
resourceName: "orders",
});
expect(result.dts).toContain("declare function getData(): ordersTable");
expect(result.dts).toContain("export default getData");
expect(result.dts).not.toContain("declare const data");
});
it("should generate function type in dts for tables without references", () => {
const csv = ["name,age", "string,number", "Alice,30"].join("\n");
const result = csvToModule(csv, {
emitTypes: true,
resourceName: "people",
});
expect(result.dts).toContain("declare function getData(): peopleTable");
expect(result.dts).toContain("export default getData");
expect(result.dts).not.toContain("declare const data");
});
it("should handle nested references in tuples", () => {
const csv = [
"id,info",
"string,[ref: @users; note: string]",
"1,[ref: 1; note: urgent]",
].join("\n");
const result = csvToModule(csv, { emitTypes: false });
expect(result.js).toContain("import _users from './users.csv'");
expect(result.js).toContain("_usersLookup");
});
});
describe("csvToModule - circular reference support", () => {
it("should emit accessor for self-referencing table without self-import", () => {
const csv = readFixture("self_ref.csv");
const result = csvToModule(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "self_ref.csv"),
});
expect(result.js).not.toContain("import _self_ref from './self_ref.csv'");
expect(result.js).toContain("export default function getData()");
expect(result.js).toContain("_self_refLookup");
expect(result.js).toContain("_self_refLookup = new Map(_raw.map");
expect(result.js).toContain(
"row.parent = _self_refLookup.get(String(row.parent));",
);
});
it("should emit accessor for cross-referencing tables", () => {
const csv = readFixture("circular_a.csv");
const result = csvToModule(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "circular_a.csv"),
});
expect(result.js).toContain("import _circular_b from './circular_b.csv'");
expect(result.js).toContain("export default function getData()");
expect(result.js).toContain("_circular_bLookup");
expect(result.js).toContain("row.related =");
});
it("should emit accessor for self-referencing table with nested reference in tuple", () => {
const csv = [
"id,name,parent_info",
"string,string,[parent: @self_ref; role: string]",
"1,Root,[parent: 2; role: admin]",
].join("\n");
const result = csvToModule(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "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("export default function getData()");
expect(result.js).toContain("row.parent_info =");
expect(result.js).toContain(
"_self_refLookup.get(String(row.parent_info[0]))",
);
});
it("should emit accessor for self-referencing table with nested reference in union with fallback", () => {
const csv = [
"id,name,ref_or_val",
"string,string,@self_ref | string",
"1,Root,2",
"2,Child,none",
].join("\n");
const result = csvToModule(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "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("export default function getData()");
expect(result.js).toContain("row.ref_or_val =");
expect(result.js).toContain(
"_self_refLookup.get(String(row.ref_or_val)) ?? row.ref_or_val",
);
});
it("should emit accessor for self-referencing table with nested reference array in tuple", () => {
const csv = [
"id,name,children",
"string,string,[kids: @self_ref[]]",
"1,Root,[[2]]",
].join("\n");
const result = csvToModule(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "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("export default function getData()");
expect(result.js).toContain("row.children =");
});
it("should generate correct type definition for self-referencing table using local singular type", () => {
const csv = readFixture("self_ref.csv");
const result = csvToModule(csv, {
emitTypes: true,
resourceName: "nodes",
currentFilePath: path.join(fixturesDir, "self_ref.csv"),
});
expect(result.dts).toContain("declare function getData(): nodesTable");
expect(result.dts).toContain("readonly parent: Nodes");
expect(result.dts).not.toContain(
"import type { Self_ref } from './self_ref.csv'",
);
expect(result.dts).not.toContain("Self_ref");
});
it("should emit accessor for cross-referencing tables with array references", () => {
const csv = readFixture("circular_a.csv");
const result = csvToModule(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "circular_a.csv"),
});
expect(result.js).toContain("import _circular_b from './circular_b.csv'");
expect(result.js).toContain("export default function getData()");
expect(result.js).toContain("_circular_bLookup");
expect(result.js).toContain(".map(id =>");
});
it("should emit accessor for nested cross-reference in tuple", () => {
const csv = [
"id,name,info",
"string,string,[ref: @circular_b; note: string]",
"1,A,[ref: 1; note: linked]",
].join("\n");
const result = csvToModule(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "circular_a.csv"),
});
expect(result.js).toContain("import _circular_b from './circular_b.csv'");
expect(result.js).toContain("_circular_bLookup");
expect(result.js).toContain("export default function getData()");
});
it("should generate union fallback with ?? for non-reference union members", () => {
const csv = ["id,value", "string,@users | string", "1,1", "2,unknown"].join(
"\n",
);
const result = csvToModule(csv, { emitTypes: false });
expect(result.js).toContain("?? row.value");
});
});
describe("csvToModule - reverse reference output", () => {
it("should emit reverse lookup code for reverse references", () => {
const csv = [
"id,name",
"string,string",
"# inject orders = ~orders(customer)",
"1,Alice",
].join("\n");
const result = csvToModule(csv, { emitTypes: false });
expect(result.js).toContain("import _orders from './orders.csv'");
expect(result.js).toContain("_ordersBy_customer");
expect(result.js).toContain("row.orders =");
expect(result.js).toContain("_ordersBy_customer.get(String(row.id))");
});
it("should emit null fallback for optional reverse references", () => {
const csv = [
"id,name",
"string,string",
"# inject orders = ~orders(customer)?",
"1,Alice",
].join("\n");
const result = csvToModule(csv, { emitTypes: false });
expect(result.js).toContain(
"_ordersBy_customer.get(String(row.id)) || null",
);
});
it("should emit empty array fallback for non-optional reverse references", () => {
const csv = [
"id,name",
"string,string",
"# inject orders = ~orders(customer)",
"1,Alice",
].join("\n");
const result = csvToModule(csv, { emitTypes: false });
expect(result.js).toContain("_ordersBy_customer.get(String(row.id)) || []");
});
it("should handle self-referencing reverse reference without self-import", () => {
const csv = [
"id,name",
"string,string",
"# inject children = ~self_ref(parent)",
"1,Root",
"2,Child",
].join("\n");
const result = csvToModule(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "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("for (const r of _raw)");
expect(result.js).toContain("row.children =");
});
it("should generate correct type definition for reverse references", () => {
const csv = [
"id,name",
"string,string",
"# inject orders = ~orders(customer)",
"1,Alice",
].join("\n");
const result = csvToModule(csv, {
emitTypes: true,
resourceName: "users",
currentFilePath: path.join(fixturesDir, "test.csv"),
});
expect(result.dts).toContain("readonly orders: Orders[]");
expect(result.dts).toContain("import type { Orders }");
});
it("should generate nullable type for optional reverse references", () => {
const csv = [
"id,name",
"string,string",
"# inject orders = ~orders(customer)?",
"1,Alice",
].join("\n");
const result = csvToModule(csv, {
emitTypes: true,
resourceName: "users",
currentFilePath: path.join(fixturesDir, "test.csv"),
});
expect(result.dts).toContain("readonly orders: Orders[] | null");
});
it("should combine forward and reverse references to the same table", () => {
const csv = [
"id,name,manager",
"string,string,@users",
"# inject reports = ~users(manager)",
"1,Alice,2",
"2,Bob,",
].join("\n");
const result = csvToModule(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "users.csv"),
});
expect(result.js).toContain("_usersLookup");
expect(result.js).toContain("_usersBy_manager");
expect(result.js).not.toContain("import _users from './users.csv'");
});
it("should import referenced table when forward and reverse reference a different table", () => {
const csv = [
"id,name,creator",
"string,string,@users",
"# inject reviews = ~users(reviewer)",
"1,Doc,1",
].join("\n");
const result = csvToModule(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "test.csv"),
});
expect(result.js).toContain("_usersLookup");
expect(result.js).toContain("_usersBy_reviewer");
expect(result.js).toContain("import _users from './users.csv'");
});
});
+252
View File
@@ -0,0 +1,252 @@
import * as path from "path";
import { parseCsv } from "./loader.js";
import { hasNestedReferences } from "./reference-resolver.js";
import type { Schema } from "../types.js";
import type { CsvLoaderOptions, PropertyConfig } from "./types.js";
/**
* Generate runtime reference resolution code for a schema.
* Returns a JS expression string that resolves references using lookup maps.
*/
function generateSchemaResolutionCode(
schema: Schema,
valueExpr: string,
lookupVar: (tableName: string) => string,
pkField: string,
reverseLookupVar?: (tableName: string, foreignKey: string) => string,
): string {
switch (schema.type) {
case "reference": {
const lookup = lookupVar(schema.tableName);
if (schema.isOptional) {
if (schema.isArray) {
return `(${valueExpr} === null || ${valueExpr} === undefined ? ${valueExpr} : (Array.isArray(${valueExpr}) ? ${valueExpr}.map(id => ${lookup}.get(String(id))) : ${lookup}.get(String(${valueExpr}))))`;
}
return `(${valueExpr} === null || ${valueExpr} === undefined ? ${valueExpr} : ${lookup}.get(String(${valueExpr})))`;
}
if (schema.isArray) {
return `(Array.isArray(${valueExpr}) ? ${valueExpr}.map(id => ${lookup}.get(String(id))) : ${valueExpr})`;
}
return `${lookup}.get(String(${valueExpr}))`;
}
case "reverseReference": {
if (!reverseLookupVar) return valueExpr;
const reverseLookup = reverseLookupVar(
schema.tableName,
schema.foreignKey,
);
if (schema.isOptional) {
return `(${reverseLookup}.get(String(row.${pkField})) || null)`;
}
return `(${reverseLookup}.get(String(row.${pkField})) || [])`;
}
case "tuple": {
const elementResolvers = schema.elements.map((el, i) => {
if (hasNestedReferences(el.schema)) {
return generateSchemaResolutionCode(
el.schema,
`${valueExpr}[${i}]`,
lookupVar,
pkField,
reverseLookupVar,
);
}
return `${valueExpr}[${i}]`;
});
return `[${elementResolvers.join(", ")}]`;
}
case "array": {
if (hasNestedReferences(schema.element)) {
const itemResolve = generateSchemaResolutionCode(
schema.element,
"item",
lookupVar,
pkField,
reverseLookupVar,
);
return `(${valueExpr}).map(item => ${itemResolve})`;
}
return valueExpr;
}
case "union": {
const refMembers = schema.members.filter((m) => hasNestedReferences(m));
const nonRefMembers = schema.members.filter(
(m) => !hasNestedReferences(m),
);
const resolveParts: string[] = [];
for (const member of refMembers) {
const resolveCode = generateSchemaResolutionCode(
member,
valueExpr,
lookupVar,
pkField,
reverseLookupVar,
);
resolveParts.push(resolveCode);
}
if (nonRefMembers.length > 0) {
resolveParts.push(valueExpr);
}
if (resolveParts.length === 0) return valueExpr;
if (resolveParts.length === 1) return resolveParts[0];
return `(${resolveParts.join(" ?? ")})`;
}
default:
return valueExpr;
}
}
/**
* Generate JavaScript module code from CSV content.
* Emits an accessor function for tables with references (lazy resolution),
* or static JSON for tables without references.
*/
export function csvToModule(
content: string,
options: CsvLoaderOptions & { resourceName?: string } = {},
): { js: string; dts?: string } {
const result = parseCsv(content, { ...options, resolveReferences: false });
const hasRefs =
result.referenceFields.length > 0 || result.reverseReferences.length > 0;
const defaultPrimaryKey = options.defaultPrimaryKey ?? "id";
const imports: string[] = [];
const lookupInits: string[] = [];
const lookupVarMap = new Map<string, string>();
// Reverse lookup maps: grouped by (tableName, foreignKey)
const reverseLookupInits: string[] = [];
const reverseLookupVarMap = new Map<string, string>();
const currentTableName = options.currentFilePath
? path.basename(
options.currentFilePath,
path.extname(options.currentFilePath),
)
: undefined;
// Build forward lookup maps for referenced tables
const uniqueTables = new Set(result.referenceFields.map((f) => f.tableName));
// Also include tables from reverse references
for (const decl of result.reverseReferences) {
uniqueTables.add(decl.tableName);
}
uniqueTables.forEach((tableName) => {
const lookupVar = `_${tableName}Lookup`;
lookupVarMap.set(tableName, lookupVar);
if (tableName === currentTableName) {
lookupInits.push(
`const ${lookupVar} = new Map(_raw.map(p => [String(p.${defaultPrimaryKey}), p]));`,
);
} else {
const varName = `_${tableName}`;
imports.push(`import ${varName} from './${tableName}.csv';`);
lookupInits.push(
`const ${lookupVar} = new Map(${varName}().map(p => [String(p.${defaultPrimaryKey}), p]));`,
);
}
});
// Build reverse lookup maps for reverse references
for (const decl of result.reverseReferences) {
const key = `${decl.tableName}:${decl.foreignKey}`;
if (reverseLookupVarMap.has(key)) continue;
const revLookupVar = `_${decl.tableName}By_${decl.foreignKey}`;
reverseLookupVarMap.set(key, revLookupVar);
if (decl.tableName === currentTableName) {
reverseLookupInits.push(
`const ${revLookupVar} = new Map();`,
`for (const r of _raw) {`,
` const kv = r.${decl.foreignKey};`,
` const k = String(typeof kv === "object" && "${defaultPrimaryKey}" in kv ? kv.${defaultPrimaryKey} : kv);`,
` if (!${revLookupVar}.has(k)) ${revLookupVar}.set(k, []);`,
` ${revLookupVar}.get(k).push(r);`,
`}`,
);
} else {
const varName = `_${decl.tableName}`;
reverseLookupInits.push(
`const ${revLookupVar} = new Map();`,
`for (const r of ${varName}()) {`,
` const kv = r.${decl.foreignKey};`,
` const k = String(typeof kv === "object" && "${defaultPrimaryKey}" in kv ? kv.${defaultPrimaryKey} : kv);`,
` if (!${revLookupVar}.has(k)) ${revLookupVar}.set(k, []);`,
` ${revLookupVar}.get(k).push(r);`,
`}`,
);
}
}
const lookupVar = (tableName: string) => lookupVarMap.get(tableName)!;
const reverseLookupVar = (tableName: string, foreignKey: string) =>
reverseLookupVarMap.get(`${tableName}:${foreignKey}`)!;
const rowResolvers: { name: string; code: string }[] = [];
for (const config of result.propertyConfigs) {
if (config.isReverseReference) {
// Reverse reference resolution
const decl = result.reverseReferences.find(
(d) => d.fieldName === config.name,
);
if (decl) {
const revLookup = reverseLookupVar(decl.tableName, decl.foreignKey);
if (decl.isOptional) {
rowResolvers.push({
name: config.name,
code: `(${revLookup}.get(String(row.${defaultPrimaryKey})) || null)`,
});
} else {
rowResolvers.push({
name: config.name,
code: `(${revLookup}.get(String(row.${defaultPrimaryKey})) || [])`,
});
}
}
} else if (hasNestedReferences(config.schema)) {
const resolveCode = generateSchemaResolutionCode(
config.schema,
`row.${config.name}`,
lookupVar,
defaultPrimaryKey,
reverseLookupVar,
);
rowResolvers.push({ name: config.name, code: resolveCode });
}
}
const rawJson = JSON.stringify(result.data, null, 2);
const js = [
...imports,
"",
`const _raw = ${rawJson};`,
"",
"let _resolved = null;",
"",
"export default function getData() {",
" if (_resolved) return _resolved;",
" _resolved = _raw;",
...lookupInits.map((l) => ` ${l}`),
...reverseLookupInits.map((l) => ` ${l}`),
...(rowResolvers.length > 0
? [
" _resolved = _raw.map(row => {",
...rowResolvers.map((r) => ` row.${r.name} = ${r.code};`),
" return row;",
" });",
]
: []),
" return _resolved;",
"}",
].join("\n");
return {
js,
dts: result.typeDefinition,
};
}
+539
View File
@@ -0,0 +1,539 @@
import * as fs from "fs";
import * as path from "path";
import { parseValue } from "../index.js";
import type {
Schema,
ReferenceSchema,
ReverseReferenceSchema,
} from "../types.js";
import type { ReferenceFieldInfo } from "./types.js";
import { parseCsv } from "./loader.js";
/** Cache for loaded referenced tables */
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) */
const loadingFiles = new Set<string>();
export function hasNestedReferences(schema: Schema): boolean {
switch (schema.type) {
case "reference":
case "reverseReference":
return true;
case "tuple":
return schema.elements.some((el) => hasNestedReferences(el.schema));
case "array":
return hasNestedReferences(schema.element);
case "union":
return schema.members.some((m) => hasNestedReferences(m));
default:
return false;
}
}
function loadReferenceTableData(
schema: ReferenceSchema | ReverseReferenceSchema,
refBaseDir: string | undefined,
currentFilePath: string | undefined,
): {
refTable: Record<string, unknown>[];
refFilePath: string;
} {
const baseDir =
refBaseDir ||
(currentFilePath ? path.dirname(currentFilePath) : process.cwd());
const fileName = `${schema.tableName}.csv`;
const refFilePath = path.isAbsolute(fileName)
? fileName
: path.join(baseDir, fileName);
let refTable: Record<string, unknown>[];
if (referenceTableCache.has(refFilePath)) {
refTable = referenceTableCache.get(refFilePath)!;
} else {
if (loadingFiles.has(refFilePath)) {
throw new Error(
`Circular reference detected: table "${schema.tableName}" (${refFilePath}) is already being loaded`,
);
}
loadingFiles.add(refFilePath);
try {
const refContent = fs.readFileSync(refFilePath, "utf-8");
const refResult = parseCsv(refContent, {
currentFilePath: refFilePath,
emitTypes: false,
});
refTable = refResult.data;
referenceTableCache.set(refFilePath, refTable);
} catch (error) {
throw new Error(
`Failed to load referenced table "${schema.tableName}" from ${refFilePath}: ${error instanceof Error ? error.message : String(error)}`,
);
} finally {
loadingFiles.delete(refFilePath);
}
}
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>>();
refTable.forEach((row) => {
const pkValue = row[defaultPrimaryKey];
if (pkValue !== undefined) {
lookup.set(String(pkValue), row);
}
});
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(
id: string,
lookup: Map<string, Record<string, unknown>>,
tableName: string,
): Record<string, unknown> {
const obj = lookup.get(id);
if (!obj) {
throw new Error(`Reference to "${tableName}" with id="${id}" not found`);
}
return obj;
}
export function parseReferenceIds(
schema: ReferenceSchema,
valueString: string,
): unknown {
const trimmed = valueString.trim();
if (schema.isOptional && trimmed === "") {
return null;
}
return parseValue(schema, trimmed);
}
export function parseValueWithReferenceIds(
valueString: string,
schema: Schema,
): unknown {
if (!hasNestedReferences(schema)) {
return parseValue(schema, valueString);
}
switch (schema.type) {
case "reference":
return parseReferenceIds(schema, valueString);
case "reverseReference":
// Reverse references don't store IDs; they're derived at resolution time
return null;
case "tuple": {
const parsed = parseValue(schema, valueString) as unknown[];
return schema.elements.map((el, i) =>
hasNestedReferences(el.schema)
? extractNestedReferenceIds(parsed[i], el.schema)
: parsed[i],
);
}
case "array": {
const parsed = parseValue(schema, valueString) as unknown[];
return parsed.map((item) =>
hasNestedReferences(schema.element)
? extractNestedReferenceIds(item, schema.element)
: item,
);
}
case "union": {
for (const member of schema.members) {
if (hasNestedReferences(member)) {
try {
const parsed = parseValue(member, valueString);
return extractNestedReferenceIds(parsed, member);
} catch {}
}
}
return parseValue(schema, valueString);
}
default:
return parseValue(schema, valueString);
}
}
export function extractNestedReferenceIds(
value: unknown,
schema: Schema,
): unknown {
switch (schema.type) {
case "reference":
if (value === null || value === undefined) return value;
if (schema.isArray) {
const ids = Array.isArray(value) ? value : [value];
return ids.map((id) => String(id));
}
return String(value);
case "reverseReference":
// Reverse references don't store IDs; return null placeholder
return null;
case "tuple": {
if (!Array.isArray(value)) return value;
return schema.elements.map((el, i) =>
hasNestedReferences(el.schema)
? extractNestedReferenceIds(value[i], el.schema)
: value[i],
);
}
case "array": {
if (!Array.isArray(value)) return value;
return value.map((item) =>
hasNestedReferences(schema.element)
? extractNestedReferenceIds(item, schema.element)
: item,
);
}
case "union": {
for (const member of schema.members) {
if (hasNestedReferences(member)) {
try {
return extractNestedReferenceIds(value, member);
} catch {}
}
}
return value;
}
default:
return value;
}
}
export function collectReferenceFields(
schema: Schema,
name: string,
): ReferenceFieldInfo[] {
const fields: ReferenceFieldInfo[] = [];
switch (schema.type) {
case "reference":
fields.push({
name,
tableName: schema.tableName,
isArray: schema.isArray,
schema,
});
break;
case "reverseReference":
fields.push({
name,
tableName: schema.tableName,
isArray: true,
foreignKey: schema.foreignKey,
schema,
});
break;
case "tuple":
for (const el of schema.elements) {
fields.push(...collectReferenceFields(el.schema, name));
}
break;
case "array":
fields.push(...collectReferenceFields(schema.element, name));
break;
case "union":
for (const member of schema.members) {
fields.push(...collectReferenceFields(member, name));
}
break;
}
return fields;
}
export function parseValueWithReferences(
valueString: string,
schema: Schema,
refBaseDir: string | undefined,
defaultPrimaryKey: string,
currentFilePath: string | undefined,
currentRowPk?: unknown,
): unknown {
if (!hasNestedReferences(schema)) {
return parseValue(schema, valueString);
}
switch (schema.type) {
case "reference":
return parseReferenceValue(
schema,
valueString,
refBaseDir,
defaultPrimaryKey,
currentFilePath,
);
case "reverseReference": {
if (currentRowPk === undefined) return [];
return resolveReverseReference(
schema,
currentRowPk,
refBaseDir,
defaultPrimaryKey,
currentFilePath,
);
}
case "tuple": {
const parsed = parseValue(schema, valueString) as unknown[];
return schema.elements.map((el, i) =>
resolveNestedReferences(
parsed[i],
el.schema,
refBaseDir,
defaultPrimaryKey,
currentFilePath,
currentRowPk,
),
);
}
case "array": {
const parsed = parseValue(schema, valueString) as unknown[];
return parsed.map((item) =>
resolveNestedReferences(
item,
schema.element,
refBaseDir,
defaultPrimaryKey,
currentFilePath,
currentRowPk,
),
);
}
case "union": {
// Try reference members first (more specific), then non-reference members.
// This makes the fallback structural rather than error-message-driven.
const refMembers = schema.members.filter(hasNestedReferences);
const nonRefMembers = schema.members.filter((m) => !hasNestedReferences(m));
const errors: Error[] = [];
for (const member of [...refMembers, ...nonRefMembers]) {
try {
const parsed = parseValue(member, valueString);
return resolveNestedReferences(
parsed,
member,
refBaseDir,
defaultPrimaryKey,
currentFilePath,
currentRowPk,
);
} catch (e) {
errors.push(e instanceof Error ? e : new Error(String(e)));
}
}
throw errors[0] ?? new Error("Value does not match any union member");
}
default:
return parseValue(schema, valueString);
}
}
export function resolveReverseReference(
schema: ReverseReferenceSchema,
pkValue: unknown,
refBaseDir: string | undefined,
defaultPrimaryKey: string,
currentFilePath: string | undefined,
): Record<string, unknown>[] {
const lookup = getReverseLookup(
schema,
refBaseDir,
defaultPrimaryKey,
currentFilePath,
);
return lookup.get(String(pkValue)) ?? [];
}
export function resolveNestedReferences(
value: unknown,
schema: Schema,
refBaseDir: string | undefined,
defaultPrimaryKey: string,
currentFilePath: string | undefined,
currentRowPk?: unknown,
): unknown {
switch (schema.type) {
case "reference": {
if (value === null || value === undefined) return value;
const { lookup } = loadReferenceTable(
schema,
refBaseDir,
defaultPrimaryKey,
currentFilePath,
);
if (schema.isArray) {
const ids = Array.isArray(value) ? value : [value];
return ids.map((id) =>
resolveReferenceId(String(id), lookup, schema.tableName),
);
}
return resolveReferenceId(String(value), lookup, schema.tableName);
}
case "reverseReference": {
if (currentRowPk === undefined) return [];
const results = resolveReverseReference(
schema,
currentRowPk,
refBaseDir,
defaultPrimaryKey,
currentFilePath,
);
return results;
}
case "tuple": {
if (!Array.isArray(value)) return value;
return schema.elements.map((el, i) =>
resolveNestedReferences(
value[i],
el.schema,
refBaseDir,
defaultPrimaryKey,
currentFilePath,
currentRowPk,
),
);
}
case "array": {
if (!Array.isArray(value)) return value;
return value.map((item) =>
resolveNestedReferences(
item,
schema.element,
refBaseDir,
defaultPrimaryKey,
currentFilePath,
currentRowPk,
),
);
}
case "union": {
// Try reference members first (more specific), then non-reference members.
const refMembers = schema.members.filter(hasNestedReferences);
const nonRefMembers = schema.members.filter((m) => !hasNestedReferences(m));
const errors: Error[] = [];
for (const member of [...refMembers, ...nonRefMembers]) {
try {
return resolveNestedReferences(
value,
member,
refBaseDir,
defaultPrimaryKey,
currentFilePath,
currentRowPk,
);
} catch (e) {
errors.push(e instanceof Error ? e : new Error(String(e)));
}
}
if (errors.length > 0) {
throw errors[0];
}
return value;
}
default:
return value;
}
}
export function parseReferenceValue(
schema: ReferenceSchema,
valueString: string,
refBaseDir: string | undefined,
defaultPrimaryKey: string,
currentFilePath: string | undefined,
): unknown {
const trimmed = valueString.trim();
if (schema.isOptional && trimmed === "") {
return null;
}
const { lookup } = loadReferenceTable(
schema,
refBaseDir,
defaultPrimaryKey,
currentFilePath,
);
const ids = parseValue(schema, trimmed) as string | string[] | null;
if (ids === null) return null;
if (schema.isArray && Array.isArray(ids)) {
return ids.map((id) => resolveReferenceId(id, lookup, schema.tableName));
}
return resolveReferenceId(ids as string, lookup, schema.tableName);
}
+16 -16
View File
@@ -1,7 +1,7 @@
import type { CsvLoaderOptions } from './loader.js';
import { csvToModule } from './loader.js';
import * as path from 'path';
import * as fs from 'fs';
import * as path from "path";
import * as fs from "fs";
import { csvToModule } from "./module-gen";
import { CsvLoaderOptions } from "./types";
export interface CsvRollupOptions extends CsvLoaderOptions {
/** Include pattern for CSV files (default: /\.csv$/) */
@@ -16,7 +16,7 @@ export interface CsvRollupOptions extends CsvLoaderOptions {
function matchesPattern(
id: string,
pattern: RegExp | string | Array<RegExp | string> | undefined
pattern: RegExp | string | Array<RegExp | string> | undefined,
): boolean {
if (!pattern) return true;
const patterns = Array.isArray(pattern) ? pattern : [pattern];
@@ -34,7 +34,7 @@ interface TransformResult {
}
interface EmitFileOptions {
type: 'asset';
type: "asset";
fileName: string;
source: string;
}
@@ -48,12 +48,12 @@ interface RollupPlugin {
transform: (
this: PluginContext,
code: string,
id: string
id: string,
) => TransformResult | null;
}
/**
* 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).
*/
export function csvLoader(options: CsvRollupOptions = {}): RollupPlugin {
@@ -61,13 +61,13 @@ export function csvLoader(options: CsvRollupOptions = {}): RollupPlugin {
include = /\.csv$/,
exclude,
emitTypes = true,
typesOutputDir = '',
typesOutputDir = "",
writeToDisk = false,
...parseOptions
} = options;
return {
name: 'inline-schema-csv',
name: "typed-csv",
transform(code: string, id: string) {
// Check if file matches the include/exclude patterns
@@ -75,12 +75,12 @@ export function csvLoader(options: CsvRollupOptions = {}): RollupPlugin {
if (exclude && matchesPattern(id, exclude)) return null;
// Only process .csv files
if (!id.endsWith('.csv')) return null;
if (!id.endsWith(".csv")) return null;
// Infer resource name from filename
const fileName = path.basename(id, '.csv').split('.')[0];
const fileName = path.basename(id, ".csv").split(".")[0];
const resourceName = fileName
.replace(/[-_\s]+(.)?/g, (_, char) => char ? char.toUpperCase() : '')
.replace(/[-_\s]+(.)?/g, (_, char) => (char ? char.toUpperCase() : ""))
.replace(/^(.)/, (_, char) => char.toUpperCase());
const result = csvToModule(code, {
@@ -95,8 +95,8 @@ export function csvLoader(options: CsvRollupOptions = {}): RollupPlugin {
// Emit type definition file if enabled
if (emitTypes && result.dts) {
const dtsPath = typesOutputDir
? path.join(typesOutputDir, path.basename(id) + '.d.ts')
: id + '.d.ts';
? path.join(typesOutputDir, path.basename(id) + ".d.ts")
: id + ".d.ts";
if (writeToDisk) {
// Write directly to disk
@@ -108,7 +108,7 @@ export function csvLoader(options: CsvRollupOptions = {}): RollupPlugin {
} else {
// Emit to Rollup's virtual module system
this.emitFile({
type: 'asset',
type: "asset",
fileName: dtsPath,
source: result.dts,
});
+8
View File
@@ -0,0 +1,8 @@
import * as path from "path";
import * as fs from "fs";
export const fixturesDir = path.join(__dirname, "fixtures");
export function readFixture(name: string): string {
return fs.readFileSync(path.join(fixturesDir, name), "utf-8");
}
+127
View File
@@ -0,0 +1,127 @@
import { describe, it, expect } from "vitest";
import { parseCsv } from "../loader";
describe("parseCsv - basic parsing", () => {
it("should parse a simple CSV with primitive types", () => {
const csv = [
"name,age,active",
"string,number,boolean",
"Alice,30,true",
"Bob,25,false",
].join("\n");
const result = parseCsv(csv, { emitTypes: false });
expect(result.data).toHaveLength(2);
expect(result.data[0]).toEqual({ name: "Alice", age: 30, active: true });
expect(result.data[1]).toEqual({ name: "Bob", age: 25, active: false });
});
it("should parse CSV with int and float columns", () => {
const csv = [
"id,count,price",
"int,int,float",
"1,5,9.99",
"2,3,4.50",
].join("\n");
const result = parseCsv(csv, { emitTypes: false });
expect(result.data).toHaveLength(2);
expect(result.data[0]).toEqual({ id: 1, count: 5, price: 9.99 });
expect(result.data[1]).toEqual({ id: 2, count: 3, price: 4.5 });
});
it("should parse CSV with non-ASCII characters and comments", () => {
const csv = [
'# id: unique intent state ID (e.g. "仙人掌怪-boost")',
"id",
"string",
"仙人掌怪-boost",
"仙人掌怪-defend",
"仙人掌怪-attack",
].join("\n");
const result = parseCsv(csv, { emitTypes: false });
expect(result.data).toHaveLength(3);
expect(result.data[0]).toEqual({ id: "仙人掌怪-boost" });
expect(result.data[1]).toEqual({ id: "仙人掌怪-defend" });
expect(result.data[2]).toEqual({ id: "仙人掌怪-attack" });
});
it("should parse CSV with string literal columns (unquoted in CSV)", () => {
const csv = [
"name,status",
"string,'on' | 'off'",
"Alice,on",
"Bob,off",
].join("\n");
const result = parseCsv(csv, { emitTypes: false });
expect(result.data).toHaveLength(2);
expect(result.data[0]).toEqual({ name: "Alice", status: "on" });
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", () => {
const csv = [
"name,tags",
"string,string[]",
"Alice,[dev; admin]",
"Bob,[user]",
].join("\n");
const result = parseCsv(csv, { emitTypes: false });
expect(result.data).toHaveLength(2);
expect(result.data[0]).toEqual({ name: "Alice", tags: ["dev", "admin"] });
expect(result.data[1]).toEqual({ name: "Bob", tags: ["user"] });
});
it("should parse CSV with tuple columns", () => {
const csv = [
"name,coords",
"string,[number; number]",
"Alice,[1; 2]",
"Bob,[3; 4]",
].join("\n");
const result = parseCsv(csv, { emitTypes: false });
expect(result.data).toHaveLength(2);
expect(result.data[0]).toEqual({ name: "Alice", coords: [1, 2] });
expect(result.data[1]).toEqual({ name: "Bob", coords: [3, 4] });
});
it("should require at least 2 rows (header + schema)", () => {
const csv = "name,age\nstring,number";
expect(() => parseCsv(csv, { emitTypes: false })).not.toThrow();
const csv1Row = "name,age";
expect(() => parseCsv(csv1Row, { emitTypes: false })).toThrow(
"at least 2 rows",
);
});
it("should throw if header and schema count mismatch", () => {
const csv = "name,age\nstring";
expect(() => parseCsv(csv, { emitTypes: false })).toThrow("does not match");
});
});
@@ -0,0 +1,25 @@
import { describe, it, expect } from "vitest";
import { parseCsv } from "../loader";
import * as path from "path";
import { fixturesDir, readFixture } from "../test-utils";
describe("parseCsv - caching", () => {
it("should cache referenced table and not re-read on subsequent references", () => {
const usersCsv = readFixture("users.csv");
const csv = ["id,creator,reviewer", "string,@users,@users", "1,1,2"].join(
"\n",
);
const result = parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "test.csv"),
});
const creator = result.data[0].creator as Record<string, unknown>;
const reviewer = result.data[0].reviewer as Record<string, unknown>;
expect(creator).not.toEqual(reviewer);
expect(creator.id).toBe("1");
expect(reviewer.id).toBe("2");
});
});
@@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest";
import { parseCsv } from "../loader";
import * as path from "path";
import { fixturesDir, readFixture } from "../test-utils";
describe("parseCsv - circular reference detection", () => {
it("should detect self-referencing circular reference", () => {
const csv = ["id,name,parent", "string,string,@self_ref", "1,Root,2"].join(
"\n",
);
expect(() =>
parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "self_ref.csv"),
}),
).toThrow(/Circular reference detected/);
});
it("should detect mutual circular reference (A -> B -> A)", () => {
const csv = readFixture("circular_a.csv");
expect(() =>
parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "circular_a.csv"),
}),
).toThrow(/Circular reference detected/);
});
it("should allow same table referenced from multiple columns without circular reference", () => {
const usersCsv = readFixture("users.csv");
const csv = ["id,creator,reviewer", "string,@users,@users", "1,1,2"].join(
"\n",
);
const result = parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "test.csv"),
});
expect(result.data[0].creator).toEqual({
id: "1",
name: "Alice",
email: "alice@example.com",
});
expect(result.data[0].reviewer).toEqual({
id: "2",
name: "Bob",
email: "bob@example.com",
});
});
});
@@ -0,0 +1,234 @@
import { describe, it, expect } from "vitest";
import { parseCsv } from "../loader";
import * as path from "path";
import { fixturesDir, readFixture } from "../test-utils";
describe("parseCsv - references in combinatory schemas", () => {
it("should resolve reference inside a tuple", () => {
const csv = [
"id,info",
"string,[ref: @users; note: string]",
"1,[ref: 1; note: urgent]",
].join("\n");
const result = parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "test.csv"),
});
expect(result.data).toHaveLength(1);
const info = result.data[0].info as unknown[];
expect(info).toHaveLength(2);
expect(info[0]).toEqual({
id: "1",
name: "Alice",
email: "alice@example.com",
});
expect(info[1]).toBe("urgent");
});
it("should resolve reference array inside a tuple", () => {
const csv = [
"id,info",
"string,[refs: @users[]; note: string]",
"1,[refs: [1; 2]; note: test]",
].join("\n");
const result = parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "test.csv"),
});
expect(result.data).toHaveLength(1);
const info = result.data[0].info as unknown[];
expect(info).toHaveLength(2);
const refs = info[0] as Record<string, unknown>[];
expect(refs).toHaveLength(2);
expect(refs[0]).toEqual({
id: "1",
name: "Alice",
email: "alice@example.com",
});
expect(refs[1]).toEqual({ id: "2", name: "Bob", email: "bob@example.com" });
expect(info[1]).toBe("test");
});
it("should resolve array of tuples containing references", () => {
const csv = [
"id,pairs",
"string,[@users; number][]",
"1,[[1; 10]; [2; 20]]",
].join("\n");
const result = parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "test.csv"),
});
expect(result.data).toHaveLength(1);
const pairs = result.data[0].pairs as unknown[][];
expect(pairs).toHaveLength(2);
expect(pairs[0]).toHaveLength(2);
expect(pairs[0][0]).toEqual({
id: "1",
name: "Alice",
email: "alice@example.com",
});
expect(pairs[0][1]).toBe(10);
expect(pairs[1][0]).toEqual({
id: "2",
name: "Bob",
email: "bob@example.com",
});
expect(pairs[1][1]).toBe(20);
});
it("should resolve reference in union (@users | string)", () => {
const csv = ["id,value", "string,@users | string", "1,1", "2,unknown"].join(
"\n",
);
const result = parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "test.csv"),
});
expect(result.data).toHaveLength(2);
expect(result.data[0].value).toEqual({
id: "1",
name: "Alice",
email: "alice@example.com",
});
expect(result.data[1].value).toBe("unknown");
});
it("should resolve reference in union (@users[] | string)", () => {
const csv = [
"id,value",
"string,@users[] | string",
"1,[1; 2]",
"2,none",
].join("\n");
const result = parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "test.csv"),
});
expect(result.data).toHaveLength(2);
const arr = result.data[0].value as Record<string, unknown>[];
expect(arr).toHaveLength(2);
expect(arr[0]).toEqual({
id: "1",
name: "Alice",
email: "alice@example.com",
});
expect(result.data[1].value).toBe("none");
});
it("should resolve array of reference unions (@users | @parts)[]", () => {
const csv = ["id,items", "string,(@users | @parts)[]", "1,[1; 2]"].join(
"\n",
);
const result = parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "test.csv"),
});
expect(result.data).toHaveLength(1);
});
it("should resolve named tuple with reference and other fields", () => {
const csv = [
"id,details",
"string,[owner: @users; count: number]",
"1,[owner: 1; count: 5]",
].join("\n");
const result = parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "test.csv"),
});
expect(result.data).toHaveLength(1);
const details = result.data[0].details as unknown[];
expect(details).toHaveLength(2);
expect(details[0]).toEqual({
id: "1",
name: "Alice",
email: "alice@example.com",
});
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]);
});
});
@@ -0,0 +1,190 @@
import { describe, it, expect } from "vitest";
import { parseCsv } from "../loader";
import * as path from "path";
import { fixturesDir, readFixture } from "../test-utils";
describe("parseCsv - resolveReferences: false", () => {
it("should store IDs instead of resolved objects for reference fields", () => {
const csv = [
"id,customer,items",
"string,@users,@parts[]",
"1,1,[1; 2]",
].join("\n");
const result = parseCsv(csv, {
emitTypes: false,
resolveReferences: false,
});
expect(result.data[0].customer).toBe("1");
expect(result.data[0].items).toEqual(["1", "2"]);
});
it("should populate referenceFields with metadata", () => {
const csv = [
"id,customer,items",
"string,@users,@parts[]",
"1,1,[1; 2]",
].join("\n");
const result = parseCsv(csv, {
emitTypes: false,
resolveReferences: false,
});
expect(result.referenceFields).toHaveLength(2);
expect(result.referenceFields[0]).toEqual({
name: "customer",
tableName: "users",
isArray: false,
schema: expect.objectContaining({
type: "reference",
tableName: "users",
isArray: false,
}),
});
expect(result.referenceFields[1]).toEqual({
name: "items",
tableName: "parts",
isArray: true,
schema: expect.objectContaining({
type: "reference",
tableName: "parts",
isArray: true,
}),
});
});
it("should not load referenced CSV files", () => {
const csv = ["id,customer", "string,@nonexistent", "1,someid"].join("\n");
expect(() =>
parseCsv(csv, {
emitTypes: false,
resolveReferences: false,
}),
).not.toThrow();
});
it("should store IDs for nested references in tuples", () => {
const csv = [
"id,info",
"string,[ref: @users; note: string]",
"1,[ref: 1; note: urgent]",
].join("\n");
const result = parseCsv(csv, {
emitTypes: false,
resolveReferences: false,
});
expect((result.data[0].info as unknown[])[0]).toBe("1");
expect((result.data[0].info as unknown[])[1]).toBe("urgent");
expect(result.referenceFields).toHaveLength(1);
expect(result.referenceFields[0].tableName).toBe("users");
});
it("should store IDs for references in unions", () => {
const csv = ["id,value", "string,@users | string", "1,1", "2,unknown"].join(
"\n",
);
const result = parseCsv(csv, {
emitTypes: false,
resolveReferences: false,
});
expect(result.data[0].value).toBe("1");
expect(result.data[1].value).toBe("unknown");
});
it("should not throw for self-referencing table when resolveReferences is false", () => {
const csv = readFixture("self_ref.csv");
const result = parseCsv(csv, {
emitTypes: false,
resolveReferences: false,
currentFilePath: path.join(fixturesDir, "self_ref.csv"),
});
expect(result.data).toHaveLength(2);
expect(result.data[0].parent).toBe("2");
expect(result.data[1].parent).toBe("1");
expect(result.referenceFields).toHaveLength(1);
expect(result.referenceFields[0].tableName).toBe("self_ref");
});
it("should not throw for cross-referencing tables when resolveReferences is false", () => {
const csv = readFixture("circular_a.csv");
const result = parseCsv(csv, {
emitTypes: false,
resolveReferences: false,
currentFilePath: path.join(fixturesDir, "circular_a.csv"),
});
expect(result.data).toHaveLength(1);
expect(result.data[0].related).toEqual(["1"]);
expect(result.referenceFields).toHaveLength(1);
expect(result.referenceFields[0].tableName).toBe("circular_b");
});
it("should store IDs for nested self-reference in tuple", () => {
const csv = [
"id,name,parent_info",
"string,string,[parent: @self_ref; role: string]",
"1,Root,[parent: 2; role: admin]",
].join("\n");
const result = parseCsv(csv, {
emitTypes: false,
resolveReferences: false,
currentFilePath: path.join(fixturesDir, "self_ref.csv"),
});
const parentInfo = result.data[0].parent_info as unknown[];
expect(parentInfo[0]).toBe("2");
expect(parentInfo[1]).toBe("admin");
expect(result.referenceFields).toHaveLength(1);
expect(result.referenceFields[0].tableName).toBe("self_ref");
});
it("should store IDs for self-reference in union", () => {
const csv = [
"id,name,ref_or_val",
"string,string,@self_ref | string",
"1,Root,2",
"2,Child,none",
].join("\n");
const result = parseCsv(csv, {
emitTypes: false,
resolveReferences: false,
currentFilePath: path.join(fixturesDir, "self_ref.csv"),
});
expect(result.data[0].ref_or_val).toBe("2");
expect(result.data[1].ref_or_val).toBe("none");
expect(result.referenceFields).toHaveLength(1);
expect(result.referenceFields[0].tableName).toBe("self_ref");
});
it("should store IDs for self-reference array in tuple", () => {
const csv = [
"id,name,children",
"string,string,[@self_ref[]]",
"1,Root,[[2]]",
].join("\n");
const result = parseCsv(csv, {
emitTypes: false,
resolveReferences: false,
currentFilePath: path.join(fixturesDir, "self_ref.csv"),
});
const children = result.data[0].children as unknown[];
expect(children[0]).toEqual(["2"]);
expect(result.referenceFields).toHaveLength(1);
expect(result.referenceFields[0].tableName).toBe("self_ref");
});
});
@@ -0,0 +1,20 @@
import { describe, it, expect } from "vitest";
import { parseCsv } from "../loader";
import { fixturesDir } from "../test-utils";
describe("parseCsv - refBaseDir option", () => {
it("should use refBaseDir to resolve reference paths", () => {
const csv = ["id,customer", "string,@users", "1,1"].join("\n");
const result = parseCsv(csv, {
emitTypes: false,
refBaseDir: fixturesDir,
});
expect(result.data[0].customer).toEqual({
id: "1",
name: "Alice",
email: "alice@example.com",
});
});
});
@@ -0,0 +1,148 @@
import { describe, it, expect } from "vitest";
import { parseCsv } from "../loader";
import * as path from "path";
import { fixturesDir, readFixture } from "../test-utils";
import fs from "fs";
describe("parseCsv - reference resolution", () => {
it("should resolve single reference to another CSV table", () => {
const usersCsv = readFixture("users.csv");
const result = parseCsv(usersCsv, { emitTypes: false });
expect(result.data).toHaveLength(3);
expect(result.data[0]).toEqual({
id: "1",
name: "Alice",
email: "alice@example.com",
});
});
it("should resolve reference values using parseCsv with referenced tables", () => {
const ordersCsv = [
"id,customer,total",
"string,@users,number",
"1,1,100",
].join("\n");
const result = parseCsv(ordersCsv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "orders.csv"),
});
expect(result.data).toHaveLength(1);
expect(result.data[0].customer).toEqual({
id: "1",
name: "Alice",
email: "alice@example.com",
});
expect(result.data[0].total).toBe(100);
});
it("should resolve array reference values", () => {
const ordersCsv = [
"id,items,total",
"string,@parts[],number",
"1,[1; 2],35.5",
].join("\n");
const result = parseCsv(ordersCsv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "orders.csv"),
});
expect(result.data).toHaveLength(1);
const items = result.data[0].items as Record<string, unknown>[];
expect(items).toHaveLength(2);
expect(items[0]).toEqual({ id: "1", name: "Widget", price: 10.5 });
expect(items[1]).toEqual({ id: "2", name: "Gadget", price: 25 });
expect(result.data[0].total).toBe(35.5);
});
it("should resolve mixed single and array references", () => {
const ordersCsv = [
"id,customer,items,total",
"string,@users,@parts[],number",
"1,1,[1; 2],35.5",
].join("\n");
const result = parseCsv(ordersCsv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "orders.csv"),
});
expect(result.data).toHaveLength(1);
expect(result.data[0].customer).toEqual({
id: "1",
name: "Alice",
email: "alice@example.com",
});
const items = result.data[0].items as Record<string, unknown>[];
expect(items).toHaveLength(2);
expect(items[0]).toEqual({ id: "1", name: "Widget", price: 10.5 });
});
it("should throw error for reference to non-existent ID", () => {
const ordersCsv = ["id,customer", "string,@users", "1,999"].join("\n");
expect(() =>
parseCsv(ordersCsv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "orders.csv"),
}),
).toThrow(/not found/);
});
it("should throw error for reference to non-existent table", () => {
const csv = ["id,ref", "string,@nonexistent", "1,someid"].join("\n");
expect(() =>
parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "test.csv"),
}),
).toThrow(/Failed to load referenced table/);
});
it("should collect reference table names", () => {
const csv = ["id,customer,items", "string,@users,@parts[]", "1,1,[1]"].join(
"\n",
);
const result = parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "test.csv"),
});
expect(result.references.has("users")).toBe(true);
expect(result.references.has("parts")).toBe(true);
});
it("should use custom primary key", () => {
const nameCsv = [
"code,name",
"string,string",
"US,United States",
"UK,United Kingdom",
].join("\n");
const nameCsvPath = path.join(fixturesDir, "countries.csv");
fs.writeFileSync(nameCsvPath, nameCsv);
try {
const refCsv = ["id,country", "string,@countries", "1,US"].join("\n");
const result = parseCsv(refCsv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "ref.csv"),
defaultPrimaryKey: "code",
});
expect(result.data[0].country).toEqual({
code: "US",
name: "United States",
});
} finally {
fs.unlinkSync(nameCsvPath);
}
});
});
@@ -0,0 +1,303 @@
import { describe, it, expect } from "vitest";
import { parseCsv } from "../loader";
import * as path from "path";
import { fixturesDir } from "../test-utils";
import fs from "fs";
describe("parseCsv - reverse reference resolution", () => {
it("should resolve reverse reference from comment declaration", () => {
const ordersCsvPath = path.join(fixturesDir, "rev_orders.csv");
const ordersContent = [
"id,customer,total",
"string,string,number",
"1,1,100",
"2,1,50",
"3,2,75",
].join("\n");
fs.writeFileSync(ordersCsvPath, ordersContent);
try {
const csv = [
"id,name",
"string,string",
"# inject orders = ~rev_orders(customer)",
"1,Alice",
"2,Bob",
].join("\n");
const result = parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "test.csv"),
});
expect(result.data).toHaveLength(2);
const aliceOrders = result.data[0].orders as Record<string, unknown>[];
expect(aliceOrders).toHaveLength(2);
expect(aliceOrders[0]).toEqual({ id: "1", customer: "1", total: 100 });
expect(aliceOrders[1]).toEqual({ id: "2", customer: "1", total: 50 });
const bobOrders = result.data[1].orders as Record<string, unknown>[];
expect(bobOrders).toHaveLength(1);
expect(bobOrders[0].id).toBe("3");
} finally {
fs.unlinkSync(ordersCsvPath);
}
});
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", () => {
const ordersCsvPath = path.join(fixturesDir, "rev_orders.csv");
const ordersContent = [
"id,customer,total",
"string,string,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",
"99,Nobody",
].join("\n");
const result = parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "test.csv"),
});
expect(result.data).toHaveLength(2);
expect(result.data[0].orders).toHaveLength(2);
expect(result.data[1].orders).toEqual([]);
} finally {
fs.unlinkSync(ordersCsvPath);
}
});
it("should return null for optional reverse reference with no matches", () => {
const csv = [
"id,name",
"string,string",
"# inject orders = ~orders(customer)?",
"99,Nobody",
].join("\n");
const result = parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "test.csv"),
});
expect(result.data).toHaveLength(1);
expect(result.data[0].orders).toBeNull();
});
it("should populate reverseReferences in result", () => {
const csv = [
"id,name",
"string,string",
"# inject orders = ~orders(customer)",
"1,Alice",
].join("\n");
const result = parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "test.csv"),
});
expect(result.reverseReferences).toHaveLength(1);
expect(result.reverseReferences[0]).toEqual({
fieldName: "orders",
tableName: "orders",
foreignKey: "customer",
isOptional: false,
schema: {
type: "reverseReference",
tableName: "orders",
foreignKey: "customer",
isOptional: false,
},
});
});
it("should include reverse reference tables in references set", () => {
const csv = [
"id,name",
"string,string",
"# inject orders = ~orders(customer)",
"1,Alice",
].join("\n");
const result = parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "test.csv"),
});
expect(result.references.has("orders")).toBe(true);
});
it("should support multiple reverse reference declarations", () => {
const csv = [
"id,name",
"string,string",
"# inject orders = ~orders(customer)",
"# inject parts = ~parts(user)",
"1,Alice",
].join("\n");
const result = parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "test.csv"),
});
expect(result.reverseReferences).toHaveLength(2);
expect(result.reverseReferences[0].fieldName).toBe("orders");
expect(result.reverseReferences[1].fieldName).toBe("parts");
expect(result.data[0]).toHaveProperty("orders");
expect(result.data[0]).toHaveProperty("parts");
});
it("should ignore comment lines that are not reverse reference declarations", () => {
const csv = [
"id,name",
"string,string",
"# This is just a comment",
"# inject orders = ~orders(customer)",
"1,Alice",
].join("\n");
const result = parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "test.csv"),
});
expect(result.reverseReferences).toHaveLength(1);
expect(result.data[0]).toHaveProperty("orders");
});
it("should handle multiple comment lines including plain comments and reverse references", () => {
const orderCsvPath = path.join(fixturesDir, "order.csv");
const orderContent = [
"id,user,total",
"string,string,number",
"o01,u01,100",
"o02,u01,50",
"o03,u02,75",
].join("\n");
fs.writeFileSync(orderCsvPath, orderContent);
try {
const csv = [
"# id: id of user",
"# orders: list of related orders",
"# inject orders = ~order(user)",
"id,name",
"string,string",
"u01,Alice",
"u02,Bob",
].join("\n");
const result = parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "test.csv"),
});
expect(result.reverseReferences).toHaveLength(1);
expect(result.reverseReferences[0].fieldName).toBe("orders");
expect(result.data).toHaveLength(2);
expect(result.data[0].id).toBe("u01");
expect(result.data[1].id).toBe("u02");
const aliceOrders = result.data[0].orders as Record<string, unknown>[];
expect(aliceOrders).toHaveLength(2);
expect(aliceOrders[0]).toEqual({ id: "o01", user: "u01", total: 100 });
expect(aliceOrders[1]).toEqual({ id: "o02", user: "u01", total: 50 });
const bobOrders = result.data[1].orders as Record<string, unknown>[];
expect(bobOrders).toHaveLength(1);
expect(bobOrders[0].id).toBe("o03");
} finally {
fs.unlinkSync(orderCsvPath);
}
});
});
describe("parseCsv - reverse reference with resolveReferences: false", () => {
it("should populate referenceFields for reverse references", () => {
const csv = [
"id,name",
"string,string",
"# inject orders = ~orders(customer)",
"1,Alice",
].join("\n");
const result = parseCsv(csv, {
emitTypes: false,
resolveReferences: false,
});
const withForeignKey = result.referenceFields.filter(
(f) => f.foreignKey === "customer",
);
expect(withForeignKey).toHaveLength(1);
expect(withForeignKey[0]).toEqual({
name: "orders",
tableName: "orders",
isArray: true,
foreignKey: "customer",
schema: expect.objectContaining({
type: "reverseReference",
tableName: "orders",
foreignKey: "customer",
}),
});
});
it("should not load referenced CSV files for reverse references", () => {
const csv = [
"id,name",
"string,string",
"# inject nonexistent = ~nonexistent(some_key)",
"1,Alice",
].join("\n");
expect(() =>
parseCsv(csv, {
emitTypes: false,
resolveReferences: false,
}),
).not.toThrow();
});
});
@@ -0,0 +1,330 @@
import { describe, it, expect } from "vitest";
import { parseCsv } from "../loader";
import * as path from "path";
import { fixturesDir } from "../test-utils";
describe("parseCsv - type declarations", () => {
it("should parse type declaration from comment line", () => {
const csv = [
"# type Trigger = 'onPlay' | 'onDraw' | 'onDiscard'",
"id,trigger",
"string,Trigger",
"attack,onPlay",
].join("\n");
const result = parseCsv(csv, { emitTypes: false });
expect(result.typeDeclarations).toHaveLength(1);
expect(result.typeDeclarations[0]).toMatchObject({
name: "Trigger",
schema: {
type: "union",
members: [
{ type: "stringLiteral", value: "onPlay" },
{ type: "stringLiteral", value: "onDraw" },
{ type: "stringLiteral", value: "onDiscard" },
],
},
});
});
it("should use declared type as column schema", () => {
const csv = [
"# type Trigger = 'onPlay' | 'onDraw' | 'onDiscard'",
"id,trigger",
"string,Trigger",
"attack,onPlay",
].join("\n");
const result = parseCsv(csv, { emitTypes: false });
expect(result.data).toHaveLength(1);
expect(result.data[0]).toEqual({ id: "attack", trigger: "onPlay" });
});
it("should include declared types in type definition", () => {
const csv = [
"# type Trigger = 'onPlay' | 'onDraw' | 'onDiscard'",
"id,trigger",
"string,Trigger",
"attack,onPlay",
].join("\n");
const result = parseCsv(csv, {
emitTypes: true,
resourceName: "card",
currentFilePath: path.join(fixturesDir, "card.csv"),
});
expect(result.typeDefinition).toContain("export type Trigger =");
expect(result.typeDefinition).toContain(
"'onPlay' | 'onDraw' | 'onDiscard'",
);
});
it("should parse multiple type declarations", () => {
const csv = [
"# type Trigger = 'onPlay' | 'onDraw'",
"# type Status = 'active' | 'inactive'",
"id,trigger,status",
"string,Trigger,Status",
"attack,onPlay,active",
].join("\n");
const result = parseCsv(csv, { emitTypes: false });
expect(result.typeDeclarations).toHaveLength(2);
expect(result.typeDeclarations[0].name).toBe("Trigger");
expect(result.typeDeclarations[1].name).toBe("Status");
});
it("should include multiple type declarations in type definition", () => {
const csv = [
"# type Trigger = 'onPlay' | 'onDraw'",
"# type Status = 'active' | 'inactive'",
"id,trigger,status",
"string,Trigger,Status",
"attack,onPlay,active",
].join("\n");
const result = parseCsv(csv, {
emitTypes: true,
resourceName: "card",
currentFilePath: path.join(fixturesDir, "card.csv"),
});
expect(result.typeDefinition).toContain("export type Trigger =");
expect(result.typeDefinition).toContain("export type Status =");
});
it("should ignore comment lines that are not type declarations", () => {
const csv = [
"# This is just a comment",
"# type Trigger = 'onPlay' | 'onDraw'",
"id,trigger",
"string,Trigger",
"attack,onPlay",
].join("\n");
const result = parseCsv(csv, { emitTypes: false });
expect(result.typeDeclarations).toHaveLength(1);
expect(result.typeDeclarations[0].name).toBe("Trigger");
});
it("should handle type declaration with array schema", () => {
const csv = [
"# type Tags = string[]",
"id,tags",
"string,Tags",
"1,[dev; admin; user]",
].join("\n");
const result = parseCsv(csv, { emitTypes: false });
expect(result.typeDeclarations).toHaveLength(1);
expect(result.typeDeclarations[0].name).toBe("Tags");
expect(result.data[0].tags).toEqual(["dev", "admin", "user"]);
});
it("should include type declaration before table type in output", () => {
const csv = [
"# type Status = 'active' | 'inactive'",
"id,status",
"string,Status",
"1,active",
].join("\n");
const result = parseCsv(csv, {
emitTypes: true,
resourceName: "item",
currentFilePath: path.join(fixturesDir, "item.csv"),
});
// Type declaration should appear before the table type
const typeDef = result.typeDefinition!;
const statusIndex = typeDef.indexOf("type Status =");
const itemTableIndex = typeDef.indexOf("type itemTable =");
expect(statusIndex).toBeLessThan(itemTableIndex);
});
it("should work with type declarations alongside reverse references", () => {
const csv = [
"# type Trigger = 'onPlay' | 'onDraw'",
"id,trigger",
"string,Trigger",
"attack,onPlay",
].join("\n");
const result = parseCsv(csv, { emitTypes: false });
expect(result.typeDeclarations).toHaveLength(1);
expect(result.reverseReferences).toHaveLength(0);
});
it("should resolve type references inside tuple type declarations", () => {
const csv = [
"# type Trigger = 'onPlay' | 'onDraw' | 'onDiscard'",
"# type Effect = [Trigger; @effect; int]",
"id,trigger",
"string,Trigger",
"attack,onPlay",
].join("\n");
const result = parseCsv(csv, { emitTypes: false });
expect(result.typeDeclarations).toHaveLength(2);
expect(result.typeDeclarations[0].name).toBe("Trigger");
expect(result.typeDeclarations[1].name).toBe("Effect");
// Effect should have a tuple schema with Trigger's schema inlined
expect(result.typeDeclarations[1].schema.type).toBe("tuple");
});
it("should include resolved type references in generated type definition", () => {
const csv = [
"# type Trigger = 'onPlay' | 'onDraw' | 'onDiscard'",
"# type Effect = [Trigger; @effect; int]",
"id,trigger",
"string,Trigger",
"attack,onPlay",
].join("\n");
const result = parseCsv(csv, {
emitTypes: true,
resourceName: "card",
currentFilePath: path.join(fixturesDir, "card.csv"),
});
// Both type declarations should appear
expect(result.typeDefinition).toContain("export type Trigger =");
expect(result.typeDefinition).toContain("export type Effect =");
// Column should use the declared type name, not expanded union
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", () => {
it("should populate typeDeclarations even when resolveReferences is false", () => {
const csv = [
"# type Trigger = 'onPlay' | 'onDraw'",
"id,trigger",
"string,Trigger",
"attack,onPlay",
].join("\n");
const result = parseCsv(csv, {
emitTypes: false,
resolveReferences: false,
});
expect(result.typeDeclarations).toHaveLength(1);
});
});
@@ -0,0 +1,78 @@
import { describe, it, expect } from "vitest";
import { parseCsv } from "../loader";
import * as path from "path";
import { fixturesDir } from "../test-utils";
describe("parseCsv - type generation", () => {
it("should generate type definition with emitTypes enabled", () => {
const csv = ["name,age", "string,number", "Alice,30"].join("\n");
const result = parseCsv(csv, { emitTypes: true, resourceName: "people" });
expect(result.typeDefinition).toBeDefined();
expect(result.typeDefinition).toContain("peopleTable");
expect(result.typeDefinition).toContain("readonly name: string");
expect(result.typeDefinition).toContain("readonly age: number");
});
it("should include reference imports in type definition", () => {
const csv = ["id,customer", "string,@users", "1,1"].join("\n");
const result = parseCsv(csv, {
emitTypes: true,
resourceName: "orders",
currentFilePath: path.join(fixturesDir, "test.csv"),
});
expect(result.typeDefinition).toBeDefined();
expect(result.typeDefinition).toContain("Users");
expect(result.typeDefinition).toContain("users.csv");
});
it("should not generate type definition when emitTypes is false", () => {
const csv = ["name,age", "string,number", "Alice,30"].join("\n");
const result = parseCsv(csv, { emitTypes: false });
expect(result.typeDefinition).toBeUndefined();
});
it("should generate correct type for reference column", () => {
const csv = ["id,customer", "string,@users", "1,1"].join("\n");
const result = parseCsv(csv, {
emitTypes: true,
resourceName: "orders",
currentFilePath: path.join(fixturesDir, "test.csv"),
});
expect(result.typeDefinition).toContain("readonly customer: Users");
});
it("should generate correct type for array reference column", () => {
const csv = ["id,items", "string,@parts[]", "1,[1]"].join("\n");
const result = parseCsv(csv, {
emitTypes: true,
resourceName: "orders",
currentFilePath: path.join(fixturesDir, "test.csv"),
});
expect(result.typeDefinition).toContain("readonly items: Parts[]");
});
it("should generate correct type for reference in tuple", () => {
const csv = [
"id,info",
"string,[ref: @users; note: string]",
"1,[ref: 1; note: test]",
].join("\n");
const result = parseCsv(csv, {
emitTypes: true,
resourceName: "data",
currentFilePath: path.join(fixturesDir, "test.csv"),
});
expect(result.typeDefinition).toContain("Users");
});
});
+256
View File
@@ -0,0 +1,256 @@
import type { Schema, ReverseReferenceSchema } from "../types.js";
import { parseSchema } from "../parser.js";
export interface ReverseReferenceDeclaration {
fieldName: string;
tableName: string;
foreignKey: string;
isOptional: boolean;
schema: ReverseReferenceSchema;
}
export interface TypeDeclaration {
name: string;
schema: Schema;
}
/**
* Parse a type declaration from a comment line.
* Format: # type TypeName = schema
* Returns null if the line is not a type declaration.
*/
export function parseTypeDeclaration(
line: string,
commentChar: string = "#",
): { typeName: string; schemaString: string } | null {
const trimmed = line.trim();
// Must start with the comment character
if (!trimmed.startsWith(commentChar)) return null;
const content = trimmed.slice(commentChar.length).trim();
// Match pattern: type TypeName = schema
const match = content.match(/^type\s+([A-Z][a-zA-Z0-9]*)\s*=\s*(.+)$/);
if (!match) return null;
const [, typeName, schemaString] = match;
return { typeName, schemaString };
}
/**
* Expand a type name to its schema by replacing the type name with its schema inline.
* Returns the schema string with type names expanded, or null if not a type name.
*/
function expandTypeName(
schemaString: string,
declaredTypes: Map<string, string>,
): string | null {
const trimmed = schemaString.trim();
if (declaredTypes.has(trimmed)) {
return declaredTypes.get(trimmed)!;
}
return null;
}
/**
* Recursively expand all type name references in a schema string.
* Handles unions, tuples, arrays, and nested structures.
*/
export function expandSchemaString(
schemaString: string,
declaredTypes: Map<string, string>,
): string {
let result = schemaString;
// Keep expanding until no more changes (handles recursive dependencies)
let prev = "";
while (prev !== result) {
prev = result;
result = expandSchemaInString(result, declaredTypes);
}
return result;
}
/**
* Single pass of type name expansion in a schema string.
*/
function expandSchemaInString(
schemaString: string,
declaredTypes: Map<string, string>,
): string {
// Check if the entire string is a type name
const expanded = expandTypeName(schemaString.trim(), declaredTypes);
if (expanded !== null) {
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)
if (schemaString.includes("|")) {
// Split by | but respect quotes
const parts = splitByToken(schemaString, "|");
if (parts.length > 1) {
const expandedParts = parts.map((part) =>
expandSchemaInString(part.trim(), declaredTypes),
);
return expandedParts.join(" | ");
}
}
// Handle tuple/array syntax [el1; el2; ...] or [elements]
// Check if it's a bracketed structure
if (schemaString.startsWith("[") && schemaString.endsWith("]")) {
const inner = schemaString.slice(1, -1);
// Check if it's semicolon-separated (tuple syntax)
if (inner.includes(";")) {
const elements = splitByToken(inner, ";");
const expandedElements = elements.map((el) =>
expandSchemaInString(el.trim(), declaredTypes),
);
return `[${expandedElements.join("; ")}]`;
}
// Otherwise it's a simple array, expand recursively
return `[${expandSchemaInString(inner, declaredTypes)}]`;
}
// Check if it's a type name reference (only uppercase start to avoid conflicts with primitives)
const typeNameMatch = schemaString.trim().match(/^[A-Z][a-zA-Z0-9]*$/);
if (typeNameMatch) {
const expanded = expandTypeName(schemaString.trim(), declaredTypes);
if (expanded !== null) {
return expanded;
}
}
return schemaString;
}
/**
* Split a string by a token, respecting quoted strings.
*/
function splitByToken(str: string, token: string): string[] {
const result: string[] = [];
let current = "";
let inQuote: string | null = null;
for (let i = 0; i < str.length; i++) {
const char = str[i];
if (inQuote) {
if (char === inQuote && str[i - 1] !== "\\") {
inQuote = null;
}
current += char;
} else if (char === '"' || char === "'") {
inQuote = char;
current += char;
} else if (char === token && inQuote === null) {
result.push(current);
current = "";
} else {
current += char;
}
}
if (current.length > 0 || str.endsWith(token)) {
result.push(current);
}
return result;
}
/**
* Resolve type name references within a schema using declared types.
* For example, if "Trigger" is a declared type, references to "Trigger" in
* other schemas will be replaced with the actual Trigger schema definition.
*/
export function resolveTypeReferences(
schema: Schema,
declaredTypes: Map<string, Schema>,
): Schema {
switch (schema.type) {
case "union":
return {
type: "union",
members: schema.members.map((m) =>
resolveTypeReferences(m, declaredTypes),
),
};
case "tuple":
return {
type: "tuple",
elements: schema.elements.map((el) => ({
name: el.name,
schema: resolveTypeReferences(el.schema, declaredTypes),
})),
};
case "array":
return {
type: "array",
element: resolveTypeReferences(schema.element, declaredTypes),
};
case "reference":
// Don't resolve references to other tables
return schema;
default:
return schema;
}
}
/**
* Resolve type name references in a type declaration's schema string.
* Called after all type names are known.
*/
export function resolveTypeDeclarationSchema(
schemaString: string,
declaredTypes: Map<string, Schema>,
): Schema {
const schema = parseSchema(schemaString.trim());
return resolveTypeReferences(schema, declaredTypes);
}
/**
* Parse a reverse reference declaration from a comment line.
* Format: # inject fieldName = ~tableName(foreignKey)
* Returns null if the line is not a reverse reference declaration.
*/
export function parseReverseReferenceDeclaration(
line: string,
commentChar: string = "#",
): ReverseReferenceDeclaration | null {
const trimmed = line.trim();
// Must start with the comment character
if (!trimmed.startsWith(commentChar)) return null;
const content = trimmed.slice(commentChar.length).trim();
// Match pattern: inject fieldName = ~tableName(foreignKey)
const match = content.match(/^inject\s+(\w+)\s*=\s*~(\w+)\((\w+)\)(\?)?$/);
if (!match) return null;
const [, fieldName, tableName, foreignKey, optionalMark] = match;
const isOptional = optionalMark === "?";
const schema: ReverseReferenceSchema = {
type: "reverseReference",
tableName,
foreignKey,
isOptional,
};
return {
fieldName,
tableName,
foreignKey,
isOptional,
schema,
};
}
+99
View File
@@ -0,0 +1,99 @@
import * as path from "path";
import { schemaToTypeString } from "../index.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
*/
export function generateTypeDefinition(
resourceName: string,
propertyConfigs: PropertyConfig[],
references: Set<string>,
currentFilePath?: string,
typeDeclarations: TypeDeclaration[] = [],
): string {
const typeName = resourceName ? `${resourceName}Table` : "Table";
const currentTableName = currentFilePath
? path.basename(currentFilePath, path.extname(currentFilePath))
: undefined;
const singularType = resourceName
? resourceName.charAt(0).toUpperCase() + resourceName.slice(1)
: `${typeName}[number]`;
// Generate import statements for referenced tables
const imports: string[] = [];
const resourceNames = new Map<string, string>();
references.forEach((tableName) => {
if (tableName === currentTableName) {
resourceNames.set(tableName, singularType);
return;
}
// Convert table name to type name by capitalizing
const typeBase = tableName.charAt(0).toUpperCase() + tableName.slice(1);
resourceNames.set(tableName, typeBase);
// Generate import path based on current file path
let importPath: string;
if (currentFilePath) {
importPath = `./${tableName}.csv`;
} else {
importPath = `../${tableName}.csv`;
}
imports.push(`import type { ${typeBase} } from '${importPath}';`);
});
const importSection = imports.length > 0 ? imports.join("\n") + "\n\n" : "";
// Generate type declarations for user-defined types
const typeDeclarationSection =
typeDeclarations.length > 0
? typeDeclarations
.map(
(decl) =>
`export type ${decl.name} = ${decl.schemaString ? resolveReferencesInSchemaString(decl.schemaString, resourceNames) : schemaToTypeString(decl.schema, resourceNames)};`,
)
.join("\n") + "\n\n"
: "";
const properties = propertyConfigs
.map((config) => {
const typeStr = config.declaredTypeName
? config.declaredTypeName
: config.schemaString
? resolveReferencesInSchemaString(config.schemaString, resourceNames)
: schemaToTypeString(config.schema, resourceNames);
return ` readonly ${config.name}: ${typeStr};`;
})
.join("\n");
let exportAlias = "";
if (resourceName) {
const singularType =
resourceName.charAt(0).toUpperCase() + resourceName.slice(1);
exportAlias = `\nexport type ${singularType} = ${typeName}[number];`;
}
return `${importSection}${typeDeclarationSection}type ${typeName} = readonly {
${properties}
}[];
${exportAlias}
declare function getData(): ${typeName};
export default getData;
`;
}
+107
View File
@@ -0,0 +1,107 @@
import type {
Schema,
ReferenceSchema,
ReverseReferenceSchema,
} from "../types.js";
export interface CsvLoaderOptions {
delimiter?: string;
quote?: string;
escape?: string;
bom?: boolean;
comment?: string | false;
trim?: boolean;
/** Generate TypeScript declaration file (.d.ts) */
emitTypes?: boolean;
/** Output directory for generated type files (relative to output path) */
typesOutputDir?: string;
/** Write .d.ts files to disk (useful for dev server) */
writeToDisk?: boolean;
/** Base directory for resolving referenced CSV files (default: directory of current file) */
refBaseDir?: string;
/** Primary key field name for referenced tables (default: 'id') */
defaultPrimaryKey?: string;
/** Current file path (used to resolve relative references) */
currentFilePath?: string;
/**
* When false, reference fields store parsed IDs instead of resolved objects.
* Used by csvToModule to emit accessor-based code with lazy resolution.
* Default: true (resolves references eagerly by loading referenced CSV files).
*/
resolveReferences?: boolean;
}
export interface ReferenceFieldInfo {
/** Column name in the CSV */
name: string;
/** Referenced table name */
tableName: string;
/** Whether it's an array reference */
isArray: boolean;
/** The schema of this field (for nested references) */
schema: Schema;
/** For reverse references: the foreign key field name in the referenced table */
foreignKey?: string;
}
export interface CsvParseResult {
/** Parsed CSV data as array of objects */
data: Record<string, unknown>[];
/** Generated TypeScript type definition string (if emitTypes is true) */
typeDefinition?: string;
/** Property configurations for the CSV columns */
propertyConfigs: PropertyConfig[];
/** Referenced table names */
references: Set<string>;
/** Reference field metadata (populated when resolveReferences is false) */
referenceFields: ReferenceFieldInfo[];
/** Reverse reference declarations parsed from comment lines */
reverseReferences: ReverseReferenceDeclaration[];
/** Type declarations parsed from comment lines */
typeDeclarations: TypeDeclaration[];
}
export interface PropertyConfig {
name: string;
schema: any;
validator: (value: unknown) => boolean;
parser: (valueString: string) => unknown;
/** Whether this property is a reference to another table */
isReference?: boolean;
/** Referenced table name (if isReference is true) */
referenceTableName?: string;
/** Whether it's an array reference */
referenceIsArray?: boolean;
/** Whether this is a reverse reference (one-to-many) */
isReverseReference?: boolean;
/** Foreign key field name for reverse references */
reverseReferenceForeignKey?: string;
/** When a column uses a declared type name, this stores that name */
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 */
export interface ReverseReferenceDeclaration {
/** Field name in the current table */
fieldName: string;
/** Referenced table name */
tableName: string;
/** Foreign key field name in the referenced table */
foreignKey: string;
/** Whether it's optional */
isOptional: boolean;
/** The parsed schema */
schema: ReverseReferenceSchema;
}
/** Parsed type declaration from a comment line */
export interface TypeDeclaration {
/** Name of the type being defined */
name: string;
/** The original schema string (preserves type name references for output) */
schemaString: string;
/** The parsed schema for this type */
schema: Schema;
}
+18 -14
View File
@@ -1,8 +1,8 @@
import type { LoaderContext } from '@rspack/core';
import type { CsvLoaderOptions } from './loader.js';
import { csvToModule } from './loader.js';
import * as path from 'path';
import * as fs from 'fs';
import type { LoaderContext } from "@rspack/core";
import * as path from "path";
import * as fs from "fs";
import { csvToModule } from "./module-gen";
import { CsvLoaderOptions } from "./types";
export interface CsvWebpackLoaderOptions extends CsvLoaderOptions {
/** Output directory for generated type files (relative to output path) */
@@ -17,17 +17,17 @@ export interface CsvWebpackLoaderOptions extends CsvLoaderOptions {
export default function csvLoader(
this: LoaderContext<CsvWebpackLoaderOptions>,
content: string
content: string,
): string | Buffer {
const options = this.getOptions() as CsvWebpackLoaderOptions | undefined;
const emitTypes = options?.emitTypes ?? true;
const typesOutputDir = options?.typesOutputDir ?? '';
const typesOutputDir = options?.typesOutputDir ?? "";
const writeToDisk = options?.writeToDisk ?? false;
// Infer resource name from filename
const fileName = path.basename(this.resourcePath, '.csv').split('.')[0];
const fileName = path.basename(this.resourcePath, ".csv").split(".")[0];
const resourceName = fileName
.replace(/[-_\s]+(.)?/g, (_, char) => char ? char.toUpperCase() : '')
.replace(/[-_\s]+(.)?/g, (_, char) => (char ? char.toUpperCase() : ""))
.replace(/^(.)/, (_, char) => char.toUpperCase());
const result = csvToModule(content, {
@@ -38,13 +38,13 @@ export default function csvLoader(
// Emit type definition file if enabled
if (emitTypes && result.dts) {
const context = this.context || '';
const context = this.context || "";
// Get relative path from context, normalize to forward slashes
let relativePath = this.resourcePath.replace(context, '');
if (relativePath.startsWith('\\') || relativePath.startsWith('/')) {
let relativePath = this.resourcePath.replace(context, "");
if (relativePath.startsWith("\\") || relativePath.startsWith("/")) {
relativePath = relativePath.substring(1);
}
relativePath = relativePath.replace(/\\/g, '/');
relativePath = relativePath.replace(/\\/g, "/");
// Replace .csv with .csv.d.ts for the output filename
const dtsFileName = `${relativePath}.d.ts`;
@@ -54,7 +54,11 @@ export default function csvLoader(
if (writeToDisk) {
// Write directly to disk (useful for dev server)
const absolutePath = path.join(this.context || process.cwd(), typesOutputDir || '', dtsFileName);
const absolutePath = path.join(
this.context || process.cwd(),
typesOutputDir || "",
dtsFileName,
);
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
fs.writeFileSync(absolutePath, result.dts);
} else {
+13 -9
View File
@@ -174,10 +174,11 @@ describe("Tuples", () => {
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 value = schema.parse("hello; 42");
const value = schema.parse("[hello; 42]");
expect(value).toEqual(["hello", 42]);
expect(() => schema.parse("hello; 42")).toThrow(ParseError);
});
it("should parse named tuple", () => {
@@ -212,10 +213,11 @@ describe("Arrays", () => {
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 value = schema.parse("hello; world; test");
const value = schema.parse("[hello; world; test]");
expect(value).toEqual(["hello", "world", "test"]);
expect(() => schema.parse("hello; world; test")).toThrow(ParseError);
});
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 value = schema.parse("[a; 1]; [b; 2]; [c; 3]");
const value = schema.parse("[[a; 1]; [b; 2]; [c; 3]]");
expect(value).toEqual([
["a", 1],
["b", 2],
["c", 3],
]);
expect(() => schema.parse("[a; 1]; [b; 2]; [c; 3]")).toThrow(ParseError);
});
it("should parse array of unions", () => {
@@ -283,7 +286,7 @@ describe("Escaping", () => {
it("should handle escaped semicolon in tuple", () => {
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"]);
});
});
@@ -682,14 +685,15 @@ describe("Reference value parsing (parseValue)", () => {
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 = {
type: "reference",
tableName: "users",
isArray: true,
};
const result = parseValue(schema, "1; 2; 3");
const result = parseValue(schema, "[1; 2; 3]");
expect(result).toEqual(["1", "2", "3"]);
expect(() => parseValue(schema, "1; 2; 3")).toThrow(ParseError);
});
it("should parse empty array reference", () => {
+14 -17
View File
@@ -14,10 +14,20 @@ export class ParseError extends Error {
constructor(
message: string,
public position?: number,
public schema?: string,
public value?: string,
) {
super(
position !== undefined ? `${message} at position ${position}` : message,
);
let fullMessage = 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";
}
}
@@ -240,20 +250,7 @@ class Parser {
throw new ParseError("Expected ]", this.pos);
}
if (this.consumeStr("[")) {
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 };
}
// [t] is always a 1-tuple (the [Type][] array form was removed)
return { type: "tuple", elements };
}
+104
View File
@@ -0,0 +1,104 @@
import type {
Schema,
ReferenceSchema,
ReverseReferenceSchema,
UnionSchema,
} from "./types";
export function schemaToTypeString(
schema: Schema,
resourceNames?: Map<string, string>,
): string {
switch (schema.type) {
case "string":
return "string";
case "number":
case "int":
case "float":
return "number";
case "boolean":
return "boolean";
case "stringLiteral":
return `"${schema.value}"`;
case "union":
return schema.members
.map((m) => schemaToTypeString(m, resourceNames))
.join(" | ");
case "reference": {
const typeName =
resourceNames?.get(schema.tableName) ||
schema.tableName.charAt(0).toUpperCase() + schema.tableName.slice(1);
const baseType = schema.isArray ? `${typeName}[]` : typeName;
return schema.isOptional ? `${baseType} | null` : baseType;
}
case "reverseReference": {
const typeName =
resourceNames?.get(schema.tableName) ||
schema.tableName.charAt(0).toUpperCase() + schema.tableName.slice(1);
const baseType = `${typeName}[]`;
return schema.isOptional ? `${baseType} | null` : baseType;
}
case "array":
const elementType = schemaToTypeString(schema.element, resourceNames);
if (schema.element.type === "union") {
return `(${elementType})[]`;
}
return `${elementType}[]`;
case "tuple":
const tupleElements = schema.elements.map((el) => {
const typeStr = schemaToTypeString(el.schema, resourceNames);
return el.name ? `${el.name}: ${typeStr}` : typeStr;
});
return `[${tupleElements.join(", ")}]`;
default:
return "unknown";
}
}
export function createValidator(schema: Schema): (value: unknown) => boolean {
return function validate(value: unknown): boolean {
switch (schema.type) {
case "string":
return typeof value === "string";
case "number":
return typeof value === "number" && !isNaN(value);
case "int":
return (
typeof value === "number" && !isNaN(value) && Number.isInteger(value)
);
case "float":
return typeof value === "number" && !isNaN(value);
case "boolean":
return typeof value === "boolean";
case "stringLiteral":
return typeof value === "string" && value === schema.value;
case "union":
return schema.members.some((member) => createValidator(member)(value));
case "tuple":
if (!Array.isArray(value)) return false;
if (value.length !== schema.elements.length) return false;
return schema.elements.every((elementSchema, index) =>
createValidator(elementSchema.schema)(value[index]),
);
case "array":
if (!Array.isArray(value)) return false;
return value.every((item) => createValidator(schema.element)(item));
case "reference":
if (schema.isOptional && value === null) return true;
if (schema.isArray) {
return (
Array.isArray(value) && value.every((id) => typeof id === "string")
);
}
return (
typeof value === "string" ||
(Array.isArray(value) && value.every((id) => typeof id === "string"))
);
case "reverseReference":
if (schema.isOptional && value === null) return true;
return Array.isArray(value);
default:
return false;
}
};
}
+3 -540
View File
@@ -1,540 +1,3 @@
import type {
Schema,
PrimitiveSchema,
TupleSchema,
ArraySchema,
NamedSchema,
ReferenceSchema,
ReverseReferenceSchema,
StringLiteralSchema,
UnionSchema,
} from "./types";
import { ParseError } from "./parser";
class ValueParser {
private input: string;
private pos: number = 0;
constructor(input: string) {
this.input = input;
}
private peek(): string {
return this.input[this.pos] || "";
}
private consume(): string {
return this.input[this.pos++] || "";
}
private skipWhitespace(): void {
while (this.pos < this.input.length && /\s/.test(this.input[this.pos])) {
this.pos++;
}
}
private consumeStr(str: string): boolean {
if (this.input.slice(this.pos, this.pos + str.length) === str) {
this.pos += str.length;
return true;
}
return false;
}
parseValue(schema: Schema, allowOmitBrackets: boolean = false): unknown {
this.skipWhitespace();
switch (schema.type) {
case "string":
return this.parseStringValue();
case "number":
return this.parseNumberValue();
case "int":
return this.parseIntValue();
case "float":
return this.parseFloatValue();
case "boolean":
return this.parseBooleanValue();
case "stringLiteral":
return this.parseStringLiteralValue(schema);
case "union":
return this.parseUnionValue(schema);
case "tuple":
return this.parseTupleValue(schema, allowOmitBrackets);
case "array":
return this.parseArrayValue(schema, allowOmitBrackets);
case "reference":
// Reference values are parsed as strings (IDs) initially, resolved later
return this.parseReferenceValue(schema);
case "reverseReference":
// Reverse references are derived fields, not stored in CSV cells
// They resolve to null at parse time; actual resolution happens in the loader
return null;
default:
throw new ParseError(
`Unknown schema type: ${(schema as { type: string }).type}`,
this.pos,
);
}
}
private parseStringValue(): string {
let result = "";
while (this.pos < this.input.length) {
const char = this.peek();
if (char === "\\") {
this.consume();
const nextChar = this.consume();
if (
nextChar === ";" ||
nextChar === "[" ||
nextChar === "]" ||
nextChar === "\\"
) {
result += nextChar;
} else {
result += "\\" + nextChar;
}
} else if (char === ";" || char === "]") {
break;
} else {
result += this.consume();
}
}
return result.trim();
}
private parseNumberValue(): number {
let numStr = "";
while (this.pos < this.input.length && /[\d.\-+eE]/.test(this.peek())) {
numStr += this.consume();
}
const num = parseFloat(numStr);
if (isNaN(num)) {
throw new ParseError("Invalid number", this.pos - numStr.length);
}
return num;
}
private parseIntValue(): number {
let numStr = "";
while (this.pos < this.input.length && /[\d.\-+eE]/.test(this.peek())) {
numStr += this.consume();
}
const num = parseFloat(numStr);
if (isNaN(num)) {
throw new ParseError("Invalid number", this.pos - numStr.length);
}
if (!Number.isInteger(num)) {
throw new ParseError("Expected integer value", this.pos - numStr.length);
}
return num;
}
private parseFloatValue(): number {
return this.parseNumberValue();
}
private parseBooleanValue(): boolean {
if (this.consumeStr("true")) {
return true;
}
if (this.consumeStr("false")) {
return false;
}
throw new ParseError("Expected true or false", this.pos);
}
private parseStringLiteralValue(schema: StringLiteralSchema): string {
const quote = this.peek();
// 支持带引号或不带引号的字符串值
if (quote === '"' || quote === "'") {
this.consume(); // Consume opening quote
let value = "";
while (this.pos < this.input.length) {
const char = this.peek();
if (char === "\\") {
this.consume();
const nextChar = this.consume();
if (
nextChar === '"' ||
nextChar === "'" ||
nextChar === "\\" ||
nextChar === ";"
) {
value += nextChar;
} else {
value += "\\" + nextChar;
}
} else if (char === quote) {
this.consume(); // Consume closing quote
if (value !== schema.value) {
throw new ParseError(
`Invalid value '"${value}"'. Expected '"${schema.value}"'`,
this.pos,
);
}
return value;
} else {
value += this.consume();
}
}
throw new ParseError("Unterminated string literal", this.pos);
} else {
// 不带引号的字符串,像普通字符串一样解析
let value = "";
while (this.pos < this.input.length) {
const char = this.peek();
if (char === ";" || char === "]" || char === ")") {
break;
}
value += this.consume();
}
value = value.trim();
if (value !== schema.value) {
throw new ParseError(
`Invalid value '${value}'. Expected '${schema.value}'`,
this.pos - value.length,
);
}
return value;
}
}
private parseUnionValue(schema: UnionSchema): unknown {
const savedPos = this.pos;
const errors: Error[] = [];
// Try each union member until one succeeds
for (let i = 0; i < schema.members.length; i++) {
this.pos = savedPos;
try {
return this.parseValue(schema.members[i], false);
} catch (e) {
errors.push(e as Error);
// Continue to next member
}
}
// If all members fail, throw a descriptive error
throw new ParseError(
`Value does not match any union member. Tried ${schema.members.length} alternatives.`,
this.pos,
);
}
private parseTupleValue(
schema: TupleSchema,
allowOmitBrackets: boolean,
): unknown[] {
let hasOpenBracket = false;
if (this.peek() === "[") {
this.consume();
hasOpenBracket = true;
} else if (!allowOmitBrackets) {
throw new ParseError("Expected [", this.pos);
}
this.skipWhitespace();
if (this.peek() === "]" && hasOpenBracket) {
this.consume();
return [];
}
const result: unknown[] = [];
for (let i = 0; i < schema.elements.length; i++) {
this.skipWhitespace();
const elementSchema = schema.elements[i];
// Try to consume optional name prefix (e.g., "current:")
if (elementSchema.name) {
this.skipWhitespace();
const savedPos = this.pos;
if (this.consumeStr(`${elementSchema.name}:`)) {
this.skipWhitespace();
} else {
// Name not found, reset position and continue without name
this.pos = savedPos;
}
}
result.push(this.parseValue(elementSchema.schema, false));
this.skipWhitespace();
if (i < schema.elements.length - 1) {
if (!this.consumeStr(";")) {
throw new ParseError("Expected ;", this.pos);
}
}
}
this.skipWhitespace();
if (hasOpenBracket) {
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
}
return result;
}
private parseArrayValue(
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()) {
return [];
}
if (this.peek() === "[") {
if (!elementIsTupleOrArray) {
this.consume();
hasOpenBracket = true;
} else if (this.input[this.pos + 1] === "[") {
this.consume();
hasOpenBracket = true;
}
}
if (!hasOpenBracket && !allowOmitBrackets && !elementIsTupleOrArray) {
throw new ParseError("Expected [", this.pos);
}
this.skipWhitespace();
if (this.peek() === "]" && hasOpenBracket) {
this.consume();
return [];
}
const result: unknown[] = [];
while (true) {
this.skipWhitespace();
result.push(this.parseValue(schema.element, false));
this.skipWhitespace();
if (!this.consumeStr(";")) {
break;
}
}
this.skipWhitespace();
if (hasOpenBracket) {
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
}
return result;
}
private parseReferenceValue(
schema: ReferenceSchema,
): string | string[] | null {
if (schema.isOptional) {
this.skipWhitespace();
if (this.pos >= this.input.length) {
return null;
}
}
if (schema.isArray) {
// Parse array of IDs: [id1; id2; id3]
let hasOpenBracket = false;
if (this.peek() === "[") {
this.consume();
hasOpenBracket = true;
}
this.skipWhitespace();
if (this.peek() === "]" && hasOpenBracket) {
this.consume();
return [];
}
const ids: string[] = [];
while (true) {
this.skipWhitespace();
// Parse each ID as a string
let id = "";
while (
this.pos < this.input.length &&
this.peek() !== ";" &&
this.peek() !== "]"
) {
id += this.consume();
}
ids.push(id.trim());
this.skipWhitespace();
if (!this.consumeStr(";")) {
break;
}
}
if (hasOpenBracket) {
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
}
return ids;
} else {
// Parse single ID as string
let id = "";
while (this.pos < this.input.length) {
const char = this.peek();
if (char === ";" || char === "]" || char === ",") {
break;
}
id += this.consume();
}
return id.trim();
}
}
getPosition(): number {
return this.pos;
}
getInputLength(): number {
return this.input.length;
}
}
export function parseValue(schema: Schema, valueString: string): unknown {
const parser = new ValueParser(valueString.trim());
const allowOmitBrackets = schema.type === "tuple" || schema.type === "array";
const value = parser.parseValue(schema, allowOmitBrackets);
if (parser.getPosition() < parser.getInputLength()) {
throw new ParseError("Unexpected input after value", parser.getPosition());
}
return value;
}
export function schemaToTypeString(
schema: Schema,
resourceNames?: Map<string, string>,
): string {
switch (schema.type) {
case "string":
return "string";
case "number":
case "int":
case "float":
return "number";
case "boolean":
return "boolean";
case "stringLiteral":
return `"${schema.value}"`;
case "union":
return schema.members
.map((m) => schemaToTypeString(m, resourceNames))
.join(" | ");
case "reference": {
const typeName =
resourceNames?.get(schema.tableName) ||
schema.tableName.charAt(0).toUpperCase() + schema.tableName.slice(1);
const baseType = schema.isArray ? `${typeName}[]` : typeName;
return schema.isOptional ? `${baseType} | null` : baseType;
}
case "reverseReference": {
const typeName =
resourceNames?.get(schema.tableName) ||
schema.tableName.charAt(0).toUpperCase() + schema.tableName.slice(1);
// Reverse references always resolve to an array (one-to-many)
const baseType = `${typeName}[]`;
return schema.isOptional ? `${baseType} | null` : baseType;
}
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);
if (schema.element.type === "union") {
return `(${elementType})[]`;
}
return `${elementType}[]`;
case "tuple":
const tupleElements = schema.elements.map((el) => {
const typeStr = schemaToTypeString(el.schema, resourceNames);
return el.name ? `${el.name}: ${typeStr}` : typeStr;
});
return `[${tupleElements.join(", ")}]`;
default:
return "unknown";
}
}
export function createValidator(schema: Schema): (value: unknown) => boolean {
return function validate(value: unknown): boolean {
switch (schema.type) {
case "string":
return typeof value === "string";
case "number":
return typeof value === "number" && !isNaN(value);
case "int":
return (
typeof value === "number" && !isNaN(value) && Number.isInteger(value)
);
case "float":
return typeof value === "number" && !isNaN(value);
case "boolean":
return typeof value === "boolean";
case "stringLiteral":
return typeof value === "string" && value === schema.value;
case "union":
return schema.members.some((member) => createValidator(member)(value));
case "tuple":
if (!Array.isArray(value)) return false;
if (value.length !== schema.elements.length) return false;
return schema.elements.every((elementSchema, index) =>
createValidator(elementSchema.schema)(value[index]),
);
case "array":
if (!Array.isArray(value)) return false;
return value.every((item) => createValidator(schema.element)(item));
case "reference":
if (schema.isOptional && value === null) return true;
if (schema.isArray) {
return (
Array.isArray(value) && value.every((id) => typeof id === "string")
);
}
return (
typeof value === "string" ||
(Array.isArray(value) && value.every((id) => typeof id === "string"))
);
case "reverseReference":
if (schema.isOptional && value === null) return true;
return Array.isArray(value);
default:
return false;
}
};
}
// Re-export everything from the split modules
export { parseValue } from "./value-parser";
export { schemaToTypeString, createValidator } from "./type-utils";
+481
View File
@@ -0,0 +1,481 @@
import type {
Schema,
TupleSchema,
ArraySchema,
ReferenceSchema,
StringLiteralSchema,
UnionSchema,
} from "./types";
import { ParseError } from "./parser";
import { schemaToTypeString } from "./type-utils";
class ValueParser {
private input: string;
private schemaString: string;
private pos: number = 0;
constructor(input: string, schemaString: string) {
this.input = input;
this.schemaString = schemaString;
}
private peek(): string {
return this.input[this.pos] || "";
}
private consume(): string {
return this.input[this.pos++] || "";
}
private skipWhitespace(): void {
while (this.pos < this.input.length && /\s/.test(this.input[this.pos])) {
this.pos++;
}
}
private consumeStr(str: string): boolean {
if (this.input.slice(this.pos, this.pos + str.length) === str) {
this.pos += str.length;
return true;
}
return false;
}
parseValue(schema: Schema): unknown {
this.skipWhitespace();
switch (schema.type) {
case "string":
return this.parseStringValue();
case "number":
return this.parseNumberValue();
case "int":
return this.parseIntValue();
case "float":
return this.parseFloatValue();
case "boolean":
return this.parseBooleanValue();
case "stringLiteral":
return this.parseStringLiteralValue(schema);
case "union":
return this.parseUnionValue(schema);
case "tuple":
return this.parseTupleValue(schema);
case "array":
return this.parseArrayValue(schema);
case "reference":
// Reference values are parsed as strings (IDs) initially, resolved later
return this.parseReferenceValue(schema);
case "reverseReference":
// Reverse references are derived fields, not stored in CSV cells
// They resolve to null at parse time; actual resolution happens in the loader
return null;
default:
throw new ParseError(
`Unknown schema type: ${(schema as { type: string }).type}`,
this.pos,
this.schemaString,
this.input,
);
}
}
private parseStringValue(): string {
let result = "";
while (this.pos < this.input.length) {
const char = this.peek();
if (char === "\\") {
this.consume();
const nextChar = this.consume();
if (
nextChar === ";" ||
nextChar === "[" ||
nextChar === "]" ||
nextChar === "\\"
) {
result += nextChar;
} else {
result += "\\" + nextChar;
}
} else if (char === ";" || char === "]") {
break;
} else {
result += this.consume();
}
}
return result.trim();
}
private parseNumberValue(): number {
let numStr = "";
while (this.pos < this.input.length && /[\d.\-+eE]/.test(this.peek())) {
numStr += this.consume();
}
const num = parseFloat(numStr);
if (isNaN(num)) {
throw new ParseError(
"Invalid number",
this.pos - numStr.length,
this.schemaString,
this.input,
);
}
return num;
}
private parseIntValue(): number {
let numStr = "";
while (this.pos < this.input.length && /[\d.\-+eE]/.test(this.peek())) {
numStr += this.consume();
}
const num = parseFloat(numStr);
if (isNaN(num)) {
throw new ParseError(
"Invalid number",
this.pos - numStr.length,
this.schemaString,
this.input,
);
}
if (!Number.isInteger(num)) {
throw new ParseError(
"Expected integer value",
this.pos - numStr.length,
this.schemaString,
this.input,
);
}
return num;
}
private parseFloatValue(): number {
return this.parseNumberValue();
}
private parseBooleanValue(): boolean {
if (this.consumeStr("true")) {
return true;
}
if (this.consumeStr("false")) {
return false;
}
throw new ParseError(
"Expected true or false",
this.pos,
this.schemaString,
this.input,
);
}
private parseStringLiteralValue(schema: StringLiteralSchema): string {
const quote = this.peek();
// 支持带引号或不带引号的字符串值
if (quote === '"' || quote === "'") {
this.consume(); // Consume opening quote
let value = "";
while (this.pos < this.input.length) {
const char = this.peek();
if (char === "\\") {
this.consume();
const nextChar = this.consume();
if (
nextChar === '"' ||
nextChar === "'" ||
nextChar === "\\" ||
nextChar === ";"
) {
value += nextChar;
} else {
value += "\\" + nextChar;
}
} else if (char === quote) {
this.consume(); // Consume closing quote
if (value !== schema.value) {
throw new ParseError(
`Invalid value '"${value}"'. Expected '"${schema.value}"'`,
this.pos,
this.schemaString,
this.input,
);
}
return value;
} else {
value += this.consume();
}
}
throw new ParseError(
"Unterminated string literal",
this.pos,
this.schemaString,
this.input,
);
} else {
// 不带引号的字符串,像普通字符串一样解析
let value = "";
while (this.pos < this.input.length) {
const char = this.peek();
if (char === ";" || char === "]" || char === ")") {
break;
}
value += this.consume();
}
value = value.trim();
if (value !== schema.value) {
throw new ParseError(
`Invalid value '${value}'. Expected '${schema.value}'`,
this.pos - value.length,
this.schemaString,
this.input,
);
}
return value;
}
}
private parseUnionValue(schema: UnionSchema): unknown {
const savedPos = this.pos;
const errors: Error[] = [];
// Try each union member until one succeeds
for (let i = 0; i < schema.members.length; i++) {
this.pos = savedPos;
try {
return this.parseValue(schema.members[i]);
} catch (e) {
errors.push(e as Error);
// Continue to next member
}
}
// If all members fail, throw a descriptive error
throw new ParseError(
`Value does not match any union member. Tried ${schema.members.length} alternatives.`,
this.pos,
this.schemaString,
this.input,
);
}
private parseTupleValue(schema: TupleSchema): unknown[] {
if (!this.consumeStr("[")) {
throw new ParseError(
"Expected [",
this.pos,
this.schemaString,
this.input,
);
}
this.skipWhitespace();
if (this.peek() === "]") {
this.consume();
return [];
}
const result: unknown[] = [];
for (let i = 0; i < schema.elements.length; i++) {
this.skipWhitespace();
const elementSchema = schema.elements[i];
// Try to consume optional name prefix (e.g., "current:")
if (elementSchema.name) {
this.skipWhitespace();
const savedPos = this.pos;
if (this.consumeStr(`${elementSchema.name}:`)) {
this.skipWhitespace();
} else {
// Name not found, reset position and continue without name
this.pos = savedPos;
}
}
result.push(this.parseValue(elementSchema.schema));
this.skipWhitespace();
if (i < schema.elements.length - 1) {
if (!this.consumeStr(";")) {
throw new ParseError(
"Expected ;",
this.pos,
this.schemaString,
this.input,
);
}
}
}
this.skipWhitespace();
if (!this.consumeStr("]")) {
throw new ParseError(
"Expected ]",
this.pos,
this.schemaString,
this.input,
);
}
return result;
}
private parseArrayValue(schema: ArraySchema): unknown[] {
if (this.pos >= this.input.length || !this.input.trim()) {
return [];
}
if (!this.consumeStr("[")) {
throw new ParseError(
"Expected [",
this.pos,
this.schemaString,
this.input,
);
}
this.skipWhitespace();
if (this.peek() === "]") {
this.consume();
return [];
}
const result: unknown[] = [];
while (true) {
this.skipWhitespace();
result.push(this.parseValue(schema.element));
this.skipWhitespace();
if (!this.consumeStr(";")) {
break;
}
}
this.skipWhitespace();
if (!this.consumeStr("]")) {
throw new ParseError(
"Expected ]",
this.pos,
this.schemaString,
this.input,
);
}
return result;
}
private parseReferenceValue(
schema: ReferenceSchema,
): string | string[] | null {
if (schema.isOptional) {
this.skipWhitespace();
if (this.pos >= this.input.length) {
return null;
}
}
if (schema.isArray) {
// Parse array of IDs: [id1; id2; id3]
if (!this.consumeStr("[")) {
throw new ParseError(
"Expected [",
this.pos,
this.schemaString,
this.input,
);
}
this.skipWhitespace();
if (this.peek() === "]") {
this.consume();
return [];
}
const ids: string[] = [];
while (true) {
this.skipWhitespace();
// Parse each ID as a string
let id = "";
while (
this.pos < this.input.length &&
this.peek() !== ";" &&
this.peek() !== "]"
) {
id += this.consume();
}
ids.push(id.trim());
this.skipWhitespace();
if (!this.consumeStr(";")) {
break;
}
}
this.skipWhitespace();
if (!this.consumeStr("]")) {
throw new ParseError(
"Expected ]",
this.pos,
this.schemaString,
this.input,
);
}
return ids;
} else {
// Parse single ID as string
let id = "";
while (this.pos < this.input.length) {
const char = this.peek();
if (char === ";" || char === "]" || char === ",") {
break;
}
id += this.consume();
}
return id.trim();
}
}
getPosition(): number {
return this.pos;
}
getInputLength(): number {
return this.input.length;
}
}
export function parseValue(
schema: Schema,
valueString: string,
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()) {
throw new ParseError(
"Unexpected input after value",
parser.getPosition(),
sStr,
valueString.trim(),
);
}
return value;
}