Compare commits

...
47 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
hypercross e76ae79b2d Add reverse reference schema (~tablename(foreignKey))
Support reverse references via ~tablename(foreignKey) syntax,
complementing forward @tablename references. Includes parser,
validator, and CSV loader integration with the new
ReverseReferenceSchema type.
2026-04-18 22:47:58 +08:00
hypercross 0954dcf594 fix: avoid exporting loader in index 2026-04-17 17:27:19 +08:00
hypercross 740bb503b1 fix: fix type generator for tuples 2026-04-17 15:11:46 +08:00
hypercross c8f0d6b0de refactor: avoid readonly in nested types 2026-04-17 14:53:49 +08:00
hypercross 075045223f feat: add @type? optional notation 2026-04-17 11:41:06 +08:00
hypercross 1f3a812728 fix: all tables generate accessors 2026-04-15 14:52:41 +08:00
hypercross 852a108c53 fix: update type generation 2026-04-15 14:46:03 +08:00
hypercross 6eba70bb3b refactor: accessor based imports 2026-04-15 14:36:52 +08:00
hypercross 392d5f1431 fix: fix minor gotchas 2026-04-15 14:12:16 +08:00
hypercross ae2445b79c feat: add AGENTS.md 2026-04-15 14:01:50 +08:00
41 changed files with 5287 additions and 2202 deletions
+43
View File
@@ -0,0 +1,43 @@
# AGENTS.md
## Commands
- **Build:** `npm run build` (tsup, CJS + ESM + d.ts for all entry points)
- **Test:** `npm run test` (vitest run) | `npm run test:watch` (vitest watch)
- **Type check:** `npm run typecheck`
- **Run a single test:** `npx vitest run -t "test name pattern"`
No linter or formatter is configured. No CI pipeline exists.
## Architecture
Single-package TypeScript library with two runtime entry points:
- **`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`
- **`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`
Build produces separate bundles per entry point (see `tsup.config.ts`). The csv-loader entries externalize `csv-parse`, `@rspack/core`, `esbuild`, and `rollup`.
## Key conventions
- 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
- References can appear nested inside tuples, arrays, and unions; the loader resolves them recursively
## Gotchas
- **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** — 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 ## Installation
```bash ```bash
npm install inline-schema npm install typed-csv
``` ```
## Usage ## Usage
@@ -13,7 +13,7 @@ npm install inline-schema
### Basic Example ### Basic Example
```typescript ```typescript
import { defineSchema } from 'inline-schema'; import { defineSchema } from 'typed-csv';
// Define a schema // Define a schema
const stringSchema = defineSchema('string'); const stringSchema = defineSchema('string');
@@ -35,14 +35,9 @@ numberSchema.validator(name); // false
```typescript ```typescript
const tupleSchema = defineSchema('[string; number; boolean]'); const tupleSchema = defineSchema('[string; number; boolean]');
// With brackets
const value1 = tupleSchema.parse('[hello; 42; true]'); const value1 = tupleSchema.parse('[hello; 42; true]');
// ["hello", 42, true] // ["hello", 42, true]
// Without brackets (outermost brackets are optional)
const value2 = tupleSchema.parse('hello; 42; true');
// ["hello", 42, true]
tupleSchema.validator(value1); // true tupleSchema.validator(value1); // true
tupleSchema.validator(['a', 'b', true]); // false (second element should be number) tupleSchema.validator(['a', 'b', true]); // false (second element should be number)
``` ```
@@ -50,16 +45,11 @@ tupleSchema.validator(['a', 'b', true]); // false (second element should be num
### Arrays ### Arrays
```typescript ```typescript
// Array syntax: Type[] or [Type][] // Array syntax: Type[]
const stringArray = defineSchema('string[]'); const stringArray = defineSchema('string[]');
const numberArray = defineSchema('[number][]'); const numberArray = defineSchema('number[]');
// With brackets const names = stringArray.parse('[alice; bob; charlie]');
const names1 = stringArray.parse('[alice; bob; charlie]');
// ["alice", "bob", "charlie"]
// Without brackets (outermost brackets are optional)
const names2 = stringArray.parse('alice; bob; charlie');
// ["alice", "bob", "charlie"] // ["alice", "bob", "charlie"]
const numbers = numberArray.parse('[1; 2; 3; 4; 5]'); const numbers = numberArray.parse('[1; 2; 3; 4; 5]');
@@ -71,12 +61,7 @@ const numbers = numberArray.parse('[1; 2; 3; 4; 5]');
```typescript ```typescript
const schema = defineSchema('[string; number][]'); const schema = defineSchema('[string; number][]');
// With outer brackets const data = schema.parse('[[a; 1]; [b; 2]; [c; 3]]');
const data1 = schema.parse('[[a; 1]; [b; 2]; [c; 3]]');
// [["a", 1], ["b", 2], ["c", 3]]
// Without outer brackets
const data2 = schema.parse('[a; 1]; [b; 2]; [c; 3]');
// [["a", 1], ["b", 2], ["c", 3]] // [["a", 1], ["b", 2], ["c", 3]]
``` ```
@@ -128,27 +113,63 @@ Parses a value string according to the given schema.
Creates a validation function for the given schema. Creates a validation function for the given schema.
### `schemaToTypeString(schema: Schema): string`
Converts a schema AST back to a human-readable type string.
### `ParseError`
Error class thrown for invalid schema syntax.
### Types
The following TypeScript types are exported:
- `Schema` — union of all schema AST node types
- `ParsedSchema` — the return type of `defineSchema()`
- `PrimitiveSchema`, `TupleSchema`, `ArraySchema` — AST node types
- `ReferenceSchema`, `ReverseReferenceSchema` — reference AST node types
- `StringLiteralSchema`, `UnionSchema` — literal and union AST node types
## Schema Syntax ## Schema Syntax
| Type | Schema | Example Value | | Type | Schema | Example Value |
|------|--------|---------------| |------|--------|---------------|
| String | `string` or `identifier` | `hello` | | String | `string` or `identifier` | `hello` |
| Number | `number` | `42` | | Int | `int` | `42` |
| Float | `float` | `3.14` |
| Number | `number` | `42` or `3.14` |
> **Note:** `int`, `float`, and `number` all collapse to the `number` type in generated TypeScript declarations (`schemaToTypeString`). The distinction is preserved at parse time — `int` rejects non-integer values while `float`/`number` accept them.
| Boolean | `boolean` | `true` or `false` | | Boolean | `boolean` | `true` or `false` |
| Tuple | `[Type1; Type2; ...]` | `[hello; 42; true]` or `hello; 42; true` | | Tuple | `[Type1; Type2; ...]` | `[hello; 42; true]` |
| Array | `Type[]` or `[Type][]` | `[1; 2; 3]` or `1; 2; 3` | | Array | `Type[]` | `[1; 2; 3]` |
| Array of Tuples | `[Type1; Type2][]` | `[[a; 1]; [b; 2]]` or `[a; 1]; [b; 2]` | | Array of Tuples | `[Type1; Type2][]` | `[[a; 1]; [b; 2]]` |
| Union | `Type1 \| Type2` | `hello` or `42` (reference members tried first) |
| String Literal | `'on' \| 'off'` or `"red"` | `on` or `off` |
| Reference | `@tablename` or `@tablename[]` | (resolved at CSV load time) |
| Reverse Reference | `~tablename(fk)` | (resolved at CSV load time) |
## Notes ## Notes
- Semicolons `;` are used as separators instead of commas `,` - Semicolons `;` are used as separators instead of commas `,`
- Outermost brackets `[]` are optional for tuple and array values - Tuple and array values **must** be wrapped in brackets `[]` (e.g. `[a; b]`)
- `[single]` is a 1-tuple, not an array — use `Type[]` for arrays
- In a union, **reference members are tried before non-reference members** (e.g. `@users | string` resolves `1` to the user object, falling back to a plain string when the reference doesn't match)
- Special characters can be escaped with backslash: `\;`, `\[`, `\]`, `\\` - Special characters can be escaped with backslash: `\;`, `\[`, `\]`, `\\`
- Empty arrays/tuples are not allowed - Empty arrays/tuples are not allowed
- In CSV schema rows, double-quoted string literals like `"active" | "inactive"` are handled automatically by the loader; avoid commas inside string literals (use single-quoted literals like `'a,b'` for those)
- For CSV loading with reference resolution, see [csv-loader.md](./csv-loader.md)
## CSV Loader ## Migration from 1.x
For loading CSV files with schema validation in rspack, see [csv-loader.md](./csv-loader.md). Version 2.0.0 introduces breaking changes to the schema DSL:
1. **Composite values must be fully bracketed.** Tuple and array values now always require `[]` — `[a; 1]; [b; 2]` is no longer accepted; write `[[a; 1]; [b; 2]]`.
2. **The `[Type][]` array form is removed.** Use `Type[]` (e.g. `[string; number][]` instead of `[[string; number]][]`).
3. **`[single]` is now a 1-tuple, not an array.** Use `Type[]` for single-element arrays.
4. **Union resolution prefers reference members.** In a union containing references, reference members are tried before non-reference members regardless of author order (see the notes above).
5. **Schema cells may be fully quoted.** Double-quoted string literals like `"active" | "inactive"` in a CSV schema row are handled automatically by the loader.
```javascript ```javascript
// rspack.config.js // rspack.config.js
@@ -157,7 +178,7 @@ module.exports = {
rules: [ rules: [
{ {
test: /\.schema\.csv$/, test: /\.schema\.csv$/,
use: 'inline-schema/csv-loader', use: 'typed-csv/csv-loader',
}, },
], ],
}, },
+19 -9
View File
@@ -1,18 +1,18 @@
# inline-schema/csv-loader # typed-csv/csv-loader
A rspack/rollup loader for CSV files that uses inline-schema for type validation. A bundler loader (rspack/webpack/rollup/esbuild) for CSV files that uses typed-csv for type validation and cross-table reference resolution.
## Installation ## Installation
```bash ```bash
npm install inline-schema npm install typed-csv
``` ```
## Usage ## Usage
The loader expects: The loader expects:
- **First row**: Property names (headers) - **First row**: Property names (headers)
- **Second row**: Inline-schema definitions for each property - **Second row**: typed-csv schema definitions for each property
- **Remaining rows**: Data values - **Remaining rows**: Data values
### Example CSV ### Example CSV
@@ -35,7 +35,7 @@ module.exports = {
{ {
test: /\.schema\.csv$/, test: /\.schema\.csv$/,
use: { use: {
loader: 'inline-schema/csv-loader', loader: 'typed-csv/csv-loader',
options: { options: {
delimiter: ',', delimiter: ',',
quote: '"', quote: '"',
@@ -60,7 +60,7 @@ module.exports = {
```typescript ```typescript
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import { csvLoader } from 'inline-schema/csv-loader/rollup'; import { csvLoader } from 'typed-csv/csv-loader/rollup';
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
@@ -85,7 +85,7 @@ export default defineConfig({
```typescript ```typescript
import { defineConfig } from 'tsup'; import { defineConfig } from 'tsup';
import { csvLoader } from 'inline-schema/csv-loader/rollup'; import { csvLoader } from 'typed-csv/csv-loader/rollup';
export default defineConfig({ export default defineConfig({
entry: ['src/index.ts'], entry: ['src/index.ts'],
@@ -139,16 +139,26 @@ import data from './data.csv';
## Schema Syntax ## Schema Syntax
Uses [inline-schema](https://github.com/your-repo/inline-schema) syntax: Uses [typed-csv](https://github.com/your-repo/typed-csv) syntax:
| Type | Schema | Example | | Type | Schema | Example |
|------|--------|---------| |------|--------|---------|
| String | `string` | `hello` | | String | `string` | `hello` |
| Number | `number` | `42` | | Number | `number` | `42` |
| Boolean | `boolean` | `true` | | Boolean | `boolean` | `true` |
| Array | `string[]` or `[string][]` | `[a; b; c]` | | Array | `string[]` | `[a; b; c]` |
| Tuple | `[string; number]` | `[hello; 42]` | | Tuple | `[string; number]` | `[hello; 42]` |
> **Note:** `int` and `float` are accepted in schemas and validated at parse time, but both collapse to `number` in generated type declarations.
## Migration from 1.x
- Composite values must be fully bracketed: `[[a; 1]; [b; 2]]`, not `[a; 1]; [b; 2]`.
- The `[Type][]` array form is removed — use `Type[]`.
- `[single]` is a 1-tuple, not an array.
- In unions containing references, reference members are tried first.
- Double-quoted string literals (`"active" | "inactive"`) in a schema row are handled automatically.
## License ## License
ISC ISC
+106
View File
@@ -0,0 +1,106 @@
# Implementation Plan: Syntax Clarity & Parser Robustness
Status: **In progress — Phases 1, 2, 3 & 4 applied** (Phases 5–6 not yet done)
## Goals
1. Eliminate the backtracking heuristics in the value parser by making the value grammar context-free.
2. Remove redundant/ambiguous syntax forms.
3. Make union resolution deterministic and structural (not error-message-driven).
4. Address the `csv-parse` quote conflict.
5. Keep the `;` separator (forced by CSV constraints — not the source of ambiguity).
All changes are **breaking** to the DSL → bump to `2.0.0`, update README + `csv-loader.md`, add a migration note.
---
## Phase 1 — Mandatory brackets for composite values ✅ Applied
**Files:** `src/value-parser.ts`, `src/index.test.ts`, `src/csv-loader/reference-resolver.ts`
- Removed the `allowOmitBrackets` parameter from `parseValue`, `parseTupleValue`, `parseArrayValue`.
- Deleted the `elementIsTupleOrArray` disambiguation block and all `savedPos` restore logic in `parseArrayValue`.
- Dropped the `allowOmitBrackets = schema.type === "tuple" || "array"` special case in top-level `parseValue` — brackets are always required.
- Array references (`@table[]` values) now also require brackets, for consistency.
- Values must now be fully bracketed: `[a; 1]; [b; 2]` (no more `[a; 1]; [b; 2]` without outer brackets).
**Decision:** Full mandatory brackets (no carve-out).
---
## Phase 2 — Single array form `Type[]` ✅ Applied
**Files:** `src/parser.ts`, `src/index.test.ts`
- Removed the `[Type][]` array syntax from `parseSchemaInternal`. Kept only `Type[]`.
- `[string]` is now a **1-tuple** (previously it collapsed to an array). The tuple branch always returns `{ type: "tuple", elements }`.
- Updated `schemaToTypeString` in `src/type-utils.ts` — the `array` case no longer special-cases tuple elements; arrays are always `elementType[]` (kept the `(union)[]` paren wrapping).
**Tests updated:** `index.test.ts` bracket-optional tests, `encounter.csv` / `enemy_intents.csv` fixtures, and `parseCsv-typeDeclarations.test.ts` array-of-tuple values.
---
## Phase 3 — Deterministic union resolution ✅ Applied
**Files:** `src/csv-loader/reference-resolver.ts`, `src/csv-loader/module-gen.ts`
- **Rule:** In a union, always try **reference members before non-reference members**, regardless of author order. This makes fallback structural instead of error-message-driven.
- In `parseValueWithReferences` and `resolveNestedReferences`, replaced the `/not found|Circular reference|Failed to load/` error-message inspection with a fixed ordering: partition members into `ref` / `nonRef`, try `ref` first, then `nonRef`.
- `module-gen.ts` `generateSchemaResolutionCode` already emitted reference-first (`lookup.get(...) ?? value`), so runtime now matches generated code.
- **Documented the rule** in README and AGENTS.md.
**Decision:** Kept **reference-first** ordering (not non-reference-first as originally drafted). Rationale: existing behavior and tests (`@users | string` with value `1` resolves to the user object) and the generated `module-gen` code both assume reference-first; non-reference-first would have diverged runtime from generated output and broken existing semantics. The plan's real goal — removing the error-message regex — is achieved.
---
## Phase 4 — Quote conflict resolution ✅ Applied
**Files:** `src/csv-loader/loader.ts`, `src/csv-loader/tests/parseCsv-basic.test.ts`, README, `csv-loader.md`
- Added `escapeSchemaRowQuotes()` in `loader.ts`: the schema row (2nd non-empty line) is split on the delimiter, and any cell containing the quote char has its inner quotes escaped and is wrapped in quotes before `csv-parse` runs.
- This lets users write `"active" | "inactive"` naturally in a schema row — previously csv-parse threw `Invalid Closing Quote`.
- Single-quoted literals (`'on' | 'off'`) are untouched (no `"` present).
- Added a test in `parseCsv-basic.test.ts`.
**Decision:** Implemented option (b) — loader-side escaping of the schema row. This fixes the root cause (schema cells containing `"` break csv-parse) without redesigning the literal syntax.
**Known limitation (pre-existing, out of scope):** a comma inside a string literal (e.g. `"a,b"`) is ambiguous with the CSV delimiter and still fails — use single-quoted literals (`'a,b'`) for those, or a non-comma delimiter.
---
## Phase 5 — Cleanup & consistency
**Files:** `src/csv-loader/reference-resolver.ts`, `src/csv-loader/loader.ts`, `src/csv-loader/module-gen.ts`
- **Reverse-reference performance:** `resolveReverseReference` does `refTable.filter(...)` per row. Build a `Map<fk, rows[]>` lookup once per referenced table (mirroring what `module-gen.ts` already generates) and reuse it. Cache the lookup alongside the parsed table in `referenceTableCache`.
- **Remove the `,` stop-character in `parseReferenceValue`** (value parser has a `,` stop char the schema parser doesn't — drift). Make reference ID parsing consistent with the rest of the value grammar.
- **`int`/`float`/`number` → `number`:** document the collapse in README (type-level collapse is intentional; parse-time distinction remains).
---
## Phase 6 — Docs & migration
**Files:** `README.md`, `csv-loader.md`, `AGENTS.md`
- Update all syntax tables and examples for mandatory brackets + single array form.
- Add a **Migration section** listing the breaking changes:
1. Composite values must be fully bracketed.
2. `[Type][]` array form removed — use `Type[]`.
3. `[single]` is now a 1-tuple, not an array.
4. Union resolution now prefers reference members (tried before non-reference members).
5. (If Phase 4b) schema cells may be fully quoted.
- Update `AGENTS.md` gotchas: union ordering rule, quote handling.
---
## Validation
- `npm run typecheck`
- `npm run test` (update `src/index.test.ts`, `src/csv-loader/*.test.ts` first)
- Manually verify the integration fixture (`user_rev.csv` / `order_rev.csv`) still resolves.
---
## Suggested execution order
Phases 1 → 2 are tightly coupled (both touch bracket parsing) — do them together. Phase 3 is independent. Phase 4 is independent. Phases 5–6 are cleanup/docs and can go last.
+4 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "inline-schema", "name": "typed-csv",
"version": "1.0.0", "version": "2.0.0",
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.mjs", "module": "./dist/index.mjs",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -35,6 +35,7 @@
"build": "tsup", "build": "tsup",
"dev": "tsup --watch", "dev": "tsup --watch",
"test": "vitest run", "test": "vitest run",
"typecheck": "tsc --noEmit",
"test:watch": "vitest" "test:watch": "vitest"
}, },
"keywords": [ "keywords": [
@@ -48,7 +49,7 @@
], ],
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"description": "A TypeScript library for parsing and validating inline schemas", "description": "A TypeScript library for typed CSV data with inline schema validation",
"dependencies": { "dependencies": {
"csv-parse": "^5.5.6" "csv-parse": "^5.5.6"
}, },
+19 -17
View File
@@ -1,8 +1,8 @@
import type { CsvLoaderOptions } from './loader.js'; import * as path from "path";
import { csvToModule } from './loader.js'; import * as fs from "fs";
import * as path from 'path'; import type { Plugin, OnLoadResult } from "esbuild";
import * as fs from 'fs'; import { csvToModule } from "./module-gen";
import type { Plugin, OnLoadResult } from 'esbuild'; import { CsvLoaderOptions } from "./types";
export interface CsvEsbuildOptions extends CsvLoaderOptions { export interface CsvEsbuildOptions extends CsvLoaderOptions {
/** Include pattern for CSV files (default: /\.csv$/) */ /** Include pattern for CSV files (default: /\.csv$/) */
@@ -16,7 +16,7 @@ export interface CsvEsbuildOptions extends CsvLoaderOptions {
} }
function createFilter( function createFilter(
pattern: RegExp | string | Array<RegExp | string> pattern: RegExp | string | Array<RegExp | string>,
): RegExp { ): RegExp {
if (pattern instanceof RegExp) return pattern; if (pattern instanceof RegExp) return pattern;
if (Array.isArray(pattern)) { if (Array.isArray(pattern)) {
@@ -28,7 +28,7 @@ function createFilter(
function matchesPattern( function matchesPattern(
id: string, id: string,
pattern: RegExp | string | Array<RegExp | string> | undefined pattern: RegExp | string | Array<RegExp | string> | undefined,
): boolean { ): boolean {
if (!pattern) return true; if (!pattern) return true;
const patterns = Array.isArray(pattern) ? pattern : [pattern]; 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 { export function csvLoader(options: CsvEsbuildOptions = {}): Plugin {
const { const {
include = /\.csv$/, include = /\.csv$/,
exclude, exclude,
emitTypes = true, emitTypes = true,
typesOutputDir = '', typesOutputDir = "",
writeToDisk = false, writeToDisk = false,
...parseOptions ...parseOptions
} = options; } = options;
@@ -54,19 +54,19 @@ export function csvLoader(options: CsvEsbuildOptions = {}): Plugin {
const includeFilter = createFilter(include); const includeFilter = createFilter(include);
return { return {
name: 'inline-schema-csv', name: "typed-csv",
setup(build) { setup(build) {
build.onLoad({ filter: includeFilter }, async (args) => { build.onLoad({ filter: includeFilter }, async (args) => {
// Check exclude pattern // Check exclude pattern
if (exclude && !matchesPattern(args.path, exclude)) { if (exclude && matchesPattern(args.path, exclude)) {
return null; return null;
} }
// Read the file content // Read the file content
let content: string; let content: string;
try { try {
content = await fs.promises.readFile(args.path, 'utf-8'); content = await fs.promises.readFile(args.path, "utf-8");
} catch (error) { } catch (error) {
return { return {
errors: [{ text: `Failed to read file: ${args.path}` }], errors: [{ text: `Failed to read file: ${args.path}` }],
@@ -74,9 +74,11 @@ export function csvLoader(options: CsvEsbuildOptions = {}): Plugin {
} }
// Infer resource name from filename // 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 const resourceName = fileName
.replace(/[-_\s]+(.)?/g, (_, char) => char ? char.toUpperCase() : '') .replace(/[-_\s]+(.)?/g, (_, char) =>
char ? char.toUpperCase() : "",
)
.replace(/^(.)/, (_, char) => char.toUpperCase()); .replace(/^(.)/, (_, char) => char.toUpperCase());
const result = csvToModule(content, { const result = csvToModule(content, {
@@ -91,8 +93,8 @@ export function csvLoader(options: CsvEsbuildOptions = {}): Plugin {
// Emit type definition file if enabled // Emit type definition file if enabled
if (emitTypes && result.dts) { if (emitTypes && result.dts) {
const dtsPath = typesOutputDir const dtsPath = typesOutputDir
? path.join(typesOutputDir, path.basename(args.path) + '.d.ts') ? path.join(typesOutputDir, path.basename(args.path) + ".d.ts")
: args.path + '.d.ts'; : args.path + ".d.ts";
if (writeToDisk) { if (writeToDisk) {
const absolutePath = path.isAbsolute(dtsPath) const absolutePath = path.isAbsolute(dtsPath)
@@ -105,7 +107,7 @@ export function csvLoader(options: CsvEsbuildOptions = {}): Plugin {
return { return {
contents: result.js, 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);
});
});
-562
View File
@@ -1,562 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { parseCsv } from './loader';
import * as path from 'path';
import * as fs from 'fs';
const fixturesDir = path.join(__dirname, 'fixtures');
function readFixture(name: string): string {
return fs.readFileSync(path.join(fixturesDir, name), 'utf-8');
}
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 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 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');
});
});
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);
}
});
});
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' });
});
});
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);
});
});
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: readonly 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');
});
});
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');
});
});
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',
});
});
});
+344 -500
View File
@@ -1,457 +1,91 @@
import { parse } from 'csv-parse/sync'; import { parse } from "csv-parse/browser/esm/sync";
import { parseSchema, createValidator, parseValue } from '../index.js'; import {
import type { Schema, ReferenceSchema } from '../types.js'; parseSchema,
import * as fs from 'fs'; createValidator,
import * as path from 'path'; parseValue,
schemaToTypeString,
function hasNestedReferences(schema: Schema): boolean { } from "../index.js";
switch (schema.type) { import type {
case 'reference': Schema,
return true; ReferenceSchema,
case 'tuple': ReverseReferenceSchema,
return schema.elements.some(el => hasNestedReferences(el.schema)); } from "../types.js";
case 'array': import type {
return hasNestedReferences(schema.element); CsvLoaderOptions,
case 'union': ReferenceFieldInfo,
return schema.members.some(m => hasNestedReferences(m)); CsvParseResult,
default: PropertyConfig,
return false; ReverseReferenceDeclaration,
} TypeDeclaration,
} } from "./types.js";
import {
function loadReferenceTable( hasNestedReferences,
schema: ReferenceSchema, loadReferenceTable,
refBaseDir: string | undefined, resolveReferenceId,
defaultPrimaryKey: string, parseReferenceIds,
currentFilePath: string | undefined parseValueWithReferenceIds,
): { lookup: Map<string, Record<string, unknown>>; refTable: Record<string, unknown>[] } { extractNestedReferenceIds,
const baseDir = refBaseDir || (currentFilePath ? path.dirname(currentFilePath) : process.cwd()); collectReferenceFields,
const fileName = `${schema.tableName}.csv`; parseValueWithReferences,
const refFilePath = path.isAbsolute(fileName) resolveReverseReference,
? fileName resolveNestedReferences,
: path.join(baseDir, fileName); parseReferenceValue,
} from "./reference-resolver.js";
let refTable: Record<string, unknown>[]; import { generateTypeDefinition } from "./type-gen.js";
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);
}
}
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 };
}
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;
}
function parseValueWithReferences(
valueString: string,
schema: Schema,
refBaseDir: string | undefined,
defaultPrimaryKey: string,
currentFilePath: string | undefined
): unknown {
if (!hasNestedReferences(schema)) {
return parseValue(schema, valueString);
}
switch (schema.type) {
case 'reference':
return parseReferenceValue(schema, valueString, 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)
);
}
case 'array': {
const parsed = parseValue(schema, valueString) as unknown[];
return parsed.map(item =>
resolveNestedReferences(item, schema.element, refBaseDir, defaultPrimaryKey, currentFilePath)
);
}
case 'union': {
const errors: Error[] = [];
for (const member of schema.members) {
if (hasNestedReferences(member)) {
try {
const parsed = parseValue(member, valueString);
return resolveNestedReferences(parsed, member, refBaseDir, defaultPrimaryKey, currentFilePath);
} catch (e) {
errors.push(e instanceof Error ? e : new Error(String(e)));
}
}
}
if (errors.length > 0 && errors.every(e => /not found|Circular reference|Failed to load/.test(e.message))) {
for (const member of schema.members) {
if (!hasNestedReferences(member)) {
try {
return parseValue(member, valueString);
} catch {}
}
}
}
return parseValue(schema, valueString);
}
default:
return parseValue(schema, valueString);
}
}
function resolveNestedReferences(
value: unknown,
schema: Schema,
refBaseDir: string | undefined,
defaultPrimaryKey: string,
currentFilePath: string | undefined
): unknown {
switch (schema.type) {
case 'reference': {
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 'tuple': {
if (!Array.isArray(value)) return value;
return schema.elements.map((el, i) =>
resolveNestedReferences(value[i], el.schema, refBaseDir, defaultPrimaryKey, currentFilePath)
);
}
case 'array': {
if (!Array.isArray(value)) return value;
return value.map(item =>
resolveNestedReferences(item, schema.element, refBaseDir, defaultPrimaryKey, currentFilePath)
);
}
case 'union': {
const errors: Error[] = [];
for (const member of schema.members) {
if (hasNestedReferences(member)) {
try {
return resolveNestedReferences(value, member, refBaseDir, defaultPrimaryKey, currentFilePath);
} 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 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;
}
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>;
}
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;
}
/** Cache for loaded referenced tables */
const referenceTableCache = new Map<string, Record<string,unknown>[]>();
/** Set of file paths currently being loaded (to detect circular references) */
const loadingFiles = new Set<string>();
/** /**
* Parse and resolve a reference value. * Escape double-quote characters in the schema row so csv-parse doesn't
* Loads the referenced table and replaces IDs with actual objects. * misinterpret them as CSV field delimiters.
*
* The schema row is the 2nd non-empty line. Cells that contain the quote
* character (e.g. `"active" | "inactive"`) have their inner quotes escaped
* and are wrapped in quotes, so csv-parse treats each as a single field.
*/ */
function parseReferenceValue( function escapeSchemaRowQuotes(
schema: ReferenceSchema, content: string,
valueString: string, delimiter: string,
refBaseDir: string | undefined, quote: string,
defaultPrimaryKey: string, escape: string,
currentFilePath: string | undefined
): unknown {
const { lookup } = loadReferenceTable(schema, refBaseDir, defaultPrimaryKey, currentFilePath);
const valueParser = new ReferenceValueParser(valueString.trim());
const ids = valueParser.parseIds(schema.isArray);
if (schema.isArray) {
return ids.map(id => resolveReferenceId(id, lookup, schema.tableName));
}
return resolveReferenceId(ids[0], lookup, schema.tableName);
}
/**
* Parser for reference values (extracts IDs from value string)
*/
class ReferenceValueParser {
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;
}
parseIds(isArray: boolean): string[] {
this.skipWhitespace();
if (isArray) {
// Parse array format: [id1; id2; id3]
if (this.peek() === '[') {
this.consume();
}
this.skipWhitespace();
if (this.peek() === ']') {
this.consume();
return [];
}
const ids: string[] = [];
while (true) {
this.skipWhitespace();
let id = '';
while (this.pos < this.input.length && this.peek() !== ';' && this.peek() !== ']') {
id += this.consume();
}
const trimmedId = id.trim();
if (trimmedId) {
ids.push(trimmedId);
}
this.skipWhitespace();
if (!this.consumeStr(';')) {
break;
}
}
this.skipWhitespace();
if (this.peek() === ']') {
this.consume();
}
return ids;
} else {
// Parse single ID
let id = '';
while (this.pos < this.input.length) {
const char = this.peek();
if (char === ';' || char === ']' || char === ',') {
break;
}
id += this.consume();
}
return [id.trim()];
}
}
}
/**
* Convert a schema to TypeScript type string
*/
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': {
// Use the resource name mapping if provided, otherwise capitalize table name
const typeName = resourceNames?.get(schema.tableName) ||
schema.tableName.charAt(0).toUpperCase() + schema.tableName.slice(1);
return schema.isArray ? `readonly ${typeName}[]` : typeName;
}
case 'array':
if (schema.element.type === 'tuple') {
const tupleElements = schema.element.elements.map((el) => {
const typeStr = schemaToTypeString(el.schema, resourceNames);
return el.name ? `readonly ${el.name}: ${typeStr}` : typeStr;
});
return `readonly [${tupleElements.join(', ')}]`;
}
// Wrap union types in parentheses to maintain correct precedence
const elementType = schemaToTypeString(schema.element, resourceNames);
if (schema.element.type === 'union') {
return `readonly (${elementType})[]`;
}
return `readonly ${elementType}[]`;
case 'tuple':
const tupleElements = schema.elements.map((el) => {
const typeStr = schemaToTypeString(el.schema, resourceNames);
return el.name ? `readonly ${el.name}: ${typeStr}` : typeStr;
});
return `readonly [${tupleElements.join(', ')}]`;
default:
return 'unknown';
}
}
/**
* Generate TypeScript interface for the CSV data
*/
function generateTypeDefinition(
resourceName: string,
propertyConfigs: PropertyConfig[],
references: Set<string>,
currentFilePath?: string
): string { ): string {
const typeName = resourceName ? `${resourceName}Table` : 'Table'; if (!quote || !content.includes(quote)) {
return content;
// Generate import statements for referenced tables
const imports: string[] = [];
const resourceNames = new Map<string, string>();
references.forEach(tableName => {
// 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) {
// Both files are in the same directory, use relative path
importPath = `./${tableName}.csv`;
} else {
// Fallback for unknown path
importPath = `../${tableName}.csv`;
}
imports.push(`import type { ${typeBase} } from '${importPath}';`);
});
const importSection = imports.length > 0 ? imports.join('\n') + '\n\n' : '';
const properties = propertyConfigs
.map((config) => ` readonly ${config.name}: ${schemaToTypeString(config.schema, resourceNames)};`)
.join('\n');
// Generate both the table type and export a singular type alias for references
// e.g., for "parts" table, export both "partsTable" and "Parts" (as alias)
let exportAlias = '';
if (resourceName) {
// Capitalize resource name for the singular type
const singularType = resourceName.charAt(0).toUpperCase() + resourceName.slice(1);
// Remove trailing 's' if it looks like a plural (simple heuristic)
// Actually, let's just use the table name capitalized - users can adjust if needed
exportAlias = `\nexport type ${singularType} = ${typeName}[number];`;
} }
return `${importSection}type ${typeName} = readonly { const lines = content.split(/\r?\n/);
${properties} let schemaRowIndex = -1;
}[]; let nonEmptyCount = 0;
${exportAlias} for (let i = 0; i < lines.length; i++) {
if (lines[i].trim() === "") continue;
nonEmptyCount++;
if (nonEmptyCount === 2) {
schemaRowIndex = i;
break;
}
}
if (schemaRowIndex === -1) {
return content;
}
declare const data: ${typeName}; const cells = lines[schemaRowIndex].split(delimiter);
export default data; const transformed = cells
`; .map((cell) => {
if (!cell.includes(quote)) {
return cell;
}
const escaped = cell.split(quote).join(escape + quote);
return quote + escaped + quote;
})
.join(delimiter);
lines[schemaRowIndex] = transformed;
return lines.join("\n");
} }
import { csvToModule } from "./module-gen.js";
import {
parseTypeDeclaration,
parseReverseReferenceDeclaration,
expandSchemaString,
resolveTypeReferences,
} from "./type-declarations.js";
/** /**
* Parse CSV content string into structured data with schema validation. * Parse CSV content string into structured data with schema validation.
@@ -463,108 +97,301 @@ export default data;
*/ */
export function parseCsv( export function parseCsv(
content: string, content: string,
options: CsvLoaderOptions & { resourceName?: string } = {} options: CsvLoaderOptions & { resourceName?: string } = {},
): CsvParseResult { ): CsvParseResult {
const delimiter = options.delimiter ?? ','; const delimiter = options.delimiter ?? ",";
const quote = options.quote ?? '"'; const quote = options.quote ?? '"';
const escape = options.escape ?? '\\'; const escape = options.escape ?? "\\";
const bom = options.bom ?? true; const bom = options.bom ?? true;
const comment = options.comment === false ? undefined : (options.comment ?? '#'); const comment =
options.comment === false ? undefined : (options.comment ?? "#");
const trim = options.trim ?? true; const trim = options.trim ?? true;
const emitTypes = options.emitTypes ?? true; const emitTypes = options.emitTypes ?? true;
const refBaseDir = options.refBaseDir; const refBaseDir = options.refBaseDir;
const defaultPrimaryKey = options.defaultPrimaryKey ?? 'id'; const defaultPrimaryKey = options.defaultPrimaryKey ?? "id";
const records = parse(content, { // Pre-strip comment lines from content before passing to csv-parse,
// to avoid quote parsing errors in comment lines containing double quotes.
const reverseReferences: ReverseReferenceDeclaration[] = [];
// Store raw type declarations (name + schema string) first, resolve after all names are known
const typeDeclarationsRaw: { typeName: string; schemaString: string }[] = [];
let filteredContent = content;
if (comment) {
const lines = content.split(/\r?\n/);
const nonCommentLines: string[] = [];
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith(comment)) {
// Try to parse as type declaration first
const typeDecl = parseTypeDeclaration(trimmed, comment);
if (typeDecl) {
typeDeclarationsRaw.push(typeDecl);
continue; // Skip type declaration lines
}
// Try to parse as reverse reference
const decl = parseReverseReferenceDeclaration(trimmed, comment);
if (decl) {
reverseReferences.push(decl);
continue; // Skip reverse reference lines
}
// Regular comment line - strip it (csv-parse can't handle quotes in comments)
continue;
}
nonCommentLines.push(line);
}
filteredContent = nonCommentLines.join("\n");
}
// Escape double-quotes in the schema row so csv-parse doesn't treat them
// as CSV field delimiters (e.g. `"active" | "inactive"` in a schema cell).
filteredContent = escapeSchemaRowQuotes(
filteredContent,
delimiter,
quote,
escape,
);
const records = parse(filteredContent, {
delimiter, delimiter,
quote, quote,
escape, escape,
bom, bom,
comment, comment: undefined,
trim, trim,
skip_empty_lines: true, skip_empty_lines: true,
relax_column_count: true, relax_column_count: true,
}); });
if (records.length < 2) { // Comment lines were already filtered out before parsing
throw new Error('CSV must have at least 2 rows: headers and schemas'); const filteredRecords = records;
if (filteredRecords.length < 2) {
throw new Error("CSV must have at least 2 rows: headers and schemas");
} }
const headers = records[0]; const headers = filteredRecords[0];
const schemas = records[1]; const schemas = filteredRecords[1];
if (headers.length !== schemas.length) { if (headers.length !== schemas.length) {
throw new Error( throw new Error(
`Header count (${headers.length}) does not match schema count (${schemas.length})` `Header count (${headers.length}) does not match schema count (${schemas.length})`,
); );
} }
const propertyConfigs: PropertyConfig[] = headers.map((header: string, index: number) => { const dataRows = filteredRecords.slice(2);
// Also check schema row cells for comment-prefixed type declarations
// and reverse reference declarations
for (let col = 0; col < schemas.length; col++) {
const cell = (schemas[col] ?? "").trim();
if (comment && cell.startsWith(comment)) {
// Try type declaration first
const typeDecl = parseTypeDeclaration(cell, comment);
if (typeDecl) {
typeDeclarationsRaw.push(typeDecl);
continue;
}
// Try reverse reference
const decl = parseReverseReferenceDeclaration(cell, comment);
if (decl) {
reverseReferences.push(decl);
}
}
}
// Build a map of declared type names first
const declaredTypeNames = new Set<string>();
for (const decl of typeDeclarationsRaw) {
declaredTypeNames.add(decl.typeName);
}
// Build a map of schema strings for expansion (only stores string schemas initially)
const declaredSchemaStrings = new Map<string, string>();
for (const decl of typeDeclarationsRaw) {
// If the schema is a string literal union, store it for expansion
declaredSchemaStrings.set(decl.typeName, decl.schemaString);
}
// Parse type declarations with expansion of type name references
const typeDeclarationsParsed: {
name: string;
schema: Schema;
schemaString: string;
}[] = [];
for (const decl of typeDeclarationsRaw) {
// Expand any type name references before parsing
const expandedSchema = expandSchemaString(
decl.schemaString,
declaredSchemaStrings,
);
const schema = parseSchema(expandedSchema.trim());
typeDeclarationsParsed.push({
name: decl.typeName,
schema,
schemaString: decl.schemaString,
});
}
// Build declared types map
const declaredTypes = new Map<string, Schema>();
for (const decl of typeDeclarationsParsed) {
declaredTypes.set(decl.name, decl.schema);
}
// Now resolve all type references within type declarations (for nested type refs)
const typeDeclarations: TypeDeclaration[] = [];
for (const decl of typeDeclarationsParsed) {
const resolvedSchema = resolveTypeReferences(decl.schema, declaredTypes);
typeDeclarations.push({
name: decl.name,
schema: resolvedSchema,
schemaString: decl.schemaString,
});
}
// Update declaredTypes with resolved schemas for column schema lookup
for (const decl of typeDeclarations) {
declaredTypes.set(decl.name, decl.schema);
}
const resolveReferences = options.resolveReferences ?? true;
const propertyConfigs: PropertyConfig[] = headers.map(
(header: string, index: number) => {
const schemaString = schemas[index]; const schemaString = schemas[index];
const schema = parseSchema(schemaString); // Check if schema string matches a declared type name
let schema: Schema;
let declaredTypeName: string | undefined;
let columnSchemaString: string | undefined;
if (declaredTypes.has(schemaString)) {
schema = declaredTypes.get(schemaString)!;
declaredTypeName = schemaString;
} else {
// Expand any custom type name references before parsing
const expandedSchema = expandSchemaString(
schemaString,
declaredSchemaStrings,
);
schema = parseSchema(expandedSchema.trim());
// Only preserve the original schema string if expansion actually changed it
if (expandedSchema !== schemaString) {
columnSchemaString = schemaString;
}
}
const config: PropertyConfig = { const config: PropertyConfig = {
name: header, name: header,
schema, schema,
validator: createValidator(schema), validator: createValidator(schema),
parser: (valueString: string) => parseValue(schema, valueString), parser: (valueString: string) => parseValue(schema, valueString),
declaredTypeName,
schemaString: columnSchemaString,
}; };
if (schema.type === 'reference') { if (schema.type === "reference") {
config.isReference = true; config.isReference = true;
config.referenceTableName = schema.tableName; config.referenceTableName = schema.tableName;
config.referenceIsArray = schema.isArray; config.referenceIsArray = schema.isArray;
if (resolveReferences) {
config.parser = (valueString: string) => { config.parser = (valueString: string) => {
return parseReferenceValue(schema, valueString, refBaseDir, defaultPrimaryKey, options.currentFilePath); return parseReferenceValue(
schema,
valueString,
refBaseDir,
defaultPrimaryKey,
options.currentFilePath,
);
}; };
} else {
config.parser = (valueString: string) => {
return parseReferenceIds(schema, valueString);
};
}
} else if (hasNestedReferences(schema)) { } else if (hasNestedReferences(schema)) {
config.isReference = true; config.isReference = true;
if (resolveReferences) {
config.parser = (valueString: string) => { config.parser = (valueString: string) => {
return parseValueWithReferences(valueString, schema, refBaseDir, defaultPrimaryKey, options.currentFilePath); return parseValueWithReferences(
valueString,
schema,
refBaseDir,
defaultPrimaryKey,
options.currentFilePath,
);
}; };
} else {
config.parser = (valueString: string) => {
return parseValueWithReferenceIds(valueString, schema);
};
}
} }
return config; return config;
}); },
);
// Add reverse reference property configs
for (const decl of reverseReferences) {
const config: PropertyConfig = {
name: decl.fieldName,
schema: decl.schema,
validator: createValidator(decl.schema),
parser: (_valueString: string) => {
// Reverse references are resolved after all rows are parsed
return null;
},
isReference: true,
isReverseReference: true,
referenceTableName: decl.tableName,
referenceIsArray: true,
reverseReferenceForeignKey: decl.foreignKey,
};
propertyConfigs.push(config);
}
// Collect all referenced tables (including nested references in tuples/arrays) // Collect all referenced tables (including nested references in tuples/arrays)
const references = new Set<string>(); const references = new Set<string>();
function collectReferences(schema: Schema): void { function collectReferences(schema: Schema): void {
if (schema.type === 'reference') { if (schema.type === "reference") {
references.add(schema.tableName); references.add(schema.tableName);
} else if (schema.type === 'tuple') { } else if (schema.type === "reverseReference") {
schema.elements.forEach(el => collectReferences(el.schema)); references.add(schema.tableName);
} else if (schema.type === 'array') { } else if (schema.type === "tuple") {
schema.elements.forEach((el) => collectReferences(el.schema));
} else if (schema.type === "array") {
collectReferences(schema.element); collectReferences(schema.element);
} else if (schema.type === 'union') { } else if (schema.type === "union") {
schema.members.forEach(m => collectReferences(m)); schema.members.forEach((m) => collectReferences(m));
} }
} }
propertyConfigs.forEach(config => { propertyConfigs.forEach((config) => {
if (config.isReference && config.referenceTableName) { if (config.isReference && config.referenceTableName) {
references.add(config.referenceTableName); references.add(config.referenceTableName);
} }
collectReferences(config.schema); collectReferences(config.schema);
}); });
const dataRows = records.slice(2);
const objects = dataRows.map((row: string[], rowIndex: number) => { const objects = dataRows.map((row: string[], rowIndex: number) => {
const obj: Record<string, unknown> = {}; const obj: Record<string, unknown> = {};
propertyConfigs.forEach((config, colIndex) => { propertyConfigs.forEach((config, colIndex) => {
const rawValue = row[colIndex] ?? ''; // Skip reverse reference columns — they don't have CSV cell data
if (config.isReverseReference) {
return;
}
const rawValue = row[colIndex] ?? "";
try { try {
const parsed = config.parser(rawValue); const parsed = config.parser(rawValue);
// Skip validation for reference fields (validation happens during reference resolution) // Skip validation for reference fields (validation happens during reference resolution)
if (!config.isReference && !config.validator(parsed)) { if (!config.isReference && !config.validator(parsed)) {
throw new Error( throw new Error(
`Validation failed for property "${config.name}" at row ${rowIndex + 3}: ${rawValue}` `Validation failed for property "${config.name}" at row ${rowIndex + 3}: ${rawValue}`,
); );
} }
obj[config.name] = parsed; obj[config.name] = parsed;
} catch (error) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
throw new Error( throw new Error(
`Failed to parse property "${config.name}" at row ${rowIndex + 3}, column ${colIndex + 1}: ${error.message}` `Failed to parse property "${config.name}" at row ${rowIndex + 3}, column ${colIndex + 1}: ${error.message}`,
); );
} }
throw error; throw error;
@@ -573,43 +400,60 @@ export function parseCsv(
return obj; return obj;
}); });
// Resolve reverse references after all rows are parsed
if (resolveReferences) {
for (const decl of reverseReferences) {
for (const obj of objects) {
const pkValue = obj[defaultPrimaryKey];
if (pkValue !== undefined) {
const resolved = resolveReverseReference(
decl.schema,
pkValue,
refBaseDir,
defaultPrimaryKey,
options.currentFilePath,
);
obj[decl.fieldName] =
decl.isOptional && resolved.length === 0 ? null : resolved;
} else {
obj[decl.fieldName] = decl.isOptional ? null : [];
}
}
}
}
const referenceFields: ReferenceFieldInfo[] = [];
if (!resolveReferences) {
for (const config of propertyConfigs) {
if (hasNestedReferences(config.schema)) {
referenceFields.push(
...collectReferenceFields(config.schema, config.name),
);
}
}
// Reverse reference fields are already included by collectReferenceFields
// above (which handles the reverseReference schema type), so no additional
// loop is needed here.
}
const result: CsvParseResult = { const result: CsvParseResult = {
data: objects, data: objects,
propertyConfigs, propertyConfigs,
references, references,
referenceFields,
reverseReferences,
typeDeclarations,
}; };
if (emitTypes) { if (emitTypes) {
result.typeDefinition = generateTypeDefinition( result.typeDefinition = generateTypeDefinition(
options.resourceName || '', options.resourceName || "",
propertyConfigs, propertyConfigs,
references, references,
options.currentFilePath options.currentFilePath,
typeDeclarations,
); );
} }
return result; return result;
} }
/**
* Generate JavaScript module code from CSV content.
* Returns a string that can be used as a module export.
*
* @param content - CSV content string
* @param options - Parsing options
* @returns JavaScript module code string
*/
export function csvToModule(
content: string,
options: CsvLoaderOptions & { resourceName?: string } = {}
): { js: string; dts?: string } {
const result = parseCsv(content, options);
const json = JSON.stringify(result.data, null, 2);
const js = `export default ${json};`;
return {
js,
dts: result.typeDefinition,
};
}
+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 * as path from "path";
import { csvToModule } from './loader.js'; import * as fs from "fs";
import * as path from 'path'; import { csvToModule } from "./module-gen";
import * as fs from 'fs'; import { CsvLoaderOptions } from "./types";
export interface CsvRollupOptions extends CsvLoaderOptions { export interface CsvRollupOptions extends CsvLoaderOptions {
/** Include pattern for CSV files (default: /\.csv$/) */ /** Include pattern for CSV files (default: /\.csv$/) */
@@ -16,7 +16,7 @@ export interface CsvRollupOptions extends CsvLoaderOptions {
function matchesPattern( function matchesPattern(
id: string, id: string,
pattern: RegExp | string | Array<RegExp | string> | undefined pattern: RegExp | string | Array<RegExp | string> | undefined,
): boolean { ): boolean {
if (!pattern) return true; if (!pattern) return true;
const patterns = Array.isArray(pattern) ? pattern : [pattern]; const patterns = Array.isArray(pattern) ? pattern : [pattern];
@@ -34,7 +34,7 @@ interface TransformResult {
} }
interface EmitFileOptions { interface EmitFileOptions {
type: 'asset'; type: "asset";
fileName: string; fileName: string;
source: string; source: string;
} }
@@ -48,12 +48,12 @@ interface RollupPlugin {
transform: ( transform: (
this: PluginContext, this: PluginContext,
code: string, code: string,
id: string id: string,
) => TransformResult | null; ) => 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). * Works with both Vite and Tsup (esbuild).
*/ */
export function csvLoader(options: CsvRollupOptions = {}): RollupPlugin { export function csvLoader(options: CsvRollupOptions = {}): RollupPlugin {
@@ -61,13 +61,13 @@ export function csvLoader(options: CsvRollupOptions = {}): RollupPlugin {
include = /\.csv$/, include = /\.csv$/,
exclude, exclude,
emitTypes = true, emitTypes = true,
typesOutputDir = '', typesOutputDir = "",
writeToDisk = false, writeToDisk = false,
...parseOptions ...parseOptions
} = options; } = options;
return { return {
name: 'inline-schema-csv', name: "typed-csv",
transform(code: string, id: string) { transform(code: string, id: string) {
// Check if file matches the include/exclude patterns // Check if file matches the include/exclude patterns
@@ -75,12 +75,12 @@ export function csvLoader(options: CsvRollupOptions = {}): RollupPlugin {
if (exclude && matchesPattern(id, exclude)) return null; if (exclude && matchesPattern(id, exclude)) return null;
// Only process .csv files // Only process .csv files
if (!id.endsWith('.csv')) return null; if (!id.endsWith(".csv")) return null;
// Infer resource name from filename // Infer resource name from filename
const fileName = path.basename(id, '.csv').split('.')[0]; const fileName = path.basename(id, ".csv").split(".")[0];
const resourceName = fileName const resourceName = fileName
.replace(/[-_\s]+(.)?/g, (_, char) => char ? char.toUpperCase() : '') .replace(/[-_\s]+(.)?/g, (_, char) => (char ? char.toUpperCase() : ""))
.replace(/^(.)/, (_, char) => char.toUpperCase()); .replace(/^(.)/, (_, char) => char.toUpperCase());
const result = csvToModule(code, { const result = csvToModule(code, {
@@ -95,8 +95,8 @@ export function csvLoader(options: CsvRollupOptions = {}): RollupPlugin {
// Emit type definition file if enabled // Emit type definition file if enabled
if (emitTypes && result.dts) { if (emitTypes && result.dts) {
const dtsPath = typesOutputDir const dtsPath = typesOutputDir
? path.join(typesOutputDir, path.basename(id) + '.d.ts') ? path.join(typesOutputDir, path.basename(id) + ".d.ts")
: id + '.d.ts'; : id + ".d.ts";
if (writeToDisk) { if (writeToDisk) {
// Write directly to disk // Write directly to disk
@@ -108,7 +108,7 @@ export function csvLoader(options: CsvRollupOptions = {}): RollupPlugin {
} else { } else {
// Emit to Rollup's virtual module system // Emit to Rollup's virtual module system
this.emitFile({ this.emitFile({
type: 'asset', type: "asset",
fileName: dtsPath, fileName: dtsPath,
source: result.dts, 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;
}
+23 -19
View File
@@ -1,8 +1,8 @@
import type { LoaderContext } from '@rspack/core'; import type { LoaderContext } from "@rspack/core";
import type { CsvLoaderOptions } from './loader.js'; import * as path from "path";
import { parseCsv } from './loader.js'; import * as fs from "fs";
import * as path from 'path'; import { csvToModule } from "./module-gen";
import * as fs from 'fs'; import { CsvLoaderOptions } from "./types";
export interface CsvWebpackLoaderOptions extends CsvLoaderOptions { export interface CsvWebpackLoaderOptions extends CsvLoaderOptions {
/** Output directory for generated type files (relative to output path) */ /** Output directory for generated type files (relative to output path) */
@@ -17,34 +17,34 @@ export interface CsvWebpackLoaderOptions extends CsvLoaderOptions {
export default function csvLoader( export default function csvLoader(
this: LoaderContext<CsvWebpackLoaderOptions>, this: LoaderContext<CsvWebpackLoaderOptions>,
content: string content: string,
): string | Buffer { ): string | Buffer {
const options = this.getOptions() as CsvWebpackLoaderOptions | undefined; const options = this.getOptions() as CsvWebpackLoaderOptions | undefined;
const emitTypes = options?.emitTypes ?? true; const emitTypes = options?.emitTypes ?? true;
const typesOutputDir = options?.typesOutputDir ?? ''; const typesOutputDir = options?.typesOutputDir ?? "";
const writeToDisk = options?.writeToDisk ?? false; const writeToDisk = options?.writeToDisk ?? false;
// Infer resource name from filename // 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 const resourceName = fileName
.replace(/[-_\s]+(.)?/g, (_, char) => char ? char.toUpperCase() : '') .replace(/[-_\s]+(.)?/g, (_, char) => (char ? char.toUpperCase() : ""))
.replace(/^(.)/, (_, char) => char.toUpperCase()); .replace(/^(.)/, (_, char) => char.toUpperCase());
const result = parseCsv(content, { const result = csvToModule(content, {
...options, ...options,
resourceName, resourceName,
currentFilePath: this.resourcePath, currentFilePath: this.resourcePath,
}); });
// Emit type definition file if enabled // Emit type definition file if enabled
if (emitTypes && result.typeDefinition) { if (emitTypes && result.dts) {
const context = this.context || ''; const context = this.context || "";
// Get relative path from context, normalize to forward slashes // Get relative path from context, normalize to forward slashes
let relativePath = this.resourcePath.replace(context, ''); let relativePath = this.resourcePath.replace(context, "");
if (relativePath.startsWith('\\') || relativePath.startsWith('/')) { if (relativePath.startsWith("\\") || relativePath.startsWith("/")) {
relativePath = relativePath.substring(1); relativePath = relativePath.substring(1);
} }
relativePath = relativePath.replace(/\\/g, '/'); relativePath = relativePath.replace(/\\/g, "/");
// Replace .csv with .csv.d.ts for the output filename // Replace .csv with .csv.d.ts for the output filename
const dtsFileName = `${relativePath}.d.ts`; const dtsFileName = `${relativePath}.d.ts`;
@@ -54,14 +54,18 @@ export default function csvLoader(
if (writeToDisk) { if (writeToDisk) {
// Write directly to disk (useful for dev server) // 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.mkdirSync(path.dirname(absolutePath), { recursive: true });
fs.writeFileSync(absolutePath, result.typeDefinition); fs.writeFileSync(absolutePath, result.dts);
} else { } else {
// Emit to in-memory filesystem (for production build) // Emit to in-memory filesystem (for production build)
this.emitFile?.(outputPath, result.typeDefinition); this.emitFile?.(outputPath, result.dts);
} }
} }
return `export default ${JSON.stringify(result.data, null, 2)};`; return result.js;
} }
+581 -288
View File
File diff suppressed because it is too large Load Diff
+32 -6
View File
@@ -1,7 +1,17 @@
import { parseSchema } from './parser'; import { parseSchema } from "./parser";
import { parseValue, createValidator } from './validator'; import { parseValue, createValidator, schemaToTypeString } from "./validator";
import type { Schema, PrimitiveSchema, TupleSchema, ArraySchema, ReferenceSchema, StringLiteralSchema, UnionSchema, ParsedSchema } from './types'; import type {
import { ParseError } from './parser'; Schema,
PrimitiveSchema,
TupleSchema,
ArraySchema,
ReferenceSchema,
ReverseReferenceSchema,
StringLiteralSchema,
UnionSchema,
ParsedSchema,
} from "./types";
import { ParseError } from "./parser";
export function defineSchema(schemaString: string): ParsedSchema { export function defineSchema(schemaString: string): ParsedSchema {
const schema = parseSchema(schemaString); const schema = parseSchema(schemaString);
@@ -14,5 +24,21 @@ export function defineSchema(schemaString: string): ParsedSchema {
}; };
} }
export { parseSchema, parseValue, createValidator, ParseError }; export {
export type { Schema, PrimitiveSchema, TupleSchema, ArraySchema, ReferenceSchema, StringLiteralSchema, UnionSchema, ParsedSchema }; parseSchema,
parseValue,
createValidator,
ParseError,
schemaToTypeString,
};
export type {
Schema,
PrimitiveSchema,
TupleSchema,
ArraySchema,
ReferenceSchema,
ReverseReferenceSchema,
StringLiteralSchema,
UnionSchema,
ParsedSchema,
};
+177 -108
View File
@@ -1,9 +1,34 @@
import type { Schema, PrimitiveSchema, TupleSchema, ArraySchema, NamedSchema, ReferenceSchema, StringLiteralSchema, UnionSchema } from './types'; import type {
Schema,
PrimitiveSchema,
TupleSchema,
ArraySchema,
NamedSchema,
ReferenceSchema,
ReverseReferenceSchema,
StringLiteralSchema,
UnionSchema,
} from "./types";
export class ParseError extends Error { export class ParseError extends Error {
constructor(message: string, public position?: number) { constructor(
super(position !== undefined ? `${message} at position ${position}` : message); message: string,
this.name = 'ParseError'; public position?: number,
public schema?: string,
public value?: string,
) {
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";
} }
} }
@@ -23,11 +48,11 @@ class Parser {
} }
private peek(): string { private peek(): string {
return this.input[this.pos] || ''; return this.input[this.pos] || "";
} }
private consume(): string { private consume(): string {
return this.input[this.pos++] || ''; return this.input[this.pos++] || "";
} }
private skipWhitespace(): void { private skipWhitespace(): void {
@@ -64,17 +89,17 @@ class Parser {
this.skipWhitespace(); this.skipWhitespace();
// Check for array suffix: type[] // Check for array suffix: type[]
if (this.consumeStr('[')) { if (this.consumeStr("[")) {
this.skipWhitespace(); this.skipWhitespace();
if (!this.consumeStr(']')) { if (!this.consumeStr("]")) {
throw new ParseError('Expected ]', this.pos); throw new ParseError("Expected ]", this.pos);
} }
schema = { type: 'array', element: schema }; schema = { type: "array", element: schema };
this.skipWhitespace(); this.skipWhitespace();
} }
// Check for union type (| symbol) // Check for union type (| symbol)
if (this.consumeStr('|')) { if (this.consumeStr("|")) {
const members: Schema[] = [schema]; const members: Schema[] = [schema];
while (true) { while (true) {
@@ -83,14 +108,14 @@ class Parser {
members.push(member); members.push(member);
this.skipWhitespace(); this.skipWhitespace();
if (!this.consumeStr('|')) { if (!this.consumeStr("|")) {
break; break;
} }
} }
return { return {
type: 'union', type: "union",
members members,
}; };
} }
@@ -101,23 +126,23 @@ class Parser {
this.skipWhitespace(); this.skipWhitespace();
// Check for parentheses (grouping) // Check for parentheses (grouping)
if (this.consumeStr('(')) { if (this.consumeStr("(")) {
this.skipWhitespace(); this.skipWhitespace();
const schema = this.parseSchema(); // Recursive call for nested unions const schema = this.parseSchema(); // Recursive call for nested unions
this.skipWhitespace(); this.skipWhitespace();
if (!this.consumeStr(')')) { if (!this.consumeStr(")")) {
throw new ParseError('Expected )', this.pos); throw new ParseError("Expected )", this.pos);
} }
// Check if there's an array suffix: (union)[] // Check if there's an array suffix: (union)[]
this.skipWhitespace(); this.skipWhitespace();
if (this.consumeStr('[')) { if (this.consumeStr("[")) {
this.skipWhitespace(); this.skipWhitespace();
if (!this.consumeStr(']')) { if (!this.consumeStr("]")) {
throw new ParseError('Expected ]', this.pos); throw new ParseError("Expected ]", this.pos);
} }
return { type: 'array', element: schema }; return { type: "array", element: schema };
} }
return schema; return schema;
@@ -128,86 +153,91 @@ class Parser {
return this.parseStringLiteralSchema(); return this.parseStringLiteralSchema();
} }
// Check for reverse reference syntax: ~tablename(foreignKey)
if (this.consumeStr("~")) {
return this.parseReverseReferenceSchema();
}
// Check for reference syntax: @tablename[] // Check for reference syntax: @tablename[]
if (this.consumeStr('@')) { if (this.consumeStr("@")) {
return this.parseReferenceSchema(); return this.parseReferenceSchema();
} }
if (this.consumeStr('string')) { if (this.consumeStr("string")) {
if (this.consumeStr('[')) { if (this.consumeStr("[")) {
this.skipWhitespace(); this.skipWhitespace();
if (!this.consumeStr(']')) { if (!this.consumeStr("]")) {
throw new ParseError('Expected ]', this.pos); throw new ParseError("Expected ]", this.pos);
} }
return { type: 'array', element: { type: 'string' } }; return { type: "array", element: { type: "string" } };
} }
return { type: 'string' }; return { type: "string" };
} }
if (this.consumeStr('number')) { if (this.consumeStr("number")) {
if (this.consumeStr('[')) { if (this.consumeStr("[")) {
this.skipWhitespace(); this.skipWhitespace();
if (!this.consumeStr(']')) { if (!this.consumeStr("]")) {
throw new ParseError('Expected ]', this.pos); throw new ParseError("Expected ]", this.pos);
} }
return { type: 'array', element: { type: 'number' } }; return { type: "array", element: { type: "number" } };
} }
return { type: 'number' }; return { type: "number" };
} }
if (this.consumeStr('int')) { if (this.consumeStr("int")) {
if (this.consumeStr('[')) { if (this.consumeStr("[")) {
this.skipWhitespace(); this.skipWhitespace();
if (!this.consumeStr(']')) { if (!this.consumeStr("]")) {
throw new ParseError('Expected ]', this.pos); throw new ParseError("Expected ]", this.pos);
} }
return { type: 'array', element: { type: 'int' } }; return { type: "array", element: { type: "int" } };
} }
return { type: 'int' }; return { type: "int" };
} }
if (this.consumeStr('float')) { if (this.consumeStr("float")) {
if (this.consumeStr('[')) { if (this.consumeStr("[")) {
this.skipWhitespace(); this.skipWhitespace();
if (!this.consumeStr(']')) { if (!this.consumeStr("]")) {
throw new ParseError('Expected ]', this.pos); throw new ParseError("Expected ]", this.pos);
} }
return { type: 'array', element: { type: 'float' } }; return { type: "array", element: { type: "float" } };
} }
return { type: 'float' }; return { type: "float" };
} }
if (this.consumeStr('boolean')) { if (this.consumeStr("boolean")) {
if (this.consumeStr('[')) { if (this.consumeStr("[")) {
this.skipWhitespace(); this.skipWhitespace();
if (!this.consumeStr(']')) { if (!this.consumeStr("]")) {
throw new ParseError('Expected ]', this.pos); throw new ParseError("Expected ]", this.pos);
} }
return { type: 'array', element: { type: 'boolean' } }; return { type: "array", element: { type: "boolean" } };
} }
return { type: 'boolean' }; return { type: "boolean" };
} }
if (this.consumeStr('[')) { if (this.consumeStr("[")) {
const elements: NamedSchema[] = []; const elements: NamedSchema[] = [];
this.skipWhitespace(); this.skipWhitespace();
if (this.peek() === ']') { if (this.peek() === "]") {
this.consume(); this.consume();
throw new ParseError('Empty array/tuple not allowed', this.pos); throw new ParseError("Empty array/tuple not allowed", this.pos);
} }
elements.push(this.parseNamedSchema()); elements.push(this.parseNamedSchema());
this.skipWhitespace(); this.skipWhitespace();
if (this.consumeStr(';')) { if (this.consumeStr(";")) {
const remainingElements: NamedSchema[] = []; const remainingElements: NamedSchema[] = [];
while (true) { while (true) {
this.skipWhitespace(); this.skipWhitespace();
remainingElements.push(this.parseNamedSchema()); remainingElements.push(this.parseNamedSchema());
this.skipWhitespace(); this.skipWhitespace();
if (!this.consumeStr(';')) { if (!this.consumeStr(";")) {
break; break;
} }
} }
@@ -216,73 +246,54 @@ class Parser {
this.skipWhitespace(); this.skipWhitespace();
if (!this.consumeStr(']')) { if (!this.consumeStr("]")) {
throw new ParseError('Expected ]', this.pos); throw new ParseError("Expected ]", this.pos);
} }
if (this.consumeStr('[')) { // [t] is always a 1-tuple (the [Type][] array form was removed)
this.skipWhitespace(); return { type: "tuple", elements };
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) { throw new ParseError(
return { type: 'array', element: elements[0].schema }; `Unknown type: ${this.peek() || "end of input"}`,
} this.pos,
return { type: 'tuple', elements }; );
}
let identifier = '';
while (this.pos < this.input.length && /[a-zA-Z0-9\-_]/.test(this.peek())) {
identifier += this.consume();
}
if (identifier.length > 0) {
if (this.consumeStr('[')) {
this.skipWhitespace();
if (!this.consumeStr(']')) {
throw new ParseError('Expected ]', this.pos);
}
return { type: 'array', element: { type: 'string' } };
}
return { type: 'string' };
}
throw new ParseError(`Unexpected character: ${this.peek()}`, this.pos);
} }
private parseStringLiteralSchema(): Schema { private parseStringLiteralSchema(): Schema {
const value = this.parseStringLiteral(); const value = this.parseStringLiteral();
return { return {
type: 'stringLiteral', type: "stringLiteral",
value value,
}; };
} }
private parseStringLiteral(): string { private parseStringLiteral(): string {
const quote = this.peek(); const quote = this.peek();
if (quote !== '"' && quote !== "'") { if (quote !== '"' && quote !== "'") {
throw new ParseError('Expected string literal with quotes', this.pos); throw new ParseError("Expected string literal with quotes", this.pos);
} }
this.consume(); // Consume opening quote this.consume(); // Consume opening quote
let value = ''; let value = "";
while (this.pos < this.input.length) { while (this.pos < this.input.length) {
const char = this.peek(); const char = this.peek();
if (char === '\\') { if (char === "\\") {
this.consume(); this.consume();
const nextChar = this.consume(); const nextChar = this.consume();
if (nextChar === '"' || nextChar === "'" || nextChar === '\\' || if (
nextChar === '|' || nextChar === ';' || nextChar === '(' || nextChar === ')') { nextChar === '"' ||
nextChar === "'" ||
nextChar === "\\" ||
nextChar === "|" ||
nextChar === ";" ||
nextChar === "(" ||
nextChar === ")"
) {
value += nextChar; value += nextChar;
} else { } else {
value += '\\' + nextChar; value += "\\" + nextChar;
} }
} else if (char === quote) { } else if (char === quote) {
this.consume(); // Consume closing quote this.consume(); // Consume closing quote
@@ -292,14 +303,14 @@ class Parser {
} }
} }
throw new ParseError('Unterminated string literal', this.pos); throw new ParseError("Unterminated string literal", this.pos);
} }
private parseNamedSchema(): NamedSchema { private parseNamedSchema(): NamedSchema {
this.skipWhitespace(); this.skipWhitespace();
const startpos = this.pos; const startpos = this.pos;
let identifier = ''; let identifier = "";
while (this.pos < this.input.length && /[a-zA-Z0-9\-_]/.test(this.peek())) { while (this.pos < this.input.length && /[a-zA-Z0-9\-_]/.test(this.peek())) {
identifier += this.consume(); identifier += this.consume();
@@ -312,7 +323,7 @@ class Parser {
this.skipWhitespace(); this.skipWhitespace();
if (this.consumeStr(':')) { if (this.consumeStr(":")) {
this.skipWhitespace(); this.skipWhitespace();
const name = identifier; const name = identifier;
const schema = this.parseSchema(); const schema = this.parseSchema();
@@ -326,31 +337,89 @@ class Parser {
private parseReferenceSchema(): Schema { private parseReferenceSchema(): Schema {
// Parse table name // Parse table name
let tableName = ''; let tableName = "";
while (this.pos < this.input.length && /[a-zA-Z0-9\-_]/.test(this.peek())) { while (this.pos < this.input.length && /[a-zA-Z0-9\-_]/.test(this.peek())) {
tableName += this.consume(); tableName += this.consume();
} }
if (tableName.length === 0) { if (tableName.length === 0) {
throw new ParseError('Expected table name after @', this.pos); throw new ParseError("Expected table name after @", this.pos);
} }
this.skipWhitespace(); this.skipWhitespace();
// Check for array syntax // Check for array syntax
if (this.consumeStr('[]')) { if (this.consumeStr("[]")) {
this.skipWhitespace();
const isOptional = this.consumeStr("?");
return { return {
type: 'reference', type: "reference",
tableName, tableName,
isArray: true, isArray: true,
isOptional,
}; };
} }
// Check for optional suffix
const isOptional = this.consumeStr("?");
// Single reference (non-array) // Single reference (non-array)
return { return {
type: 'reference', type: "reference",
tableName, tableName,
isArray: false, isArray: false,
isOptional,
};
}
private parseReverseReferenceSchema(): Schema {
// Parse table name
let tableName = "";
while (this.pos < this.input.length && /[a-zA-Z0-9\-_]/.test(this.peek())) {
tableName += this.consume();
}
if (tableName.length === 0) {
throw new ParseError("Expected table name after ~", this.pos);
}
this.skipWhitespace();
// Parse (foreignKey)
if (!this.consumeStr("(")) {
throw new ParseError(
"Expected ( after reverse reference table name",
this.pos,
);
}
this.skipWhitespace();
let foreignKey = "";
while (this.pos < this.input.length && /[a-zA-Z0-9\-_]/.test(this.peek())) {
foreignKey += this.consume();
}
if (foreignKey.length === 0) {
throw new ParseError("Expected foreign key name inside ()", this.pos);
}
this.skipWhitespace();
if (!this.consumeStr(")")) {
throw new ParseError("Expected ) after foreign key name", this.pos);
}
this.skipWhitespace();
// Check for optional suffix
const isOptional = this.consumeStr("?");
return {
type: "reverseReference",
tableName,
foreignKey,
isOptional,
}; };
} }
} }
@@ -360,7 +429,7 @@ export function parseSchema(schemaString: string): Schema {
const schema = parser.parseSchema(); const schema = parser.parseSchema();
if (parser.getPosition() < parser.getInputLength()) { if (parser.getPosition() < parser.getInputLength()) {
throw new ParseError('Unexpected input after schema', parser.getPosition()); throw new ParseError("Unexpected input after schema", parser.getPosition());
} }
return schema; return schema;
-131
View File
@@ -1,131 +0,0 @@
import { defineSchema, parseSchema, parseValue, createValidator } from './index';
console.log('=== Testing Schema Parser ===\n');
const testCases = [
{ schema: 'string', value: 'hello', description: 'Simple string' },
{ schema: 'number', value: '42', description: 'Simple number' },
{ schema: 'int', value: '42', description: 'Simple int' },
{ schema: 'float', value: '3.14', description: 'Simple float' },
{ schema: 'float', value: '42', description: 'Float with integer value' },
{ schema: 'boolean', value: 'true', description: 'Simple boolean' },
{ schema: '[string; number]', value: '[hello; 42]', description: 'Tuple' },
{ schema: '[string; number]', value: 'hello; 42', description: 'Tuple without brackets' },
{ schema: 'string[]', value: '[hello; world; test]', description: 'Array of strings' },
{ schema: 'string[]', value: 'hello; world; test', description: 'Array without brackets' },
{ schema: 'number[]', value: '[1; 2; 3; 4]', description: 'Array of numbers' },
{ schema: 'int[]', value: '[1; 2; 3; 4]', description: 'Array of ints' },
{ schema: 'float[]', value: '[1.5; 2.5; 3.5]', description: 'Array of floats' },
{ schema: '[string; number][]', value: '[[a; 1]; [b; 2]; [c; 3]]', description: 'Array of tuples' },
{ schema: '[string; number][]', value: '[a; 1]; [b; 2]; [c; 3]', description: 'Array of tuples without outer brackets' },
{ schema: 'word-smith', value: 'word-smith', description: 'String with hyphen' },
{ schema: 'string', value: 'hello\\;world', description: 'Escaped semicolon' },
{ schema: 'string', value: 'hello\\[world', description: 'Escaped bracket' },
{ schema: 'string', value: 'hello\\\\world', description: 'Escaped backslash' },
{ schema: '[string; string]', value: 'hello\\;world; test', description: 'Tuple with escaped semicolon' },
{ schema: '[x: number; y: number]', value: '[x: 10; y: 20]', description: 'Named tuple' },
{ schema: '[x: number; y: number]', value: 'x: 10; y: 20', description: 'Named tuple without brackets' },
{ schema: '[name: string; age: number; active: boolean]', value: '[name: Alice; age: 30; active: true]', description: 'Named tuple with mixed types' },
{ schema: '[name: string; age: number]', value: 'name: Bob; age: 25', description: 'Named tuple without brackets' },
{ schema: '[point: [x: number; y: number]]', value: '[point: [x: 5; y: 10]]', description: 'Nested named tuple' },
];
console.log('=== Testing String Literals ===\n');
const stringLiteralCases = [
{ schema: '"hello"', value: '"hello"', description: 'Simple string literal' },
{ schema: "'world'", value: "'world'", description: 'Single quoted string literal' },
{ schema: '"on"', value: '"on"', description: 'String literal "on"' },
{ schema: '"off"', value: '"off"', description: 'String literal "off"' },
{ schema: '"hello;world"', value: '"hello;world"', description: 'String literal with semicolon' },
{ schema: '"hello\\"world"', value: '"hello\\"world"', description: 'String literal with escaped quote' },
];
testCases.push(...stringLiteralCases);
console.log('=== Testing Union Types (Enums) ===\n');
const unionCases = [
{ schema: '"on" | "off"', value: '"on"', description: 'Union: on' },
{ schema: '"on" | "off"', value: '"off"', description: 'Union: off' },
{ schema: '"pending" | "approved" | "rejected"', value: '"approved"', description: 'Union: approved' },
{ schema: '( "active" | "inactive" )', value: '"active"', description: 'Union with parentheses' },
{ schema: '[name: string; status: "active" | "inactive"]', value: '[myName; "active"]', description: 'Tuple with union field' },
{ schema: '("pending" | "approved" | "rejected")[]', value: '["pending"; "approved"; "rejected"]', description: 'Array of unions' },
{ schema: 'string | number', value: 'hello', description: 'Union: string' },
{ schema: 'string | number', value: '42', description: 'Union: number' },
{ schema: 'string | "special"', value: 'normal', description: 'Union: string type' },
{ schema: 'string | "special"', value: '"special"', description: 'Union: string literal' },
];
testCases.push(...unionCases);
testCases.forEach(({ schema, value, description }) => {
try {
console.log(`Test: ${description}`);
console.log(` Schema: ${schema}`);
console.log(` Value: "${value}"`);
const parsed = defineSchema(schema);
const parsedValue = parsed.parse(value);
const isValid = parsed.validator(parsedValue);
console.log(` Parsed: ${JSON.stringify(parsedValue)}`);
console.log(` Valid: ${isValid}`);
console.log(' ✓ Passed\n');
} catch (error) {
console.log(` ✗ Failed: ${(error as Error).message}\n`);
}
});
console.log('=== Testing Validation ===\n');
const stringSchema = defineSchema('string');
console.log('String schema validation:');
console.log(` "hello" is valid: ${stringSchema.validator('hello')}`);
console.log(` 42 is valid: ${stringSchema.validator(42)}\n`);
const numberSchema = defineSchema('number');
console.log('Number schema validation:');
console.log(` 42 is valid: ${numberSchema.validator(42)}`);
console.log(` "42" is valid: ${numberSchema.validator('42')}\n`);
const tupleSchema = defineSchema('[string; number; boolean]');
console.log('Tuple [string; number; boolean] validation:');
console.log(` ["hello", 42, true] is valid: ${tupleSchema.validator(['hello', 42, true])}`);
console.log(` ["hello", "42", true] is valid: ${tupleSchema.validator(['hello', '42', true])}\n`);
const arraySchema = defineSchema('number[]');
console.log('Array number[] validation:');
console.log(` [1, 2, 3] is valid: ${arraySchema.validator([1, 2, 3])}`);
console.log(` [1, "2", 3] is valid: ${arraySchema.validator([1, '2', 3])}\n`);
const arrayOfTuplesSchema = defineSchema('[string; number][]');
console.log('Array of tuples [string; number][] validation:');
console.log(` [["a", 1], ["b", 2]] is valid: ${arrayOfTuplesSchema.validator([['a', 1], ['b', 2]])}`);
console.log(` [["a", "1"], ["b", 2]] is valid: ${arrayOfTuplesSchema.validator([['a', '1'], ['b', 2]])}\n`);
console.log('=== Testing Int and Float Types ===\n');
const intSchema = defineSchema('int');
console.log('Int schema validation:');
console.log(` 42 is valid: ${intSchema.validator(42)}`);
console.log(` 3.14 is valid: ${intSchema.validator(3.14)}\n`);
const floatSchema = defineSchema('float');
console.log('Float schema validation:');
console.log(` 3.14 is valid: ${floatSchema.validator(3.14)}`);
console.log(` 42 is valid: ${floatSchema.validator(42)}`);
console.log(` "3.14" is valid: ${floatSchema.validator('3.14')}\n`);
const intArraySchema = defineSchema('int[]');
console.log('Int array validation:');
console.log(` [1, 2, 3] is valid: ${intArraySchema.validator([1, 2, 3])}`);
console.log(` [1, 2.5, 3] is valid: ${intArraySchema.validator([1, 2.5, 3])}\n`);
const floatArraySchema = defineSchema('float[]');
console.log('Float array validation:');
console.log(` [1.5, 2.5, 3.5] is valid: ${floatArraySchema.validator([1.5, 2.5, 3.5])}`);
console.log(` [1, 2, 3] is valid: ${floatArraySchema.validator([1, 2, 3])}\n`);
console.log('=== All tests completed ===');
+12 -38
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { defineSchema, parseSchema } from './index'; import { defineSchema, parseSchema, schemaToTypeString } from './index';
import type { Schema, StringLiteralSchema, UnionSchema } from './types'; import type { Schema, StringLiteralSchema, UnionSchema } from './types';
describe('Type generation for string literals and unions', () => { describe('Type generation for string literals and unions', () => {
@@ -136,65 +136,39 @@ describe('Type generation for string literals and unions', () => {
}); });
describe('Type string generation', () => { describe('Type string generation', () => {
// Helper function to test type generation
function generateType(schema: Schema): string {
function toType(s: Schema): string {
switch (s.type) {
case 'string': return 'string';
case 'number':
case 'int':
case 'float': return 'number';
case 'boolean': return 'boolean';
case 'stringLiteral': return `"${s.value}"`;
case 'union': return s.members.map(m => toType(m)).join(' | ');
case 'array':
const elemType = toType(s.element);
if (s.element.type === 'union') {
return `readonly (${elemType})[]`;
}
return `readonly ${elemType}[]`;
case 'tuple':
const elements = s.elements.map(el => {
const typeStr = toType(el.schema);
return el.name ? `readonly ${el.name}: ${typeStr}` : typeStr;
});
return `readonly [${elements.join(', ')}]`;
default: return 'unknown';
}
}
return toType(schema);
}
it('should generate "on" for string literal', () => { it('should generate "on" for string literal', () => {
const schema = parseSchema('"on"'); const schema = parseSchema('"on"');
expect(generateType(schema)).toBe('"on"'); expect(schemaToTypeString(schema)).toBe('"on"');
}); });
it('should generate "on" | "off" for union of string literals', () => { it('should generate "on" | "off" for union of string literals', () => {
const schema = parseSchema('"on" | "off"'); const schema = parseSchema('"on" | "off"');
expect(generateType(schema)).toBe('"on" | "off"'); expect(schemaToTypeString(schema)).toBe('"on" | "off"');
}); });
it('should generate correct type for array of string literals', () => { it('should generate correct type for array of string literals', () => {
const schema = parseSchema('"item"[]'); const schema = parseSchema('"item"[]');
expect(generateType(schema)).toBe('readonly "item"[]'); expect(schemaToTypeString(schema)).toBe('"item"[]');
}); });
it('should generate correct type for array of unions', () => { it('should generate correct type for array of unions', () => {
const schema = parseSchema('("pending" | "approved" | "rejected")[]'); const schema = parseSchema('("pending" | "approved" | "rejected")[]');
// Union types in arrays need parentheses for correct precedence expect(schemaToTypeString(schema)).toBe('("pending" | "approved" | "rejected")[]');
expect(generateType(schema)).toBe('readonly ("pending" | "approved" | "rejected")[]');
}); });
it('should generate correct type for tuple with union field', () => { it('should generate correct type for tuple with union field', () => {
const schema = parseSchema('[name: string; status: "active" | "inactive"]'); const schema = parseSchema('[name: string; status: "active" | "inactive"]');
// Note: tuple elements have readonly modifier expect(schemaToTypeString(schema)).toBe('[name: string, status: "active" | "inactive"]');
expect(generateType(schema)).toBe('readonly [readonly name: string, readonly status: "active" | "inactive"]');
}); });
it('should generate correct type for complex union', () => { it('should generate correct type for complex union', () => {
const schema = parseSchema('string | number | "special"'); const schema = parseSchema('string | number | "special"');
expect(generateType(schema)).toBe('string | number | "special"'); expect(schemaToTypeString(schema)).toBe('string | number | "special"');
});
it('should generate correct type for nested tuple with references', () => {
const schema = parseSchema('[data: @enemy; hp: int; ([effect: @effect;stacks: int])[]][]');
expect(schemaToTypeString(schema)).toBe('[data: Enemy, hp: number, [effect: Effect, stacks: number][]][]');
}); });
}); });
}); });
+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;
}
};
}
+26 -7
View File
@@ -1,4 +1,4 @@
export type SchemaType = 'string' | 'number' | 'int' | 'float' | 'boolean'; export type SchemaType = "string" | "number" | "int" | "float" | "boolean";
export interface PrimitiveSchema { export interface PrimitiveSchema {
type: SchemaType; type: SchemaType;
@@ -10,34 +10,53 @@ export interface NamedSchema {
} }
export interface TupleSchema { export interface TupleSchema {
type: 'tuple'; type: "tuple";
elements: NamedSchema[]; elements: NamedSchema[];
} }
export interface ArraySchema { export interface ArraySchema {
type: 'array'; type: "array";
element: Schema; element: Schema;
} }
export interface ReferenceSchema { export interface ReferenceSchema {
type: 'reference'; type: "reference";
/** Referenced table name (e.g., 'parts' from '@parts[]') */ /** Referenced table name (e.g., 'parts' from '@parts[]') */
tableName: string; tableName: string;
/** Whether it's an array reference */ /** Whether it's an array reference */
isArray: boolean; isArray: boolean;
/** Whether it's optional (allows empty input resolving to null) */
isOptional?: boolean;
}
export interface ReverseReferenceSchema {
type: "reverseReference";
/** Referenced table name (e.g., 'orders' from '~orders(user)') */
tableName: string;
/** The foreign key field name in the referenced table (e.g., 'user' from '~orders(user)') */
foreignKey: string;
/** Whether it's optional (null if no matches, instead of empty array) */
isOptional?: boolean;
} }
export interface StringLiteralSchema { export interface StringLiteralSchema {
type: 'stringLiteral'; type: "stringLiteral";
value: string; // The literal string value (without quotes) value: string; // The literal string value (without quotes)
} }
export interface UnionSchema { export interface UnionSchema {
type: 'union'; type: "union";
members: Schema[]; // Union members members: Schema[]; // Union members
} }
export type Schema = PrimitiveSchema | TupleSchema | ArraySchema | ReferenceSchema | StringLiteralSchema | UnionSchema; export type Schema =
| PrimitiveSchema
| TupleSchema
| ArraySchema
| ReferenceSchema
| ReverseReferenceSchema
| StringLiteralSchema
| UnionSchema;
export interface ParsedSchema { export interface ParsedSchema {
schema: Schema; schema: Schema;
+3 -425
View File
@@ -1,425 +1,3 @@
import type { Schema, PrimitiveSchema, TupleSchema, ArraySchema, NamedSchema, ReferenceSchema, StringLiteralSchema, UnionSchema } from './types'; // Re-export everything from the split modules
import { ParseError } from './parser'; export { parseValue } from "./value-parser";
export { schemaToTypeString, createValidator } from "./type-utils";
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);
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[] {
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 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':
// Reference can be a string (single ID) or array of strings (IDs)
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'));
default:
return false;
}
};
}
+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;
}