diff --git a/.gitignore b/.gitignore index 3c3629e..a7880cf 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ node_modules +pull_request.md +PULL_REQUEST.md diff --git a/CHANGELOG.md b/CHANGELOG.md index cd4dff6..7024695 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ All notable changes to this project will be documented in this file. ### Added - **Extended test coverage**: 8 new tests (60 total) — `{ type: undefined/null/"string" }` schema rejection, `Union(Array, String)` value discrimination, scalar-only coerce tests, and `Union(Object, String)` skipping object-key validation for string values. +- **Readonly enum array support**: Updated `FieldConfig.enum` to allow `readonly any[]` for `as const` schema declarations. ### Changed diff --git a/README.md b/README.md index dc79f72..ea94920 100644 --- a/README.md +++ b/README.md @@ -475,13 +475,13 @@ ModelCore throws typed error classes for every failure mode: | Error class | Trigger | |---|---| | `ValidationError` | Custom `validate` hook throws | -| `TypeValidationError` | Value constructor doesn't match schema type | +| `TypeValidationError` | Value constructor doesn't match schema type, or value has no prototype chain | | `RequiredError` | Required field is missing | | `EnumValueError` | Value not in allowed enum set | | `RangeError` | Value exceeds min/max | | `ImmutableObjectError` | Write to immutable class | | `ImmutablePropertyError` | Write to immutable field | -| `SchemaDefinitionError` | Malformed schema definition | +| `SchemaDefinitionError` | Malformed schema definition, `{ type: undefined }`, `{ type: null }`, or missing `static schema` | | `MissingPropertyError` | Nested required property missing | | `ValueError` | Array method misuse (e.g., `fill()`) | @@ -489,6 +489,21 @@ All errors extend `ModelCoreError`, which carries `source`, `path`, `expected`, --- +## Defensive guarantees + +ModelCore guards against several degenerate inputs that would otherwise cause raw `TypeError` crashes or silent data corruption: + +| Input | Before | After | +|---|---|---| +| `{ type: undefined }` in schema | Raw `TypeError: Cannot read properties of undefined` | `SchemaDefinitionError` with path and code | +| `{ type: null }` in schema | Raw `TypeError` | `SchemaDefinitionError` with path and code | +| `Object.create(null)` as field value | Raw `TypeError: Cannot read properties of undefined (reading 'name')` | `TypeValidationError` with message `"...got null-prototype object"` | +| `Union(Array, String)` with string input | String silently split into char array `['h','e','l','l','o']` | String preserved as-is | +| `"42"` for a `Number` field (no `coerce: true`) | Silently accepted as string | `TypeValidationError` — use `coerce: true` to auto-convert | +| Subclass with no `static schema` | Silent empty object | `SchemaDefinitionError` with class name | + + + ## Performance ModelCore achieves **>200K ops/sec** on full model construction with nested containers (5 fields, 100K iterations, Node 24). Scalar-only models reach **~1.9M ops/sec**. diff --git a/src/typing.d.ts b/src/typing.d.ts index a99512a..aff1dd9 100644 --- a/src/typing.d.ts +++ b/src/typing.d.ts @@ -4,7 +4,7 @@ export interface FieldConfig { optional?: boolean; required?: boolean; default?: any; - enum?: any[]; + enum?: any[] | readonly any[]; max?: number; min?: number; beforeChecks?: (value: any) => any; diff --git a/test/base.test.js b/test/base.test.js index 5a46dfc..4b41fcb 100644 --- a/test/base.test.js +++ b/test/base.test.js @@ -837,6 +837,8 @@ test("error.expected contains meaningful values (type, min, max, enum)", () => { } }); +// ==================== NULL & EDGE-CASE GUARDS ==================== + test("schema field with { type: undefined } throws SchemaDefinitionError", () => { class U extends Base { static schema = { name: { type: undefined } }; @@ -950,3 +952,30 @@ test("Union(Object, String) with string value preserves string (does not run Obj assert.equal(typeof u.val, "string"); assert.equal(u.val, "hello"); }); + +test("supports readonly enum arrays (as const assertions)", () => { + const RO_ROLES = Object.freeze(["ADMIN", "USER", "GUEST"]); + class U extends Base { + static schema = { + role: { type: String, enum: RO_ROLES } + }; + } + + const u = new U({ role: "ADMIN" }); + assert.equal(u.role, "ADMIN"); + assert.throws(() => new U({ role: "INVALID" }), /Invalid value for 'role'/); +}); + +test("handles null-prototype inputs gracefully", () => { + class U extends Base { + static schema = { + name: { type: String } + }; + } + + const nullProtoObj = Object.create(null); + nullProtoObj.name = "Alice"; + + const u = new U(nullProtoObj); + assert.equal(u.name, "Alice"); +});