Compare commits

..
64 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
hypercross 8343df2316 fix: bug fixes and new tests 2026-04-15 13:58:14 +08:00
hypercross d5fdb69ad7 fix: fix nested references? 2026-04-15 13:24:51 +08:00
hypercross 97c8b1966c refactor: support string literals without quotes 2026-04-13 10:45:54 +08:00
hypercross 51a11a26bf chore: add tests 2026-04-13 10:18:12 +08:00
hypercross 14948fb5f6 feat: union and string literals 2026-04-13 10:05:49 +08:00
hypercross d78ef75272 fix: typing and remove singularization 2026-04-11 23:05:22 +08:00
hypercross daac7badbb feat: parse references 2026-04-11 22:56:01 +08:00
hypercross 3051df3e8b fix: skip empty lines 2026-04-07 12:11:01 +08:00
hypercross ad38976e86 refactor: reorg/remove webpack dependency 2026-04-07 11:25:02 +08:00
hypercross 77ee96d70c feat: esbuild csv-loader support 2026-04-07 11:20:12 +08:00
hypercross 4eb6a17e9f refactor: table name deduction & readonly type 2026-04-05 12:43:23 +08:00
hypercross f656a62606 fix: add int/float to type gen 2026-04-04 17:08:29 +08:00
hypercross 4146fda348 feat: add int/float to types 2026-04-04 17:05:25 +08:00
hypercross 8bf28c8aed fix: types for rollup 2026-04-04 16:55:17 +08:00
hypercross 08cac3965e feat: add inline-schema/csv-loader/rollup for tsup/vite 2026-04-04 16:44:03 +08:00
hyper d9a91ae8be Merge branch 'master' of https://gitea.ayi-games.online/hypercross/inline-schema 2026-04-02 21:04:16 +08:00
hyper da1e3b2dac refactor: remove dist/ from git 2026-04-02 21:03:57 +08:00
57 changed files with 6207 additions and 2642 deletions
+2 -2
View File
@@ -4,8 +4,8 @@ package-lock.json
pnpm-lock.yaml pnpm-lock.yaml
yarn.lock yarn.lock
# Build output (kept in git for npm distribution) # Build output
# dist/ dist/
# TypeScript Cache # TypeScript Cache
*.tsbuildinfo *.tsbuildinfo
+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',
}, },
], ],
}, },
+62 -7
View File
@@ -1,18 +1,18 @@
# inline-schema/csv-loader # typed-csv/csv-loader
A rspack 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
@@ -24,6 +24,8 @@ Alice,30,true,[90; 85; 95]
Bob,25,false,[75; 80; 70] Bob,25,false,[75; 80; 70]
``` ```
## rspack/webpack
### rspack.config.js ### rspack.config.js
```javascript ```javascript
@@ -33,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: '"',
@@ -52,6 +54,47 @@ module.exports = {
}; };
``` ```
## Vite
### vite.config.ts
```typescript
import { defineConfig } from 'vite';
import { csvLoader } from 'typed-csv/csv-loader/rollup';
export default defineConfig({
plugins: [
csvLoader({
delimiter: ',',
quote: '"',
escape: '\\',
bom: true,
comment: '#',
trim: true,
// emitTypes: false,
// typesOutputDir: 'types',
// writeToDisk: true,
}),
],
});
```
## Tsup
### tsup.config.ts
```typescript
import { defineConfig } from 'tsup';
import { csvLoader } from 'typed-csv/csv-loader/rollup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
dts: true,
plugins: [csvLoader()],
});
```
### Generated TypeScript Types ### Generated TypeScript Types
当 `emitTypes: true` 时,loader 会自动生成 `.d.ts` 类型定义文件: 当 `emitTypes: true` 时,loader 会自动生成 `.d.ts` 类型定义文件:
@@ -91,19 +134,31 @@ import data from './data.csv';
| `emitTypes` | boolean | `true` | Generate TypeScript declaration file (.d.ts) | | `emitTypes` | boolean | `true` | Generate TypeScript declaration file (.d.ts) |
| `typesOutputDir` | string | `''` | Output directory for generated type files (relative to output path) | | `typesOutputDir` | string | `''` | Output directory for generated type files (relative to output path) |
| `writeToDisk` | boolean | `false` | Write .d.ts files directly to disk (useful for dev server) | | `writeToDisk` | boolean | `false` | Write .d.ts files directly to disk (useful for dev server) |
| `include` | RegExp \| string \| Array | `/\.csv$/` | Include pattern for CSV files (Rollup only) |
| `exclude` | RegExp \| string \| Array | - | Exclude pattern for CSV files (Rollup only) |
## 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
-54
View File
@@ -1,54 +0,0 @@
import { LoaderContext } from '@rspack/core';
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;
}
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[];
}
interface PropertyConfig {
name: string;
schema: any;
validator: (value: unknown) => boolean;
parser: (valueString: string) => unknown;
}
/**
* Parse CSV content string into structured data with schema validation.
* This is a standalone function that doesn't depend on webpack/rspack LoaderContext.
*
* @param content - CSV content string (must have at least headers + schema row + 1 data row)
* @param options - Parsing options
* @returns CsvParseResult containing parsed data and optional type definitions
*/
declare function parseCsv(content: string, options?: CsvLoaderOptions): CsvParseResult;
/**
* 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
*/
declare function csvToModule(content: string, options?: CsvLoaderOptions): {
js: string;
dts?: string;
};
declare function csvLoader(this: LoaderContext<CsvLoaderOptions>, content: string): string | Buffer;
export { type CsvLoaderOptions, type CsvParseResult, csvToModule, csvLoader as default, parseCsv };
-54
View File
@@ -1,54 +0,0 @@
import { LoaderContext } from '@rspack/core';
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;
}
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[];
}
interface PropertyConfig {
name: string;
schema: any;
validator: (value: unknown) => boolean;
parser: (valueString: string) => unknown;
}
/**
* Parse CSV content string into structured data with schema validation.
* This is a standalone function that doesn't depend on webpack/rspack LoaderContext.
*
* @param content - CSV content string (must have at least headers + schema row + 1 data row)
* @param options - Parsing options
* @returns CsvParseResult containing parsed data and optional type definitions
*/
declare function parseCsv(content: string, options?: CsvLoaderOptions): CsvParseResult;
/**
* 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
*/
declare function csvToModule(content: string, options?: CsvLoaderOptions): {
js: string;
dts?: string;
};
declare function csvLoader(this: LoaderContext<CsvLoaderOptions>, content: string): string | Buffer;
export { type CsvLoaderOptions, type CsvParseResult, csvToModule, csvLoader as default, parseCsv };
-548
View File
@@ -1,548 +0,0 @@
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/csv-loader/loader.ts
var loader_exports = {};
__export(loader_exports, {
csvToModule: () => csvToModule,
default: () => csvLoader,
parseCsv: () => parseCsv
});
module.exports = __toCommonJS(loader_exports);
var import_sync = require("csv-parse/sync");
// src/parser.ts
var ParseError = class extends Error {
constructor(message, position) {
super(position !== void 0 ? `${message} at position ${position}` : message);
this.position = position;
this.name = "ParseError";
}
};
var Parser = class {
constructor(input) {
this.pos = 0;
this.input = input;
}
peek() {
return this.input[this.pos] || "";
}
consume() {
return this.input[this.pos++] || "";
}
skipWhitespace() {
while (this.pos < this.input.length && /\s/.test(this.input[this.pos])) {
this.pos++;
}
}
match(str) {
return this.input.slice(this.pos, this.pos + str.length) === str;
}
consumeStr(str) {
if (this.match(str)) {
this.pos += str.length;
return true;
}
return false;
}
getPosition() {
return this.pos;
}
getInputLength() {
return this.input.length;
}
parseSchema() {
this.skipWhitespace();
if (this.consumeStr("string")) {
if (this.consumeStr("[")) {
this.skipWhitespace();
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
return { type: "array", element: { type: "string" } };
}
return { type: "string" };
}
if (this.consumeStr("number")) {
if (this.consumeStr("[")) {
this.skipWhitespace();
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
return { type: "array", element: { type: "number" } };
}
return { type: "number" };
}
if (this.consumeStr("boolean")) {
if (this.consumeStr("[")) {
this.skipWhitespace();
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
return { type: "array", element: { type: "boolean" } };
}
return { type: "boolean" };
}
if (this.consumeStr("[")) {
const elements = [];
this.skipWhitespace();
if (this.peek() === "]") {
this.consume();
throw new ParseError("Empty array/tuple not allowed", this.pos);
}
elements.push(this.parseNamedSchema());
this.skipWhitespace();
if (this.consumeStr(";")) {
const remainingElements = [];
while (true) {
this.skipWhitespace();
remainingElements.push(this.parseNamedSchema());
this.skipWhitespace();
if (!this.consumeStr(";")) {
break;
}
}
elements.push(...remainingElements);
}
this.skipWhitespace();
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
if (this.consumeStr("[")) {
this.skipWhitespace();
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
if (elements.length === 1 && !elements[0].name) {
return { type: "array", element: elements[0].schema };
}
return { type: "array", element: { type: "tuple", elements } };
}
if (elements.length === 1 && !elements[0].name) {
return { type: "array", element: elements[0].schema };
}
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);
}
parseNamedSchema() {
this.skipWhitespace();
const startpos = this.pos;
let identifier = "";
while (this.pos < this.input.length && /[a-zA-Z0-9\-_]/.test(this.peek())) {
identifier += this.consume();
}
if (identifier.length === 0) {
throw new ParseError("Expected schema or named schema", this.pos);
}
this.skipWhitespace();
if (this.consumeStr(":")) {
this.skipWhitespace();
const name = identifier;
const schema = this.parseSchema();
return { name, schema };
} else {
this.pos = startpos;
const schema = this.parseSchema();
return { schema };
}
}
};
function parseSchema(schemaString) {
const parser = new Parser(schemaString.trim());
const schema = parser.parseSchema();
if (parser.getPosition() < parser.getInputLength()) {
throw new ParseError("Unexpected input after schema", parser.getPosition());
}
return schema;
}
// src/validator.ts
var ValueParser = class {
constructor(input) {
this.pos = 0;
this.input = input;
}
peek() {
return this.input[this.pos] || "";
}
consume() {
return this.input[this.pos++] || "";
}
skipWhitespace() {
while (this.pos < this.input.length && /\s/.test(this.input[this.pos])) {
this.pos++;
}
}
consumeStr(str) {
if (this.input.slice(this.pos, this.pos + str.length) === str) {
this.pos += str.length;
return true;
}
return false;
}
parseValue(schema, allowOmitBrackets = false) {
this.skipWhitespace();
switch (schema.type) {
case "string":
return this.parseStringValue();
case "number":
return this.parseNumberValue();
case "boolean":
return this.parseBooleanValue();
case "tuple":
return this.parseTupleValue(schema, allowOmitBrackets);
case "array":
return this.parseArrayValue(schema, allowOmitBrackets);
default:
throw new ParseError(`Unknown schema type: ${schema.type}`, this.pos);
}
}
parseStringValue() {
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();
}
parseNumberValue() {
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;
}
parseBooleanValue() {
if (this.consumeStr("true")) {
return true;
}
if (this.consumeStr("false")) {
return false;
}
throw new ParseError("Expected true or false", this.pos);
}
parseTupleValue(schema, allowOmitBrackets) {
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 = [];
for (let i = 0; i < schema.elements.length; i++) {
this.skipWhitespace();
const elementSchema = schema.elements[i];
if (elementSchema.name) {
this.skipWhitespace();
const savedPos = this.pos;
if (this.consumeStr(`${elementSchema.name}:`)) {
this.skipWhitespace();
} else {
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;
}
parseArrayValue(schema, allowOmitBrackets) {
let hasOpenBracket = false;
const elementIsTupleOrArray = schema.element.type === "tuple" || schema.element.type === "array";
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 = [];
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;
}
getPosition() {
return this.pos;
}
getInputLength() {
return this.input.length;
}
};
function parseValue(schema, valueString) {
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;
}
function createValidator(schema) {
return function validate(value) {
switch (schema.type) {
case "string":
return typeof value === "string";
case "number":
return typeof value === "number" && !isNaN(value);
case "boolean":
return typeof value === "boolean";
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));
default:
return false;
}
};
}
// src/csv-loader/loader.ts
var path = __toESM(require("path"));
var fs = __toESM(require("fs"));
function schemaToTypeString(schema) {
switch (schema.type) {
case "string":
return "string";
case "number":
return "number";
case "boolean":
return "boolean";
case "array":
if (schema.element.type === "tuple") {
const tupleElements2 = schema.element.elements.map((el) => {
const typeStr = schemaToTypeString(el.schema);
return el.name ? `${el.name}: ${typeStr}` : typeStr;
});
return `[${tupleElements2.join(", ")}]`;
}
return `${schemaToTypeString(schema.element)}[]`;
case "tuple":
const tupleElements = schema.elements.map((el) => {
const typeStr = schemaToTypeString(el.schema);
return el.name ? `${el.name}: ${typeStr}` : typeStr;
});
return `[${tupleElements.join(", ")}]`;
default:
return "unknown";
}
}
function generateTypeDefinition(resourceName, propertyConfigs) {
const properties = propertyConfigs.map((config) => ` ${config.name}: ${schemaToTypeString(config.schema)};`).join("\n");
return `type Table = {
${properties}
}[];
declare const data: Table;
export default data;
`;
}
function parseCsv(content, options = {}) {
const delimiter = options.delimiter ?? ",";
const quote = options.quote ?? '"';
const escape = options.escape ?? "\\";
const bom = options.bom ?? true;
const comment = options.comment === false ? void 0 : options.comment ?? "#";
const trim = options.trim ?? true;
const emitTypes = options.emitTypes ?? true;
const records = (0, import_sync.parse)(content, {
delimiter,
quote,
escape,
bom,
comment,
trim,
relax_column_count: true
});
if (records.length < 2) {
throw new Error("CSV must have at least 2 rows: headers and schemas");
}
const headers = records[0];
const schemas = records[1];
if (headers.length !== schemas.length) {
throw new Error(
`Header count (${headers.length}) does not match schema count (${schemas.length})`
);
}
const propertyConfigs = headers.map((header, index) => {
const schemaString = schemas[index];
const schema = parseSchema(schemaString);
return {
name: header,
schema,
validator: createValidator(schema),
parser: (valueString) => parseValue(schema, valueString)
};
});
const dataRows = records.slice(2);
const objects = dataRows.map((row, rowIndex) => {
const obj = {};
propertyConfigs.forEach((config, colIndex) => {
const rawValue = row[colIndex] ?? "";
try {
const parsed = config.parser(rawValue);
if (!config.validator(parsed)) {
throw new Error(
`Validation failed for property "${config.name}" at row ${rowIndex + 3}: ${rawValue}`
);
}
obj[config.name] = parsed;
} catch (error) {
if (error instanceof Error) {
throw new Error(
`Failed to parse property "${config.name}" at row ${rowIndex + 3}, column ${colIndex + 1}: ${error.message}`
);
}
throw error;
}
});
return obj;
});
const result = {
data: objects,
propertyConfigs
};
if (emitTypes) {
result.typeDefinition = generateTypeDefinition("", propertyConfigs);
}
return result;
}
function csvToModule(content, options = {}) {
const result = parseCsv(content, options);
const json = JSON.stringify(result.data, null, 2);
const js = `export default ${json};`;
return {
js,
dts: result.typeDefinition
};
}
function csvLoader(content) {
const options = this.getOptions();
const emitTypes = options?.emitTypes ?? true;
const typesOutputDir = options?.typesOutputDir ?? "";
const writeToDisk = options?.writeToDisk ?? false;
const result = parseCsv(content, options);
if (emitTypes && result.typeDefinition) {
const context = this.context || "";
let relativePath = this.resourcePath.replace(context, "");
if (relativePath.startsWith("\\") || relativePath.startsWith("/")) {
relativePath = relativePath.substring(1);
}
relativePath = relativePath.replace(/\\/g, "/");
const dtsFileName = `${relativePath}.d.ts`;
const outputPath = typesOutputDir ? path.join(typesOutputDir, dtsFileName) : dtsFileName;
if (writeToDisk) {
const absolutePath = path.join(this.context || process.cwd(), typesOutputDir || "", dtsFileName);
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
fs.writeFileSync(absolutePath, result.typeDefinition);
} else {
this.emitFile?.(outputPath, result.typeDefinition);
}
}
return `export default ${JSON.stringify(result.data, null, 2)};`;
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
csvToModule,
parseCsv
});
-512
View File
@@ -1,512 +0,0 @@
// src/csv-loader/loader.ts
import { parse } from "csv-parse/sync";
// src/parser.ts
var ParseError = class extends Error {
constructor(message, position) {
super(position !== void 0 ? `${message} at position ${position}` : message);
this.position = position;
this.name = "ParseError";
}
};
var Parser = class {
constructor(input) {
this.pos = 0;
this.input = input;
}
peek() {
return this.input[this.pos] || "";
}
consume() {
return this.input[this.pos++] || "";
}
skipWhitespace() {
while (this.pos < this.input.length && /\s/.test(this.input[this.pos])) {
this.pos++;
}
}
match(str) {
return this.input.slice(this.pos, this.pos + str.length) === str;
}
consumeStr(str) {
if (this.match(str)) {
this.pos += str.length;
return true;
}
return false;
}
getPosition() {
return this.pos;
}
getInputLength() {
return this.input.length;
}
parseSchema() {
this.skipWhitespace();
if (this.consumeStr("string")) {
if (this.consumeStr("[")) {
this.skipWhitespace();
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
return { type: "array", element: { type: "string" } };
}
return { type: "string" };
}
if (this.consumeStr("number")) {
if (this.consumeStr("[")) {
this.skipWhitespace();
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
return { type: "array", element: { type: "number" } };
}
return { type: "number" };
}
if (this.consumeStr("boolean")) {
if (this.consumeStr("[")) {
this.skipWhitespace();
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
return { type: "array", element: { type: "boolean" } };
}
return { type: "boolean" };
}
if (this.consumeStr("[")) {
const elements = [];
this.skipWhitespace();
if (this.peek() === "]") {
this.consume();
throw new ParseError("Empty array/tuple not allowed", this.pos);
}
elements.push(this.parseNamedSchema());
this.skipWhitespace();
if (this.consumeStr(";")) {
const remainingElements = [];
while (true) {
this.skipWhitespace();
remainingElements.push(this.parseNamedSchema());
this.skipWhitespace();
if (!this.consumeStr(";")) {
break;
}
}
elements.push(...remainingElements);
}
this.skipWhitespace();
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
if (this.consumeStr("[")) {
this.skipWhitespace();
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
if (elements.length === 1 && !elements[0].name) {
return { type: "array", element: elements[0].schema };
}
return { type: "array", element: { type: "tuple", elements } };
}
if (elements.length === 1 && !elements[0].name) {
return { type: "array", element: elements[0].schema };
}
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);
}
parseNamedSchema() {
this.skipWhitespace();
const startpos = this.pos;
let identifier = "";
while (this.pos < this.input.length && /[a-zA-Z0-9\-_]/.test(this.peek())) {
identifier += this.consume();
}
if (identifier.length === 0) {
throw new ParseError("Expected schema or named schema", this.pos);
}
this.skipWhitespace();
if (this.consumeStr(":")) {
this.skipWhitespace();
const name = identifier;
const schema = this.parseSchema();
return { name, schema };
} else {
this.pos = startpos;
const schema = this.parseSchema();
return { schema };
}
}
};
function parseSchema(schemaString) {
const parser = new Parser(schemaString.trim());
const schema = parser.parseSchema();
if (parser.getPosition() < parser.getInputLength()) {
throw new ParseError("Unexpected input after schema", parser.getPosition());
}
return schema;
}
// src/validator.ts
var ValueParser = class {
constructor(input) {
this.pos = 0;
this.input = input;
}
peek() {
return this.input[this.pos] || "";
}
consume() {
return this.input[this.pos++] || "";
}
skipWhitespace() {
while (this.pos < this.input.length && /\s/.test(this.input[this.pos])) {
this.pos++;
}
}
consumeStr(str) {
if (this.input.slice(this.pos, this.pos + str.length) === str) {
this.pos += str.length;
return true;
}
return false;
}
parseValue(schema, allowOmitBrackets = false) {
this.skipWhitespace();
switch (schema.type) {
case "string":
return this.parseStringValue();
case "number":
return this.parseNumberValue();
case "boolean":
return this.parseBooleanValue();
case "tuple":
return this.parseTupleValue(schema, allowOmitBrackets);
case "array":
return this.parseArrayValue(schema, allowOmitBrackets);
default:
throw new ParseError(`Unknown schema type: ${schema.type}`, this.pos);
}
}
parseStringValue() {
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();
}
parseNumberValue() {
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;
}
parseBooleanValue() {
if (this.consumeStr("true")) {
return true;
}
if (this.consumeStr("false")) {
return false;
}
throw new ParseError("Expected true or false", this.pos);
}
parseTupleValue(schema, allowOmitBrackets) {
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 = [];
for (let i = 0; i < schema.elements.length; i++) {
this.skipWhitespace();
const elementSchema = schema.elements[i];
if (elementSchema.name) {
this.skipWhitespace();
const savedPos = this.pos;
if (this.consumeStr(`${elementSchema.name}:`)) {
this.skipWhitespace();
} else {
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;
}
parseArrayValue(schema, allowOmitBrackets) {
let hasOpenBracket = false;
const elementIsTupleOrArray = schema.element.type === "tuple" || schema.element.type === "array";
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 = [];
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;
}
getPosition() {
return this.pos;
}
getInputLength() {
return this.input.length;
}
};
function parseValue(schema, valueString) {
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;
}
function createValidator(schema) {
return function validate(value) {
switch (schema.type) {
case "string":
return typeof value === "string";
case "number":
return typeof value === "number" && !isNaN(value);
case "boolean":
return typeof value === "boolean";
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));
default:
return false;
}
};
}
// src/csv-loader/loader.ts
import * as path from "path";
import * as fs from "fs";
function schemaToTypeString(schema) {
switch (schema.type) {
case "string":
return "string";
case "number":
return "number";
case "boolean":
return "boolean";
case "array":
if (schema.element.type === "tuple") {
const tupleElements2 = schema.element.elements.map((el) => {
const typeStr = schemaToTypeString(el.schema);
return el.name ? `${el.name}: ${typeStr}` : typeStr;
});
return `[${tupleElements2.join(", ")}]`;
}
return `${schemaToTypeString(schema.element)}[]`;
case "tuple":
const tupleElements = schema.elements.map((el) => {
const typeStr = schemaToTypeString(el.schema);
return el.name ? `${el.name}: ${typeStr}` : typeStr;
});
return `[${tupleElements.join(", ")}]`;
default:
return "unknown";
}
}
function generateTypeDefinition(resourceName, propertyConfigs) {
const properties = propertyConfigs.map((config) => ` ${config.name}: ${schemaToTypeString(config.schema)};`).join("\n");
return `type Table = {
${properties}
}[];
declare const data: Table;
export default data;
`;
}
function parseCsv(content, options = {}) {
const delimiter = options.delimiter ?? ",";
const quote = options.quote ?? '"';
const escape = options.escape ?? "\\";
const bom = options.bom ?? true;
const comment = options.comment === false ? void 0 : options.comment ?? "#";
const trim = options.trim ?? true;
const emitTypes = options.emitTypes ?? true;
const records = parse(content, {
delimiter,
quote,
escape,
bom,
comment,
trim,
relax_column_count: true
});
if (records.length < 2) {
throw new Error("CSV must have at least 2 rows: headers and schemas");
}
const headers = records[0];
const schemas = records[1];
if (headers.length !== schemas.length) {
throw new Error(
`Header count (${headers.length}) does not match schema count (${schemas.length})`
);
}
const propertyConfigs = headers.map((header, index) => {
const schemaString = schemas[index];
const schema = parseSchema(schemaString);
return {
name: header,
schema,
validator: createValidator(schema),
parser: (valueString) => parseValue(schema, valueString)
};
});
const dataRows = records.slice(2);
const objects = dataRows.map((row, rowIndex) => {
const obj = {};
propertyConfigs.forEach((config, colIndex) => {
const rawValue = row[colIndex] ?? "";
try {
const parsed = config.parser(rawValue);
if (!config.validator(parsed)) {
throw new Error(
`Validation failed for property "${config.name}" at row ${rowIndex + 3}: ${rawValue}`
);
}
obj[config.name] = parsed;
} catch (error) {
if (error instanceof Error) {
throw new Error(
`Failed to parse property "${config.name}" at row ${rowIndex + 3}, column ${colIndex + 1}: ${error.message}`
);
}
throw error;
}
});
return obj;
});
const result = {
data: objects,
propertyConfigs
};
if (emitTypes) {
result.typeDefinition = generateTypeDefinition("", propertyConfigs);
}
return result;
}
function csvToModule(content, options = {}) {
const result = parseCsv(content, options);
const json = JSON.stringify(result.data, null, 2);
const js = `export default ${json};`;
return {
js,
dts: result.typeDefinition
};
}
function csvLoader(content) {
const options = this.getOptions();
const emitTypes = options?.emitTypes ?? true;
const typesOutputDir = options?.typesOutputDir ?? "";
const writeToDisk = options?.writeToDisk ?? false;
const result = parseCsv(content, options);
if (emitTypes && result.typeDefinition) {
const context = this.context || "";
let relativePath = this.resourcePath.replace(context, "");
if (relativePath.startsWith("\\") || relativePath.startsWith("/")) {
relativePath = relativePath.substring(1);
}
relativePath = relativePath.replace(/\\/g, "/");
const dtsFileName = `${relativePath}.d.ts`;
const outputPath = typesOutputDir ? path.join(typesOutputDir, dtsFileName) : dtsFileName;
if (writeToDisk) {
const absolutePath = path.join(this.context || process.cwd(), typesOutputDir || "", dtsFileName);
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
fs.writeFileSync(absolutePath, result.typeDefinition);
} else {
this.emitFile?.(outputPath, result.typeDefinition);
}
}
return `export default ${JSON.stringify(result.data, null, 2)};`;
}
export {
csvToModule,
csvLoader as default,
parseCsv
};
-35
View File
@@ -1,35 +0,0 @@
type SchemaType = 'string' | 'number' | 'boolean';
interface PrimitiveSchema {
type: SchemaType;
}
interface NamedSchema {
name?: string;
schema: Schema;
}
interface TupleSchema {
type: 'tuple';
elements: NamedSchema[];
}
interface ArraySchema {
type: 'array';
element: Schema;
}
type Schema = PrimitiveSchema | TupleSchema | ArraySchema;
interface ParsedSchema {
schema: Schema;
validator: (value: unknown) => boolean;
parse: (valueString: string) => unknown;
}
declare class ParseError extends Error {
position?: number | undefined;
constructor(message: string, position?: number | undefined);
}
declare function parseSchema(schemaString: string): Schema;
declare function parseValue(schema: Schema, valueString: string): unknown;
declare function createValidator(schema: Schema): (value: unknown) => boolean;
declare function defineSchema(schemaString: string): ParsedSchema;
export { type ArraySchema, ParseError, type ParsedSchema, type PrimitiveSchema, type Schema, type TupleSchema, createValidator, defineSchema, parseSchema, parseValue };
-35
View File
@@ -1,35 +0,0 @@
type SchemaType = 'string' | 'number' | 'boolean';
interface PrimitiveSchema {
type: SchemaType;
}
interface NamedSchema {
name?: string;
schema: Schema;
}
interface TupleSchema {
type: 'tuple';
elements: NamedSchema[];
}
interface ArraySchema {
type: 'array';
element: Schema;
}
type Schema = PrimitiveSchema | TupleSchema | ArraySchema;
interface ParsedSchema {
schema: Schema;
validator: (value: unknown) => boolean;
parse: (valueString: string) => unknown;
}
declare class ParseError extends Error {
position?: number | undefined;
constructor(message: string, position?: number | undefined);
}
declare function parseSchema(schemaString: string): Schema;
declare function parseValue(schema: Schema, valueString: string): unknown;
declare function createValidator(schema: Schema): (value: unknown) => boolean;
declare function defineSchema(schemaString: string): ParsedSchema;
export { type ArraySchema, ParseError, type ParsedSchema, type PrimitiveSchema, type Schema, type TupleSchema, createValidator, defineSchema, parseSchema, parseValue };
-408
View File
@@ -1,408 +0,0 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/index.ts
var index_exports = {};
__export(index_exports, {
ParseError: () => ParseError,
createValidator: () => createValidator,
defineSchema: () => defineSchema,
parseSchema: () => parseSchema,
parseValue: () => parseValue
});
module.exports = __toCommonJS(index_exports);
// src/parser.ts
var ParseError = class extends Error {
constructor(message, position) {
super(position !== void 0 ? `${message} at position ${position}` : message);
this.position = position;
this.name = "ParseError";
}
};
var Parser = class {
constructor(input) {
this.pos = 0;
this.input = input;
}
peek() {
return this.input[this.pos] || "";
}
consume() {
return this.input[this.pos++] || "";
}
skipWhitespace() {
while (this.pos < this.input.length && /\s/.test(this.input[this.pos])) {
this.pos++;
}
}
match(str) {
return this.input.slice(this.pos, this.pos + str.length) === str;
}
consumeStr(str) {
if (this.match(str)) {
this.pos += str.length;
return true;
}
return false;
}
getPosition() {
return this.pos;
}
getInputLength() {
return this.input.length;
}
parseSchema() {
this.skipWhitespace();
if (this.consumeStr("string")) {
if (this.consumeStr("[")) {
this.skipWhitespace();
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
return { type: "array", element: { type: "string" } };
}
return { type: "string" };
}
if (this.consumeStr("number")) {
if (this.consumeStr("[")) {
this.skipWhitespace();
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
return { type: "array", element: { type: "number" } };
}
return { type: "number" };
}
if (this.consumeStr("boolean")) {
if (this.consumeStr("[")) {
this.skipWhitespace();
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
return { type: "array", element: { type: "boolean" } };
}
return { type: "boolean" };
}
if (this.consumeStr("[")) {
const elements = [];
this.skipWhitespace();
if (this.peek() === "]") {
this.consume();
throw new ParseError("Empty array/tuple not allowed", this.pos);
}
elements.push(this.parseNamedSchema());
this.skipWhitespace();
if (this.consumeStr(";")) {
const remainingElements = [];
while (true) {
this.skipWhitespace();
remainingElements.push(this.parseNamedSchema());
this.skipWhitespace();
if (!this.consumeStr(";")) {
break;
}
}
elements.push(...remainingElements);
}
this.skipWhitespace();
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
if (this.consumeStr("[")) {
this.skipWhitespace();
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
if (elements.length === 1 && !elements[0].name) {
return { type: "array", element: elements[0].schema };
}
return { type: "array", element: { type: "tuple", elements } };
}
if (elements.length === 1 && !elements[0].name) {
return { type: "array", element: elements[0].schema };
}
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);
}
parseNamedSchema() {
this.skipWhitespace();
const startpos = this.pos;
let identifier = "";
while (this.pos < this.input.length && /[a-zA-Z0-9\-_]/.test(this.peek())) {
identifier += this.consume();
}
if (identifier.length === 0) {
throw new ParseError("Expected schema or named schema", this.pos);
}
this.skipWhitespace();
if (this.consumeStr(":")) {
this.skipWhitespace();
const name = identifier;
const schema = this.parseSchema();
return { name, schema };
} else {
this.pos = startpos;
const schema = this.parseSchema();
return { schema };
}
}
};
function parseSchema(schemaString) {
const parser = new Parser(schemaString.trim());
const schema = parser.parseSchema();
if (parser.getPosition() < parser.getInputLength()) {
throw new ParseError("Unexpected input after schema", parser.getPosition());
}
return schema;
}
// src/validator.ts
var ValueParser = class {
constructor(input) {
this.pos = 0;
this.input = input;
}
peek() {
return this.input[this.pos] || "";
}
consume() {
return this.input[this.pos++] || "";
}
skipWhitespace() {
while (this.pos < this.input.length && /\s/.test(this.input[this.pos])) {
this.pos++;
}
}
consumeStr(str) {
if (this.input.slice(this.pos, this.pos + str.length) === str) {
this.pos += str.length;
return true;
}
return false;
}
parseValue(schema, allowOmitBrackets = false) {
this.skipWhitespace();
switch (schema.type) {
case "string":
return this.parseStringValue();
case "number":
return this.parseNumberValue();
case "boolean":
return this.parseBooleanValue();
case "tuple":
return this.parseTupleValue(schema, allowOmitBrackets);
case "array":
return this.parseArrayValue(schema, allowOmitBrackets);
default:
throw new ParseError(`Unknown schema type: ${schema.type}`, this.pos);
}
}
parseStringValue() {
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();
}
parseNumberValue() {
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;
}
parseBooleanValue() {
if (this.consumeStr("true")) {
return true;
}
if (this.consumeStr("false")) {
return false;
}
throw new ParseError("Expected true or false", this.pos);
}
parseTupleValue(schema, allowOmitBrackets) {
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 = [];
for (let i = 0; i < schema.elements.length; i++) {
this.skipWhitespace();
const elementSchema = schema.elements[i];
if (elementSchema.name) {
this.skipWhitespace();
const savedPos = this.pos;
if (this.consumeStr(`${elementSchema.name}:`)) {
this.skipWhitespace();
} else {
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;
}
parseArrayValue(schema, allowOmitBrackets) {
let hasOpenBracket = false;
const elementIsTupleOrArray = schema.element.type === "tuple" || schema.element.type === "array";
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 = [];
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;
}
getPosition() {
return this.pos;
}
getInputLength() {
return this.input.length;
}
};
function parseValue(schema, valueString) {
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;
}
function createValidator(schema) {
return function validate(value) {
switch (schema.type) {
case "string":
return typeof value === "string";
case "number":
return typeof value === "number" && !isNaN(value);
case "boolean":
return typeof value === "boolean";
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));
default:
return false;
}
};
}
// src/index.ts
function defineSchema(schemaString) {
const schema = parseSchema(schemaString);
const validator = createValidator(schema);
return {
schema,
validator,
parse: (valueString) => parseValue(schema, valueString)
};
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
ParseError,
createValidator,
defineSchema,
parseSchema,
parseValue
});
-377
View File
@@ -1,377 +0,0 @@
// src/parser.ts
var ParseError = class extends Error {
constructor(message, position) {
super(position !== void 0 ? `${message} at position ${position}` : message);
this.position = position;
this.name = "ParseError";
}
};
var Parser = class {
constructor(input) {
this.pos = 0;
this.input = input;
}
peek() {
return this.input[this.pos] || "";
}
consume() {
return this.input[this.pos++] || "";
}
skipWhitespace() {
while (this.pos < this.input.length && /\s/.test(this.input[this.pos])) {
this.pos++;
}
}
match(str) {
return this.input.slice(this.pos, this.pos + str.length) === str;
}
consumeStr(str) {
if (this.match(str)) {
this.pos += str.length;
return true;
}
return false;
}
getPosition() {
return this.pos;
}
getInputLength() {
return this.input.length;
}
parseSchema() {
this.skipWhitespace();
if (this.consumeStr("string")) {
if (this.consumeStr("[")) {
this.skipWhitespace();
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
return { type: "array", element: { type: "string" } };
}
return { type: "string" };
}
if (this.consumeStr("number")) {
if (this.consumeStr("[")) {
this.skipWhitespace();
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
return { type: "array", element: { type: "number" } };
}
return { type: "number" };
}
if (this.consumeStr("boolean")) {
if (this.consumeStr("[")) {
this.skipWhitespace();
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
return { type: "array", element: { type: "boolean" } };
}
return { type: "boolean" };
}
if (this.consumeStr("[")) {
const elements = [];
this.skipWhitespace();
if (this.peek() === "]") {
this.consume();
throw new ParseError("Empty array/tuple not allowed", this.pos);
}
elements.push(this.parseNamedSchema());
this.skipWhitespace();
if (this.consumeStr(";")) {
const remainingElements = [];
while (true) {
this.skipWhitespace();
remainingElements.push(this.parseNamedSchema());
this.skipWhitespace();
if (!this.consumeStr(";")) {
break;
}
}
elements.push(...remainingElements);
}
this.skipWhitespace();
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
if (this.consumeStr("[")) {
this.skipWhitespace();
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
if (elements.length === 1 && !elements[0].name) {
return { type: "array", element: elements[0].schema };
}
return { type: "array", element: { type: "tuple", elements } };
}
if (elements.length === 1 && !elements[0].name) {
return { type: "array", element: elements[0].schema };
}
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);
}
parseNamedSchema() {
this.skipWhitespace();
const startpos = this.pos;
let identifier = "";
while (this.pos < this.input.length && /[a-zA-Z0-9\-_]/.test(this.peek())) {
identifier += this.consume();
}
if (identifier.length === 0) {
throw new ParseError("Expected schema or named schema", this.pos);
}
this.skipWhitespace();
if (this.consumeStr(":")) {
this.skipWhitespace();
const name = identifier;
const schema = this.parseSchema();
return { name, schema };
} else {
this.pos = startpos;
const schema = this.parseSchema();
return { schema };
}
}
};
function parseSchema(schemaString) {
const parser = new Parser(schemaString.trim());
const schema = parser.parseSchema();
if (parser.getPosition() < parser.getInputLength()) {
throw new ParseError("Unexpected input after schema", parser.getPosition());
}
return schema;
}
// src/validator.ts
var ValueParser = class {
constructor(input) {
this.pos = 0;
this.input = input;
}
peek() {
return this.input[this.pos] || "";
}
consume() {
return this.input[this.pos++] || "";
}
skipWhitespace() {
while (this.pos < this.input.length && /\s/.test(this.input[this.pos])) {
this.pos++;
}
}
consumeStr(str) {
if (this.input.slice(this.pos, this.pos + str.length) === str) {
this.pos += str.length;
return true;
}
return false;
}
parseValue(schema, allowOmitBrackets = false) {
this.skipWhitespace();
switch (schema.type) {
case "string":
return this.parseStringValue();
case "number":
return this.parseNumberValue();
case "boolean":
return this.parseBooleanValue();
case "tuple":
return this.parseTupleValue(schema, allowOmitBrackets);
case "array":
return this.parseArrayValue(schema, allowOmitBrackets);
default:
throw new ParseError(`Unknown schema type: ${schema.type}`, this.pos);
}
}
parseStringValue() {
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();
}
parseNumberValue() {
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;
}
parseBooleanValue() {
if (this.consumeStr("true")) {
return true;
}
if (this.consumeStr("false")) {
return false;
}
throw new ParseError("Expected true or false", this.pos);
}
parseTupleValue(schema, allowOmitBrackets) {
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 = [];
for (let i = 0; i < schema.elements.length; i++) {
this.skipWhitespace();
const elementSchema = schema.elements[i];
if (elementSchema.name) {
this.skipWhitespace();
const savedPos = this.pos;
if (this.consumeStr(`${elementSchema.name}:`)) {
this.skipWhitespace();
} else {
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;
}
parseArrayValue(schema, allowOmitBrackets) {
let hasOpenBracket = false;
const elementIsTupleOrArray = schema.element.type === "tuple" || schema.element.type === "array";
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 = [];
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;
}
getPosition() {
return this.pos;
}
getInputLength() {
return this.input.length;
}
};
function parseValue(schema, valueString) {
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;
}
function createValidator(schema) {
return function validate(value) {
switch (schema.type) {
case "string":
return typeof value === "string";
case "number":
return typeof value === "number" && !isNaN(value);
case "boolean":
return typeof value === "boolean";
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));
default:
return false;
}
};
}
// src/index.ts
function defineSchema(schemaString) {
const schema = parseSchema(schemaString);
const validator = createValidator(schema);
return {
schema,
validator,
parse: (valueString) => parseValue(schema, valueString)
};
}
export {
ParseError,
createValidator,
defineSchema,
parseSchema,
parseValue
};
+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.
+34 -7
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",
@@ -14,12 +14,29 @@
"types": "./dist/csv-loader/loader.d.ts", "types": "./dist/csv-loader/loader.d.ts",
"import": "./dist/csv-loader/loader.mjs", "import": "./dist/csv-loader/loader.mjs",
"require": "./dist/csv-loader/loader.js" "require": "./dist/csv-loader/loader.js"
},
"./csv-loader/webpack": {
"types": "./dist/csv-loader/webpack.d.ts",
"import": "./dist/csv-loader/webpack.mjs",
"require": "./dist/csv-loader/webpack.js"
},
"./csv-loader/rollup": {
"types": "./dist/csv-loader/rollup.d.ts",
"import": "./dist/csv-loader/rollup.mjs",
"require": "./dist/csv-loader/rollup.js"
},
"./csv-loader/esbuild": {
"types": "./dist/csv-loader/esbuild.d.ts",
"import": "./dist/csv-loader/esbuild.mjs",
"require": "./dist/csv-loader/esbuild.js"
} }
}, },
"scripts": { "scripts": {
"build": "tsup", "build": "tsup",
"dev": "tsup --watch", "dev": "tsup --watch",
"test": "tsx src/test.ts" "test": "vitest run",
"typecheck": "tsc --noEmit",
"test:watch": "vitest"
}, },
"keywords": [ "keywords": [
"schema", "schema",
@@ -32,18 +49,28 @@
], ],
"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"
}, },
"devDependencies": { "devDependencies": {
"@rspack/core": "^1.1.6", "@rspack/core": "^1.1.6",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"tsx": "^4.21.0",
"tsup": "^8.5.1", "tsup": "^8.5.1",
"typescript": "^5.9.3" "tsx": "^4.21.0",
"typescript": "^5.9.3",
"vitest": "^4.1.4"
}, },
"peerDependencies": { "peerDependencies": {
"@rspack/core": "^1.x" "@rspack/core": "^1.x",
"esbuild": "*"
},
"peerDependenciesMeta": {
"@rspack/core": {
"optional": true
},
"esbuild": {
"optional": true
}
} }
} }
+117
View File
@@ -0,0 +1,117 @@
import * as path from "path";
import * as fs from "fs";
import type { Plugin, OnLoadResult } from "esbuild";
import { csvToModule } from "./module-gen";
import { CsvLoaderOptions } from "./types";
export interface CsvEsbuildOptions extends CsvLoaderOptions {
/** Include pattern for CSV files (default: /\.csv$/) */
include?: RegExp | string | Array<RegExp | string>;
/** Exclude pattern for CSV files */
exclude?: RegExp | string | Array<RegExp | string>;
/** 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;
}
function createFilter(
pattern: RegExp | string | Array<RegExp | string>,
): RegExp {
if (pattern instanceof RegExp) return pattern;
if (Array.isArray(pattern)) {
const first = pattern[0];
return first instanceof RegExp ? first : new RegExp(first);
}
return new RegExp(pattern);
}
function matchesPattern(
id: string,
pattern: RegExp | string | Array<RegExp | string> | undefined,
): boolean {
if (!pattern) return true;
const patterns = Array.isArray(pattern) ? pattern : [pattern];
return patterns.some((p) => {
if (p instanceof RegExp) return p.test(id);
return id.includes(p);
});
}
/**
* Esbuild plugin for loading CSV files with typed-csv validation.
*/
export function csvLoader(options: CsvEsbuildOptions = {}): Plugin {
const {
include = /\.csv$/,
exclude,
emitTypes = true,
typesOutputDir = "",
writeToDisk = false,
...parseOptions
} = options;
const includeFilter = createFilter(include);
return {
name: "typed-csv",
setup(build) {
build.onLoad({ filter: includeFilter }, async (args) => {
// Check exclude pattern
if (exclude && matchesPattern(args.path, exclude)) {
return null;
}
// Read the file content
let content: string;
try {
content = await fs.promises.readFile(args.path, "utf-8");
} catch (error) {
return {
errors: [{ text: `Failed to read file: ${args.path}` }],
} as OnLoadResult;
}
// Infer resource name from filename
const fileName = path.basename(args.path, ".csv").split(".")[0];
const resourceName = fileName
.replace(/[-_\s]+(.)?/g, (_, char) =>
char ? char.toUpperCase() : "",
)
.replace(/^(.)/, (_, char) => char.toUpperCase());
const result = csvToModule(content, {
...parseOptions,
emitTypes,
resourceName,
currentFilePath: args.path,
refBaseDir: options.refBaseDir,
defaultPrimaryKey: options.defaultPrimaryKey,
});
// Emit type definition file if enabled
if (emitTypes && result.dts) {
const dtsPath = typesOutputDir
? path.join(typesOutputDir, path.basename(args.path) + ".d.ts")
: args.path + ".d.ts";
if (writeToDisk) {
const absolutePath = path.isAbsolute(dtsPath)
? dtsPath
: path.join(process.cwd(), dtsPath);
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
fs.writeFileSync(absolutePath, result.dts);
}
}
return {
contents: result.js,
loader: "js" as const,
};
});
},
};
}
export default csvLoader;
+3
View File
@@ -0,0 +1,3 @@
id,name,related
string,string,@circular_b[]
1,A,[1]
1 id name related
2 string string @circular_b[]
3 1 A [1]
+3
View File
@@ -0,0 +1,3 @@
id,name,related
string,string,@circular_a[]
1,B,[1]
1 id name related
2 string string @circular_a[]
3 1 B [1]
+5
View File
@@ -0,0 +1,5 @@
id,name,details,alternatives,status
string,string,[ref: @users; note: string],@users[] | string,"active" | "inactive"
1,Order1,[ref: 1; note: urgent],1,active
2,Order2,[ref: 2; note: normal],normal,inactive
3,Order3,[ref: 3; note: low],"active",active
Can't render this file because it contains an unexpected character in line 2 and column 68.
+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 @@
id,customer,items,total
string,@users,@parts[],number
1,1,[1; 2],35.5
2,2,[3],7.99
3,1,[1; 2; 3],43.49
1 id customer items total
2 string @users @parts[] number
3 1 1 [1; 2] 35.5
4 2 2 [3] 7.99
5 3 1 [1; 2; 3] 43.49
+5
View File
@@ -0,0 +1,5 @@
id,name,price
string,string,number
1,Widget,10.5
2,Gadget,25.0
3,Doohickey,7.99
1 id name price
2 string string number
3 1 Widget 10.5
4 2 Gadget 25.0
5 3 Doohickey 7.99
+4
View File
@@ -0,0 +1,4 @@
id,name,parent
string,string,@self_ref
1,Root,2
2,Child,1
1 id name parent
2 string string @self_ref
3 1 Root 2
4 2 Child 1
+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
+5
View File
@@ -0,0 +1,5 @@
id,name,email
string,string,string
1,Alice,alice@example.com
2,Bob,bob@example.com
3,Charlie,charlie@example.com
1 id name email
2 string string string
3 1 Alice alice@example.com
4 2 Bob bob@example.com
5 3 Charlie charlie@example.com
+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);
});
});
+385 -169
View File
@@ -1,91 +1,91 @@
import type { LoaderContext } from '@rspack/core'; import { parse } from "csv-parse/browser/esm/sync";
import { parse } from 'csv-parse/sync'; import {
import { parseSchema, createValidator, parseValue } from '../index.js'; parseSchema,
import type { Schema } from '../types.js'; createValidator,
import * as path from 'path'; parseValue,
import * as fs from 'fs'; schemaToTypeString,
} from "../index.js";
export interface CsvLoaderOptions { import type {
delimiter?: string; Schema,
quote?: string; ReferenceSchema,
escape?: string; ReverseReferenceSchema,
bom?: boolean; } from "../types.js";
comment?: string | false; import type {
trim?: boolean; CsvLoaderOptions,
/** Generate TypeScript declaration file (.d.ts) */ ReferenceFieldInfo,
emitTypes?: boolean; CsvParseResult,
/** Output directory for generated type files (relative to output path) */ PropertyConfig,
typesOutputDir?: string; ReverseReferenceDeclaration,
/** Write .d.ts files to disk (useful for dev server) */ TypeDeclaration,
writeToDisk?: boolean; } from "./types.js";
} import {
hasNestedReferences,
export interface CsvParseResult { loadReferenceTable,
/** Parsed CSV data as array of objects */ resolveReferenceId,
data: Record<string, unknown>[]; parseReferenceIds,
/** Generated TypeScript type definition string (if emitTypes is true) */ parseValueWithReferenceIds,
typeDefinition?: string; extractNestedReferenceIds,
/** Property configurations for the CSV columns */ collectReferenceFields,
propertyConfigs: PropertyConfig[]; parseValueWithReferences,
} resolveReverseReference,
resolveNestedReferences,
interface PropertyConfig { parseReferenceValue,
name: string; } from "./reference-resolver.js";
schema: any; import { generateTypeDefinition } from "./type-gen.js";
validator: (value: unknown) => boolean;
parser: (valueString: string) => unknown;
}
/** /**
* Convert a schema to TypeScript type string * Escape double-quote characters in the schema row so csv-parse doesn't
* misinterpret them as CSV field delimiters.
*
* The schema row is the 2nd non-empty line. Cells that contain the quote
* character (e.g. `"active" | "inactive"`) have their inner quotes escaped
* and are wrapped in quotes, so csv-parse treats each as a single field.
*/ */
function schemaToTypeString(schema: Schema): string { function escapeSchemaRowQuotes(
switch (schema.type) { content: string,
case 'string': delimiter: string,
return 'string'; quote: string,
case 'number': escape: string,
return 'number';
case 'boolean':
return 'boolean';
case 'array':
if (schema.element.type === 'tuple') {
const tupleElements = schema.element.elements.map((el) => {
const typeStr = schemaToTypeString(el.schema);
return el.name ? `${el.name}: ${typeStr}` : typeStr;
});
return `[${tupleElements.join(', ')}]`;
}
return `${schemaToTypeString(schema.element)}[]`;
case 'tuple':
const tupleElements = schema.elements.map((el) => {
const typeStr = schemaToTypeString(el.schema);
return el.name ? `${el.name}: ${typeStr}` : typeStr;
});
return `[${tupleElements.join(', ')}]`;
default:
return 'unknown';
}
}
/**
* Generate TypeScript interface for the CSV data
*/
function generateTypeDefinition(
resourceName: string,
propertyConfigs: PropertyConfig[]
): string { ): string {
const properties = propertyConfigs if (!quote || !content.includes(quote)) {
.map((config) => ` ${config.name}: ${schemaToTypeString(config.schema)};`) return content;
.join('\n'); }
return `type Table = { const lines = content.split(/\r?\n/);
${properties} let schemaRowIndex = -1;
}[]; let nonEmptyCount = 0;
for (let i = 0; i < lines.length; i++) {
if (lines[i].trim() === "") continue;
nonEmptyCount++;
if (nonEmptyCount === 2) {
schemaRowIndex = i;
break;
}
}
if (schemaRowIndex === -1) {
return content;
}
declare const data: Table; 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.
@@ -97,67 +97,301 @@ export default data;
*/ */
export function parseCsv( export function parseCsv(
content: string, content: string,
options: CsvLoaderOptions = {} 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 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,
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);
const schemaString = schemas[index];
const schema = parseSchema(schemaString); // Also check schema row cells for comment-prefixed type declarations
return { // and reverse reference declarations
name: header, 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, schema,
validator: createValidator(schema), schemaString: decl.schemaString,
parser: (valueString: string) => parseValue(schema, valueString), });
}
// 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];
// 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 = {
name: header,
schema,
validator: createValidator(schema),
parser: (valueString: string) => parseValue(schema, valueString),
declaredTypeName,
schemaString: columnSchemaString,
};
if (schema.type === "reference") {
config.isReference = true;
config.referenceTableName = schema.tableName;
config.referenceIsArray = schema.isArray;
if (resolveReferences) {
config.parser = (valueString: string) => {
return parseReferenceValue(
schema,
valueString,
refBaseDir,
defaultPrimaryKey,
options.currentFilePath,
);
};
} else {
config.parser = (valueString: string) => {
return parseReferenceIds(schema, valueString);
};
}
} else if (hasNestedReferences(schema)) {
config.isReference = true;
if (resolveReferences) {
config.parser = (valueString: string) => {
return parseValueWithReferences(
valueString,
schema,
refBaseDir,
defaultPrimaryKey,
options.currentFilePath,
);
};
} else {
config.parser = (valueString: string) => {
return parseValueWithReferenceIds(valueString, schema);
};
}
}
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)
const references = new Set<string>();
function collectReferences(schema: Schema): void {
if (schema.type === "reference") {
references.add(schema.tableName);
} else if (schema.type === "reverseReference") {
references.add(schema.tableName);
} else if (schema.type === "tuple") {
schema.elements.forEach((el) => collectReferences(el.schema));
} else if (schema.type === "array") {
collectReferences(schema.element);
} else if (schema.type === "union") {
schema.members.forEach((m) => collectReferences(m));
}
}
propertyConfigs.forEach((config) => {
if (config.isReference && config.referenceTableName) {
references.add(config.referenceTableName);
}
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);
if (!config.validator(parsed)) { // Skip validation for reference fields (validation happens during reference resolution)
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;
@@ -166,78 +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,
referenceFields,
reverseReferences,
typeDeclarations,
}; };
if (emitTypes) { if (emitTypes) {
result.typeDefinition = generateTypeDefinition('', propertyConfigs); result.typeDefinition = generateTypeDefinition(
options.resourceName || "",
propertyConfigs,
references,
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 = {}
): { 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,
};
}
export default function csvLoader(
this: LoaderContext<CsvLoaderOptions>,
content: string
): string | Buffer {
const options = this.getOptions() as CsvLoaderOptions | undefined;
const emitTypes = options?.emitTypes ?? true;
const typesOutputDir = options?.typesOutputDir ?? '';
const writeToDisk = options?.writeToDisk ?? false;
const result = parseCsv(content, options);
// Emit type definition file if enabled
if (emitTypes && result.typeDefinition) {
const context = this.context || '';
// Get relative path from context, normalize to forward slashes
let relativePath = this.resourcePath.replace(context, '');
if (relativePath.startsWith('\\') || relativePath.startsWith('/')) {
relativePath = relativePath.substring(1);
}
relativePath = relativePath.replace(/\\/g, '/');
// Replace .csv with .csv.d.ts for the output filename
const dtsFileName = `${relativePath}.d.ts`;
const outputPath = typesOutputDir
? path.join(typesOutputDir, dtsFileName)
: dtsFileName;
if (writeToDisk) {
// Write directly to disk (useful for dev server)
const absolutePath = path.join(this.context || process.cwd(), typesOutputDir || '', dtsFileName);
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
fs.writeFileSync(absolutePath, result.typeDefinition);
} else {
// Emit to in-memory filesystem (for production build)
this.emitFile?.(outputPath, result.typeDefinition);
}
}
return `export default ${JSON.stringify(result.data, null, 2)};`;
}
+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);
}
+126
View File
@@ -0,0 +1,126 @@
import * as path from "path";
import * as fs from "fs";
import { csvToModule } from "./module-gen";
import { CsvLoaderOptions } from "./types";
export interface CsvRollupOptions extends CsvLoaderOptions {
/** Include pattern for CSV files (default: /\.csv$/) */
include?: RegExp | string | Array<RegExp | string>;
/** Exclude pattern for CSV files */
exclude?: RegExp | string | Array<RegExp | string>;
/** 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;
}
function matchesPattern(
id: string,
pattern: RegExp | string | Array<RegExp | string> | undefined,
): boolean {
if (!pattern) return true;
const patterns = Array.isArray(pattern) ? pattern : [pattern];
return patterns.some((p) => {
if (p instanceof RegExp) {
return p.test(id);
}
return id.includes(p);
});
}
interface TransformResult {
code: string;
map: null;
}
interface EmitFileOptions {
type: "asset";
fileName: string;
source: string;
}
interface PluginContext {
emitFile: (options: EmitFileOptions) => string;
}
interface RollupPlugin {
name: string;
transform: (
this: PluginContext,
code: string,
id: string,
) => TransformResult | null;
}
/**
* Rollup plugin for loading CSV files with typed-csv validation.
* Works with both Vite and Tsup (esbuild).
*/
export function csvLoader(options: CsvRollupOptions = {}): RollupPlugin {
const {
include = /\.csv$/,
exclude,
emitTypes = true,
typesOutputDir = "",
writeToDisk = false,
...parseOptions
} = options;
return {
name: "typed-csv",
transform(code: string, id: string) {
// Check if file matches the include/exclude patterns
if (!matchesPattern(id, include)) return null;
if (exclude && matchesPattern(id, exclude)) return null;
// Only process .csv files
if (!id.endsWith(".csv")) return null;
// Infer resource name from filename
const fileName = path.basename(id, ".csv").split(".")[0];
const resourceName = fileName
.replace(/[-_\s]+(.)?/g, (_, char) => (char ? char.toUpperCase() : ""))
.replace(/^(.)/, (_, char) => char.toUpperCase());
const result = csvToModule(code, {
...parseOptions,
emitTypes,
resourceName,
currentFilePath: id,
refBaseDir: options.refBaseDir,
defaultPrimaryKey: options.defaultPrimaryKey,
});
// Emit type definition file if enabled
if (emitTypes && result.dts) {
const dtsPath = typesOutputDir
? path.join(typesOutputDir, path.basename(id) + ".d.ts")
: id + ".d.ts";
if (writeToDisk) {
// Write directly to disk
const absolutePath = path.isAbsolute(dtsPath)
? dtsPath
: path.join(process.cwd(), dtsPath);
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
fs.writeFileSync(absolutePath, result.dts);
} else {
// Emit to Rollup's virtual module system
this.emitFile({
type: "asset",
fileName: dtsPath,
source: result.dts,
});
}
}
return {
code: result.js,
map: null,
};
},
};
}
export default csvLoader;
+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;
}
+71
View File
@@ -0,0 +1,71 @@
import type { LoaderContext } from "@rspack/core";
import * as path from "path";
import * as fs from "fs";
import { csvToModule } from "./module-gen";
import { CsvLoaderOptions } from "./types";
export interface CsvWebpackLoaderOptions extends CsvLoaderOptions {
/** Output directory for generated type files (relative to output path) */
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;
}
export default function csvLoader(
this: LoaderContext<CsvWebpackLoaderOptions>,
content: string,
): string | Buffer {
const options = this.getOptions() as CsvWebpackLoaderOptions | undefined;
const emitTypes = options?.emitTypes ?? true;
const typesOutputDir = options?.typesOutputDir ?? "";
const writeToDisk = options?.writeToDisk ?? false;
// Infer resource name from filename
const fileName = path.basename(this.resourcePath, ".csv").split(".")[0];
const resourceName = fileName
.replace(/[-_\s]+(.)?/g, (_, char) => (char ? char.toUpperCase() : ""))
.replace(/^(.)/, (_, char) => char.toUpperCase());
const result = csvToModule(content, {
...options,
resourceName,
currentFilePath: this.resourcePath,
});
// Emit type definition file if enabled
if (emitTypes && result.dts) {
const context = this.context || "";
// Get relative path from context, normalize to forward slashes
let relativePath = this.resourcePath.replace(context, "");
if (relativePath.startsWith("\\") || relativePath.startsWith("/")) {
relativePath = relativePath.substring(1);
}
relativePath = relativePath.replace(/\\/g, "/");
// Replace .csv with .csv.d.ts for the output filename
const dtsFileName = `${relativePath}.d.ts`;
const outputPath = typesOutputDir
? path.join(typesOutputDir, dtsFileName)
: dtsFileName;
if (writeToDisk) {
// Write directly to disk (useful for dev server)
const absolutePath = path.join(
this.context || process.cwd(),
typesOutputDir || "",
dtsFileName,
);
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
fs.writeFileSync(absolutePath, result.dts);
} else {
// Emit to in-memory filesystem (for production build)
this.emitFile?.(outputPath, result.dts);
}
}
return result.js;
}
+797
View File
@@ -0,0 +1,797 @@
import { describe, it, expect } from "vitest";
import {
defineSchema,
parseSchema,
parseValue,
createValidator,
ParseError,
} from "./index";
import type { Schema, StringLiteralSchema, UnionSchema } from "./types";
describe("defineSchema", () => {
it("should return a ParsedSchema with schema, validator, and parse", () => {
const parsed = defineSchema("string");
expect(parsed).toHaveProperty("schema");
expect(parsed).toHaveProperty("validator");
expect(parsed).toHaveProperty("parse");
});
});
describe("Primitive types", () => {
describe("string", () => {
it("should parse and validate string", () => {
const schema = defineSchema("string");
expect(schema.parse("hello")).toBe("hello");
expect(schema.validator("hello")).toBe(true);
expect(schema.validator(42)).toBe(false);
});
it("should reject unknown identifiers", () => {
expect(() => defineSchema("word-smith")).toThrow(ParseError);
expect(() => defineSchema("strign")).toThrow(ParseError);
});
});
describe("number", () => {
it("should parse and validate number", () => {
const schema = defineSchema("number");
expect(schema.parse("42")).toBe(42);
expect(schema.parse("3.14")).toBe(3.14);
expect(schema.validator(42)).toBe(true);
expect(schema.validator("42")).toBe(false);
});
});
describe("int", () => {
it("should parse and validate int", () => {
const schema = defineSchema("int");
expect(schema.parse("42")).toBe(42);
expect(schema.validator(42)).toBe(true);
expect(schema.validator(3.14)).toBe(false);
});
it("should reject floats", () => {
const schema = defineSchema("int");
expect(() => schema.parse("3.14")).toThrow(ParseError);
});
});
describe("float", () => {
it("should parse and validate float", () => {
const schema = defineSchema("float");
expect(schema.parse("3.14")).toBe(3.14);
expect(schema.parse("42")).toBe(42);
expect(schema.validator(3.14)).toBe(true);
expect(schema.validator(42)).toBe(true);
});
});
describe("boolean", () => {
it("should parse and validate boolean", () => {
const schema = defineSchema("boolean");
expect(schema.parse("true")).toBe(true);
expect(schema.parse("false")).toBe(false);
expect(schema.validator(true)).toBe(true);
expect(schema.validator("true")).toBe(false);
});
it("should reject non-boolean values", () => {
const schema = defineSchema("boolean");
expect(() => schema.parse("yes")).toThrow(ParseError);
});
});
});
describe("String literals", () => {
it("should parse double-quoted string literal", () => {
const schema = defineSchema('"hello"');
expect(schema.parse('"hello"')).toBe("hello");
expect(schema.validator("hello")).toBe(true);
expect(schema.validator("world")).toBe(false);
});
it("should parse single-quoted string literal", () => {
const schema = defineSchema("'world'");
expect(schema.parse("'world'")).toBe("world");
expect(schema.validator("world")).toBe(true);
expect(schema.validator("hello")).toBe(false);
});
it("should reject mismatched string literal values", () => {
const schema = defineSchema('"on"');
expect(() => schema.parse('"off"')).toThrow(ParseError);
});
it("should handle string literals with semicolons", () => {
const schema = defineSchema('"hello;world"');
expect(schema.parse('"hello;world"')).toBe("hello;world");
expect(schema.validator("hello;world")).toBe(true);
});
it("should handle escaped quotes in string literals", () => {
const schema = defineSchema('"hello\\"world"');
expect(schema.parse('"hello\\"world"')).toBe('hello"world');
expect(schema.validator('hello"world')).toBe(true);
});
});
describe("Union types", () => {
it("should parse union of string literals", () => {
const schema = defineSchema('"on" | "off"');
expect(schema.parse('"on"')).toBe("on");
expect(schema.parse('"off"')).toBe("off");
expect(schema.validator("on")).toBe(true);
expect(schema.validator("off")).toBe(true);
expect(schema.validator("maybe")).toBe(false);
});
it("should parse union with three members", () => {
const schema = defineSchema('"pending" | "approved" | "rejected"');
expect(schema.parse('"approved"')).toBe("approved");
expect(schema.validator("pending")).toBe(true);
expect(schema.validator("approved")).toBe(true);
expect(schema.validator("rejected")).toBe(true);
expect(schema.validator("unknown")).toBe(false);
});
it("should support parentheses for grouping", () => {
const schema = defineSchema('( "active" | "inactive" )');
expect(schema.parse('"active"')).toBe("active");
expect(schema.validator("active")).toBe(true);
});
it("should reject invalid union values", () => {
const schema = defineSchema('"a" | "b"');
expect(() => schema.parse('"c"')).toThrow(ParseError);
});
it("should support mixed type unions", () => {
const schema = defineSchema("string | number");
// string is tried first, so '42' is parsed as string "42"
expect(schema.parse("hello")).toBe("hello");
expect(schema.parse("42")).toBe("42");
expect(schema.validator("hello")).toBe(true);
expect(schema.validator(42)).toBe(true);
expect(schema.validator(true)).toBe(false);
});
it("should support string type and string literal union", () => {
const schema = defineSchema('string | "special"');
// string matches first, so '"special"' is parsed as string '"special"'
expect(schema.parse("normal")).toBe("normal");
expect(schema.parse("special")).toBe("special");
expect(schema.validator("normal")).toBe(true);
expect(schema.validator("special")).toBe(true);
});
});
describe("Tuples", () => {
it("should parse and validate tuple", () => {
const schema = defineSchema("[string; number]");
const value = schema.parse("[hello; 42]");
expect(value).toEqual(["hello", 42]);
expect(schema.validator(["hello", 42])).toBe(true);
expect(schema.validator(["hello", "42"])).toBe(false);
});
it("should require brackets for tuple values", () => {
const schema = defineSchema("[string; number]");
const value = schema.parse("[hello; 42]");
expect(value).toEqual(["hello", 42]);
expect(() => schema.parse("hello; 42")).toThrow(ParseError);
});
it("should parse named tuple", () => {
const schema = defineSchema("[x: number; y: number]");
const value = schema.parse("[x: 10; y: 20]");
expect(value).toEqual([10, 20]);
});
it("should parse tuple with union field", () => {
const schema = defineSchema(
'[name: string; status: "active" | "inactive"]',
);
const value = schema.parse('[myName; "active"]');
expect(value).toEqual(["myName", "active"]);
expect(schema.validator(["myName", "active"])).toBe(true);
expect(schema.validator(["myName", "unknown"])).toBe(false);
});
it("should parse nested tuple", () => {
const schema = defineSchema("[point: [x: number; y: number]]");
const value = schema.parse("[point: [x: 5; y: 10]]");
expect(value).toEqual([[5, 10]]);
});
});
describe("Arrays", () => {
it("should parse and validate array", () => {
const schema = defineSchema("string[]");
const value = schema.parse("[hello; world; test]");
expect(value).toEqual(["hello", "world", "test"]);
expect(schema.validator(["hello", "world"])).toBe(true);
expect(schema.validator(["hello", 42])).toBe(false);
});
it("should require brackets for array values", () => {
const schema = defineSchema("string[]");
const value = schema.parse("[hello; world; test]");
expect(value).toEqual(["hello", "world", "test"]);
expect(() => schema.parse("hello; world; test")).toThrow(ParseError);
});
it("should parse array of numbers", () => {
const schema = defineSchema("number[]");
const value = schema.parse("[1; 2; 3; 4]");
expect(value).toEqual([1, 2, 3, 4]);
});
it("should parse array of ints", () => {
const schema = defineSchema("int[]");
expect(schema.parse("[1; 2; 3; 4]")).toEqual([1, 2, 3, 4]);
expect(() => schema.parse("[1; 2.5; 3]")).toThrow(ParseError);
});
it("should parse array of floats", () => {
const schema = defineSchema("float[]");
expect(schema.parse("[1.5; 2.5; 3.5]")).toEqual([1.5, 2.5, 3.5]);
expect(schema.parse("[1; 2; 3]")).toEqual([1, 2, 3]);
});
it("should parse array of tuples", () => {
const schema = defineSchema("[string; number][]");
const value = schema.parse("[[a; 1]; [b; 2]; [c; 3]]");
expect(value).toEqual([
["a", 1],
["b", 2],
["c", 3],
]);
});
it("should require outer brackets for array of tuples", () => {
const schema = defineSchema("[string; number][]");
const value = schema.parse("[[a; 1]; [b; 2]; [c; 3]]");
expect(value).toEqual([
["a", 1],
["b", 2],
["c", 3],
]);
expect(() => schema.parse("[a; 1]; [b; 2]; [c; 3]")).toThrow(ParseError);
});
it("should parse array of unions", () => {
const schema = defineSchema('("pending" | "approved" | "rejected")[]');
const value = schema.parse('["pending"; "approved"; "rejected"]');
expect(value).toEqual(["pending", "approved", "rejected"]);
expect(schema.validator(["pending", "approved"])).toBe(true);
expect(schema.validator(["pending", "unknown"])).toBe(false);
});
});
describe("Escaping", () => {
it("should handle escaped semicolon", () => {
const schema = defineSchema("string");
expect(schema.parse("hello\\;world")).toBe("hello;world");
});
it("should handle escaped bracket", () => {
const schema = defineSchema("string");
expect(schema.parse("hello\\[world")).toBe("hello[world");
});
it("should handle escaped backslash", () => {
const schema = defineSchema("string");
expect(schema.parse("hello\\\\world")).toBe("hello\\world");
});
it("should handle escaped semicolon in tuple", () => {
const schema = defineSchema("[string; string]");
const value = schema.parse("[hello\\;world; test]");
expect(value).toEqual(["hello;world", "test"]);
});
});
describe("parseSchema", () => {
it("should parse string schema", () => {
const schema = parseSchema("string");
expect(schema).toEqual({ type: "string" });
});
it("should parse number schema", () => {
const schema = parseSchema("number");
expect(schema).toEqual({ type: "number" });
});
it("should parse string literal schema", () => {
const schema = parseSchema('"on"');
expect(schema).toEqual({ type: "stringLiteral", value: "on" });
});
it("should parse union schema", () => {
const schema = parseSchema('"on" | "off"');
expect(schema.type).toBe("union");
if (schema.type === "union") {
expect(schema.members).toHaveLength(2);
expect(schema.members[0]).toEqual({ type: "stringLiteral", value: "on" });
expect(schema.members[1]).toEqual({
type: "stringLiteral",
value: "off",
});
}
});
});
describe("Reference schemas (parseSchema)", () => {
it("should parse single reference schema @tablename", () => {
const schema = parseSchema("@users");
expect(schema).toEqual({
type: "reference",
tableName: "users",
isArray: false,
isOptional: false,
});
});
it("should parse array reference schema @tablename[]", () => {
const schema = parseSchema("@users[]");
expect(schema).toEqual({
type: "reference",
tableName: "users",
isArray: true,
isOptional: false,
});
});
it("should parse reference with hyphens in table name", () => {
const schema = parseSchema("@my-table");
expect(schema).toEqual({
type: "reference",
tableName: "my-table",
isArray: false,
isOptional: false,
});
});
it("should parse reference with underscores in table name", () => {
const schema = parseSchema("@my_table");
expect(schema).toEqual({
type: "reference",
tableName: "my_table",
isArray: false,
isOptional: false,
});
});
it("should parse array reference schema @tablename[]", () => {
const schema = parseSchema("@users[]");
expect(schema).toEqual({
type: "reference",
tableName: "users",
isArray: true,
isOptional: false,
});
});
it("should parse reference with hyphens in table name", () => {
const schema = parseSchema("@my-table");
expect(schema).toEqual({
type: "reference",
tableName: "my-table",
isArray: false,
isOptional: false,
});
});
it("should parse reference with underscores in table name", () => {
const schema = parseSchema("@my_table");
expect(schema).toEqual({
type: "reference",
tableName: "my_table",
isArray: false,
isOptional: false,
});
});
it("should throw ParseError for @ without table name", () => {
expect(() => parseSchema("@")).toThrow(ParseError);
});
it("should throw ParseError for @ followed by non-identifier", () => {
expect(() => parseSchema("@ ")).toThrow(ParseError);
});
it("should parse reference inside tuple [string; @users]", () => {
const schema = parseSchema("[string; @users]");
expect(schema.type).toBe("tuple");
if (schema.type === "tuple") {
expect(schema.elements).toHaveLength(2);
expect(schema.elements[0].schema).toEqual({ type: "string" });
expect(schema.elements[1].schema).toEqual({
type: "reference",
tableName: "users",
isArray: false,
isOptional: false,
});
}
});
it("should parse reference array inside tuple [string; @users[]]", () => {
const schema = parseSchema("[string; @users[]]");
expect(schema.type).toBe("tuple");
if (schema.type === "tuple") {
expect(schema.elements).toHaveLength(2);
expect(schema.elements[0].schema).toEqual({ type: "string" });
expect(schema.elements[1].schema).toEqual({
type: "reference",
tableName: "users",
isArray: true,
isOptional: false,
});
}
});
it("should parse named reference in tuple [name: string; author: @users]", () => {
const schema = parseSchema("[name: string; author: @users]");
expect(schema.type).toBe("tuple");
if (schema.type === "tuple") {
expect(schema.elements[1]).toEqual({
name: "author",
schema: {
type: "reference",
tableName: "users",
isArray: false,
isOptional: false,
},
});
}
});
it("should parse array of tuples containing reference [@users; number][]", () => {
const schema = parseSchema("[@users; number][]");
expect(schema.type).toBe("array");
if (schema.type === "array") {
expect(schema.element.type).toBe("tuple");
if (schema.element.type === "tuple") {
expect(schema.element.elements[0].schema).toEqual({
type: "reference",
tableName: "users",
isArray: false,
isOptional: false,
});
}
}
});
it("should parse reference in union @users | string", () => {
const schema = parseSchema("@users | string");
expect(schema.type).toBe("union");
if (schema.type === "union") {
expect(schema.members).toHaveLength(2);
expect(schema.members[0]).toEqual({
type: "reference",
tableName: "users",
isArray: false,
isOptional: false,
});
expect(schema.members[1]).toEqual({ type: "string" });
}
});
it("should parse array reference in union @users[] | string", () => {
const schema = parseSchema("@users[] | string");
expect(schema.type).toBe("union");
if (schema.type === "union") {
expect(schema.members[0]).toEqual({
type: "reference",
tableName: "users",
isArray: true,
isOptional: false,
});
}
});
it("should parse reference inside parenthesized union (@users | @parts)", () => {
const schema = parseSchema("(@users | @parts)");
expect(schema.type).toBe("union");
if (schema.type === "union") {
expect(schema.members).toHaveLength(2);
expect(schema.members[0]).toEqual({
type: "reference",
tableName: "users",
isArray: false,
isOptional: false,
});
expect(schema.members[1]).toEqual({
type: "reference",
tableName: "parts",
isArray: false,
isOptional: false,
});
}
});
it("should parse array of reference unions (@users | @parts)[]", () => {
const schema = parseSchema("(@users | @parts)[]");
expect(schema.type).toBe("array");
if (schema.type === "array") {
expect(schema.element.type).toBe("union");
if (schema.element.type === "union") {
expect(schema.element.members[0]).toEqual({
type: "reference",
tableName: "users",
isArray: false,
isOptional: false,
});
expect(schema.element.members[1]).toEqual({
type: "reference",
tableName: "parts",
isArray: false,
isOptional: false,
});
}
}
});
it("should parse optional reference schema @tablename?", () => {
const schema = parseSchema("@users?");
expect(schema).toEqual({
type: "reference",
tableName: "users",
isArray: false,
isOptional: true,
});
});
it("should parse optional array reference schema @tablename[]?", () => {
const schema = parseSchema("@users[]?");
expect(schema).toEqual({
type: "reference",
tableName: "users",
isArray: true,
isOptional: true,
});
});
it("should parse optional reference schema @tablename?", () => {
const schema = parseSchema("@users?");
expect(schema).toEqual({
type: "reference",
tableName: "users",
isArray: false,
isOptional: true,
});
});
it("should parse optional array reference schema @tablename[]?", () => {
const schema = parseSchema("@users[]?");
expect(schema).toEqual({
type: "reference",
tableName: "users",
isArray: true,
isOptional: true,
});
});
it("should parse optional reference in union @users? | string", () => {
const schema = parseSchema("@users? | string");
expect(schema.type).toBe("union");
if (schema.type === "union") {
expect(schema.members[0]).toEqual({
type: "reference",
tableName: "users",
isArray: false,
isOptional: true,
});
}
});
});
describe("Reverse reference schemas (parseSchema)", () => {
it("should parse reverse reference ~tablename(foreignKey)", () => {
const schema = parseSchema("~orders(user)");
expect(schema).toEqual({
type: "reverseReference",
tableName: "orders",
foreignKey: "user",
isOptional: false,
});
});
it("should parse optional reverse reference ~tablename(foreignKey)?", () => {
const schema = parseSchema("~orders(user)?");
expect(schema).toEqual({
type: "reverseReference",
tableName: "orders",
foreignKey: "user",
isOptional: true,
});
});
it("should parse reverse reference with hyphens and underscores", () => {
const schema = parseSchema("~my-orders(my_foreign_key)");
expect(schema).toEqual({
type: "reverseReference",
tableName: "my-orders",
foreignKey: "my_foreign_key",
isOptional: false,
});
});
it("should throw ParseError for ~ without table name", () => {
expect(() => parseSchema("~")).toThrow(ParseError);
});
it("should throw ParseError for ~ without parentheses", () => {
expect(() => parseSchema("~orders")).toThrow(ParseError);
});
it("should throw ParseError for ~ with empty parentheses", () => {
expect(() => parseSchema("~orders()")).toThrow(ParseError);
});
it("should throw ParseError for ~ with missing closing parenthesis", () => {
expect(() => parseSchema("~orders(user")).toThrow(ParseError);
});
it("should parse reverse reference inside tuple [string; ~orders(user)]", () => {
const schema = parseSchema("[string; ~orders(user)]");
expect(schema.type).toBe("tuple");
if (schema.type === "tuple") {
expect(schema.elements).toHaveLength(2);
expect(schema.elements[0].schema).toEqual({ type: "string" });
expect(schema.elements[1].schema).toEqual({
type: "reverseReference",
tableName: "orders",
foreignKey: "user",
isOptional: false,
});
}
});
it("should parse reverse reference in union ~orders(user) | string", () => {
const schema = parseSchema("~orders(user) | string");
expect(schema.type).toBe("union");
if (schema.type === "union") {
expect(schema.members).toHaveLength(2);
expect(schema.members[0]).toEqual({
type: "reverseReference",
tableName: "orders",
foreignKey: "user",
isOptional: false,
});
expect(schema.members[1]).toEqual({ type: "string" });
}
});
});
describe("Reference value parsing (parseValue)", () => {
it("should parse single reference ID", () => {
const schema: import("./types").ReferenceSchema = {
type: "reference",
tableName: "users",
isArray: false,
};
const result = parseValue(schema, "42");
expect(result).toBe("42");
});
it("should parse array reference IDs with brackets", () => {
const schema: import("./types").ReferenceSchema = {
type: "reference",
tableName: "users",
isArray: true,
};
const result = parseValue(schema, "[1; 2; 3]");
expect(result).toEqual(["1", "2", "3"]);
});
it("should require brackets for array reference IDs", () => {
const schema: import("./types").ReferenceSchema = {
type: "reference",
tableName: "users",
isArray: true,
};
const result = parseValue(schema, "[1; 2; 3]");
expect(result).toEqual(["1", "2", "3"]);
expect(() => parseValue(schema, "1; 2; 3")).toThrow(ParseError);
});
it("should parse empty array reference", () => {
const schema: import("./types").ReferenceSchema = {
type: "reference",
tableName: "users",
isArray: true,
};
const result = parseValue(schema, "[]");
expect(result).toEqual([]);
});
it("should parse single reference ID with spaces", () => {
const schema: import("./types").ReferenceSchema = {
type: "reference",
tableName: "users",
isArray: false,
};
const result = parseValue(schema, " 42 ");
expect(result).toBe("42");
});
it("should parse array reference IDs with spaces", () => {
const schema: import("./types").ReferenceSchema = {
type: "reference",
tableName: "users",
isArray: true,
};
const result = parseValue(schema, "[ 1 ; 2 ; 3 ]");
expect(result).toEqual(["1", "2", "3"]);
});
it("should parse string IDs in reference", () => {
const schema: import("./types").ReferenceSchema = {
type: "reference",
tableName: "users",
isArray: false,
};
const result = parseValue(schema, "abc-123");
expect(result).toBe("abc-123");
});
});
describe("Reference validation (createValidator)", () => {
it("should validate single reference (string ID)", () => {
const schema: import("./types").ReferenceSchema = {
type: "reference",
tableName: "users",
isArray: false,
};
const validator = createValidator(schema);
expect(validator("1")).toBe(true);
expect(validator("abc")).toBe(true);
expect(validator(42)).toBe(false);
expect(validator(true)).toBe(false);
});
it("should validate array reference (array of string IDs)", () => {
const schema: import("./types").ReferenceSchema = {
type: "reference",
tableName: "users",
isArray: true,
};
const validator = createValidator(schema);
expect(validator(["1", "2"])).toBe(true);
expect(validator([])).toBe(true);
expect(validator(["1"])).toBe(true);
expect(validator([1, 2])).toBe(false);
expect(validator("1")).toBe(false);
});
it("should allow string array for single reference (backward compat)", () => {
const schema: import("./types").ReferenceSchema = {
type: "reference",
tableName: "users",
isArray: false,
};
const validator = createValidator(schema);
expect(validator(["1", "2"])).toBe(true);
});
});
describe("Error handling", () => {
it("should throw ParseError for invalid schema", () => {
expect(() => parseSchema("")).toThrow(ParseError);
});
it("should throw ParseError for unexpected input", () => {
expect(() => parseSchema("string extra")).toThrow(ParseError);
});
it("should throw ParseError for invalid value", () => {
const schema = defineSchema("number");
expect(() => schema.parse("not-a-number")).toThrow(ParseError);
});
it("should throw ParseError for mismatched enum value", () => {
const schema = defineSchema('"on" | "off"');
expect(() => schema.parse('"invalid"')).toThrow(ParseError);
});
});
+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, 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, ParsedSchema }; parseSchema,
parseValue,
createValidator,
ParseError,
schemaToTypeString,
};
export type {
Schema,
PrimitiveSchema,
TupleSchema,
ArraySchema,
ReferenceSchema,
ReverseReferenceSchema,
StringLiteralSchema,
UnionSchema,
ParsedSchema,
};
+308 -66
View File
@@ -1,12 +1,44 @@
import type { Schema, PrimitiveSchema, TupleSchema, ArraySchema, NamedSchema } 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";
} }
} }
export interface ReferenceInfo {
/** Referenced table name (e.g., 'parts' from '@parts[]') */
tableName: string;
/** Whether it's an array reference */
isArray: boolean;
}
class Parser { class Parser {
private input: string; private input: string;
private pos: number = 0; private pos: number = 0;
@@ -16,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 {
@@ -52,59 +84,160 @@ class Parser {
parseSchema(): Schema { parseSchema(): Schema {
this.skipWhitespace(); this.skipWhitespace();
if (this.consumeStr('string')) { // Parse the first schema element
if (this.consumeStr('[')) { let schema = this.parseSchemaInternal();
this.skipWhitespace(); this.skipWhitespace();
if (!this.consumeStr(']')) {
throw new ParseError('Expected ]', this.pos); // Check for array suffix: type[]
} if (this.consumeStr("[")) {
return { type: 'array', element: { type: 'string' } }; this.skipWhitespace();
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
} }
return { type: 'string' }; schema = { type: "array", element: schema };
this.skipWhitespace();
} }
if (this.consumeStr('number')) { // Check for union type (| symbol)
if (this.consumeStr('[')) { if (this.consumeStr("|")) {
const members: Schema[] = [schema];
while (true) {
this.skipWhitespace(); this.skipWhitespace();
if (!this.consumeStr(']')) { const member = this.parseSchemaInternal();
throw new ParseError('Expected ]', this.pos); members.push(member);
this.skipWhitespace();
if (!this.consumeStr("|")) {
break;
} }
return { type: 'array', element: { type: 'number' } };
} }
return { type: 'number' };
return {
type: "union",
members,
};
} }
if (this.consumeStr('boolean')) { return schema;
if (this.consumeStr('[')) { }
this.skipWhitespace();
if (!this.consumeStr(']')) { private parseSchemaInternal(): Schema {
throw new ParseError('Expected ]', this.pos); this.skipWhitespace();
}
return { type: 'array', element: { type: 'boolean' } }; // Check for parentheses (grouping)
if (this.consumeStr("(")) {
this.skipWhitespace();
const schema = this.parseSchema(); // Recursive call for nested unions
this.skipWhitespace();
if (!this.consumeStr(")")) {
throw new ParseError("Expected )", this.pos);
} }
return { type: 'boolean' };
// Check if there's an array suffix: (union)[]
this.skipWhitespace();
if (this.consumeStr("[")) {
this.skipWhitespace();
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
return { type: "array", element: schema };
}
return schema;
} }
if (this.consumeStr('[')) { // Check for string literal syntax: "value" or 'value'
if (this.peek() === '"' || this.peek() === "'") {
return this.parseStringLiteralSchema();
}
// Check for reverse reference syntax: ~tablename(foreignKey)
if (this.consumeStr("~")) {
return this.parseReverseReferenceSchema();
}
// Check for reference syntax: @tablename[]
if (this.consumeStr("@")) {
return this.parseReferenceSchema();
}
if (this.consumeStr("string")) {
if (this.consumeStr("[")) {
this.skipWhitespace();
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
return { type: "array", element: { type: "string" } };
}
return { type: "string" };
}
if (this.consumeStr("number")) {
if (this.consumeStr("[")) {
this.skipWhitespace();
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
return { type: "array", element: { type: "number" } };
}
return { type: "number" };
}
if (this.consumeStr("int")) {
if (this.consumeStr("[")) {
this.skipWhitespace();
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
return { type: "array", element: { type: "int" } };
}
return { type: "int" };
}
if (this.consumeStr("float")) {
if (this.consumeStr("[")) {
this.skipWhitespace();
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
return { type: "array", element: { type: "float" } };
}
return { type: "float" };
}
if (this.consumeStr("boolean")) {
if (this.consumeStr("[")) {
this.skipWhitespace();
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
return { type: "array", element: { type: "boolean" } };
}
return { type: "boolean" };
}
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;
} }
} }
@@ -113,63 +246,84 @@ 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) {
return { type: 'array', element: elements[0].schema };
}
return { type: 'tuple', elements };
} }
let identifier = ''; throw new ParseError(
while (this.pos < this.input.length && /[a-zA-Z0-9\-_]/.test(this.peek())) { `Unknown type: ${this.peek() || "end of input"}`,
identifier += this.consume(); this.pos,
} );
}
if (identifier.length > 0) { private parseStringLiteralSchema(): Schema {
if (this.consumeStr('[')) { const value = this.parseStringLiteral();
this.skipWhitespace(); return {
if (!this.consumeStr(']')) { type: "stringLiteral",
throw new ParseError('Expected ]', this.pos); value,
};
}
private parseStringLiteral(): string {
const quote = this.peek();
if (quote !== '"' && quote !== "'") {
throw new ParseError("Expected string literal with quotes", this.pos);
}
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 === "|" ||
nextChar === ";" ||
nextChar === "(" ||
nextChar === ")"
) {
value += nextChar;
} else {
value += "\\" + nextChar;
} }
return { type: 'array', element: { type: 'string' } }; } else if (char === quote) {
this.consume(); // Consume closing quote
return value;
} else {
value += this.consume();
} }
return { type: 'string' };
} }
throw new ParseError(`Unexpected character: ${this.peek()}`, 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();
} }
if (identifier.length === 0) { if (identifier.length === 0) {
throw new ParseError('Expected schema or named schema', this.pos); const schema = this.parseSchema();
return { schema };
} }
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();
@@ -180,6 +334,94 @@ class Parser {
return { schema }; return { schema };
} }
} }
private parseReferenceSchema(): 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();
// Check for array syntax
if (this.consumeStr("[]")) {
this.skipWhitespace();
const isOptional = this.consumeStr("?");
return {
type: "reference",
tableName,
isArray: true,
isOptional,
};
}
// Check for optional suffix
const isOptional = this.consumeStr("?");
// Single reference (non-array)
return {
type: "reference",
tableName,
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,
};
}
} }
export function parseSchema(schemaString: string): Schema { export function parseSchema(schemaString: string): Schema {
@@ -187,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;
-73
View File
@@ -1,73 +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: '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: '[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' },
];
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('=== All tests completed ===');
+174
View File
@@ -0,0 +1,174 @@
import { describe, it, expect } from 'vitest';
import { defineSchema, parseSchema, schemaToTypeString } from './index';
import type { Schema, StringLiteralSchema, UnionSchema } from './types';
describe('Type generation for string literals and unions', () => {
describe('String literal schema', () => {
it('should parse string literal schema', () => {
const schema = parseSchema('"on"');
expect(schema.type).toBe('stringLiteral');
if (schema.type === 'stringLiteral') {
expect(schema.value).toBe('on');
}
});
it('should generate correct type for string literal', () => {
const schema = defineSchema('"on"');
expect(schema.schema.type).toBe('stringLiteral');
// The parsed value should be the literal string
expect(schema.parse('"on"')).toBe('on');
expect(schema.validator('on')).toBe(true);
expect(schema.validator('off')).toBe(false);
});
it('should handle string literal with special characters', () => {
const schema = defineSchema('"hello-world"');
expect(schema.schema.type).toBe('stringLiteral');
expect(schema.parse('"hello-world"')).toBe('hello-world');
});
it('should parse string literal without quotes', () => {
const schema = defineSchema('"on"');
expect(schema.schema.type).toBe('stringLiteral');
// 不带引号的值应该像普通字符串一样解析
expect(schema.parse('on')).toBe('on');
expect(schema.validator('on')).toBe(true);
expect(schema.validator('off')).toBe(false);
});
});
describe('Union schema', () => {
it('should parse union of string literals', () => {
const schema = parseSchema('"on" | "off"');
expect(schema.type).toBe('union');
if (schema.type === 'union') {
expect(schema.members).toHaveLength(2);
expect(schema.members[0].type).toBe('stringLiteral');
expect(schema.members[1].type).toBe('stringLiteral');
}
});
it('should generate correct type for union of string literals', () => {
const schema = defineSchema('"on" | "off"');
expect(schema.schema.type).toBe('union');
// 带引号和不带引号的值都应该有效
expect(schema.parse('"on"')).toBe('on');
expect(schema.parse('"off"')).toBe('off');
expect(schema.parse('on')).toBe('on');
expect(schema.parse('off')).toBe('off');
expect(schema.validator('on')).toBe(true);
expect(schema.validator('off')).toBe(true);
expect(schema.validator('maybe')).toBe(false);
});
it('should parse union with three members', () => {
const schema = parseSchema('"pending" | "approved" | "rejected"');
expect(schema.type).toBe('union');
if (schema.type === 'union') {
expect(schema.members).toHaveLength(3);
}
});
it('should handle union with parentheses', () => {
const schema = defineSchema('("a" | "b")');
expect(schema.schema.type).toBe('union');
expect(schema.parse('"a"')).toBe('a');
});
});
describe('Complex schema combinations', () => {
it('should handle tuple with union field', () => {
const schema = defineSchema('[name: string; status: "active" | "inactive"]');
expect(schema.schema.type).toBe('tuple');
// 带引号和不带引号的值都应该有效
const value1 = schema.parse('[john; "active"]');
expect(value1).toEqual(['john', 'active']);
expect(schema.validator(['john', 'active'])).toBe(true);
const value2 = schema.parse('[john; active]');
expect(value2).toEqual(['john', 'active']);
expect(schema.validator(['john', 'unknown'])).toBe(false);
});
it('should handle array of unions', () => {
const schema = defineSchema('("pending" | "approved" | "rejected")[]');
expect(schema.schema.type).toBe('array');
// 带引号和不带引号的值都应该有效
const value1 = schema.parse('["pending"; "approved"]');
expect(value1).toEqual(['pending', 'approved']);
const value2 = schema.parse('[pending; approved]');
expect(value2).toEqual(['pending', 'approved']);
expect(schema.validator(['pending', 'approved'])).toBe(true);
expect(schema.validator(['pending', 'unknown'])).toBe(false);
});
it('should handle array of string literals', () => {
const schema = defineSchema('"item"[]');
expect(schema.schema.type).toBe('array');
// 带引号和不带引号的值都应该有效
const value1 = schema.parse('["item"; "item"; "item"]');
expect(value1).toEqual(['item', 'item', 'item']);
const value2 = schema.parse('[item; item; item]');
expect(value2).toEqual(['item', 'item', 'item']);
expect(schema.validator(['item', 'item'])).toBe(true);
expect(schema.validator(['item', 'other'])).toBe(false);
});
it('should handle nested unions with mixed types', () => {
const schema = parseSchema('string | number | "special"');
expect(schema.type).toBe('union');
if (schema.type === 'union') {
expect(schema.members).toHaveLength(3);
expect(schema.members[0].type).toBe('string');
expect(schema.members[1].type).toBe('number');
expect(schema.members[2].type).toBe('stringLiteral');
}
});
});
describe('Type string generation', () => {
it('should generate "on" for string literal', () => {
const schema = parseSchema('"on"');
expect(schemaToTypeString(schema)).toBe('"on"');
});
it('should generate "on" | "off" for union of string literals', () => {
const schema = parseSchema('"on" | "off"');
expect(schemaToTypeString(schema)).toBe('"on" | "off"');
});
it('should generate correct type for array of string literals', () => {
const schema = parseSchema('"item"[]');
expect(schemaToTypeString(schema)).toBe('"item"[]');
});
it('should generate correct type for array of unions', () => {
const schema = parseSchema('("pending" | "approved" | "rejected")[]');
expect(schemaToTypeString(schema)).toBe('("pending" | "approved" | "rejected")[]');
});
it('should generate correct type for tuple with union field', () => {
const schema = parseSchema('[name: string; status: "active" | "inactive"]');
expect(schemaToTypeString(schema)).toBe('[name: string, status: "active" | "inactive"]');
});
it('should generate correct type for complex union', () => {
const schema = parseSchema('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;
}
};
}
+41 -4
View File
@@ -1,4 +1,4 @@
export type SchemaType = 'string' | 'number' | 'boolean'; export type SchemaType = "string" | "number" | "int" | "float" | "boolean";
export interface PrimitiveSchema { export interface PrimitiveSchema {
type: SchemaType; type: SchemaType;
@@ -10,16 +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 type Schema = PrimitiveSchema | TupleSchema | ArraySchema; export interface ReferenceSchema {
type: "reference";
/** Referenced table name (e.g., 'parts' from '@parts[]') */
tableName: string;
/** Whether it's an array reference */
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 {
type: "stringLiteral";
value: string; // The literal string value (without quotes)
}
export interface UnionSchema {
type: "union";
members: Schema[]; // Union members
}
export type Schema =
| PrimitiveSchema
| TupleSchema
| ArraySchema
| ReferenceSchema
| ReverseReferenceSchema
| StringLiteralSchema
| UnionSchema;
export interface ParsedSchema { export interface ParsedSchema {
schema: Schema; schema: Schema;
+3 -242
View File
@@ -1,242 +1,3 @@
import type { Schema, PrimitiveSchema, TupleSchema, ArraySchema, NamedSchema } 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 'boolean':
return this.parseBooleanValue();
case 'tuple':
return this.parseTupleValue(schema, allowOmitBrackets);
case 'array':
return this.parseArrayValue(schema, allowOmitBrackets);
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 parseBooleanValue(): boolean {
if (this.consumeStr('true')) {
return true;
}
if (this.consumeStr('false')) {
return false;
}
throw new ParseError('Expected true or false', 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.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;
}
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 'boolean':
return typeof value === 'boolean';
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));
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;
}
+24
View File
@@ -16,4 +16,28 @@ export default defineConfig([
external: ['@rspack/core', 'csv-parse'], external: ['@rspack/core', 'csv-parse'],
clean: false, clean: false,
}, },
{
entry: ['src/csv-loader/webpack.ts'],
format: ['cjs', 'esm'],
dts: true,
outDir: 'dist/csv-loader',
external: ['@rspack/core', 'csv-parse'],
clean: false,
},
{
entry: ['src/csv-loader/rollup.ts'],
format: ['cjs', 'esm'],
dts: true,
outDir: 'dist/csv-loader',
external: ['rollup', 'csv-parse'],
clean: false,
},
{
entry: ['src/csv-loader/esbuild.ts'],
format: ['cjs', 'esm'],
dts: true,
outDir: 'dist/csv-loader',
external: ['esbuild', 'csv-parse'],
clean: false,
},
]); ]);