-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add an extend static for building a new entity from an existing one #17
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| --- | ||
| "@btravstack/entity": minor | ||
| --- | ||
|
|
||
| New `extend` static on every entity: build a new entity from an existing | ||
| one's declaration. It is `SomeEntity.extend(tag)(fields)`, a static on the | ||
| class — not a property of the `Entity` builder. | ||
|
|
||
| ```ts | ||
| class PersonWithAge extends Person.extend("PersonWithAge")({ age: Age }) { | ||
| get isAdult(): boolean { | ||
| return this.age >= 18; | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| The result is its own entity — own tag, own schemas, own `equals` identity — | ||
| rather than a variant of the parent, which is what distinguishes it from the | ||
| bare subclassing that remains refused. | ||
|
|
||
| The parent's options are inherited and merged per key, child winning, so an | ||
| extension is never quietly laxer than what it extends. Extensions can | ||
| themselves be extended. | ||
|
|
||
| `extend` rebuilds from the declaration, so class-body members (a getter, a | ||
| method) are not carried over; re-declare them or use a plain function. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| import { P } from "unthrown"; | ||
| import { expect, test } from "vitest"; | ||
| import { z } from "zod"; | ||
|
|
||
| import { Entity, computed } from "./index.js"; | ||
|
|
||
| const PersonId = z.uuid().brand("PersonId"); | ||
| const Name = z.string().min(1).brand("Name"); | ||
| const Age = z.number().int().min(0).brand("Age"); | ||
| const Upper = z.string().min(1).brand("Upper"); | ||
|
|
||
| class Person extends Entity("Person")( | ||
| { id: PersonId, name: Name }, | ||
| { | ||
| immutable: ["id"], | ||
| computed: { shout: computed(Upper, (d) => d.name.toUpperCase() as z.infer<typeof Upper>) }, | ||
| invariants: (d) => (d.name.length <= 20 ? [] : ["name must be at most 20 chars"]), | ||
| }, | ||
| ) {} | ||
|
|
||
| class PersonWithAge extends Person.extend("PersonWithAge")({ age: Age }) { | ||
| get isAdult(): boolean { | ||
| return this.age >= 18; | ||
| } | ||
| } | ||
|
|
||
| const id = "0199b1f4-1b1e-7000-8000-000000000000"; | ||
|
|
||
| test("an extension carries the parent's fields plus its own", () => { | ||
| const p = PersonWithAge.make({ id, name: "ada", age: 36 }).getOrThrow(); | ||
| expect(p.name).toBe("ada"); | ||
| expect(p.age).toBe(36); | ||
| }); | ||
|
|
||
| test("a class-body getter sees the extended fields", () => { | ||
| const p = PersonWithAge.make({ id, name: "ada", age: 36 }).getOrThrow(); | ||
| expect(p.isAdult).toBe(true); | ||
| expect(PersonWithAge.make({ id, name: "kid", age: 9 }).getOrThrow().isAdult).toBe(false); | ||
| }); | ||
|
|
||
| test("the extension is its own entity, with its own tag", () => { | ||
| const p = PersonWithAge.make({ id, name: "ada", age: 36 }).getOrThrow(); | ||
| expect(p._tag).toBe("PersonWithAge"); | ||
| expect(PersonWithAge.entityName).toBe("PersonWithAge"); | ||
| expect(p).not.toBeInstanceOf(Person); | ||
| }); | ||
|
|
||
| test("the parent's computed fields carry over and still re-derive", () => { | ||
| const p = PersonWithAge.make({ id, name: "ada", age: 36 }).getOrThrow(); | ||
| expect(p.shout).toBe("ADA"); | ||
| expect(p.update({ name: "grace" as z.infer<typeof Name> }).getOrThrow().shout).toBe("GRACE"); | ||
| }); | ||
|
|
||
| test("the parent's invariants carry over", () => { | ||
| const long = "x".repeat(21); | ||
| expect(PersonWithAge.make({ id, name: long, age: 36 }).isErr()).toBe(true); | ||
| }); | ||
|
|
||
| test("the parent's immutable list carries over", () => { | ||
| expect(Object.keys(PersonWithAge.updateInput.shape).toSorted()).toEqual(["age", "name"]); | ||
| }); | ||
|
|
||
| test("a child option overrides the parent's for that key", () => { | ||
| class Loose extends Person.extend("Loose")({ age: Age }, { invariants: () => [] }) {} | ||
| expect(Loose.make({ id, name: "x".repeat(21), age: 1 }).isOk()).toBe(true); | ||
| }); | ||
|
|
||
| test("the extension's own schemas include both halves", () => { | ||
| expect(Object.keys(PersonWithAge.input.shape).toSorted()).toEqual(["age", "id", "name"]); | ||
| expect(Object.keys(PersonWithAge.output.shape).toSorted()).toEqual([ | ||
| "age", | ||
| "id", | ||
| "name", | ||
| "shout", | ||
| ]); | ||
| }); | ||
|
|
||
| test("parent and extension are never equal, even with matching data", () => { | ||
| const parent = Person.make({ id, name: "ada" }).getOrThrow(); | ||
| const child = PersonWithAge.make({ id, name: "ada", age: 36 }).getOrThrow(); | ||
| expect(child.equals(parent)).toBe(false); | ||
| expect(parent.equals(child)).toBe(false); | ||
| }); | ||
|
|
||
| test("the extension is still sealed and still refuses a bare subclass", () => { | ||
| class Sub extends PersonWithAge {} | ||
| const outcome = Sub.make({ id, name: "ada", age: 36 }).match({ | ||
| ok: () => "WRONGLY ACCEPTED", | ||
| errCases: (m) => m.with(P.tag("InvalidEntity"), () => "invalid"), | ||
| defect: () => "defect", | ||
| }); | ||
| expect(outcome).toBe("defect"); | ||
| }); | ||
|
|
||
| test("an extension can itself be extended", () => { | ||
| const Nick = z.string().min(1).brand("Nick"); | ||
| class Deeper extends PersonWithAge.extend("Deeper")({ nickname: Nick }) {} | ||
| const d = Deeper.make({ id, name: "ada", age: 36, nickname: "ace" }).getOrThrow(); | ||
| expect(d.nickname).toBe("ace"); | ||
| expect(d.age).toBe(36); | ||
| expect(d._tag).toBe("Deeper"); | ||
| }); | ||
|
|
||
| test("extend carries declarations, not class-body members", () => { | ||
| // `extend` rebuilds from the field map and options — a getter written in the | ||
| // parent's class body is not part of either, so it does not come along. | ||
| // Re-declare it, or put shared behaviour in a plain function. | ||
| const Nick = z.string().min(1).brand("Nick"); | ||
| class Deeper extends PersonWithAge.extend("Deeper")({ nickname: Nick }) {} | ||
| const d = Deeper.make({ id, name: "ada", age: 36, nickname: "ace" }).getOrThrow(); | ||
| expect("isAdult" in d).toBe(false); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| import { test } from "vitest"; | ||
| import { z } from "zod"; | ||
|
|
||
| import { Entity } from "./index.js"; | ||
|
|
||
| const Id = z.uuid().brand("Id"); | ||
| const Name = z.string().min(1).brand("Name"); | ||
| const Age = z.number().int().brand("Age"); | ||
|
|
||
| class Person extends Entity("Person")({ id: Id, name: Name }) {} | ||
|
|
||
| test("extend enforces the same field rules as a fresh declaration", () => { | ||
| Person.extend("Ok")({ age: Age }); | ||
|
|
||
| // @ts-expect-error an unbranded field is rejected, exactly as in Entity(...) | ||
| Person.extend("Unbranded")({ plain: z.string() }); | ||
|
|
||
| // @ts-expect-error a reserved name is rejected, exactly as in Entity(...) | ||
| Person.extend("Reserved")({ update: Name }); | ||
| }); | ||
|
|
||
| test("an extension's instance carries both halves of the shape", () => { | ||
| class WithAge extends Person.extend("WithAge")({ age: Age }) {} | ||
| const p = WithAge.make({}).getOrThrow(); | ||
| const name: z.infer<typeof Name> = p.name; | ||
| const age: z.infer<typeof Age> = p.age; | ||
| const tag: "WithAge" = p._tag; | ||
| void name; | ||
| void age; | ||
| void tag; | ||
| // @ts-expect-error the extension's data is still read-only | ||
| p.age = age; | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.