Compare commits
11
Commits
89be2783d1
...
7802af577c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7802af577c | ||
|
|
1a741b09a8 | ||
|
|
6da2815301 | ||
|
|
641af7341a | ||
|
|
37e3514c0c | ||
|
|
cdff31c126 | ||
|
|
c969c7f6fc | ||
|
|
7db0742f50 | ||
|
|
a4e17a3c0b | ||
|
|
a3e0e953ff | ||
|
|
ea98399c24 |
@@ -13,12 +13,12 @@ No linter or formatter is configured. No CI pipeline exists.
|
|||||||
|
|
||||||
Single-package TypeScript library with two runtime entry points:
|
Single-package TypeScript library with two runtime entry points:
|
||||||
|
|
||||||
- **`inline-schema`** (`src/index.ts`) — core schema parser, value parser, and validator
|
- **`typed-csv`** (`src/index.ts`) — core schema parser, value parser, and validator
|
||||||
- `src/parser.ts` — `parseSchema()` turns schema strings into AST (`Schema` type)
|
- `src/parser.ts` — `parseSchema()` turns schema strings into AST (`Schema` type)
|
||||||
- `src/validator.ts` — `parseValue()` and `createValidator()` operate on the AST
|
- `src/validator.ts` — `parseValue()` and `createValidator()` operate on the AST
|
||||||
- `src/types.ts` — union type `Schema = Primitive | Tuple | Array | Reference | StringLiteral | Union`
|
- `src/types.ts` — union type `Schema = Primitive | Tuple | Array | Reference | StringLiteral | Union`
|
||||||
|
|
||||||
- **`inline-schema/csv-loader`** (`src/csv-loader/loader.ts`) — CSV loader with `@table` reference resolution
|
- **`typed-csv/csv-loader`** (`src/csv-loader/loader.ts`) — CSV loader with `@table` reference resolution
|
||||||
- `loader.ts` — `parseCsv()` (eager resolution) and `csvToModule()` (accessor-based output with lazy resolution); `resolveReferences: false` mode stores IDs instead of resolved objects
|
- `loader.ts` — `parseCsv()` (eager resolution) and `csvToModule()` (accessor-based output with lazy resolution); `resolveReferences: false` mode stores IDs instead of resolved objects
|
||||||
- `webpack.ts`, `rollup.ts`, `esbuild.ts` — bundler plugin wrappers around `csvToModule`
|
- `webpack.ts`, `rollup.ts`, `esbuild.ts` — bundler plugin wrappers around `csvToModule`
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ Build produces separate bundles per entry point (see `tsup.config.ts`). The csv-
|
|||||||
|
|
||||||
- Schema syntax uses **semicolons** (`;`) as separators, not commas
|
- Schema syntax uses **semicolons** (`;`) as separators, not commas
|
||||||
- Unknown identifiers throw a `ParseError` — only recognized keywords (`string`, `number`, `int`, `float`, `boolean`) and string literals (`"on"`, `'off'`) are valid types
|
- Unknown identifiers throw a `ParseError` — only recognized keywords (`string`, `number`, `int`, `float`, `boolean`) and string literals (`"on"`, `'off'`) are valid types
|
||||||
|
- Tuple and array **values** must be wrapped in brackets `[]` (e.g. `[a; b]`); `[single]` is a 1-tuple, not an array — use `Type[]` for arrays
|
||||||
- `@tablename` / `@tablename[]` are reference schemas resolved at CSV load time
|
- `@tablename` / `@tablename[]` are reference schemas resolved at CSV load time
|
||||||
- `csvToModule()` emits accessor functions (`getData()`) for tables with references, and static JSON for tables without; bundler loaders all use `csvToModule`
|
- `csvToModule()` emits accessor functions (`getData()`) for tables with references, and static JSON for tables without; bundler loaders all use `csvToModule`
|
||||||
- `parseCsv({ resolveReferences: false })` stores reference IDs instead of resolved objects — used by `csvToModule` to emit import-based lazy resolution
|
- `parseCsv({ resolveReferences: false })` stores reference IDs instead of resolved objects — used by `csvToModule` to emit import-based lazy resolution
|
||||||
@@ -37,6 +38,6 @@ Build produces separate bundles per entry point (see `tsup.config.ts`). The csv-
|
|||||||
|
|
||||||
- **Circular references** between CSV tables are supported in `csvToModule` output via accessor-based lazy resolution. `parseCsv()` with `resolveReferences: true` (default) still detects and throws on circular references via an in-progress loading set.
|
- **Circular references** between CSV tables are supported in `csvToModule` output via accessor-based lazy resolution. `parseCsv()` with `resolveReferences: true` (default) still detects and throws on circular references via an in-progress loading set.
|
||||||
- **Run `npm run typecheck` before committing** to catch type errors.
|
- **Run `npm run typecheck` before committing** to catch type errors.
|
||||||
- **Union member ordering matters** — `parseValue` tries union members in order; the first one that parses wins. This affects references in unions (e.g., `@users[] | string` will try `@users[]` first).
|
- **Union member ordering** — In unions containing references, **reference members are tried before non-reference members** (regardless of author order), so `@users | string` resolves `1` to the user object and falls back to a plain string only when the reference doesn't match. `parseValue` (no references) still tries members in author order; the first that parses wins.
|
||||||
- **csv-parse quote handling** — Double-quoted schema values like `"active" | "inactive"` in CSV rows confuse the csv-parse library. Use single-quoted string literals (`'on' | 'off'`) or unquoted identifiers in the schema row of CSV data when possible.
|
- **csv-parse quote handling** — Double-quoted schema values like `"active" | "inactive"` in CSV rows confuse the csv-parse library. Use single-quoted string literals (`'on' | 'off'`) or unquoted identifiers in the schema row of CSV data when possible.
|
||||||
- **Module imports use `.js` extension** — source files import from `../index.js` etc. (ESM convention), not `../index.ts`.
|
- **Module imports use `.js` extension** — source files import from `../index.js` etc. (ESM convention), not `../index.ts`.
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# inline-schema
|
# typed-csv
|
||||||
|
|
||||||
A TypeScript library for parsing and validating inline schemas with a TypeScript-like syntax using `;` instead of `,`.
|
A TypeScript library for typed CSV data with inline schema validation using a TypeScript-like syntax with `;` instead of `,`.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install inline-schema
|
npm install typed-csv
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
@@ -13,7 +13,7 @@ npm install inline-schema
|
|||||||
### Basic Example
|
### Basic Example
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { defineSchema } from 'inline-schema';
|
import { defineSchema } from 'typed-csv';
|
||||||
|
|
||||||
// Define a schema
|
// Define a schema
|
||||||
const stringSchema = defineSchema('string');
|
const stringSchema = defineSchema('string');
|
||||||
@@ -35,14 +35,9 @@ numberSchema.validator(name); // false
|
|||||||
```typescript
|
```typescript
|
||||||
const tupleSchema = defineSchema('[string; number; boolean]');
|
const tupleSchema = defineSchema('[string; number; boolean]');
|
||||||
|
|
||||||
// With brackets
|
|
||||||
const value1 = tupleSchema.parse('[hello; 42; true]');
|
const value1 = tupleSchema.parse('[hello; 42; true]');
|
||||||
// ["hello", 42, true]
|
// ["hello", 42, true]
|
||||||
|
|
||||||
// Without brackets (outermost brackets are optional)
|
|
||||||
const value2 = tupleSchema.parse('hello; 42; true');
|
|
||||||
// ["hello", 42, true]
|
|
||||||
|
|
||||||
tupleSchema.validator(value1); // true
|
tupleSchema.validator(value1); // true
|
||||||
tupleSchema.validator(['a', 'b', true]); // false (second element should be number)
|
tupleSchema.validator(['a', 'b', true]); // false (second element should be number)
|
||||||
```
|
```
|
||||||
@@ -50,16 +45,11 @@ tupleSchema.validator(['a', 'b', true]); // false (second element should be num
|
|||||||
### Arrays
|
### Arrays
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Array syntax: Type[] or [Type][]
|
// Array syntax: Type[]
|
||||||
const stringArray = defineSchema('string[]');
|
const stringArray = defineSchema('string[]');
|
||||||
const numberArray = defineSchema('[number][]');
|
const numberArray = defineSchema('number[]');
|
||||||
|
|
||||||
// With brackets
|
const names = stringArray.parse('[alice; bob; charlie]');
|
||||||
const names1 = stringArray.parse('[alice; bob; charlie]');
|
|
||||||
// ["alice", "bob", "charlie"]
|
|
||||||
|
|
||||||
// Without brackets (outermost brackets are optional)
|
|
||||||
const names2 = stringArray.parse('alice; bob; charlie');
|
|
||||||
// ["alice", "bob", "charlie"]
|
// ["alice", "bob", "charlie"]
|
||||||
|
|
||||||
const numbers = numberArray.parse('[1; 2; 3; 4; 5]');
|
const numbers = numberArray.parse('[1; 2; 3; 4; 5]');
|
||||||
@@ -71,12 +61,7 @@ const numbers = numberArray.parse('[1; 2; 3; 4; 5]');
|
|||||||
```typescript
|
```typescript
|
||||||
const schema = defineSchema('[string; number][]');
|
const schema = defineSchema('[string; number][]');
|
||||||
|
|
||||||
// With outer brackets
|
const data = schema.parse('[[a; 1]; [b; 2]; [c; 3]]');
|
||||||
const data1 = schema.parse('[[a; 1]; [b; 2]; [c; 3]]');
|
|
||||||
// [["a", 1], ["b", 2], ["c", 3]]
|
|
||||||
|
|
||||||
// Without outer brackets
|
|
||||||
const data2 = schema.parse('[a; 1]; [b; 2]; [c; 3]');
|
|
||||||
// [["a", 1], ["b", 2], ["c", 3]]
|
// [["a", 1], ["b", 2], ["c", 3]]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -128,27 +113,63 @@ Parses a value string according to the given schema.
|
|||||||
|
|
||||||
Creates a validation function for the given schema.
|
Creates a validation function for the given schema.
|
||||||
|
|
||||||
|
### `schemaToTypeString(schema: Schema): string`
|
||||||
|
|
||||||
|
Converts a schema AST back to a human-readable type string.
|
||||||
|
|
||||||
|
### `ParseError`
|
||||||
|
|
||||||
|
Error class thrown for invalid schema syntax.
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
The following TypeScript types are exported:
|
||||||
|
|
||||||
|
- `Schema` — union of all schema AST node types
|
||||||
|
- `ParsedSchema` — the return type of `defineSchema()`
|
||||||
|
- `PrimitiveSchema`, `TupleSchema`, `ArraySchema` — AST node types
|
||||||
|
- `ReferenceSchema`, `ReverseReferenceSchema` — reference AST node types
|
||||||
|
- `StringLiteralSchema`, `UnionSchema` — literal and union AST node types
|
||||||
|
|
||||||
## Schema Syntax
|
## Schema Syntax
|
||||||
|
|
||||||
| Type | Schema | Example Value |
|
| Type | Schema | Example Value |
|
||||||
|------|--------|---------------|
|
|------|--------|---------------|
|
||||||
| String | `string` or `identifier` | `hello` |
|
| String | `string` or `identifier` | `hello` |
|
||||||
| Number | `number` | `42` |
|
| Int | `int` | `42` |
|
||||||
|
| Float | `float` | `3.14` |
|
||||||
|
| Number | `number` | `42` or `3.14` |
|
||||||
|
|
||||||
|
> **Note:** `int`, `float`, and `number` all collapse to the `number` type in generated TypeScript declarations (`schemaToTypeString`). The distinction is preserved at parse time — `int` rejects non-integer values while `float`/`number` accept them.
|
||||||
| Boolean | `boolean` | `true` or `false` |
|
| Boolean | `boolean` | `true` or `false` |
|
||||||
| Tuple | `[Type1; Type2; ...]` | `[hello; 42; true]` or `hello; 42; true` |
|
| Tuple | `[Type1; Type2; ...]` | `[hello; 42; true]` |
|
||||||
| Array | `Type[]` or `[Type][]` | `[1; 2; 3]` or `1; 2; 3` |
|
| Array | `Type[]` | `[1; 2; 3]` |
|
||||||
| Array of Tuples | `[Type1; Type2][]` | `[[a; 1]; [b; 2]]` or `[a; 1]; [b; 2]` |
|
| Array of Tuples | `[Type1; Type2][]` | `[[a; 1]; [b; 2]]` |
|
||||||
|
| Union | `Type1 \| Type2` | `hello` or `42` (reference members tried first) |
|
||||||
|
| String Literal | `'on' \| 'off'` or `"red"` | `on` or `off` |
|
||||||
|
| Reference | `@tablename` or `@tablename[]` | (resolved at CSV load time) |
|
||||||
|
| Reverse Reference | `~tablename(fk)` | (resolved at CSV load time) |
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Semicolons `;` are used as separators instead of commas `,`
|
- Semicolons `;` are used as separators instead of commas `,`
|
||||||
- Outermost brackets `[]` are optional for tuple and array values
|
- Tuple and array values **must** be wrapped in brackets `[]` (e.g. `[a; b]`)
|
||||||
|
- `[single]` is a 1-tuple, not an array — use `Type[]` for arrays
|
||||||
|
- In a union, **reference members are tried before non-reference members** (e.g. `@users | string` resolves `1` to the user object, falling back to a plain string when the reference doesn't match)
|
||||||
- Special characters can be escaped with backslash: `\;`, `\[`, `\]`, `\\`
|
- Special characters can be escaped with backslash: `\;`, `\[`, `\]`, `\\`
|
||||||
- Empty arrays/tuples are not allowed
|
- Empty arrays/tuples are not allowed
|
||||||
|
- In CSV schema rows, double-quoted string literals like `"active" | "inactive"` are handled automatically by the loader; avoid commas inside string literals (use single-quoted literals like `'a,b'` for those)
|
||||||
|
- For CSV loading with reference resolution, see [csv-loader.md](./csv-loader.md)
|
||||||
|
|
||||||
## CSV Loader
|
## Migration from 1.x
|
||||||
|
|
||||||
For loading CSV files with schema validation in rspack, see [csv-loader.md](./csv-loader.md).
|
Version 2.0.0 introduces breaking changes to the schema DSL:
|
||||||
|
|
||||||
|
1. **Composite values must be fully bracketed.** Tuple and array values now always require `[]` — `[a; 1]; [b; 2]` is no longer accepted; write `[[a; 1]; [b; 2]]`.
|
||||||
|
2. **The `[Type][]` array form is removed.** Use `Type[]` (e.g. `[string; number][]` instead of `[[string; number]][]`).
|
||||||
|
3. **`[single]` is now a 1-tuple, not an array.** Use `Type[]` for single-element arrays.
|
||||||
|
4. **Union resolution prefers reference members.** In a union containing references, reference members are tried before non-reference members regardless of author order (see the notes above).
|
||||||
|
5. **Schema cells may be fully quoted.** Double-quoted string literals like `"active" | "inactive"` in a CSV schema row are handled automatically by the loader.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// rspack.config.js
|
// rspack.config.js
|
||||||
@@ -157,7 +178,7 @@ module.exports = {
|
|||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
test: /\.schema\.csv$/,
|
test: /\.schema\.csv$/,
|
||||||
use: 'inline-schema/csv-loader',
|
use: 'typed-csv/csv-loader',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
+19
-9
@@ -1,18 +1,18 @@
|
|||||||
# inline-schema/csv-loader
|
# typed-csv/csv-loader
|
||||||
|
|
||||||
A rspack/rollup loader for CSV files that uses inline-schema for type validation.
|
A bundler loader (rspack/webpack/rollup/esbuild) for CSV files that uses typed-csv for type validation and cross-table reference resolution.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install inline-schema
|
npm install typed-csv
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
The loader expects:
|
The loader expects:
|
||||||
- **First row**: Property names (headers)
|
- **First row**: Property names (headers)
|
||||||
- **Second row**: Inline-schema definitions for each property
|
- **Second row**: typed-csv schema definitions for each property
|
||||||
- **Remaining rows**: Data values
|
- **Remaining rows**: Data values
|
||||||
|
|
||||||
### Example CSV
|
### Example CSV
|
||||||
@@ -35,7 +35,7 @@ module.exports = {
|
|||||||
{
|
{
|
||||||
test: /\.schema\.csv$/,
|
test: /\.schema\.csv$/,
|
||||||
use: {
|
use: {
|
||||||
loader: 'inline-schema/csv-loader',
|
loader: 'typed-csv/csv-loader',
|
||||||
options: {
|
options: {
|
||||||
delimiter: ',',
|
delimiter: ',',
|
||||||
quote: '"',
|
quote: '"',
|
||||||
@@ -60,7 +60,7 @@ module.exports = {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import { csvLoader } from 'inline-schema/csv-loader/rollup';
|
import { csvLoader } from 'typed-csv/csv-loader/rollup';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
@@ -85,7 +85,7 @@ export default defineConfig({
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { defineConfig } from 'tsup';
|
import { defineConfig } from 'tsup';
|
||||||
import { csvLoader } from 'inline-schema/csv-loader/rollup';
|
import { csvLoader } from 'typed-csv/csv-loader/rollup';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
entry: ['src/index.ts'],
|
entry: ['src/index.ts'],
|
||||||
@@ -139,16 +139,26 @@ import data from './data.csv';
|
|||||||
|
|
||||||
## Schema Syntax
|
## Schema Syntax
|
||||||
|
|
||||||
Uses [inline-schema](https://github.com/your-repo/inline-schema) syntax:
|
Uses [typed-csv](https://github.com/your-repo/typed-csv) syntax:
|
||||||
|
|
||||||
| Type | Schema | Example |
|
| Type | Schema | Example |
|
||||||
|------|--------|---------|
|
|------|--------|---------|
|
||||||
| String | `string` | `hello` |
|
| String | `string` | `hello` |
|
||||||
| Number | `number` | `42` |
|
| Number | `number` | `42` |
|
||||||
| Boolean | `boolean` | `true` |
|
| Boolean | `boolean` | `true` |
|
||||||
| Array | `string[]` or `[string][]` | `[a; b; c]` |
|
| Array | `string[]` | `[a; b; c]` |
|
||||||
| Tuple | `[string; number]` | `[hello; 42]` |
|
| Tuple | `[string; number]` | `[hello; 42]` |
|
||||||
|
|
||||||
|
> **Note:** `int` and `float` are accepted in schemas and validated at parse time, but both collapse to `number` in generated type declarations.
|
||||||
|
|
||||||
|
## Migration from 1.x
|
||||||
|
|
||||||
|
- Composite values must be fully bracketed: `[[a; 1]; [b; 2]]`, not `[a; 1]; [b; 2]`.
|
||||||
|
- The `[Type][]` array form is removed — use `Type[]`.
|
||||||
|
- `[single]` is a 1-tuple, not an array.
|
||||||
|
- In unions containing references, reference members are tried first.
|
||||||
|
- Double-quoted string literals (`"active" | "inactive"`) in a schema row are handled automatically.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
ISC
|
ISC
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# Implementation Plan: Syntax Clarity & Parser Robustness
|
||||||
|
|
||||||
|
Status: **In progress — Phases 1, 2, 3 & 4 applied** (Phases 5–6 not yet done)
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Eliminate the backtracking heuristics in the value parser by making the value grammar context-free.
|
||||||
|
2. Remove redundant/ambiguous syntax forms.
|
||||||
|
3. Make union resolution deterministic and structural (not error-message-driven).
|
||||||
|
4. Address the `csv-parse` quote conflict.
|
||||||
|
5. Keep the `;` separator (forced by CSV constraints — not the source of ambiguity).
|
||||||
|
|
||||||
|
All changes are **breaking** to the DSL → bump to `2.0.0`, update README + `csv-loader.md`, add a migration note.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Mandatory brackets for composite values ✅ Applied
|
||||||
|
|
||||||
|
**Files:** `src/value-parser.ts`, `src/index.test.ts`, `src/csv-loader/reference-resolver.ts`
|
||||||
|
|
||||||
|
- Removed the `allowOmitBrackets` parameter from `parseValue`, `parseTupleValue`, `parseArrayValue`.
|
||||||
|
- Deleted the `elementIsTupleOrArray` disambiguation block and all `savedPos` restore logic in `parseArrayValue`.
|
||||||
|
- Dropped the `allowOmitBrackets = schema.type === "tuple" || "array"` special case in top-level `parseValue` — brackets are always required.
|
||||||
|
- Array references (`@table[]` values) now also require brackets, for consistency.
|
||||||
|
- Values must now be fully bracketed: `[a; 1]; [b; 2]` (no more `[a; 1]; [b; 2]` without outer brackets).
|
||||||
|
|
||||||
|
**Decision:** Full mandatory brackets (no carve-out).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Single array form `Type[]` ✅ Applied
|
||||||
|
|
||||||
|
**Files:** `src/parser.ts`, `src/index.test.ts`
|
||||||
|
|
||||||
|
- Removed the `[Type][]` array syntax from `parseSchemaInternal`. Kept only `Type[]`.
|
||||||
|
- `[string]` is now a **1-tuple** (previously it collapsed to an array). The tuple branch always returns `{ type: "tuple", elements }`.
|
||||||
|
- Updated `schemaToTypeString` in `src/type-utils.ts` — the `array` case no longer special-cases tuple elements; arrays are always `elementType[]` (kept the `(union)[]` paren wrapping).
|
||||||
|
|
||||||
|
**Tests updated:** `index.test.ts` bracket-optional tests, `encounter.csv` / `enemy_intents.csv` fixtures, and `parseCsv-typeDeclarations.test.ts` array-of-tuple values.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Deterministic union resolution ✅ Applied
|
||||||
|
|
||||||
|
**Files:** `src/csv-loader/reference-resolver.ts`, `src/csv-loader/module-gen.ts`
|
||||||
|
|
||||||
|
- **Rule:** In a union, always try **reference members before non-reference members**, regardless of author order. This makes fallback structural instead of error-message-driven.
|
||||||
|
- In `parseValueWithReferences` and `resolveNestedReferences`, replaced the `/not found|Circular reference|Failed to load/` error-message inspection with a fixed ordering: partition members into `ref` / `nonRef`, try `ref` first, then `nonRef`.
|
||||||
|
- `module-gen.ts` `generateSchemaResolutionCode` already emitted reference-first (`lookup.get(...) ?? value`), so runtime now matches generated code.
|
||||||
|
- **Documented the rule** in README and AGENTS.md.
|
||||||
|
|
||||||
|
**Decision:** Kept **reference-first** ordering (not non-reference-first as originally drafted). Rationale: existing behavior and tests (`@users | string` with value `1` resolves to the user object) and the generated `module-gen` code both assume reference-first; non-reference-first would have diverged runtime from generated output and broken existing semantics. The plan's real goal — removing the error-message regex — is achieved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — Quote conflict resolution ✅ Applied
|
||||||
|
|
||||||
|
**Files:** `src/csv-loader/loader.ts`, `src/csv-loader/tests/parseCsv-basic.test.ts`, README, `csv-loader.md`
|
||||||
|
|
||||||
|
- Added `escapeSchemaRowQuotes()` in `loader.ts`: the schema row (2nd non-empty line) is split on the delimiter, and any cell containing the quote char has its inner quotes escaped and is wrapped in quotes before `csv-parse` runs.
|
||||||
|
- This lets users write `"active" | "inactive"` naturally in a schema row — previously csv-parse threw `Invalid Closing Quote`.
|
||||||
|
- Single-quoted literals (`'on' | 'off'`) are untouched (no `"` present).
|
||||||
|
- Added a test in `parseCsv-basic.test.ts`.
|
||||||
|
|
||||||
|
**Decision:** Implemented option (b) — loader-side escaping of the schema row. This fixes the root cause (schema cells containing `"` break csv-parse) without redesigning the literal syntax.
|
||||||
|
|
||||||
|
**Known limitation (pre-existing, out of scope):** a comma inside a string literal (e.g. `"a,b"`) is ambiguous with the CSV delimiter and still fails — use single-quoted literals (`'a,b'`) for those, or a non-comma delimiter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5 — Cleanup & consistency
|
||||||
|
|
||||||
|
**Files:** `src/csv-loader/reference-resolver.ts`, `src/csv-loader/loader.ts`, `src/csv-loader/module-gen.ts`
|
||||||
|
|
||||||
|
- **Reverse-reference performance:** `resolveReverseReference` does `refTable.filter(...)` per row. Build a `Map<fk, rows[]>` lookup once per referenced table (mirroring what `module-gen.ts` already generates) and reuse it. Cache the lookup alongside the parsed table in `referenceTableCache`.
|
||||||
|
- **Remove the `,` stop-character in `parseReferenceValue`** (value parser has a `,` stop char the schema parser doesn't — drift). Make reference ID parsing consistent with the rest of the value grammar.
|
||||||
|
- **`int`/`float`/`number` → `number`:** document the collapse in README (type-level collapse is intentional; parse-time distinction remains).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6 — Docs & migration
|
||||||
|
|
||||||
|
**Files:** `README.md`, `csv-loader.md`, `AGENTS.md`
|
||||||
|
|
||||||
|
- Update all syntax tables and examples for mandatory brackets + single array form.
|
||||||
|
- Add a **Migration section** listing the breaking changes:
|
||||||
|
1. Composite values must be fully bracketed.
|
||||||
|
2. `[Type][]` array form removed — use `Type[]`.
|
||||||
|
3. `[single]` is now a 1-tuple, not an array.
|
||||||
|
4. Union resolution now prefers reference members (tried before non-reference members).
|
||||||
|
5. (If Phase 4b) schema cells may be fully quoted.
|
||||||
|
- Update `AGENTS.md` gotchas: union ordering rule, quote handling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- `npm run typecheck`
|
||||||
|
- `npm run test` (update `src/index.test.ts`, `src/csv-loader/*.test.ts` first)
|
||||||
|
- Manually verify the integration fixture (`user_rev.csv` / `order_rev.csv`) still resolves.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested execution order
|
||||||
|
|
||||||
|
Phases 1 → 2 are tightly coupled (both touch bracket parsing) — do them together. Phase 3 is independent. Phase 4 is independent. Phases 5–6 are cleanup/docs and can go last.
|
||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "inline-schema",
|
"name": "typed-csv",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"module": "./dist/index.mjs",
|
"module": "./dist/index.mjs",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
],
|
],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"description": "A TypeScript library for parsing and validating inline schemas",
|
"description": "A TypeScript library for typed CSV data with inline schema validation",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csv-parse": "^5.5.6"
|
"csv-parse": "^5.5.6"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ function matchesPattern(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Esbuild plugin for loading CSV files with inline-schema validation.
|
* Esbuild plugin for loading CSV files with typed-csv validation.
|
||||||
*/
|
*/
|
||||||
export function csvLoader(options: CsvEsbuildOptions = {}): Plugin {
|
export function csvLoader(options: CsvEsbuildOptions = {}): Plugin {
|
||||||
const {
|
const {
|
||||||
@@ -54,12 +54,12 @@ export function csvLoader(options: CsvEsbuildOptions = {}): Plugin {
|
|||||||
const includeFilter = createFilter(include);
|
const includeFilter = createFilter(include);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: "inline-schema-csv",
|
name: "typed-csv",
|
||||||
|
|
||||||
setup(build) {
|
setup(build) {
|
||||||
build.onLoad({ filter: includeFilter }, async (args) => {
|
build.onLoad({ filter: includeFilter }, async (args) => {
|
||||||
// Check exclude pattern
|
// Check exclude pattern
|
||||||
if (exclude && !matchesPattern(args.path, exclude)) {
|
if (exclude && matchesPattern(args.path, exclude)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,5 +3,5 @@
|
|||||||
|
|
||||||
id,type,name,description,enemies,dialogue
|
id,type,name,description,enemies,dialogue
|
||||||
string,EncounterType,string,string,EnemyList,string
|
string,EncounterType,string,string,EnemyList,string
|
||||||
cactus_pair,minion,仙人掌怪,概念:防+强化。【尖刺X】:对攻击者造成X点伤害。,[仙人掌怪;12;[]];[仙人掌怪;12;[]],
|
cactus_pair,minion,仙人掌怪,概念:防+强化。【尖刺X】:对攻击者造成X点伤害。,[[仙人掌怪;12;[]];[仙人掌怪;12;[]]],
|
||||||
snake_pair,minion,蛇,概念:攻+强化。给玩家塞入蛇毒牌(1费:打出时移除此牌。弃掉时受到3点伤害)。,[蛇;10;[[poison;2];[quick;1]]],
|
snake_pair,minion,蛇,概念:攻+强化。给玩家塞入蛇毒牌(1费:打出时移除此牌。弃掉时受到3点伤害)。,[[蛇;10;[[poison;2];[quick;1]]]],
|
||||||
|
|||||||
|
@@ -4,4 +4,4 @@
|
|||||||
|
|
||||||
id,enemy,initialIntent,nextIntents,brokenIntent,effects
|
id,enemy,initialIntent,nextIntents,brokenIntent,effects
|
||||||
string,string,boolean,string[],string[],IntentEffects
|
string,string,boolean,string[],string[],IntentEffects
|
||||||
仙人掌怪-boost,仙人掌怪,true,仙人掌怪-boost;仙人掌怪-defend,,[user;spike;1];[user;defend;4]
|
仙人掌怪-boost,仙人掌怪,true,[仙人掌怪-boost;仙人掌怪-defend],,[[user;spike;1];[user;defend;4]]
|
||||||
|
|||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { parse } from "csv-parse/sync";
|
import { parse } from "csv-parse/browser/esm/sync";
|
||||||
import {
|
import {
|
||||||
parseSchema,
|
parseSchema,
|
||||||
createValidator,
|
createValidator,
|
||||||
@@ -18,7 +18,6 @@ import type {
|
|||||||
ReverseReferenceDeclaration,
|
ReverseReferenceDeclaration,
|
||||||
TypeDeclaration,
|
TypeDeclaration,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
import { ParseError } from "../parser.js";
|
|
||||||
import {
|
import {
|
||||||
hasNestedReferences,
|
hasNestedReferences,
|
||||||
loadReferenceTable,
|
loadReferenceTable,
|
||||||
@@ -33,9 +32,54 @@ import {
|
|||||||
parseReferenceValue,
|
parseReferenceValue,
|
||||||
} from "./reference-resolver.js";
|
} from "./reference-resolver.js";
|
||||||
import { generateTypeDefinition } from "./type-gen.js";
|
import { generateTypeDefinition } from "./type-gen.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape double-quote characters in the schema row so csv-parse doesn't
|
||||||
|
* misinterpret them as CSV field delimiters.
|
||||||
|
*
|
||||||
|
* The schema row is the 2nd non-empty line. Cells that contain the quote
|
||||||
|
* character (e.g. `"active" | "inactive"`) have their inner quotes escaped
|
||||||
|
* and are wrapped in quotes, so csv-parse treats each as a single field.
|
||||||
|
*/
|
||||||
|
function escapeSchemaRowQuotes(
|
||||||
|
content: string,
|
||||||
|
delimiter: string,
|
||||||
|
quote: string,
|
||||||
|
escape: string,
|
||||||
|
): string {
|
||||||
|
if (!quote || !content.includes(quote)) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = content.split(/\r?\n/);
|
||||||
|
let schemaRowIndex = -1;
|
||||||
|
let nonEmptyCount = 0;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (lines[i].trim() === "") continue;
|
||||||
|
nonEmptyCount++;
|
||||||
|
if (nonEmptyCount === 2) {
|
||||||
|
schemaRowIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (schemaRowIndex === -1) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cells = lines[schemaRowIndex].split(delimiter);
|
||||||
|
const transformed = cells
|
||||||
|
.map((cell) => {
|
||||||
|
if (!cell.includes(quote)) {
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
const escaped = cell.split(quote).join(escape + quote);
|
||||||
|
return quote + escaped + quote;
|
||||||
|
})
|
||||||
|
.join(delimiter);
|
||||||
|
lines[schemaRowIndex] = transformed;
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
import { csvToModule } from "./module-gen.js";
|
import { csvToModule } from "./module-gen.js";
|
||||||
import * as fs from "fs";
|
|
||||||
import * as path from "path";
|
|
||||||
import {
|
import {
|
||||||
parseTypeDeclaration,
|
parseTypeDeclaration,
|
||||||
parseReverseReferenceDeclaration,
|
parseReverseReferenceDeclaration,
|
||||||
@@ -98,6 +142,15 @@ export function parseCsv(
|
|||||||
filteredContent = nonCommentLines.join("\n");
|
filteredContent = nonCommentLines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Escape double-quotes in the schema row so csv-parse doesn't treat them
|
||||||
|
// as CSV field delimiters (e.g. `"active" | "inactive"` in a schema cell).
|
||||||
|
filteredContent = escapeSchemaRowQuotes(
|
||||||
|
filteredContent,
|
||||||
|
delimiter,
|
||||||
|
quote,
|
||||||
|
escape,
|
||||||
|
);
|
||||||
|
|
||||||
const records = parse(filteredContent, {
|
const records = parse(filteredContent, {
|
||||||
delimiter,
|
delimiter,
|
||||||
quote,
|
quote,
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ import { parseCsv } from "./loader.js";
|
|||||||
/** Cache for loaded referenced tables */
|
/** Cache for loaded referenced tables */
|
||||||
const referenceTableCache = new Map<string, Record<string, unknown>[]>();
|
const referenceTableCache = new Map<string, Record<string, unknown>[]>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache for reverse-reference lookups: filePath -> (foreignKey -> (fkValue -> rows[])).
|
||||||
|
* Built once per referenced table + foreign key, mirroring what module-gen emits,
|
||||||
|
* so resolveReverseReference doesn't re-filter the whole table per row.
|
||||||
|
*/
|
||||||
|
const reverseLookupCache = new Map<
|
||||||
|
string,
|
||||||
|
Map<string, Map<string, Record<string, unknown>[]>>
|
||||||
|
>();
|
||||||
|
|
||||||
/** Set of file paths currently being loaded (to detect circular references) */
|
/** Set of file paths currently being loaded (to detect circular references) */
|
||||||
const loadingFiles = new Set<string>();
|
const loadingFiles = new Set<string>();
|
||||||
|
|
||||||
@@ -31,14 +41,13 @@ export function hasNestedReferences(schema: Schema): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadReferenceTable(
|
function loadReferenceTableData(
|
||||||
schema: ReferenceSchema | ReverseReferenceSchema,
|
schema: ReferenceSchema | ReverseReferenceSchema,
|
||||||
refBaseDir: string | undefined,
|
refBaseDir: string | undefined,
|
||||||
defaultPrimaryKey: string,
|
|
||||||
currentFilePath: string | undefined,
|
currentFilePath: string | undefined,
|
||||||
): {
|
): {
|
||||||
lookup: Map<string, Record<string, unknown>>;
|
|
||||||
refTable: Record<string, unknown>[];
|
refTable: Record<string, unknown>[];
|
||||||
|
refFilePath: string;
|
||||||
} {
|
} {
|
||||||
const baseDir =
|
const baseDir =
|
||||||
refBaseDir ||
|
refBaseDir ||
|
||||||
@@ -75,6 +84,30 @@ export function loadReferenceTable(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { refTable, refFilePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a referenced table and build a forward lookup keyed by primary key.
|
||||||
|
* Only call this when forward resolution is actually needed; the reverse
|
||||||
|
* path uses `loadReferenceTableData` directly to avoid building a throwaway map.
|
||||||
|
*/
|
||||||
|
export function loadReferenceTable(
|
||||||
|
schema: ReferenceSchema | ReverseReferenceSchema,
|
||||||
|
refBaseDir: string | undefined,
|
||||||
|
defaultPrimaryKey: string,
|
||||||
|
currentFilePath: string | undefined,
|
||||||
|
): {
|
||||||
|
lookup: Map<string, Record<string, unknown>>;
|
||||||
|
refTable: Record<string, unknown>[];
|
||||||
|
refFilePath: string;
|
||||||
|
} {
|
||||||
|
const { refTable, refFilePath } = loadReferenceTableData(
|
||||||
|
schema,
|
||||||
|
refBaseDir,
|
||||||
|
currentFilePath,
|
||||||
|
);
|
||||||
|
|
||||||
const lookup = new Map<string, Record<string, unknown>>();
|
const lookup = new Map<string, Record<string, unknown>>();
|
||||||
refTable.forEach((row) => {
|
refTable.forEach((row) => {
|
||||||
const pkValue = row[defaultPrimaryKey];
|
const pkValue = row[defaultPrimaryKey];
|
||||||
@@ -83,7 +116,50 @@ export function loadReferenceTable(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { lookup, refTable };
|
return { lookup, refTable, refFilePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build (and cache) a reverse lookup for a referenced table + foreign key:
|
||||||
|
* a map from the foreign-key value to the rows that reference it.
|
||||||
|
*/
|
||||||
|
function getReverseLookup(
|
||||||
|
schema: ReverseReferenceSchema,
|
||||||
|
refBaseDir: string | undefined,
|
||||||
|
defaultPrimaryKey: string,
|
||||||
|
currentFilePath: string | undefined,
|
||||||
|
): Map<string, Record<string, unknown>[]> {
|
||||||
|
const { refTable, refFilePath } = loadReferenceTableData(
|
||||||
|
schema,
|
||||||
|
refBaseDir,
|
||||||
|
currentFilePath,
|
||||||
|
);
|
||||||
|
|
||||||
|
let byForeignKey = reverseLookupCache.get(refFilePath);
|
||||||
|
if (!byForeignKey) {
|
||||||
|
byForeignKey = new Map();
|
||||||
|
reverseLookupCache.set(refFilePath, byForeignKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = byForeignKey.get(schema.foreignKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const lookup = new Map<string, Record<string, unknown>[]>();
|
||||||
|
for (const row of refTable) {
|
||||||
|
const fkValue = row[schema.foreignKey];
|
||||||
|
const fkStr =
|
||||||
|
fkValue !== null && fkValue !== undefined && typeof fkValue === "object"
|
||||||
|
? String((fkValue as Record<string, unknown>)[defaultPrimaryKey])
|
||||||
|
: String(fkValue);
|
||||||
|
const bucket = lookup.get(fkStr);
|
||||||
|
if (bucket) {
|
||||||
|
bucket.push(row);
|
||||||
|
} else {
|
||||||
|
lookup.set(fkStr, [row]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
byForeignKey.set(schema.foreignKey, lookup);
|
||||||
|
return lookup;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveReferenceId(
|
export function resolveReferenceId(
|
||||||
@@ -299,9 +375,12 @@ export function parseValueWithReferences(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "union": {
|
case "union": {
|
||||||
|
// Try reference members first (more specific), then non-reference members.
|
||||||
|
// This makes the fallback structural rather than error-message-driven.
|
||||||
|
const refMembers = schema.members.filter(hasNestedReferences);
|
||||||
|
const nonRefMembers = schema.members.filter((m) => !hasNestedReferences(m));
|
||||||
const errors: Error[] = [];
|
const errors: Error[] = [];
|
||||||
for (const member of schema.members) {
|
for (const member of [...refMembers, ...nonRefMembers]) {
|
||||||
if (hasNestedReferences(member)) {
|
|
||||||
try {
|
try {
|
||||||
const parsed = parseValue(member, valueString);
|
const parsed = parseValue(member, valueString);
|
||||||
return resolveNestedReferences(
|
return resolveNestedReferences(
|
||||||
@@ -316,22 +395,7 @@ export function parseValueWithReferences(
|
|||||||
errors.push(e instanceof Error ? e : new Error(String(e)));
|
errors.push(e instanceof Error ? e : new Error(String(e)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
throw errors[0] ?? new Error("Value does not match any union member");
|
||||||
if (
|
|
||||||
errors.length > 0 &&
|
|
||||||
errors.every((e) =>
|
|
||||||
/not found|Circular reference|Failed to load/.test(e.message),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
for (const member of schema.members) {
|
|
||||||
if (!hasNestedReferences(member)) {
|
|
||||||
try {
|
|
||||||
return parseValue(member, valueString);
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return parseValue(schema, valueString);
|
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return parseValue(schema, valueString);
|
return parseValue(schema, valueString);
|
||||||
@@ -345,21 +409,13 @@ export function resolveReverseReference(
|
|||||||
defaultPrimaryKey: string,
|
defaultPrimaryKey: string,
|
||||||
currentFilePath: string | undefined,
|
currentFilePath: string | undefined,
|
||||||
): Record<string, unknown>[] {
|
): Record<string, unknown>[] {
|
||||||
const { refTable } = loadReferenceTable(
|
const lookup = getReverseLookup(
|
||||||
schema,
|
schema,
|
||||||
refBaseDir,
|
refBaseDir,
|
||||||
defaultPrimaryKey,
|
defaultPrimaryKey,
|
||||||
currentFilePath,
|
currentFilePath,
|
||||||
);
|
);
|
||||||
const pkStr = String(pkValue);
|
return lookup.get(String(pkValue)) ?? [];
|
||||||
return refTable.filter((row) => {
|
|
||||||
const fkValue = row[schema.foreignKey];
|
|
||||||
const fkStr =
|
|
||||||
fkValue !== null && fkValue !== undefined && typeof fkValue === "object"
|
|
||||||
? String((fkValue as Record<string, unknown>)[defaultPrimaryKey])
|
|
||||||
: String(fkValue);
|
|
||||||
return fkStr === pkStr;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveNestedReferences(
|
export function resolveNestedReferences(
|
||||||
@@ -425,9 +481,11 @@ export function resolveNestedReferences(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "union": {
|
case "union": {
|
||||||
|
// Try reference members first (more specific), then non-reference members.
|
||||||
|
const refMembers = schema.members.filter(hasNestedReferences);
|
||||||
|
const nonRefMembers = schema.members.filter((m) => !hasNestedReferences(m));
|
||||||
const errors: Error[] = [];
|
const errors: Error[] = [];
|
||||||
for (const member of schema.members) {
|
for (const member of [...refMembers, ...nonRefMembers]) {
|
||||||
if (hasNestedReferences(member)) {
|
|
||||||
try {
|
try {
|
||||||
return resolveNestedReferences(
|
return resolveNestedReferences(
|
||||||
value,
|
value,
|
||||||
@@ -441,7 +499,6 @@ export function resolveNestedReferences(
|
|||||||
errors.push(e instanceof Error ? e : new Error(String(e)));
|
errors.push(e instanceof Error ? e : new Error(String(e)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
throw errors[0];
|
throw errors[0];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ interface RollupPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rollup plugin for loading CSV files with inline-schema validation.
|
* Rollup plugin for loading CSV files with typed-csv validation.
|
||||||
* Works with both Vite and Tsup (esbuild).
|
* Works with both Vite and Tsup (esbuild).
|
||||||
*/
|
*/
|
||||||
export function csvLoader(options: CsvRollupOptions = {}): RollupPlugin {
|
export function csvLoader(options: CsvRollupOptions = {}): RollupPlugin {
|
||||||
@@ -67,7 +67,7 @@ export function csvLoader(options: CsvRollupOptions = {}): RollupPlugin {
|
|||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: "inline-schema-csv",
|
name: "typed-csv",
|
||||||
|
|
||||||
transform(code: string, id: string) {
|
transform(code: string, id: string) {
|
||||||
// Check if file matches the include/exclude patterns
|
// Check if file matches the include/exclude patterns
|
||||||
|
|||||||
@@ -65,6 +65,21 @@ describe("parseCsv - basic parsing", () => {
|
|||||||
expect(result.data[1]).toEqual({ name: "Bob", status: "off" });
|
expect(result.data[1]).toEqual({ name: "Bob", status: "off" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should parse CSV with double-quoted string literal columns", () => {
|
||||||
|
const csv = [
|
||||||
|
"name,status",
|
||||||
|
'string,"active" | "inactive"',
|
||||||
|
"Alice,active",
|
||||||
|
"Bob,inactive",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const result = parseCsv(csv, { emitTypes: false });
|
||||||
|
|
||||||
|
expect(result.data).toHaveLength(2);
|
||||||
|
expect(result.data[0]).toEqual({ name: "Alice", status: "active" });
|
||||||
|
expect(result.data[1]).toEqual({ name: "Bob", status: "inactive" });
|
||||||
|
});
|
||||||
|
|
||||||
it("should parse CSV with array columns", () => {
|
it("should parse CSV with array columns", () => {
|
||||||
const csv = [
|
const csv = [
|
||||||
"name,tags",
|
"name,tags",
|
||||||
|
|||||||
@@ -43,6 +43,40 @@ describe("parseCsv - reverse reference resolution", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should resolve reverse reference when the foreign key is an object reference", () => {
|
||||||
|
// The referenced table's foreign key column is itself a reference, so the
|
||||||
|
// stored value is an object and the reverse lookup must key on its primary key.
|
||||||
|
const ordersCsvPath = path.join(fixturesDir, "rev_orders.csv");
|
||||||
|
const ordersContent = [
|
||||||
|
"id,customer,total",
|
||||||
|
"string,@users,number",
|
||||||
|
"1,1,100",
|
||||||
|
"2,1,50",
|
||||||
|
].join("\n");
|
||||||
|
fs.writeFileSync(ordersCsvPath, ordersContent);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const csv = [
|
||||||
|
"id,name",
|
||||||
|
"string,string",
|
||||||
|
"# inject orders = ~rev_orders(customer)",
|
||||||
|
"1,Alice",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const result = parseCsv(csv, {
|
||||||
|
emitTypes: false,
|
||||||
|
currentFilePath: path.join(fixturesDir, "test.csv"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const orders = result.data[0].orders as Record<string, unknown>[];
|
||||||
|
expect(orders).toHaveLength(2);
|
||||||
|
expect(orders[0].id).toBe("1");
|
||||||
|
expect(orders[1].id).toBe("2");
|
||||||
|
} finally {
|
||||||
|
fs.unlinkSync(ordersCsvPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("should return empty array for reverse reference with no matches", () => {
|
it("should return empty array for reverse reference with no matches", () => {
|
||||||
const ordersCsvPath = path.join(fixturesDir, "rev_orders.csv");
|
const ordersCsvPath = path.join(fixturesDir, "rev_orders.csv");
|
||||||
const ordersContent = [
|
const ordersContent = [
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ describe("parseCsv - type declarations", () => {
|
|||||||
"# type IntentEffects = IntentEffect[]",
|
"# type IntentEffects = IntentEffect[]",
|
||||||
"id,effects",
|
"id,effects",
|
||||||
"string,IntentEffects",
|
"string,IntentEffects",
|
||||||
"boost,[user;spike;1];[user;defend;4]",
|
"boost,[[user;spike;1];[user;defend;4]]",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
const result = parseCsv(csv, { emitTypes: false });
|
const result = parseCsv(csv, { emitTypes: false });
|
||||||
@@ -233,7 +233,7 @@ describe("parseCsv - type declarations", () => {
|
|||||||
"# type IntentEffects = IntentEffect[]",
|
"# type IntentEffects = IntentEffect[]",
|
||||||
"id,effects",
|
"id,effects",
|
||||||
"string,IntentEffects",
|
"string,IntentEffects",
|
||||||
"boost,[user;spike;1];[user;defend;4]",
|
"boost,[[user;spike;1];[user;defend;4]]",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
const result = parseCsv(csv, {
|
const result = parseCsv(csv, {
|
||||||
@@ -260,7 +260,7 @@ describe("parseCsv - type declarations", () => {
|
|||||||
'# type Type = "apple" | "orange"',
|
'# type Type = "apple" | "orange"',
|
||||||
"id,items",
|
"id,items",
|
||||||
"string,[Type; int][]",
|
"string,[Type; int][]",
|
||||||
"001,[apple;2];[orange;3]",
|
"001,[[apple;2];[orange;3]]",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
const result = parseCsv(csv, { emitTypes: false });
|
const result = parseCsv(csv, { emitTypes: false });
|
||||||
@@ -292,7 +292,7 @@ describe("parseCsv - type declarations", () => {
|
|||||||
"# type Entry = [@user]",
|
"# type Entry = [@user]",
|
||||||
"id,entry",
|
"id,entry",
|
||||||
"string,Entry",
|
"string,Entry",
|
||||||
"1,1",
|
"1,[1]",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
const result = parseCsv(csv, {
|
const result = parseCsv(csv, {
|
||||||
|
|||||||
+13
-9
@@ -174,10 +174,11 @@ describe("Tuples", () => {
|
|||||||
expect(schema.validator(["hello", "42"])).toBe(false);
|
expect(schema.validator(["hello", "42"])).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should parse tuple without brackets", () => {
|
it("should require brackets for tuple values", () => {
|
||||||
const schema = defineSchema("[string; number]");
|
const schema = defineSchema("[string; number]");
|
||||||
const value = schema.parse("hello; 42");
|
const value = schema.parse("[hello; 42]");
|
||||||
expect(value).toEqual(["hello", 42]);
|
expect(value).toEqual(["hello", 42]);
|
||||||
|
expect(() => schema.parse("hello; 42")).toThrow(ParseError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should parse named tuple", () => {
|
it("should parse named tuple", () => {
|
||||||
@@ -212,10 +213,11 @@ describe("Arrays", () => {
|
|||||||
expect(schema.validator(["hello", 42])).toBe(false);
|
expect(schema.validator(["hello", 42])).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should parse array without brackets", () => {
|
it("should require brackets for array values", () => {
|
||||||
const schema = defineSchema("string[]");
|
const schema = defineSchema("string[]");
|
||||||
const value = schema.parse("hello; world; test");
|
const value = schema.parse("[hello; world; test]");
|
||||||
expect(value).toEqual(["hello", "world", "test"]);
|
expect(value).toEqual(["hello", "world", "test"]);
|
||||||
|
expect(() => schema.parse("hello; world; test")).toThrow(ParseError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should parse array of numbers", () => {
|
it("should parse array of numbers", () => {
|
||||||
@@ -246,14 +248,15 @@ describe("Arrays", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should parse array of tuples without outer brackets", () => {
|
it("should require outer brackets for array of tuples", () => {
|
||||||
const schema = defineSchema("[string; number][]");
|
const schema = defineSchema("[string; number][]");
|
||||||
const value = schema.parse("[a; 1]; [b; 2]; [c; 3]");
|
const value = schema.parse("[[a; 1]; [b; 2]; [c; 3]]");
|
||||||
expect(value).toEqual([
|
expect(value).toEqual([
|
||||||
["a", 1],
|
["a", 1],
|
||||||
["b", 2],
|
["b", 2],
|
||||||
["c", 3],
|
["c", 3],
|
||||||
]);
|
]);
|
||||||
|
expect(() => schema.parse("[a; 1]; [b; 2]; [c; 3]")).toThrow(ParseError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should parse array of unions", () => {
|
it("should parse array of unions", () => {
|
||||||
@@ -283,7 +286,7 @@ describe("Escaping", () => {
|
|||||||
|
|
||||||
it("should handle escaped semicolon in tuple", () => {
|
it("should handle escaped semicolon in tuple", () => {
|
||||||
const schema = defineSchema("[string; string]");
|
const schema = defineSchema("[string; string]");
|
||||||
const value = schema.parse("hello\\;world; test");
|
const value = schema.parse("[hello\\;world; test]");
|
||||||
expect(value).toEqual(["hello;world", "test"]);
|
expect(value).toEqual(["hello;world", "test"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -682,14 +685,15 @@ describe("Reference value parsing (parseValue)", () => {
|
|||||||
expect(result).toEqual(["1", "2", "3"]);
|
expect(result).toEqual(["1", "2", "3"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should parse array reference IDs without brackets", () => {
|
it("should require brackets for array reference IDs", () => {
|
||||||
const schema: import("./types").ReferenceSchema = {
|
const schema: import("./types").ReferenceSchema = {
|
||||||
type: "reference",
|
type: "reference",
|
||||||
tableName: "users",
|
tableName: "users",
|
||||||
isArray: true,
|
isArray: true,
|
||||||
};
|
};
|
||||||
const result = parseValue(schema, "1; 2; 3");
|
const result = parseValue(schema, "[1; 2; 3]");
|
||||||
expect(result).toEqual(["1", "2", "3"]);
|
expect(result).toEqual(["1", "2", "3"]);
|
||||||
|
expect(() => parseValue(schema, "1; 2; 3")).toThrow(ParseError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should parse empty array reference", () => {
|
it("should parse empty array reference", () => {
|
||||||
|
|||||||
+1
-14
@@ -250,20 +250,7 @@ class Parser {
|
|||||||
throw new ParseError("Expected ]", this.pos);
|
throw new ParseError("Expected ]", this.pos);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.consumeStr("[")) {
|
// [t] is always a 1-tuple (the [Type][] array form was removed)
|
||||||
this.skipWhitespace();
|
|
||||||
if (!this.consumeStr("]")) {
|
|
||||||
throw new ParseError("Expected ]", this.pos);
|
|
||||||
}
|
|
||||||
if (elements.length === 1 && !elements[0].name) {
|
|
||||||
return { type: "array", element: elements[0].schema };
|
|
||||||
}
|
|
||||||
return { type: "array", element: { type: "tuple", elements } };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (elements.length === 1 && !elements[0].name) {
|
|
||||||
return { type: "array", element: elements[0].schema };
|
|
||||||
}
|
|
||||||
return { type: "tuple", elements };
|
return { type: "tuple", elements };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,13 +39,6 @@ export function schemaToTypeString(
|
|||||||
return schema.isOptional ? `${baseType} | null` : baseType;
|
return schema.isOptional ? `${baseType} | null` : baseType;
|
||||||
}
|
}
|
||||||
case "array":
|
case "array":
|
||||||
if (schema.element.type === "tuple") {
|
|
||||||
const tupleElements = schema.element.elements.map((el) => {
|
|
||||||
const typeStr = schemaToTypeString(el.schema, resourceNames);
|
|
||||||
return el.name ? `${el.name}: ${typeStr}` : typeStr;
|
|
||||||
});
|
|
||||||
return `[${tupleElements.join(", ")}][]`;
|
|
||||||
}
|
|
||||||
const elementType = schemaToTypeString(schema.element, resourceNames);
|
const elementType = schemaToTypeString(schema.element, resourceNames);
|
||||||
if (schema.element.type === "union") {
|
if (schema.element.type === "union") {
|
||||||
return `(${elementType})[]`;
|
return `(${elementType})[]`;
|
||||||
|
|||||||
+23
-64
@@ -41,7 +41,7 @@ class ValueParser {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
parseValue(schema: Schema, allowOmitBrackets: boolean = false): unknown {
|
parseValue(schema: Schema): unknown {
|
||||||
this.skipWhitespace();
|
this.skipWhitespace();
|
||||||
|
|
||||||
switch (schema.type) {
|
switch (schema.type) {
|
||||||
@@ -60,9 +60,9 @@ class ValueParser {
|
|||||||
case "union":
|
case "union":
|
||||||
return this.parseUnionValue(schema);
|
return this.parseUnionValue(schema);
|
||||||
case "tuple":
|
case "tuple":
|
||||||
return this.parseTupleValue(schema, allowOmitBrackets);
|
return this.parseTupleValue(schema);
|
||||||
case "array":
|
case "array":
|
||||||
return this.parseArrayValue(schema, allowOmitBrackets);
|
return this.parseArrayValue(schema);
|
||||||
case "reference":
|
case "reference":
|
||||||
// Reference values are parsed as strings (IDs) initially, resolved later
|
// Reference values are parsed as strings (IDs) initially, resolved later
|
||||||
return this.parseReferenceValue(schema);
|
return this.parseReferenceValue(schema);
|
||||||
@@ -250,7 +250,7 @@ class ValueParser {
|
|||||||
for (let i = 0; i < schema.members.length; i++) {
|
for (let i = 0; i < schema.members.length; i++) {
|
||||||
this.pos = savedPos;
|
this.pos = savedPos;
|
||||||
try {
|
try {
|
||||||
return this.parseValue(schema.members[i], false);
|
return this.parseValue(schema.members[i]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errors.push(e as Error);
|
errors.push(e as Error);
|
||||||
// Continue to next member
|
// Continue to next member
|
||||||
@@ -266,16 +266,8 @@ class ValueParser {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseTupleValue(
|
private parseTupleValue(schema: TupleSchema): unknown[] {
|
||||||
schema: TupleSchema,
|
if (!this.consumeStr("[")) {
|
||||||
allowOmitBrackets: boolean,
|
|
||||||
): unknown[] {
|
|
||||||
let hasOpenBracket = false;
|
|
||||||
|
|
||||||
if (this.peek() === "[") {
|
|
||||||
this.consume();
|
|
||||||
hasOpenBracket = true;
|
|
||||||
} else if (!allowOmitBrackets) {
|
|
||||||
throw new ParseError(
|
throw new ParseError(
|
||||||
"Expected [",
|
"Expected [",
|
||||||
this.pos,
|
this.pos,
|
||||||
@@ -286,7 +278,7 @@ class ValueParser {
|
|||||||
|
|
||||||
this.skipWhitespace();
|
this.skipWhitespace();
|
||||||
|
|
||||||
if (this.peek() === "]" && hasOpenBracket) {
|
if (this.peek() === "]") {
|
||||||
this.consume();
|
this.consume();
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -308,7 +300,7 @@ class ValueParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result.push(this.parseValue(elementSchema.schema, false));
|
result.push(this.parseValue(elementSchema.schema));
|
||||||
this.skipWhitespace();
|
this.skipWhitespace();
|
||||||
|
|
||||||
if (i < schema.elements.length - 1) {
|
if (i < schema.elements.length - 1) {
|
||||||
@@ -325,7 +317,6 @@ class ValueParser {
|
|||||||
|
|
||||||
this.skipWhitespace();
|
this.skipWhitespace();
|
||||||
|
|
||||||
if (hasOpenBracket) {
|
|
||||||
if (!this.consumeStr("]")) {
|
if (!this.consumeStr("]")) {
|
||||||
throw new ParseError(
|
throw new ParseError(
|
||||||
"Expected ]",
|
"Expected ]",
|
||||||
@@ -334,48 +325,16 @@ class ValueParser {
|
|||||||
this.input,
|
this.input,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseArrayValue(
|
private parseArrayValue(schema: ArraySchema): unknown[] {
|
||||||
schema: ArraySchema,
|
|
||||||
allowOmitBrackets: boolean,
|
|
||||||
): unknown[] {
|
|
||||||
let hasOpenBracket = false;
|
|
||||||
const elementIsTupleOrArray =
|
|
||||||
schema.element.type === "tuple" || schema.element.type === "array";
|
|
||||||
|
|
||||||
if (this.pos >= this.input.length || !this.input.trim()) {
|
if (this.pos >= this.input.length || !this.input.trim()) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.peek() === "[") {
|
if (!this.consumeStr("[")) {
|
||||||
if (!elementIsTupleOrArray) {
|
|
||||||
this.consume();
|
|
||||||
hasOpenBracket = true;
|
|
||||||
} else {
|
|
||||||
// Element is tuple or array - need to disambiguate
|
|
||||||
// Save position to check if this is an empty array
|
|
||||||
const savedPos = this.pos;
|
|
||||||
this.consume();
|
|
||||||
this.skipWhitespace();
|
|
||||||
if (this.peek() === "]") {
|
|
||||||
// Empty array []
|
|
||||||
this.consume();
|
|
||||||
return [];
|
|
||||||
} else if (this.peek() === "[") {
|
|
||||||
// Nested brackets [[ - this is the array opener
|
|
||||||
hasOpenBracket = true;
|
|
||||||
} else {
|
|
||||||
// [ belongs to the first element, restore position
|
|
||||||
this.pos = savedPos;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasOpenBracket && !allowOmitBrackets && !elementIsTupleOrArray) {
|
|
||||||
throw new ParseError(
|
throw new ParseError(
|
||||||
"Expected [",
|
"Expected [",
|
||||||
this.pos,
|
this.pos,
|
||||||
@@ -386,7 +345,7 @@ class ValueParser {
|
|||||||
|
|
||||||
this.skipWhitespace();
|
this.skipWhitespace();
|
||||||
|
|
||||||
if (this.peek() === "]" && hasOpenBracket) {
|
if (this.peek() === "]") {
|
||||||
this.consume();
|
this.consume();
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -395,7 +354,7 @@ class ValueParser {
|
|||||||
while (true) {
|
while (true) {
|
||||||
this.skipWhitespace();
|
this.skipWhitespace();
|
||||||
|
|
||||||
result.push(this.parseValue(schema.element, elementIsTupleOrArray));
|
result.push(this.parseValue(schema.element));
|
||||||
this.skipWhitespace();
|
this.skipWhitespace();
|
||||||
|
|
||||||
if (!this.consumeStr(";")) {
|
if (!this.consumeStr(";")) {
|
||||||
@@ -405,7 +364,6 @@ class ValueParser {
|
|||||||
|
|
||||||
this.skipWhitespace();
|
this.skipWhitespace();
|
||||||
|
|
||||||
if (hasOpenBracket) {
|
|
||||||
if (!this.consumeStr("]")) {
|
if (!this.consumeStr("]")) {
|
||||||
throw new ParseError(
|
throw new ParseError(
|
||||||
"Expected ]",
|
"Expected ]",
|
||||||
@@ -414,7 +372,6 @@ class ValueParser {
|
|||||||
this.input,
|
this.input,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -431,15 +388,18 @@ class ValueParser {
|
|||||||
|
|
||||||
if (schema.isArray) {
|
if (schema.isArray) {
|
||||||
// Parse array of IDs: [id1; id2; id3]
|
// Parse array of IDs: [id1; id2; id3]
|
||||||
let hasOpenBracket = false;
|
if (!this.consumeStr("[")) {
|
||||||
if (this.peek() === "[") {
|
throw new ParseError(
|
||||||
this.consume();
|
"Expected [",
|
||||||
hasOpenBracket = true;
|
this.pos,
|
||||||
|
this.schemaString,
|
||||||
|
this.input,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.skipWhitespace();
|
this.skipWhitespace();
|
||||||
|
|
||||||
if (this.peek() === "]" && hasOpenBracket) {
|
if (this.peek() === "]") {
|
||||||
this.consume();
|
this.consume();
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -464,7 +424,8 @@ class ValueParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasOpenBracket) {
|
this.skipWhitespace();
|
||||||
|
|
||||||
if (!this.consumeStr("]")) {
|
if (!this.consumeStr("]")) {
|
||||||
throw new ParseError(
|
throw new ParseError(
|
||||||
"Expected ]",
|
"Expected ]",
|
||||||
@@ -473,7 +434,6 @@ class ValueParser {
|
|||||||
this.input,
|
this.input,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return ids;
|
return ids;
|
||||||
} else {
|
} else {
|
||||||
@@ -506,8 +466,7 @@ export function parseValue(
|
|||||||
): unknown {
|
): unknown {
|
||||||
const sStr = schemaString || schemaToTypeString(schema);
|
const sStr = schemaString || schemaToTypeString(schema);
|
||||||
const parser = new ValueParser(valueString.trim(), sStr);
|
const parser = new ValueParser(valueString.trim(), sStr);
|
||||||
const allowOmitBrackets = schema.type === "tuple" || schema.type === "array";
|
const value = parser.parseValue(schema);
|
||||||
const value = parser.parseValue(schema, allowOmitBrackets);
|
|
||||||
|
|
||||||
if (parser.getPosition() < parser.getInputLength()) {
|
if (parser.getPosition() < parser.getInputLength()) {
|
||||||
throw new ParseError(
|
throw new ParseError(
|
||||||
|
|||||||
Reference in New Issue
Block a user