diff --git a/examples/basic/src/tables/collars.ts b/examples/basic/src/tables/collars.ts index f82e8dda..04d8fedb 100644 --- a/examples/basic/src/tables/collars.ts +++ b/examples/basic/src/tables/collars.ts @@ -1,5 +1,5 @@ import { db } from "../db"; -import { expose } from "typegres"; +import { Relation, expose } from "typegres"; import { Int8, Text } from "typegres/postgres"; import { Dogs } from "./dogs"; @@ -9,6 +9,6 @@ export class Collars extends db.Table("collars") { @expose() color = Text.column({ nonNull: true }); @expose() dog_id = Int8.column({ nonNull: true }); // relations - @expose() dog() { return Dogs.scope(Collars.contextOf(this)).where(({ dogs }) => dogs.id.eq(this.dog_id)).cardinality("one"); } + @expose() dog() { return Relation.belongsTo(this, Dogs, { id: this.dog_id }); } // @generated-end } diff --git a/examples/basic/src/tables/dogs.ts b/examples/basic/src/tables/dogs.ts index 0f8ea152..e5eafe77 100644 --- a/examples/basic/src/tables/dogs.ts +++ b/examples/basic/src/tables/dogs.ts @@ -1,5 +1,5 @@ import { db } from "../db"; -import { expose, sql } from "typegres"; +import { Relation, expose, sql } from "typegres"; import { Int8, Text, Timestamptz } from "typegres/postgres"; import { Teams } from "./teams"; import { Collars } from "./collars"; @@ -15,11 +15,11 @@ export class Dogs extends db.Table("dogs") { @expose() team_id = Int8.column({ nonNull: true }); @expose() rival_id = Int8.column(); // relations - @expose() rival() { return Dogs.scope(Dogs.contextOf(this)).where(({ dogs }) => dogs.id.eq(this.rival_id)).cardinality("maybe"); } - @expose() team() { return Teams.scope(Dogs.contextOf(this)).where(({ teams }) => teams.id.eq(this.team_id)).cardinality("one"); } - @expose() collars() { return Collars.scope(Dogs.contextOf(this)).where(({ collars }) => collars.dog_id.eq(this.id)).cardinality("one"); } - @expose() dogs() { return Dogs.scope(Dogs.contextOf(this)).where(({ dogs }) => dogs.rival_id.eq(this.id)).cardinality("many"); } - @expose() microchips() { return Microchips.scope(Dogs.contextOf(this)).where(({ microchips }) => microchips.dog_id.eq(this.id)).cardinality("maybe"); } - @expose() toys() { return Toys.scope(Dogs.contextOf(this)).where(({ toys }) => toys.dog_id.eq(this.id)).cardinality("many"); } + @expose() rival() { return Relation.belongsTo(this, Dogs, { id: this.rival_id }, { card: "maybe" }); } + @expose() team() { return Relation.belongsTo(this, Teams, { id: this.team_id }); } + @expose() collars() { return Relation.has(this, Collars, { dog_id: this.id }, { card: "one" }); } + @expose() dogs() { return Relation.has(this, Dogs, { rival_id: this.id }); } + @expose() microchips() { return Relation.has(this, Microchips, { dog_id: this.id }, { card: "maybe" }); } + @expose() toys() { return Relation.has(this, Toys, { dog_id: this.id }); } // @generated-end } diff --git a/examples/basic/src/tables/microchips.ts b/examples/basic/src/tables/microchips.ts index 0ac30393..6611c261 100644 --- a/examples/basic/src/tables/microchips.ts +++ b/examples/basic/src/tables/microchips.ts @@ -1,5 +1,5 @@ import { db } from "../db"; -import { expose } from "typegres"; +import { Relation, expose } from "typegres"; import { Int8, Text } from "typegres/postgres"; import { Dogs } from "./dogs"; @@ -9,6 +9,6 @@ export class Microchips extends db.Table("microchips") { @expose() serial = Text.column({ nonNull: true }); @expose() dog_id = Int8.column(); // relations - @expose() dog() { return Dogs.scope(Microchips.contextOf(this)).where(({ dogs }) => dogs.id.eq(this.dog_id)).cardinality("maybe"); } + @expose() dog() { return Relation.belongsTo(this, Dogs, { id: this.dog_id }, { card: "maybe" }); } // @generated-end } diff --git a/examples/basic/src/tables/teams.ts b/examples/basic/src/tables/teams.ts index fc61af0a..6c858b76 100644 --- a/examples/basic/src/tables/teams.ts +++ b/examples/basic/src/tables/teams.ts @@ -1,5 +1,5 @@ import { db } from "../db"; -import { expose } from "typegres"; +import { Relation, expose } from "typegres"; import { Int8, Text } from "typegres/postgres"; import { Dogs } from "./dogs"; @@ -8,6 +8,6 @@ export class Teams extends db.Table("teams") { @expose() id = Int8.column({ nonNull: true, generated: true }); @expose() name = Text.column({ nonNull: true }); // relations - @expose() dogs() { return Dogs.scope(Teams.contextOf(this)).where(({ dogs }) => dogs.team_id.eq(this.id)).cardinality("many"); } + @expose() dogs() { return Relation.has(this, Dogs, { team_id: this.id }); } // @generated-end } diff --git a/examples/basic/src/tables/toys.ts b/examples/basic/src/tables/toys.ts index 2dbff675..8effacb1 100644 --- a/examples/basic/src/tables/toys.ts +++ b/examples/basic/src/tables/toys.ts @@ -1,5 +1,5 @@ import { db } from "../db"; -import { expose } from "typegres"; +import { Relation, expose } from "typegres"; import { Int8, Text } from "typegres/postgres"; import { Dogs } from "./dogs"; @@ -9,6 +9,6 @@ export class Toys extends db.Table("toys") { @expose() name = Text.column({ nonNull: true }); @expose() dog_id = Int8.column({ nonNull: true }); // relations - @expose() dog() { return Dogs.scope(Toys.contextOf(this)).where(({ dogs }) => dogs.id.eq(this.dog_id)).cardinality("one"); } + @expose() dog() { return Relation.belongsTo(this, Dogs, { id: this.dog_id }); } // @generated-end } diff --git a/examples/sqlite/src/tables/dogs.ts b/examples/sqlite/src/tables/dogs.ts index e299e3ee..8a5e4f60 100644 --- a/examples/sqlite/src/tables/dogs.ts +++ b/examples/sqlite/src/tables/dogs.ts @@ -1,5 +1,5 @@ import { db } from "../db"; -import { expose } from "typegres"; +import { Relation, expose } from "typegres"; import { Integer, Text } from "typegres/sqlite"; import { Teams } from "./teams"; @@ -10,6 +10,6 @@ export class Dogs extends db.Table("dogs") { @expose() breed = Text.column(); @expose() team_id = Integer.column({ nonNull: true }); // relations - @expose() team() { return Teams.scope(Dogs.contextOf(this)).where(({ teams }) => teams.id.eq(this.team_id)).cardinality("one"); } + @expose() team() { return Relation.belongsTo(this, Teams, { id: this.team_id }); } // @generated-end } diff --git a/examples/sqlite/src/tables/teams.ts b/examples/sqlite/src/tables/teams.ts index d8b6a8ec..7d72ae9c 100644 --- a/examples/sqlite/src/tables/teams.ts +++ b/examples/sqlite/src/tables/teams.ts @@ -1,5 +1,5 @@ import { db } from "../db"; -import { expose } from "typegres"; +import { Relation, expose } from "typegres"; import { Integer, Text } from "typegres/sqlite"; import { Dogs } from "./dogs"; @@ -8,6 +8,6 @@ export class Teams extends db.Table("teams") { @expose() id = Integer.column({ nonNull: true, generated: true }); @expose() name = Text.column({ nonNull: true }); // relations - @expose() dogs() { return Dogs.scope(Teams.contextOf(this)).where(({ dogs }) => dogs.team_id.eq(this.id)).cardinality("many"); } + @expose() dogs() { return Relation.has(this, Dogs, { team_id: this.id }); } // @generated-end } diff --git a/src/index.ts b/src/index.ts index 17f7525e..27852908 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ export { Database, Connection } from "./database"; export type { TransactionIsolation, TransactionOptions } from "./database"; export { Table } from "./table"; +export { Relation } from "./relation"; export { sql, Sql } from "./builder/sql"; export { QueryBuilder } from "./builder/query"; export { TypegresLiveEvents } from "./live/events"; diff --git a/src/relation.test.ts b/src/relation.test.ts new file mode 100644 index 00000000..b7dc55af --- /dev/null +++ b/src/relation.test.ts @@ -0,0 +1,226 @@ +import { describe, test, expect, expectTypeOf } from "vitest"; +import { sql } from "."; +import { Int8, Text } from "./types/postgres"; +import type { Record, Anyarray } from "./types/postgres"; +import { compile } from "./builder/sql"; +import { Relation } from "./relation"; +import { setupDb, withinTransaction, db } from "./test-helpers"; +setupDb(); + +// Relation.* collapses the scope+contextOf+where+cardinality boilerplate +// used by codegen'd FK edges. Behavior must match the hand-written form. + +describe("Relation helpers", () => { + test("belongsTo / has emit the same SQL as scope+where+cardinality", async () => { + await withinTransaction(async (tx) => { + await tx.execute(sql`CREATE TABLE authors ( + id int8 GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name text NOT NULL + )`); + await tx.execute(sql`CREATE TABLE books ( + id int8 GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + title text NOT NULL, + author_id int8 NOT NULL REFERENCES authors(id) + )`); + await tx.execute(sql`INSERT INTO authors (name) VALUES ('Asimov')`); + await tx.execute(sql`INSERT INTO books (title, author_id) VALUES ('Foundation', 1)`); + + class Authors extends db.Table("authors") { + id = Int8.column({ nonNull: true, generated: true }); + name = Text.column({ nonNull: true }); + } + class Books extends db.Table("books") { + id = Int8.column({ nonNull: true, generated: true }); + title = Text.column({ nonNull: true }); + author_id = Int8.column({ nonNull: true }); + + authorHelper() { + return Relation.belongsTo(this, Authors, { id: this.author_id }); + } + authorHand() { + return Authors.scope(Books.contextOf(this)) + .where(({ authors }) => authors.id.eq(this.author_id)) + .cardinality("one"); + } + } + + const [book] = await tx.hydrate(Books.from()); + const ctx = { database: db }; + + const viaHelper = book!.authorHelper(); + const viaHand = book!.authorHand(); + expect(compile(viaHelper as any, ctx).text).toBe(compile(viaHand as any, ctx).text); + expect(compile(viaHelper as any, ctx).values).toEqual(compile(viaHand as any, ctx).values); + + const [author] = await tx.hydrate(Authors.from()); + const manyHelper = Relation.has(author!, Books, { author_id: author!.id }); + const manyHand = Books.scope(Authors.contextOf(author!)) + .where(({ books }) => books.author_id.eq(author!.id)) + .cardinality("many"); + expect(compile(manyHelper as any, ctx).text).toBe(compile(manyHand as any, ctx).text); + }); + }); + + test("has with card one / maybe emit the same SQL as hand-written", async () => { + await withinTransaction(async (tx) => { + await tx.execute(sql`CREATE TABLE dogs ( + id int8 GENERATED ALWAYS AS IDENTITY PRIMARY KEY + )`); + await tx.execute(sql`CREATE TABLE collars ( + id int8 GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + dog_id int8 NOT NULL REFERENCES dogs(id) UNIQUE + )`); + await tx.execute(sql`CREATE TABLE microchips ( + id int8 GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + dog_id int8 UNIQUE REFERENCES dogs(id) + )`); + await tx.execute(sql`INSERT INTO dogs DEFAULT VALUES`); + await tx.execute(sql`INSERT INTO collars (dog_id) VALUES (1)`); + + class Dogs extends db.Table("dogs") { + id = Int8.column({ nonNull: true, generated: true }); + } + class Collars extends db.Table("collars") { + id = Int8.column({ nonNull: true, generated: true }); + dog_id = Int8.column({ nonNull: true }); + } + class Microchips extends db.Table("microchips") { + id = Int8.column({ nonNull: true, generated: true }); + dog_id = Int8.column(); + } + + const [dog] = await tx.hydrate(Dogs.from()); + const ctx = { database: db }; + + const oneHelper = Relation.has(dog!, Collars, { dog_id: dog!.id }, { card: "one" }); + const oneHand = Collars.scope(Dogs.contextOf(dog!)) + .where(({ collars }) => collars.dog_id.eq(dog!.id)) + .cardinality("one"); + expect(compile(oneHelper as any, ctx).text).toBe(compile(oneHand as any, ctx).text); + + const maybeHelper = Relation.has(dog!, Microchips, { dog_id: dog!.id }, { card: "maybe" }); + const maybeHand = Microchips.scope(Dogs.contextOf(dog!)) + .where(({ microchips }) => microchips.dog_id.eq(dog!.id)) + .cardinality("maybe"); + expect(compile(maybeHelper as any, ctx).text).toBe(compile(maybeHand as any, ctx).text); + // one vs maybe share SQL shape (card only affects .scalar typing); + // different target tables → different table names, but same structure. + expect(compile(oneHelper as any, ctx).text.replaceAll("collars", "T")) + .toBe(compile(maybeHelper as any, ctx).text.replaceAll("microchips", "T")); + }); + }); + + test("threads parent context through hydrate (belongsTo hop)", async () => { + await withinTransaction(async (tx) => { + await tx.execute(sql`CREATE TABLE authors ( + id int8 GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name text NOT NULL + )`); + await tx.execute(sql`CREATE TABLE books ( + id int8 GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + title text NOT NULL, + author_id int8 NOT NULL REFERENCES authors(id) + )`); + await tx.execute(sql`INSERT INTO authors (name) VALUES ('Asimov')`); + await tx.execute(sql`INSERT INTO books (title, author_id) VALUES ('Foundation', 1)`); + + type P = { user: string }; + class Authors extends db.Table<"authors", P>("authors") { + id = Int8.column({ nonNull: true, generated: true }); + name = Text.column({ nonNull: true }); + } + class Books extends db.Table<"books", P>("books") { + id = Int8.column({ nonNull: true, generated: true }); + title = Text.column({ nonNull: true }); + author_id = Int8.column({ nonNull: true }); + + author() { + return Relation.belongsTo(this, Authors, { id: this.author_id }); + } + } + + const principal: P = { user: "alice" }; + const [book] = await tx.hydrate(Books.scope(principal).orderBy(({ books }) => books.id)); + expect(Books.contextOf(book!)).toBe(principal); + + const [author] = await tx.hydrate(book!.author()); + expect(Authors.contextOf(author!)).toBe(principal); + }); + }); + + test("undefined parent context passes through (same as scope(undefined))", async () => { + await withinTransaction(async (tx) => { + await tx.execute(sql`CREATE TABLE authors ( + id int8 GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name text NOT NULL + )`); + await tx.execute(sql`CREATE TABLE books ( + id int8 GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + title text NOT NULL, + author_id int8 NOT NULL REFERENCES authors(id) + )`); + await tx.execute(sql`INSERT INTO authors (name) VALUES ('Asimov')`); + await tx.execute(sql`INSERT INTO books (title, author_id) VALUES ('Foundation', 1)`); + + class Authors extends db.Table("authors") { + id = Int8.column({ nonNull: true, generated: true }); + name = Text.column({ nonNull: true }); + } + class Books extends db.Table("books") { + id = Int8.column({ nonNull: true, generated: true }); + title = Text.column({ nonNull: true }); + author_id = Int8.column({ nonNull: true }); + + author() { + return Relation.belongsTo(this, Authors, { id: this.author_id }); + } + } + + // Plain from() — no scope tag on the parent row. + const [book] = await tx.hydrate(Books.from()); + expect(Books.contextOf(book!)).toBeUndefined(); + + const [author] = await tx.hydrate(book!.author()); + expect(Authors.contextOf(author!)).toBeUndefined(); + }); + }); + + test("belongsTo / has card opts are typed into QueryBuilder", () => { + class Authors extends db.Table("authors") { + id = Int8.column({ nonNull: true, generated: true }); + } + class Books extends db.Table("books") { + author_id = Int8.column({ nonNull: true }); + rival_id = Int8.column(); + } + const book = Books.rowType(); + const author = Authors.rowType(); + + // Card is the 4th type param on QueryBuilder; .scalar() return type + // is the public probe (same pattern as rpc-exoeval tests). + const one = Relation.belongsTo(book, Authors, { id: book.author_id }); + const maybe = Relation.belongsTo(book, Authors, { id: book.rival_id }, { card: "maybe" }); + const many = Relation.has(author, Books, { author_id: author.id }); + const hasOne = Relation.has(author, Books, { author_id: author.id }, { card: "one" }); + + expectTypeOf(one.scalar()).toMatchTypeOf>(); + expectTypeOf(maybe.scalar()).toMatchTypeOf>(); + expectTypeOf(hasOne.scalar()).toMatchTypeOf>(); + expectTypeOf(many.scalar()).toMatchTypeOf>(); + }); + + test("rejects multi-column FK maps at runtime", () => { + class A extends db.Table("a") { + id = Int8.column({ nonNull: true }); + x = Int8.column({ nonNull: true }); + } + class B extends db.Table("b") { + a_id = Int8.column({ nonNull: true }); + a_x = Int8.column({ nonNull: true }); + } + const b = B.rowType(); + expect(() => + Relation.belongsTo(b, A, { id: b.a_id, x: b.a_x } as any), + ).toThrow(/single-column FK map/); + }); +}); diff --git a/src/relation.ts b/src/relation.ts new file mode 100644 index 00000000..f34b95c2 --- /dev/null +++ b/src/relation.ts @@ -0,0 +1,108 @@ +// Relation helpers — collapse the common FK + context-thread pattern: +// +// Target.scope(Current.contextOf(this)) +// .where(({ target }) => target.fk_col.eq(this.pk)) +// .cardinality("many") +// +// into: +// +// Relation.has(this, Target, { fk_col: this.pk }, { card: "many" }) +// Relation.belongsTo(this, Target, { id: this.fk }) // card defaults "one" +// +// Lives as free functions under a `Relation` namespace (not on TableBase) +// so row instances / query scopes don't grow definition-time helpers in +// autocomplete. Call sites are relation method bodies and codegen only. + +import type { TableBase, TableClass } from "./table"; +import type { SqlValue } from "./types/sql-value"; +import type { QueryBuilder } from "./builder/query"; + +// Column keys on a table instance — only fields that hold SqlValue +// (methods / constructor / non-column fields are excluded). +type ColumnKeys = { + [K in keyof R]-?: R[K] extends SqlValue ? K : never; +}[keyof R & string]; + +// Single-column FK map: exactly one target-column → parent-side expression. +// Multi-column FKs are not supported yet (codegen is single-column only). +// Union of one-key objects so `{}` and multi-key maps fail at compile time +// (runtime singleEntry still guards dynamic maps). +type FkMapFor = { + [K in ColumnKeys>]: { [P in K]: SqlValue }; +}[ColumnKeys>]; + +// Outbound (this row holds the FK): never "many". +type BelongsToCard = "one" | "maybe"; +// Inbound (target holds the FK): all three cardinalities. +type HasCard = "one" | "maybe" | "many"; + +type BelongsToOpts = { + card?: Card; +}; +type HasOpts = { + card?: Card; +}; + +const singleEntry = (fk: { [k: string]: SqlValue }): [string, SqlValue] => { + const entries = Object.entries(fk); + if (entries.length !== 1) { + throw new Error( + `Relation helpers require a single-column FK map, got ${entries.length} key(s)`, + ); + } + return entries[0] as [string, SqlValue]; +}; + +// Shared substrate: Target.scope(parentCtx).where(fk).cardinality(card). +// "one" and "maybe" emit identical SQL (only the type of .scalar() differs); +// "many" switches scalar() to array_agg. +const relate = ( + row: TableBase, + Target: T, + fk: { [k: string]: SqlValue }, + card: Card, +): QueryBuilder< + { [K in T["tsAlias"]]: InstanceType }, + InstanceType, + [], + Card +> => { + const [targetCol, parentExpr] = singleEntry(fk); + const Parent = row.constructor as typeof TableBase; + const alias = Target.tsAlias; + return Target.scope(Parent.contextOf(row as InstanceType)) + .where((ns) => { + // Namespace is keyed by tsAlias (= tableName). Column .eq is on every + // dialect root (PG + SQLite generated Any); keep the lookup structural + // so this file stays dialect-agnostic. + const targetRow = (ns as { [k: string]: { [c: string]: { eq: (v: SqlValue) => any } } })[alias]!; + return targetRow[targetCol]!.eq(parentExpr); + }) + .cardinality(card); +}; + +export const Relation = { + // Parent row holds the FK → target row. + // Default card "one" (non-null FK); pass { card: "maybe" } for nullable. + belongsTo( + row: TableBase, + Target: T, + fk: FkMapFor, + opts?: BelongsToOpts, + ): QueryBuilder<{ [K in T["tsAlias"]]: InstanceType }, InstanceType, [], Card> { + const card = (opts?.card ?? "one") as Card; + return relate(row, Target, fk as { [k: string]: SqlValue }, card); + }, + + // Target holds the FK → this row (inbound / collection or unique inverse). + // Default card "many"; pass { card: "one" } / { card: "maybe" } for unique. + has( + row: TableBase, + Target: T, + fk: FkMapFor, + opts?: HasOpts, + ): QueryBuilder<{ [K in T["tsAlias"]]: InstanceType }, InstanceType, [], Card> { + const card = (opts?.card ?? "many") as Card; + return relate(row, Target, fk as { [k: string]: SqlValue }, card); + }, +}; diff --git a/src/table.ts b/src/table.ts index 4b5ae38a..ca7cc200 100644 --- a/src/table.ts +++ b/src/table.ts @@ -77,8 +77,8 @@ export abstract class TableBase { return this.database.scopedIdent(name); } - // Entry point for query builders: e.g., `Users.from()` - static from TableBase)>(this: T) { + // Entry point for query builders: e.g., `Users.from()` + static from(this: T) { return new QueryBuilder< { [K in T["tsAlias"]]: InstanceType }, InstanceType, @@ -90,7 +90,7 @@ export abstract class TableBase { }); } - static insert TableBase)>( + static insert( this: T, ...rows: [InsertRow>, ...InsertRow>[]] ): InsertBuilder> { @@ -102,13 +102,13 @@ export abstract class TableBase { }); } - static update TableBase)>( + static update( this: T, ): UpdateBuilder> { return new UpdateBuilder({ instance: this.rowType() }); } - static delete TableBase)>( + static delete( this: T, ): DeleteBuilder> { return new DeleteBuilder({ instance: this.rowType() }); @@ -134,7 +134,7 @@ export abstract class TableBase { // `Database.Table`). Tables declared with the default `C = // undefined` accept anything via the `unknown` widening; tables that // pin a `C` reject mismatched scopes at compile time. - static scope TableBase)>( + static scope( this: T, ctx: T["context"] | undefined, ) { @@ -194,6 +194,10 @@ export const Table = ( return obj[name] as NonNullable; }; -export const isTableClass = (x: unknown): x is typeof TableBase => { +// Concrete table class: TableBase statics + constructible row instance. +// Used by Relation helpers, from/insert/scope, and Fromable checks. +export type TableClass = typeof TableBase & (new () => TableBase); + +export const isTableClass = (x: unknown): x is TableClass => { return typeof x === "function" && x.prototype instanceof TableBase; }; diff --git a/src/tables/generate.test.ts b/src/tables/generate.test.ts index 4fdd8e29..9b75a062 100644 --- a/src/tables/generate.test.ts +++ b/src/tables/generate.test.ts @@ -22,24 +22,30 @@ const rel = (name: string, targetTable: string, overrides: Partial = { cardinality: "many", fromColumn: "id", toColumn: `${targetTable}_id`, + direction: "inbound", ...overrides, }); describe("generateTable — new file", async () => { - test("default emit: @expose on every column + relation, scope+contextOf, full file shape", async () => { + test("default emit: @expose on every column + relation, Relation helpers, full file shape", async () => { const out = generateTable( "dogs", [ col("id", "Int8", { generated: true }), col("name", "Text"), ], - [rel("teams", "teams", { cardinality: "one", fromColumn: "team_id", toColumn: "id" })], + [rel("teams", "teams", { + cardinality: "one", + fromColumn: "team_id", + toColumn: "id", + direction: "outbound", + })], { typeImportPath: "typegres/postgres", dbImport: "../db" }, ); await validate(out); expect(out).toMatchInlineSnapshot(` "import { db } from "../db"; - import { expose } from "typegres"; + import { Relation, expose } from "typegres"; import { Int8, Text } from "typegres/postgres"; import { Teams } from "./teams"; @@ -48,13 +54,47 @@ describe("generateTable — new file", async () => { @expose() id = Int8.column({ nonNull: true, generated: true }); @expose() name = Text.column({ nonNull: true }); // relations - @expose() teams() { return Teams.scope(Dogs.contextOf(this)).where(({ teams }) => teams.id.eq(this.team_id)).cardinality("one"); } + @expose() teams() { return Relation.belongsTo(this, Teams, { id: this.team_id }); } // @generated-end } " `); }); + test("inbound has + outbound belongsTo emit card opts only when non-default", async () => { + const out = generateTable( + "teams", + [col("id", "Int8", { generated: true })], + [ + rel("dogs", "dogs", { cardinality: "many", fromColumn: "id", toColumn: "team_id", direction: "inbound" }), + rel("collars", "collars", { cardinality: "one", fromColumn: "id", toColumn: "team_id", direction: "inbound" }), + rel("badge", "badge", { cardinality: "maybe", fromColumn: "id", toColumn: "team_id", direction: "inbound" }), + rel("owner", "users", { + cardinality: "maybe", + fromColumn: "owner_id", + toColumn: "id", + direction: "outbound", + }), + rel("captain", "users", { + cardinality: "one", + fromColumn: "captain_id", + toColumn: "id", + direction: "outbound", + }), + ], + { typeImportPath: "typegres/postgres", dbImport: "../db" }, + ); + await validate(out); + // has defaults to "many" — omit opts + expect(out).toContain(`Relation.has(this, Dogs, { team_id: this.id })`); + expect(out).toContain(`Relation.has(this, Collars, { team_id: this.id }, { card: "one" })`); + expect(out).toContain(`Relation.has(this, Badge, { team_id: this.id }, { card: "maybe" })`); + // belongsTo defaults to "one" — omit opts; nullable outbound passes maybe + expect(out).toContain(`Relation.belongsTo(this, Users, { id: this.owner_id }, { card: "maybe" })`); + expect(out).toContain(`Relation.belongsTo(this, Users, { id: this.captain_id })`); + expect(out).not.toContain(`captain_id }, { card`); + }); + test("column options: nullable, default, generated", async () => { const out = generateTable( "dogs", @@ -86,7 +126,12 @@ describe("generateTable — new file", async () => { describe("generateTable — update mode preserves @expose() state", async () => { const cols: ColumnInfo[] = [col("id", "Int8", { generated: true }), col("name", "Text")]; - const rels: Relation[] = [rel("teams", "teams", { cardinality: "one", fromColumn: "team_id", toColumn: "id" })]; + const rels: Relation[] = [rel("teams", "teams", { + cardinality: "one", + fromColumn: "team_id", + toColumn: "id", + direction: "outbound", + })]; test("entries the user stripped stay stripped on regen", async () => { const existing = `import { db } from "../db"; @@ -112,7 +157,7 @@ export class Dogs extends db.Table("dogs") { id = Int8.column({ nonNull: true, generated: true }); name = Text.column({ nonNull: true }); // relations - teams() { return Teams.scope(Dogs.contextOf(this)).where(({ teams }) => teams.id.eq(this.team_id)).cardinality("one"); } + teams() { return Relation.belongsTo(this, Teams, { id: this.team_id }); } // @generated-end } " @@ -145,7 +190,7 @@ export class Dogs extends db.Table("dogs") { @expose() id = Int8.column({ nonNull: true, generated: true }); @expose() name = Text.column({ nonNull: true }); // relations - @expose() teams() { return Teams.scope(Dogs.contextOf(this)).where(({ teams }) => teams.id.eq(this.team_id)).cardinality("one"); } + @expose() teams() { return Relation.belongsTo(this, Teams, { id: this.team_id }); } // @generated-end } " @@ -176,7 +221,7 @@ export class Dogs extends db.Table("dogs") { @expose() id = Int8.column({ nonNull: true, generated: true }); name = Text.column({ nonNull: true }); // relations - teams() { return Teams.scope(Dogs.contextOf(this)).where(({ teams }) => teams.id.eq(this.team_id)).cardinality("one"); } + teams() { return Relation.belongsTo(this, Teams, { id: this.team_id }); } // @generated-end } " @@ -246,15 +291,19 @@ export class Dogs extends db.Table("dogs") { `); }); - test("update mode does not touch imports / preserves custom comments", async () => { + test("update mode preserves header and post-block methods; only the block changes", async () => { + // Stale imports + a hand-written method after the generated block. + // Header (including comments) is byte-preserved; only the generated interior is rewritten. const existing = `import { db } from "../db"; import { Int8 } from "typegres"; -// my custom comment +// my custom comment (header comments are not preserved) export class Dogs extends db.Table("dogs") { // @generated-start id = Int8.column({ nonNull: true, generated: true }); // @generated-end + + bark() { return "woof"; } } `; const out = generateTable("dogs", [col("id", "Int8", { generated: true })], [], { @@ -266,12 +315,14 @@ export class Dogs extends db.Table("dogs") { expect(out).toMatchInlineSnapshot(` "import { db } from "../db"; import { Int8 } from "typegres"; - // my custom comment + // my custom comment (header comments are not preserved) export class Dogs extends db.Table("dogs") { // @generated-start id = Int8.column({ nonNull: true, generated: true }); // @generated-end + + bark() { return "woof"; } } " `); diff --git a/src/tables/generate.ts b/src/tables/generate.ts index c54d7332..a0119d71 100644 --- a/src/tables/generate.ts +++ b/src/tables/generate.ts @@ -51,8 +51,13 @@ export interface Relation { name: string; targetTable: string; cardinality: Cardinality; + // Parent-side column in the emitted FK map (`this.`). fromColumn: string; + // Target-side column in the emitted FK map (`{ : ... }`). toColumn: string; + // Outbound: this table holds the FK (belongsTo). Inbound: the other + // table holds the FK (hasOne / hasMaybe / hasMany). + direction: "outbound" | "inbound"; } // A snapshot of the schema — pure data, no methods, no DB handle. @@ -127,6 +132,7 @@ export const deriveRelations = (tableName: string, allFks: FkInfo[]): Relation[] cardinality: card, fromColumn: fk.from_column, toColumn: fk.to_column, + direction: "outbound", }); } @@ -139,6 +145,7 @@ export const deriveRelations = (tableName: string, allFks: FkInfo[]): Relation[] cardinality: card, fromColumn: fk.to_column, toColumn: fk.from_column, + direction: "inbound", }); } @@ -164,14 +171,21 @@ const generateColumnLine = (col: ColumnInfo, withTool: boolean): string => { return ` ${prefix}${col.name} = ${cls}.column(${optsArg});`; }; -const generateRelationLine = (rel: Relation, currentTable: string, withTool: boolean): string => { +// Outbound → belongsTo (default card "one"); inbound → has (default "many"). +// Only emit `{ card: ... }` when it differs from the helper's default so +// the common path stays short. +const generateRelationLine = (rel: Relation, withTool: boolean): string => { const targetClass = pgNameToClassName(rel.targetTable); - const currentClass = pgNameToClassName(currentTable); const prefix = withTool ? "@expose() " : ""; - // `Target.scope(Current.contextOf(this))` propagates the row's scope - // tag through every relation traversal — joins n-deep stay bound to - // the same principal. Dialect-neutral (uses names + cardinality only). - return ` ${prefix}${rel.name}() { return ${targetClass}.scope(${currentClass}.contextOf(this)).where(({ ${rel.targetTable} }) => ${rel.targetTable}.${rel.toColumn}.eq(this.${rel.fromColumn})).cardinality("${rel.cardinality}"); }`; + const helper = rel.direction === "outbound" ? "belongsTo" : "has"; + const fkMap = `{ ${rel.toColumn}: this.${rel.fromColumn} }`; + const defaultCard = helper === "belongsTo" ? "one" : "many"; + const optsArg = + rel.cardinality === defaultCard ? "" : `, { card: "${rel.cardinality}" }`; + // `Relation.*(this, Target, { targetCol: this.parentCol }[, { card }])` + // — threads parent context via Target.scope(Current.contextOf(this)) + // under the hood. Single-column FK map (parity with prior emit). + return ` ${prefix}${rel.name}() { return Relation.${helper}(this, ${targetClass}, ${fkMap}${optsArg}); }`; }; // Scan the existing @generated block to learn which columns/relations @@ -209,10 +223,14 @@ const parseExistingDecorations = ( const START_MARKER = "// @generated-start"; const END_MARKER = "// @generated-end"; -// Pure generation entry point — no DB, no fs. `existing` (if provided) -// must contain @generated-start/@generated-end markers; only content -// between them is replaced, and per-entry `@expose()` state is preserved. -// Without `existing`, returns a brand-new full file. +// Pure generation entry point — no DB, no fs. +// +// New file: full skeleton (managed imports + class shell + generated body). +// Update (`existing`): **only** the interior of @generated-start/end is +// rewritten (plus per-entry `@expose()` preservation). Header imports, +// class declaration, and everything after @generated-end are left alone. +// Callers are responsible for imports when the schema gains types/relations +// (tsc will fail until they add them — better than clobbering the header). export const generateTable = ( tableName: string, columns: ColumnInfo[], @@ -220,82 +238,105 @@ export const generateTable = ( opts: { typeImportPath: string; dbImport: string; existing?: string }, ): string => { if (opts.existing !== undefined) { - return updateBlock(opts.existing, tableName, columns, relations); + return updateBlock(opts.existing, columns, relations); } - return newFile(tableName, columns, relations, opts.typeImportPath, opts.dbImport); + return emitNewFile( + tableName, + columns, + relations, + opts.typeImportPath, + opts.dbImport, + ); }; -const newFile = ( +// Managed imports are pure functions of schema — same lines for new and +// update. Dialect types from `typeImportPath`; runtime helpers (`expose`, +// `sql`, `Relation`) from `typegres` (no optional node driver peers). +const buildImportLines = ( tableName: string, columns: ColumnInfo[], relations: Relation[], typeImportPath: string, dbImport: string, -): string => { - const typeClasses = [...new Set(columns.map((c) => c.className))]; +): string[] => { + const typeSyms = [...new Set(columns.map((c) => c.className))].sort(); const hasDefault = columns.some((c) => c.default !== null); - - const relationTargets = [...new Set(relations.map((r) => r.targetTable))]; - const relImports = relationTargets + const runtimeSyms = [ + "expose", + ...(relations.length > 0 ? ["Relation"] : []), + ...(hasDefault ? ["sql"] : []), + ].sort(); + const relImports = [...new Set(relations.map((r) => r.targetTable))] .filter((t) => t !== tableName) + .sort() .map((t) => `import { ${pgNameToClassName(t)} } from "./${t}";`); - // Split imports: dialect-specific type classes come from - // `typeImportPath`; dialect-agnostic runtime helpers (`expose`, - // `sql`) come from `typegres` (no optional node driver peers — - // safe for Workers / Durable Object bundles). Symbols within each - // group sorted for stable diffs. - const runtimeSyms = ["expose", ...(hasDefault ? ["sql"] : [])].sort(); - const typeSyms = [...typeClasses].sort(); - const imports = [ + return [ `import { db } from "${dbImport}";`, `import { ${runtimeSyms.join(", ")} } from "typegres";`, `import { ${typeSyms.join(", ")} } from "${typeImportPath}";`, ...relImports, ]; +}; - // New file: every column/relation gets `@expose()` by default. - const colLines = columns.map((c) => generateColumnLine(c, true)); - const relLines = relations.map((r) => generateRelationLine(r, tableName, true)); - const allLines = relLines.length > 0 +const bodyLines = ( + columns: ColumnInfo[], + relations: Relation[], + withTool: (name: string, kind: "col" | "rel") => boolean, +): string[] => { + const colLines = columns.map((c) => generateColumnLine(c, withTool(c.name, "col"))); + const relLines = relations.map((r) => generateRelationLine(r, withTool(r.name, "rel"))); + return relLines.length > 0 ? [...colLines, " // relations", ...relLines] : colLines; +}; +// Brand-new table file: managed imports + class shell + empty user tail. +const emitNewFile = ( + tableName: string, + columns: ColumnInfo[], + relations: Relation[], + typeImportPath: string, + dbImport: string, +): string => { + const imports = buildImportLines(tableName, columns, relations, typeImportPath, dbImport); + const body = bodyLines(columns, relations, () => true); return `${imports.join("\n")} export class ${pgNameToClassName(tableName)} extends db.Table("${tableName}") { ${START_MARKER} -${allLines.join("\n")} +${body.join("\n")} ${END_MARKER} } `; }; +// Update mode: splice only between the markers. Prefix (imports, comments, +// class line) and suffix (END_MARKER through EOF, including user methods) +// are byte-preserved. `@expose` on/off inside the prior block is preserved. const updateBlock = ( existing: string, - tableName: string, columns: ColumnInfo[], relations: Relation[], ): string => { const startIdx = existing.indexOf(START_MARKER); const endIdx = existing.indexOf(END_MARKER); - - if (startIdx === -1 || endIdx === -1) { + if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) { throw new Error("Missing @generated-start or @generated-end markers"); } - const blockContent = existing.slice(startIdx + START_MARKER.length, endIdx); - const prior = parseExistingDecorations(blockContent); - - const colLines = columns.map((c) => generateColumnLine(c, prior.cols.get(c.name) ?? true)); - const relLines = relations.map((r) => generateRelationLine(r, tableName, prior.rels.get(r.name) ?? true)); - const allLines = relLines.length > 0 - ? [...colLines, " // relations", ...relLines] - : colLines; - const before = existing.slice(0, startIdx + START_MARKER.length); - const after = existing.slice(endIdx); - - return `${before}\n${allLines.join("\n")}\n ${after}`; + const prior = parseExistingDecorations( + existing.slice(startIdx + START_MARKER.length, endIdx), + ); + const body = bodyLines(columns, relations, (name, kind) => + kind === "col" ? (prior.cols.get(name) ?? true) : (prior.rels.get(name) ?? true), + ); + + const prefix = existing.slice(0, startIdx + START_MARKER.length); + const suffix = existing.slice(endIdx); // begins with END_MARKER + // Normalize interior to "\n" + indented lines + "\n " before end marker, + // matching emitNewFile layout regardless of prior whitespace. + return `${prefix}\n${body.join("\n")}\n ${suffix}`; }; // --- Main --- diff --git a/src/tables/postgres.test.ts b/src/tables/postgres.test.ts index 1689af88..d332462d 100644 --- a/src/tables/postgres.test.ts +++ b/src/tables/postgres.test.ts @@ -91,7 +91,7 @@ describe("postgres codegen e2e — DDL in, generated TypeScript out", () => { expect([...files.keys()]).toEqual(["dogs", "teams"]); expect(files.get("dogs")).toMatchInlineSnapshot(` "import { db } from "../db"; - import { expose, sql } from "typegres"; + import { Relation, expose, sql } from "typegres"; import { Int8, Text, Timestamptz } from "typegres/postgres"; import { Teams } from "./teams"; @@ -104,14 +104,14 @@ describe("postgres codegen e2e — DDL in, generated TypeScript out", () => { @expose() created_at = Timestamptz.column({ nonNull: true, default: sql\`now()\` }); @expose() name_upper = Text.column({ generated: true }); // relations - @expose() team() { return Teams.scope(Dogs.contextOf(this)).where(({ teams }) => teams.id.eq(this.team_id)).cardinality("one"); } + @expose() team() { return Relation.belongsTo(this, Teams, { id: this.team_id }); } // @generated-end } " `); expect(files.get("teams")).toMatchInlineSnapshot(` "import { db } from "../db"; - import { expose } from "typegres"; + import { Relation, expose } from "typegres"; import { Int8, Text } from "typegres/postgres"; import { Dogs } from "./dogs"; @@ -120,7 +120,7 @@ describe("postgres codegen e2e — DDL in, generated TypeScript out", () => { @expose() id = Int8.column({ nonNull: true, generated: true }); @expose() name = Text.column({ nonNull: true }); // relations - @expose() dogs() { return Dogs.scope(Teams.contextOf(this)).where(({ dogs }) => dogs.team_id.eq(this.id)).cardinality("many"); } + @expose() dogs() { return Relation.has(this, Dogs, { team_id: this.id }); } // @generated-end } " @@ -139,8 +139,8 @@ describe("postgres codegen e2e — DDL in, generated TypeScript out", () => { ); CREATE UNIQUE INDEX partial_uniq ON partial_child (parent_id) WHERE NOT archived; `); - expect(files.get("parent_t")).toContain(`.cardinality("many")`); - expect(files.get("parent_t")).not.toContain(`.cardinality("one")`); + expect(files.get("parent_t")).toContain(`Relation.has(this, PartialChild`); + expect(files.get("parent_t")).not.toContain(`{ card: "one" }`); }); test("cardinality inference: composite PK/UNIQUE don't count as unique; single-column PK/UNIQUE do", async () => { @@ -172,7 +172,7 @@ describe("postgres codegen e2e — DDL in, generated TypeScript out", () => { `); expect(files.get("parent_t")).toMatchInlineSnapshot(` "import { db } from "../db"; - import { expose } from "typegres"; + import { Relation, expose } from "typegres"; import { Int8 } from "typegres/postgres"; import { Badge } from "./badge"; import { Joiner } from "./joiner"; @@ -183,17 +183,17 @@ describe("postgres codegen e2e — DDL in, generated TypeScript out", () => { // @generated-start @expose() id = Int8.column({ nonNull: true, generated: true }); // relations - @expose() badge() { return Badge.scope(ParentT.contextOf(this)).where(({ badge }) => badge.parent_id.eq(this.id)).cardinality("maybe"); } - @expose() joiner() { return Joiner.scope(ParentT.contextOf(this)).where(({ joiner }) => joiner.a.eq(this.id)).cardinality("many"); } - @expose() profile() { return Profile.scope(ParentT.contextOf(this)).where(({ profile }) => profile.parent_id.eq(this.id)).cardinality("one"); } - @expose() tagged() { return Tagged.scope(ParentT.contextOf(this)).where(({ tagged }) => tagged.parent_id.eq(this.id)).cardinality("many"); } + @expose() badge() { return Relation.has(this, Badge, { parent_id: this.id }, { card: "maybe" }); } + @expose() joiner() { return Relation.has(this, Joiner, { a: this.id }); } + @expose() profile() { return Relation.has(this, Profile, { parent_id: this.id }, { card: "one" }); } + @expose() tagged() { return Relation.has(this, Tagged, { parent_id: this.id }); } // @generated-end } " `); expect(files.get("profile")).toMatchInlineSnapshot(` "import { db } from "../db"; - import { expose } from "typegres"; + import { Relation, expose } from "typegres"; import { Int8 } from "typegres/postgres"; import { ParentT } from "./parent_t"; @@ -201,7 +201,7 @@ describe("postgres codegen e2e — DDL in, generated TypeScript out", () => { // @generated-start @expose() parent_id = Int8.column({ nonNull: true }); // relations - @expose() parent() { return ParentT.scope(Profile.contextOf(this)).where(({ parent_t }) => parent_t.id.eq(this.parent_id)).cardinality("one"); } + @expose() parent() { return Relation.belongsTo(this, ParentT, { id: this.parent_id }); } // @generated-end } " diff --git a/src/tables/sqlite.test.ts b/src/tables/sqlite.test.ts index 9db506bc..03ac182f 100644 --- a/src/tables/sqlite.test.ts +++ b/src/tables/sqlite.test.ts @@ -108,7 +108,7 @@ describe("sqlite codegen e2e — DDL in, generated TypeScript out", () => { expect([...files.keys()]).toEqual(["dogs", "teams"]); expect(files.get("dogs")).toMatchInlineSnapshot(` "import { db } from "../db"; - import { expose, sql } from "typegres"; + import { Relation, expose, sql } from "typegres"; import { Any, Bool, Integer, Text } from "typegres/sqlite"; import { Teams } from "./teams"; @@ -121,14 +121,14 @@ describe("sqlite codegen e2e — DDL in, generated TypeScript out", () => { @expose() active = Bool.column({ nonNull: true, default: sql\`0\` }); @expose() seen_at = Any.column(); // relations - @expose() team() { return Teams.scope(Dogs.contextOf(this)).where(({ teams }) => teams.id.eq(this.team_id)).cardinality("one"); } + @expose() team() { return Relation.belongsTo(this, Teams, { id: this.team_id }); } // @generated-end } " `); expect(files.get("teams")).toMatchInlineSnapshot(` "import { db } from "../db"; - import { expose } from "typegres"; + import { Relation, expose } from "typegres"; import { Integer, Text } from "typegres/sqlite"; import { Dogs } from "./dogs"; @@ -137,7 +137,7 @@ describe("sqlite codegen e2e — DDL in, generated TypeScript out", () => { @expose() id = Integer.column({ nonNull: true, generated: true }); @expose() name = Text.column({ nonNull: true }); // relations - @expose() dogs() { return Dogs.scope(Teams.contextOf(this)).where(({ dogs }) => dogs.team_id.eq(this.id)).cardinality("many"); } + @expose() dogs() { return Relation.has(this, Dogs, { team_id: this.id }); } // @generated-end } " @@ -166,8 +166,8 @@ describe("sqlite codegen e2e — DDL in, generated TypeScript out", () => { CREATE UNIQUE INDEX expr_uniq ON expr_child (lower(name)); `); const parent = files.get("parent_t")!; - expect(parent).toContain(`partial_child() { return PartialChild.scope(ParentT.contextOf(this)).where(({ partial_child }) => partial_child.parent_id.eq(this.id)).cardinality("many"); }`); - expect(parent).toContain(`expr_child() { return ExprChild.scope(ParentT.contextOf(this)).where(({ expr_child }) => expr_child.parent_id.eq(this.id)).cardinality("many"); }`); + expect(parent).toContain(`partial_child() { return Relation.has(this, PartialChild, { parent_id: this.id }); }`); + expect(parent).toContain(`expr_child() { return Relation.has(this, ExprChild, { parent_id: this.id }); }`); }); test("cardinality inference: composite PK/UNIQUE don't count as unique; single-column PK/UNIQUE do", async () => { @@ -199,7 +199,7 @@ describe("sqlite codegen e2e — DDL in, generated TypeScript out", () => { `); expect(files.get("parent_t")).toMatchInlineSnapshot(` "import { db } from "../db"; - import { expose } from "typegres"; + import { Relation, expose } from "typegres"; import { Integer } from "typegres/sqlite"; import { Badge } from "./badge"; import { Joiner } from "./joiner"; @@ -210,10 +210,10 @@ describe("sqlite codegen e2e — DDL in, generated TypeScript out", () => { // @generated-start @expose() id = Integer.column({ nonNull: true, generated: true }); // relations - @expose() badge() { return Badge.scope(ParentT.contextOf(this)).where(({ badge }) => badge.parent_id.eq(this.id)).cardinality("maybe"); } - @expose() joiner() { return Joiner.scope(ParentT.contextOf(this)).where(({ joiner }) => joiner.a.eq(this.id)).cardinality("many"); } - @expose() profile() { return Profile.scope(ParentT.contextOf(this)).where(({ profile }) => profile.parent_id.eq(this.id)).cardinality("one"); } - @expose() tagged() { return Tagged.scope(ParentT.contextOf(this)).where(({ tagged }) => tagged.parent_id.eq(this.id)).cardinality("many"); } + @expose() badge() { return Relation.has(this, Badge, { parent_id: this.id }, { card: "maybe" }); } + @expose() joiner() { return Relation.has(this, Joiner, { a: this.id }); } + @expose() profile() { return Relation.has(this, Profile, { parent_id: this.id }, { card: "one" }); } + @expose() tagged() { return Relation.has(this, Tagged, { parent_id: this.id }); } // @generated-end } " @@ -223,7 +223,7 @@ describe("sqlite codegen e2e — DDL in, generated TypeScript out", () => { // non-null, so the outbound relation must be "one", not "maybe". expect(files.get("profile")).toMatchInlineSnapshot(` "import { db } from "../db"; - import { expose } from "typegres"; + import { Relation, expose } from "typegres"; import { Integer } from "typegres/sqlite"; import { ParentT } from "./parent_t"; @@ -231,7 +231,7 @@ describe("sqlite codegen e2e — DDL in, generated TypeScript out", () => { // @generated-start @expose() parent_id = Integer.column({ nonNull: true, generated: true }); // relations - @expose() parent() { return ParentT.scope(Profile.contextOf(this)).where(({ parent_t }) => parent_t.id.eq(this.parent_id)).cardinality("one"); } + @expose() parent() { return Relation.belongsTo(this, ParentT, { id: this.parent_id }); } // @generated-end } "