Compare commits
64
Commits
16d88d6108
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7802af577c | ||
|
|
1a741b09a8 | ||
|
|
6da2815301 | ||
|
|
641af7341a | ||
|
|
37e3514c0c | ||
|
|
cdff31c126 | ||
|
|
c969c7f6fc | ||
|
|
7db0742f50 | ||
|
|
a4e17a3c0b | ||
|
|
a3e0e953ff | ||
|
|
ea98399c24 | ||
|
|
89be2783d1 | ||
|
|
eef3dbfac8 | ||
|
|
e0317946d5 | ||
|
|
46504a53dd | ||
|
|
ea362d4229 | ||
|
|
f101c12097 | ||
|
|
f03226221d | ||
|
|
1061e12c81 | ||
|
|
b5be558b57 | ||
|
|
585f33b856 | ||
|
|
721544e7b2 | ||
|
|
e5332f506d | ||
|
|
55a33b98e5 | ||
|
|
53ccac39e6 | ||
|
|
89ac1619e7 | ||
|
|
3e768f5c83 | ||
|
|
d0ed1dc92d | ||
|
|
f66f60aa0e | ||
|
|
f94e9b68e4 | ||
|
|
eeaac92e39 | ||
|
|
c3572a5b56 | ||
|
|
5f941aba10 | ||
|
|
9a2db5edb6 | ||
|
|
5a1627c6f1 | ||
|
|
be8eb277d4 | ||
|
|
9dd4f60c2d | ||
|
|
e76ae79b2d | ||
|
|
0954dcf594 | ||
|
|
740bb503b1 | ||
|
|
c8f0d6b0de | ||
|
|
075045223f | ||
|
|
1f3a812728 | ||
|
|
852a108c53 | ||
|
|
6eba70bb3b | ||
|
|
392d5f1431 | ||
|
|
ae2445b79c | ||
|
|
8343df2316 | ||
|
|
d5fdb69ad7 | ||
|
|
97c8b1966c | ||
|
|
51a11a26bf | ||
|
|
14948fb5f6 | ||
|
|
d78ef75272 | ||
|
|
daac7badbb | ||
|
|
3051df3e8b | ||
|
|
ad38976e86 | ||
|
|
77ee96d70c | ||
|
|
4eb6a17e9f | ||
|
|
f656a62606 | ||
|
|
4146fda348 | ||
|
|
8bf28c8aed | ||
|
|
08cac3965e | ||
|
|
d9a91ae8be | ||
|
|
da1e3b2dac |
+2
-2
@@ -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
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Vendored
-54
@@ -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 };
|
|
||||||
Vendored
-54
@@ -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 };
|
|
||||||
Vendored
-548
@@ -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
|
|
||||||
});
|
|
||||||
Vendored
-512
@@ -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
|
|
||||||
};
|
|
||||||
Vendored
-35
@@ -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 };
|
|
||||||
Vendored
-35
@@ -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 };
|
|
||||||
Vendored
-408
@@ -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
|
|
||||||
});
|
|
||||||
Vendored
-377
@@ -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
|
|
||||||
};
|
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
id,name,related
|
||||||
|
string,string,@circular_b[]
|
||||||
|
1,A,[1]
|
||||||
|
@@ -0,0 +1,3 @@
|
|||||||
|
id,name,related
|
||||||
|
string,string,@circular_a[]
|
||||||
|
1,B,[1]
|
||||||
|
@@ -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.
|
@@ -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]]]],
|
||||||
|
@@ -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]]
|
||||||
|
@@ -0,0 +1,3 @@
|
|||||||
|
id,user,total
|
||||||
|
string,string,int
|
||||||
|
o01,u01,100
|
||||||
|
@@ -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
|
||||||
|
@@ -0,0 +1,5 @@
|
|||||||
|
id,name,price
|
||||||
|
string,string,number
|
||||||
|
1,Widget,10.5
|
||||||
|
2,Gadget,25.0
|
||||||
|
3,Doohickey,7.99
|
||||||
|
@@ -0,0 +1,4 @@
|
|||||||
|
id,name,parent
|
||||||
|
string,string,@self_ref
|
||||||
|
1,Root,2
|
||||||
|
2,Child,1
|
||||||
|
@@ -0,0 +1,5 @@
|
|||||||
|
# inject orders = ~order_rev(user)
|
||||||
|
id,name
|
||||||
|
string,string
|
||||||
|
u01,Alice
|
||||||
|
u02,Bob
|
||||||
|
@@ -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
|
||||||
|
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+381
-165
@@ -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);
|
||||||
|
|
||||||
|
// Also check schema row cells for comment-prefixed type declarations
|
||||||
|
// and reverse reference declarations
|
||||||
|
for (let col = 0; col < schemas.length; col++) {
|
||||||
|
const cell = (schemas[col] ?? "").trim();
|
||||||
|
if (comment && cell.startsWith(comment)) {
|
||||||
|
// Try type declaration first
|
||||||
|
const typeDecl = parseTypeDeclaration(cell, comment);
|
||||||
|
if (typeDecl) {
|
||||||
|
typeDeclarationsRaw.push(typeDecl);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Try reverse reference
|
||||||
|
const decl = parseReverseReferenceDeclaration(cell, comment);
|
||||||
|
if (decl) {
|
||||||
|
reverseReferences.push(decl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a map of declared type names first
|
||||||
|
const declaredTypeNames = new Set<string>();
|
||||||
|
for (const decl of typeDeclarationsRaw) {
|
||||||
|
declaredTypeNames.add(decl.typeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a map of schema strings for expansion (only stores string schemas initially)
|
||||||
|
const declaredSchemaStrings = new Map<string, string>();
|
||||||
|
for (const decl of typeDeclarationsRaw) {
|
||||||
|
// If the schema is a string literal union, store it for expansion
|
||||||
|
declaredSchemaStrings.set(decl.typeName, decl.schemaString);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse type declarations with expansion of type name references
|
||||||
|
const typeDeclarationsParsed: {
|
||||||
|
name: string;
|
||||||
|
schema: Schema;
|
||||||
|
schemaString: string;
|
||||||
|
}[] = [];
|
||||||
|
for (const decl of typeDeclarationsRaw) {
|
||||||
|
// Expand any type name references before parsing
|
||||||
|
const expandedSchema = expandSchemaString(
|
||||||
|
decl.schemaString,
|
||||||
|
declaredSchemaStrings,
|
||||||
|
);
|
||||||
|
const schema = parseSchema(expandedSchema.trim());
|
||||||
|
typeDeclarationsParsed.push({
|
||||||
|
name: decl.typeName,
|
||||||
|
schema,
|
||||||
|
schemaString: decl.schemaString,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build declared types map
|
||||||
|
const declaredTypes = new Map<string, Schema>();
|
||||||
|
for (const decl of typeDeclarationsParsed) {
|
||||||
|
declaredTypes.set(decl.name, decl.schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now resolve all type references within type declarations (for nested type refs)
|
||||||
|
const typeDeclarations: TypeDeclaration[] = [];
|
||||||
|
for (const decl of typeDeclarationsParsed) {
|
||||||
|
const resolvedSchema = resolveTypeReferences(decl.schema, declaredTypes);
|
||||||
|
typeDeclarations.push({
|
||||||
|
name: decl.name,
|
||||||
|
schema: resolvedSchema,
|
||||||
|
schemaString: decl.schemaString,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update declaredTypes with resolved schemas for column schema lookup
|
||||||
|
for (const decl of typeDeclarations) {
|
||||||
|
declaredTypes.set(decl.name, decl.schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveReferences = options.resolveReferences ?? true;
|
||||||
|
|
||||||
|
const propertyConfigs: PropertyConfig[] = headers.map(
|
||||||
|
(header: string, index: number) => {
|
||||||
const schemaString = schemas[index];
|
const schemaString = schemas[index];
|
||||||
const schema = parseSchema(schemaString);
|
// Check if schema string matches a declared type name
|
||||||
return {
|
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,
|
name: header,
|
||||||
schema,
|
schema,
|
||||||
validator: createValidator(schema),
|
validator: createValidator(schema),
|
||||||
parser: (valueString: string) => parseValue(schema, valueString),
|
parser: (valueString: string) => parseValue(schema, valueString),
|
||||||
|
declaredTypeName,
|
||||||
|
schemaString: columnSchemaString,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (schema.type === "reference") {
|
||||||
|
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)};`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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'");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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,
|
||||||
|
};
|
||||||
|
|||||||
+306
-64
@@ -1,10 +1,42 @@
|
|||||||
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 {
|
||||||
@@ -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("[")) {
|
||||||
|
this.skipWhitespace();
|
||||||
|
if (!this.consumeStr("]")) {
|
||||||
|
throw new ParseError("Expected ]", this.pos);
|
||||||
}
|
}
|
||||||
return { type: 'array', element: { type: 'string' } };
|
schema = { type: "array", element: schema };
|
||||||
}
|
this.skipWhitespace();
|
||||||
return { type: 'string' };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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' };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.consumeStr('boolean')) {
|
return {
|
||||||
if (this.consumeStr('[')) {
|
type: "union",
|
||||||
this.skipWhitespace();
|
members,
|
||||||
if (!this.consumeStr(']')) {
|
};
|
||||||
throw new ParseError('Expected ]', this.pos);
|
|
||||||
}
|
|
||||||
return { type: 'array', element: { type: 'boolean' } };
|
|
||||||
}
|
|
||||||
return { type: 'boolean' };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.consumeStr('[')) {
|
return schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseSchemaInternal(): Schema {
|
||||||
|
this.skipWhitespace();
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
throw new ParseError(
|
||||||
return { type: 'array', element: elements[0].schema };
|
`Unknown type: ${this.peek() || "end of input"}`,
|
||||||
}
|
this.pos,
|
||||||
return { type: 'tuple', elements };
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let identifier = '';
|
private parseStringLiteralSchema(): Schema {
|
||||||
while (this.pos < this.input.length && /[a-zA-Z0-9\-_]/.test(this.peek())) {
|
const value = this.parseStringLiteral();
|
||||||
identifier += this.consume();
|
return {
|
||||||
|
type: "stringLiteral",
|
||||||
|
value,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (identifier.length > 0) {
|
private parseStringLiteral(): string {
|
||||||
if (this.consumeStr('[')) {
|
const quote = this.peek();
|
||||||
this.skipWhitespace();
|
if (quote !== '"' && quote !== "'") {
|
||||||
if (!this.consumeStr(']')) {
|
throw new ParseError("Expected string literal with quotes", this.pos);
|
||||||
throw new ParseError('Expected ]', this.pos);
|
|
||||||
}
|
}
|
||||||
return { type: 'array', element: { type: 'string' } };
|
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;
|
||||||
|
}
|
||||||
|
} 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
@@ -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 ===');
|
|
||||||
@@ -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][]][]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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
@@ -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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
Reference in New Issue
Block a user