From eaf26cfbb07ae3915b50ffd3b935d6d3c89c3c22 Mon Sep 17 00:00:00 2001 From: David Nahodyl Date: Thu, 16 Jul 2026 16:00:25 -0400 Subject: [PATCH 1/5] added aggreagtes --- docs/content/2.models/2.retrieving.md | 24 ++++++++++++++- src/model/Builder.ts | 23 ++++++++++++++ src/model/StaticForwarder.ts | 22 ++++++++++++++ test/tests/Base.ts | 44 +++++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 1 deletion(-) diff --git a/docs/content/2.models/2.retrieving.md b/docs/content/2.models/2.retrieving.md index 937e7bb..9124fbd 100644 --- a/docs/content/2.models/2.retrieving.md +++ b/docs/content/2.models/2.retrieving.md @@ -122,12 +122,34 @@ The `paginate` method returns an object with the following properties: ## Aggregates -The query builder also provides a variety of aggregate methods such as `count`, `max`, and `sum`. +The query builder also provides a variety of aggregate methods: `count`, `max`, `min`, `avg`, and `sum`. Each of these executes the query immediately and can be combined with any other query constraints. ```ts const count = await User.count(); const max = await User.max("price"); + +const min = await User.min("price"); + +const avg = await User.avg("price"); + +const sum = await Order.where("status", "=", "paid").sum("total"); +``` + +`count` and `sum` return `0` when no records match, while `max`, `min`, and `avg` return `null`. + +### Determining if Records Exist + +Instead of counting records, you can use the `exists` and `doesntExist` methods to efficiently check whether any records match the query's constraints: + +```ts +if (await Order.where("status", "=", "pending").exists()) { + // ... +} + +if (await Order.where("user_id", "=", 1).doesntExist()) { + // ... +} ``` ## Selecting Columns diff --git a/src/model/Builder.ts b/src/model/Builder.ts index 4986d4a..cab39a9 100644 --- a/src/model/Builder.ts +++ b/src/model/Builder.ts @@ -423,6 +423,29 @@ export class Builder { + const value = await this.aggregate((eb) => eb.fn.min(column)); + return value !== null && value !== undefined ? Number(value) : null; + } + + async avg(column: keyof M["attributes"] & string): Promise { + const value = await this.aggregate((eb) => eb.fn.avg(column)); + return value !== null && value !== undefined ? Number(value) : null; + } + + /** Returns true if any record matches the accumulated constraints. */ + async exists(): Promise { + const { db, table } = this.modelMeta(); + const query = this.applyConstraints(db.selectFrom(table).select((eb: any) => eb.lit(1).as("value"))).limit(1); + const result = await query.executeTakeFirst(); + return result !== undefined; + } + + /** Returns true if no records match the accumulated constraints. */ + async doesntExist(): Promise { + return !(await this.exists()); + } + async paginate( perPage: number = 15, page: number = 1, diff --git a/src/model/StaticForwarder.ts b/src/model/StaticForwarder.ts index 49dae53..c9563fd 100644 --- a/src/model/StaticForwarder.ts +++ b/src/model/StaticForwarder.ts @@ -125,6 +125,28 @@ export abstract class StaticForwarder { return (this as any).query().max(column); } + static async min( + this: T, + column: keyof Inst["attributes"] & string, + ): Promise { + return (this as any).query().min(column); + } + + static async avg( + this: T, + column: keyof Inst["attributes"] & string, + ): Promise { + return (this as any).query().avg(column); + } + + static async exists(this: T): Promise { + return (this as any).query().exists(); + } + + static async doesntExist(this: T): Promise { + return (this as any).query().doesntExist(); + } + static async paginate( this: T, perPage?: number, diff --git a/test/tests/Base.ts b/test/tests/Base.ts index a0e82c9..6837b9e 100644 --- a/test/tests/Base.ts +++ b/test/tests/Base.ts @@ -589,6 +589,50 @@ describe("aggregates", () => { const max = await Pet.max("counter"); expect(max).toBeGreaterThanOrEqual(0); }); + + it("should return null for max on an empty result set", async () => { + const max = await Pet.where("id", "=", -999).max("counter"); + expect(max).toBeNull(); + }); + + it("should get the min value", async () => { + const min = await Pet.min("counter"); + expect(min).not.toBeNull(); + expect(min).toBeGreaterThanOrEqual(0); + + const max = await Pet.max("counter"); + expect(min).toBeLessThanOrEqual(Number(max)); + }); + + it("should return null for min on an empty result set", async () => { + const min = await Pet.where("id", "=", -999).min("counter"); + expect(min).toBeNull(); + }); + + it("should get the average value", async () => { + const avg = await Pet.avg("counter"); + expect(avg).not.toBeNull(); + + const sum = await Pet.sum("counter"); + const count = await Pet.count(); + expect(avg).toBeCloseTo(sum / count); + }); + + it("should return null for avg on an empty result set", async () => { + const avg = await Pet.where("id", "=", -999).avg("counter"); + expect(avg).toBeNull(); + }); + + it("should check if records exist", async () => { + expect(await Pet.exists()).toBe(true); + expect(await Pet.where("name", "=", "Zuko").exists()).toBe(true); + expect(await Pet.where("id", "=", -999).exists()).toBe(false); + }); + + it("should check if records don't exist", async () => { + expect(await Pet.doesntExist()).toBe(false); + expect(await Pet.where("id", "=", -999).doesntExist()).toBe(true); + }); }); describe("save", () => { From bd61b390a36dcabfb46fd6af966bd63ac8036b3a Mon Sep 17 00:00:00 2001 From: David Nahodyl Date: Thu, 16 Jul 2026 16:37:26 -0400 Subject: [PATCH 2/5] count relationships --- docs/content/2.models/5.relationships.md | 27 +++++++ src/model/Builder.ts | 80 +++++++++++++++++++++ src/model/StaticForwarder.ts | 10 +++ test/database/models/Pet.ts | 6 ++ test/tests/Base.ts | 91 ++++++++++++++++++++++++ vitest.config.ts | 2 + 6 files changed, 216 insertions(+) diff --git a/docs/content/2.models/5.relationships.md b/docs/content/2.models/5.relationships.md index eb9d228..e2b7cbf 100644 --- a/docs/content/2.models/5.relationships.md +++ b/docs/content/2.models/5.relationships.md @@ -88,6 +88,33 @@ You can even combine multiple eager loads with and without constraints in the sa const people = await Person.with("vehicles", { pets: (query) => query.where("type", "dog") }).get(); ``` +### Counting Related Records + +If you only need the number of related records, use `withCount` instead of loading the full relationship. Each result gets a `{relation}Count` attribute containing the count: + +```ts +// Get people with a count of their pets +const people = await Person.withCount("pets").get(); + +people[0].petsCount; // number +``` + +The count is computed in the same query as the main results using a subquery, so no extra queries are run. + +You can count multiple relationships at once, and add constraints to the counting query by passing an object mapping the relationship name to a callback: + +```ts +// Count all visits, plus only the visits to vet #1 +const pets = await Pet.withCount("vets", { + vetVisits: (query) => query.where("vet_id", 1), +}).get(); + +pets[0].vetsCount; // number +pets[0].vetVisitsCount; // number of visits matching the constraint +``` + +Counts are included when serializing the model with `toJSON()`. + ## Many-to-Many Relationships You can define many-to-many relationships using the `belongsToMany` method. This requires a pivot table (or join table) to link the two models together. diff --git a/src/model/Builder.ts b/src/model/Builder.ts index cab39a9..1ffc8f3 100644 --- a/src/model/Builder.ts +++ b/src/model/Builder.ts @@ -101,6 +101,18 @@ export type WithConstraints = { [K in RelationKeys]?: M[K] extends RelationBuilder ? (query: Builder) => void : never; }; +/** Constraint callbacks for withCount(), keyed by relation name. */ +export type CountConstraints = { + [K in RelationKeys]?: M[K] extends RelationBuilder ? (query: Builder) => void : never; +}; + +/** The relation names counted by a withCount() call: string arguments plus constraint object keys. */ +export type CountedRelations = A extends string ? A : keyof A & string; + +/** A model type augmented with the `${relation}Count` attributes added by withCount(). */ +export type WithCounted = M & + Record<`${R}Count`, number> & { attributes: Record<`${R}Count`, number> }; + export class Builder { protected constraints: Constraint[] = []; protected joinConstraints: JoinConstraint[] = []; @@ -112,6 +124,7 @@ export class Builder) => void }[] = []; + protected relationCounts: { relation: string; constraint?: (query: Builder) => void }[] = []; protected limitValue?: number; protected offsetValue?: number; protected orderings: { column: any; direction: "asc" | "desc" }[] = []; @@ -216,6 +229,69 @@ export class Builder | CountConstraints)[]>( + ...relations: Args + ): Builder>, S> { + for (const relation of relations) { + if (typeof relation === "string") { + this.relationCounts.push({ relation }); + } else { + for (const [key, constraint] of Object.entries(relation as Record)) { + this.relationCounts.push({ relation: key, constraint }); + } + } + } + // We must cast here because we are technically changing the builder's type signature + return this as unknown as Builder>, S>; + } + + /** Reads a relation's metadata by instantiating a throwaway model and accessing the relation getter. */ + protected relationMeta(relation: string): RelationMetadata { + const dummy = new (this.modelConstructor as any)({}); + const relationBuilder = dummy[relation] as RelationBuilder | undefined; + if (!relationBuilder?.relationMetadata) { + throw new Error(`Relation '${relation}' is not properly defined or does not return a RelationBuilder.`); + } + return relationBuilder.relationMetadata; + } + + /** Builds a correlated subquery counting related records, aliased as `${relation}Count`. */ + private relationCountSelect(eb: any, relation: string, constraint?: (query: Builder) => void): any { + const meta = this.relationMeta(relation); + const { table } = this.modelMeta(); + const relatedTable = new (meta.relatedClass as any)({}).table; + const alias = `${relation}Count`; + + let subquery: any; + if (meta.type === "belongsToMany") { + subquery = eb + .selectFrom(meta.pivotTable!) + .innerJoin( + relatedTable, + `${meta.pivotTable!}.${meta.relatedPivotKey!}`, + `${relatedTable}.${meta.matchRelatedKey}`, + ) + .whereRef(`${meta.pivotTable!}.${meta.foreignPivotKey!}`, "=", `${table}.${meta.matchThisKey}`); + } else { + subquery = eb + .selectFrom(relatedTable) + .whereRef(`${relatedTable}.${meta.matchRelatedKey}`, "=", `${table}.${meta.matchThisKey}`); + } + + if (constraint) { + const constraintBuilder = new Builder(meta.relatedClass); + constraint(constraintBuilder); + subquery = constraintBuilder.applyConstraints(subquery); + } + + // Cast to integer so drivers that return bigint counts as strings still produce numbers + return subquery.select((seb: any) => seb.cast(seb.fn.countAll(), "integer").as(alias)).as(alias); + } + protected async eagerLoad(models: any[]): Promise { if (models.length === 0 || this.eagerLoads.length === 0) { return; @@ -342,6 +418,10 @@ export class Builder this.relationCountSelect(eb, relation, constraint)) as any; + } + query = this.applyConstraints(query); for (const order of this.orderings) { diff --git a/src/model/StaticForwarder.ts b/src/model/StaticForwarder.ts index c9563fd..65a0d54 100644 --- a/src/model/StaticForwarder.ts +++ b/src/model/StaticForwarder.ts @@ -13,6 +13,9 @@ import { WhereShorthandValue, WhereValue, WithConstraints, + CountConstraints, + CountedRelations, + WithCounted, PrimaryKeyValue as ModelPrimaryKeyValue, } from "./Builder.js"; @@ -256,4 +259,11 @@ export abstract class StaticForwarder { ): Q { return (this as any).query().with(...relations); } + + static withCount> | CountConstraints>)[]>( + this: T, + ...relations: Args + ): Builder, CountedRelations>> { + return (this as any).query().withCount(...relations); + } } diff --git a/test/database/models/Pet.ts b/test/database/models/Pet.ts index 0f3a897..f6e05eb 100644 --- a/test/database/models/Pet.ts +++ b/test/database/models/Pet.ts @@ -1,6 +1,7 @@ import { defineModel, RequireSelected } from "vasta-orm"; import Person from "@/database/models/Person"; import Vet from "@/database/models/Vet"; +import VetVisit from "@/database/models/VetVisit"; import db from "@/database/db"; type Requires = RequireSelected; @@ -24,6 +25,11 @@ export default class Pet extends defineModel({ return this.belongsToMany(Vet, "vet_visits", "pet_id", "vet_id"); } + // A Pet has many VetVisits + get vetVisits() { + return this.hasMany(VetVisit, "pet_id", "id"); + } + // Restrict 'this' to require the 'counter' attribute incrementCounter(this: Requires<"counter">) { this.attributes.counter += 1; diff --git a/test/tests/Base.ts b/test/tests/Base.ts index 6837b9e..b2c60ae 100644 --- a/test/tests/Base.ts +++ b/test/tests/Base.ts @@ -1329,6 +1329,97 @@ describe("relationships", () => { }); }); +describe("withCount", () => { + it("should count hasMany related records", async () => { + resetQueryCount(); + const pets = await Pet.withCount("vetVisits").orderBy("id", "asc").limit(3).get(); + + expect(pets).toHaveLength(3); + expect(pets[0].vetVisitsCount).toBe(2); + expect(pets[1].vetVisitsCount).toBe(1); + expect(pets[2].vetVisitsCount).toBe(3); + expect(pets[0].attributes.vetVisitsCount).toBe(2); + + // Counts are added as a subquery, so this should be a single query + expect(getQueryCount()).toBe(1); + }); + + it("should count belongsToMany related records", async () => { + const pets = await Pet.withCount("vets").orderBy("id", "asc").limit(2).get(); + + expect(pets[0].vetsCount).toBe(2); + expect(pets[1].vetsCount).toBe(1); + }); + + it("should count belongsTo related records", async () => { + const pet = await Pet.withCount("owner").findOrFail(1); + expect(pet.ownerCount).toBe(1); + }); + + it("should count multiple relations at once", async () => { + const pet = await Pet.withCount("vetVisits", "vets").findOrFail(1); + expect(pet.vetVisitsCount).toBe(2); + expect(pet.vetsCount).toBe(2); + }); + + it("should count related records with constraints", async () => { + const pets = await Pet.withCount({ vetVisits: (query) => query.where("vet_id", 1) }) + .orderBy("id", "asc") + .limit(3) + .get(); + + expect(pets[0].vetVisitsCount).toBe(1); + expect(pets[1].vetVisitsCount).toBe(1); + expect(pets[2].vetVisitsCount).toBe(0); + }); + + it("should mix relation names and constraint objects in one call", async () => { + const pet = await Pet.withCount("vets", { vetVisits: (query) => query.where("vet_id", 2) }).findOrFail(1); + + expect(pet.vetsCount).toBe(2); + expect(pet.vetVisitsCount).toBe(1); + }); + + it("should work alongside eager loading", async () => { + const pet = await Pet.with("vets").withCount("vetVisits").findOrFail(1); + + expect(pet.vetVisitsCount).toBe(2); + expect(pet.loadedRelations.vets).toHaveLength(2); + }); + + it("should work alongside where constraints", async () => { + const pet = await Pet.query().where("id", 3).withCount("vetVisits").first(); + + expectToBeDefined(pet); + expect(pet.vetVisitsCount).toBe(3); + }); + + it("should include counts in serialization", async () => { + const pet = await Pet.withCount("vetVisits").findOrFail(1); + const json = pet.toJSON(); + + expect(json.vetVisitsCount).toBe(2); + }); + + it("should not mark models dirty or interfere with saving", async () => { + const pet = await Pet.withCount("vetVisits").findOrFail(1); + const originalCounter = pet.attributes.counter; + + pet.attributes.counter += 1; + await pet.save(); + + const refreshed = await Pet.findOrFail(1); + expect(refreshed.attributes.counter).toBe(originalCounter + 1); + }); + + it("should throw when counting an invalid relation", async () => { + // @ts-expect-error test invalid relation count + await expect(Pet.withCount("invalidRelation").get()).rejects.toThrow( + "Relation 'invalidRelation' is not properly defined or does not return a RelationBuilder.", + ); + }); +}); + describe("serialization", () => { it("should serialize a model to JSON", async () => { const pet = await Pet.findOrFail(1); diff --git a/vitest.config.ts b/vitest.config.ts index 43f9ba0..b5946f0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,6 +8,8 @@ export default defineConfig({ include: ["./**/*.ts"], typecheck: { tsconfig: "./test/tsconfig.json", + // Type-check the regular test files too, not just *.test-d.ts files + include: ["./**/*.ts"], }, }, resolve: { From 0b2aaee76d6f21a8ab0acd2e8301ef950b7cefa7 Mon Sep 17 00:00:00 2001 From: David Nahodyl Date: Fri, 17 Jul 2026 16:30:37 -0400 Subject: [PATCH 3/5] added transactions --- docs/content/2.models/8.transactions.md | 119 ++++++++++++++ src/model/Builder.ts | 31 +++- src/model/Model.ts | 30 +++- src/model/StaticForwarder.ts | 39 ++++- src/model/defineModel.ts | 5 +- test/tests/Transactions.ts | 200 ++++++++++++++++++++++++ 6 files changed, 408 insertions(+), 16 deletions(-) create mode 100644 docs/content/2.models/8.transactions.md create mode 100644 test/tests/Transactions.ts diff --git a/docs/content/2.models/8.transactions.md b/docs/content/2.models/8.transactions.md new file mode 100644 index 0000000..8f5a60c --- /dev/null +++ b/docs/content/2.models/8.transactions.md @@ -0,0 +1,119 @@ +--- +title: Transactions +description: Wrapping model activity in database transactions with Vasta. +navigation: + icon: i-lucide-git-commit-horizontal +seo: + description: Learn how to wrap model activity in database transactions with Vasta. + title: Transactions +--- + +## Running a Transaction + +Start a transaction with Kysely's `db.transaction().execute()` (or the [`Model.transaction`](#modeltransaction) shorthand) and pass the transaction object to the model operations that should run inside it. The transaction commits when the callback resolves and rolls back if it throws. + +More documentation on Kysely transactions is available in the [Kysely docs](https://kysely.dev/docs/examples/transactions/simple-transaction). + +```ts +import db from "./database/db"; + +await db.transaction().execute(async (trx) => { + const person = await Person.create( + { name: "Aang", birthday: new Date("1993-01-01") }, + trx, + ); + + const pet = new Pet({ name: "Appa", type: "bison", person_id: person.id }); + await pet.save(trx); +}); +``` + +## Queries + +Pass the transaction to `query()` to run a query on it. Everything chained from the builder runs on the transaction. + +```ts +await db.transaction().execute(async (trx) => { + const pets = await Pet.query(trx).where("type", "cat").get(); + const owner = await Person.query(trx).find(1); +}); +``` + +You can also bind a connection to an existing builder chain with `useConnection`. + +```ts +const query = Pet.query().where("type", "cat"); +const pets = await query.useConnection(trx).get(); +``` + +## Models Remember Their Connection + +Models loaded through a transaction-bound query keep that connection. Calling `save`, `delete`, or accessing relations on them stays inside the transaction. You don't need to pass the transaction again for subsequent operations. + +```ts +await db.transaction().execute(async (trx) => { + const pet = await Pet.query(trx).firstOrFail(); + + pet.name = "Momo"; + await pet.save(); // runs on trx + + const owner = await pet.owner; // loaded through trx +}); +``` + +The same applies to models returned by `create(attributes, trx)` and to eager-loaded relations (`Pet.query(trx).with("owner")`). + +For a model instance that isn't already bound to a transaction, either pass the transaction to `save`/`delete` directly or bind it with `useConnection`: + +```ts +const pet = new Pet({ name: "Hawky", type: "hawk" }); + +await pet.save(trx); +// or +await pet.useConnection(trx).save(); +``` + +## Creating + +`create` and `createMany` accept a connection as their second argument. + +```ts +await db.transaction().execute(async (trx) => { + await Pet.create({ name: "Momo", type: "lemur" }, trx); + + await Pet.create( + [ + { name: "Appa", type: "bison" }, + { name: "Hawky", type: "hawk" }, + ], + trx, + ); +}); +``` + +## Model.transaction + +As a shorthand, you can start a transaction directly from any model without importing the database instance. The transaction runs on that model's database and can be used with any model on the same database. + +```ts +await Person.transaction(async (trx) => { + const person = await Person.create({ name: "Katara" }, trx); + await Pet.create({ name: "Momo", type: "lemur", person_id: person.id }, trx); +}); +``` + +## Raw Kysely Queries + +The transaction is a regular Kysely `Transaction`, so raw queries can run on it alongside model activity. + +```ts +await db.transaction().execute(async (trx) => { + await Person.create({ name: "Toph" }, trx); + + await trx + .updateTable("people") + .set({ favorite_color: "green" }) + .where("name", "=", "Toph") + .execute(); +}); +``` diff --git a/src/model/Builder.ts b/src/model/Builder.ts index 1ffc8f3..fc5ac73 100644 --- a/src/model/Builder.ts +++ b/src/model/Builder.ts @@ -128,8 +128,25 @@ export class Builder; - constructor(protected modelConstructor: AnyModelConstructor) {} + constructor( + protected modelConstructor: AnyModelConstructor, + connection?: Kysely, + ) { + this.connection = connection; + } + + /** + * Sets the connection (e.g. a transaction) the query runs on instead of the model's + * configured db. Models returned by the query keep the connection, so save, delete, + * and relations on them stay on the same connection. + */ + useConnection(connection: Kysely> | undefined): this { + this.connection = connection; + return this; + } where(expression: ExpressionArg): this; where>( column: Column, @@ -324,7 +341,7 @@ export class Builder; table: string; primaryKey: string } { const dummy = new (this.modelConstructor as any)({}); - return { db: dummy.db, table: dummy.table, primaryKey: dummy.primaryKey }; + return { db: this.connection ?? dummy.db, table: dummy.table, primaryKey: dummy.primaryKey }; } /** Applies all accumulated where-constraints to a Kysely select query. */ @@ -446,6 +463,7 @@ export class Builder; }); @@ -465,6 +483,7 @@ export class Builder; @@ -648,7 +667,9 @@ export class RelationBuilder extends Builder implemen private instance: any, // The parent model instance public relationMetadata: RelationMetadata, ) { - super(modelConstructor); + // Relations run on the parent model's connection, so a model loaded in a + // transaction reads its relations through the same transaction. + super(modelConstructor, instance?.connection); } public _markClean() { diff --git a/src/model/Model.ts b/src/model/Model.ts index da4ec22..93208e0 100644 --- a/src/model/Model.ts +++ b/src/model/Model.ts @@ -122,6 +122,14 @@ export abstract class Model< exists = false; loadedRelations: Record = {}; + /** + * Connection override (e.g. a transaction) used instead of the configured db. + * Set automatically on models loaded through a connection-bound query, so + * save, delete, and relations stay on the same connection. + * Initialized explicitly so the property exists on the instance for the attributes proxy. + */ + connection: Kysely | undefined = undefined; + constructor(attributes: Partial> = {}, isNew = true) { super(); @@ -144,6 +152,15 @@ export abstract class Model< return createModelProxy(this); } + /** + * Sets the connection (e.g. a transaction) used for save, delete, and relations + * instead of the model's configured db. Pass undefined to restore the default. + */ + useConnection(connection: Kysely | undefined): this { + this.connection = connection; + return this; + } + assign(attributes: Partial>): this { this.setRawAttributes(attributes); this.applyMutators(Object.keys(attributes)); @@ -232,7 +249,8 @@ export abstract class Model< await this.events[eventName]?.(this); } - async save(): Promise { + async save(connection?: Kysely): Promise { + const db = connection ?? this.db; const pkValue = this.attributes[this.primaryKey as unknown as keyof typeof this.attributes]; const isNewModel = !this.exists; @@ -256,7 +274,7 @@ export abstract class Model< } // UPDATE - const query = (this.db as any) + const query = (db as any) .updateTable(this.table) .set(dirtyAttributes as any) .where(this.primaryKey as any, "=", pkValue); @@ -267,7 +285,7 @@ export abstract class Model< await this.dispatchEvent("updated"); } else { // INSERT - const result = await (this.db as any) + const result = await (db as any) .insertInto(this.table) .values(this.getRawAttributes() as any) .returningAll() @@ -286,15 +304,17 @@ export abstract class Model< return this; } - async delete(): Promise { + async delete(connection?: Kysely): Promise { if (!this.exists) { throw new Error("Cannot delete a model that doesn't exist in the database"); } + const db = connection ?? this.db; + await this.dispatchEvent("deleting"); const pkValue = this.attributes[this.primaryKey as unknown as keyof typeof this.attributes]; - const result = await (this.db as any) + const result = await (db as any) .deleteFrom(this.table) .where(this.primaryKey as any, "=", pkValue) .executeTakeFirst(); diff --git a/src/model/StaticForwarder.ts b/src/model/StaticForwarder.ts index 65a0d54..1fea907 100644 --- a/src/model/StaticForwarder.ts +++ b/src/model/StaticForwarder.ts @@ -1,10 +1,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { ComparisonOperatorExpression } from "kysely"; +import { ComparisonOperatorExpression, Kysely, Transaction } from "kysely"; import { Builder, ColumnArg, ExpressionArg, + ExtractDB, ModelExpressionBuilder, RelationKeys, Selection, @@ -34,10 +35,16 @@ export type PrimaryKeyValue = ModelPrimaryKeyValu */ type Inst = InstanceType; type Q = Builder, S>; +/** A connection a query can run on: the model's db or a transaction started from it. */ +type Connection = Kysely>>; export abstract class StaticForwarder { - static query(this: T): Q { - return new Builder(this as any); + /** + * Starts a query, optionally on the given connection (e.g. a transaction) instead + * of the model's configured db. Models returned by the query keep the connection. + */ + static query(this: T, connection?: Connection): Q { + return new Builder(this as any, connection); } static where(this: T, expression: ExpressionArg>): Q; @@ -192,19 +199,23 @@ export abstract class StaticForwarder { static async create( this: T, attributes: ConstructorParameters[0], + connection?: Connection, ): Promise>; static async create( this: T, attributes: ConstructorParameters[0][], + connection?: Connection, ): Promise[]>; static async create( this: T, attributes: ConstructorParameters[0] | ConstructorParameters[0][], + connection?: Connection, ): Promise | Inst[]> { if (Array.isArray(attributes)) { - return (this as any).createMany(attributes); + return (this as any).createMany(attributes, connection); } const instance = new (this as any)(attributes); + if (connection) instance.useConnection(connection); await instance.save(); return instance; } @@ -212,8 +223,13 @@ export abstract class StaticForwarder { static async createMany( this: T, attributes: ConstructorParameters[0][], + connection?: Connection, ): Promise[]> { - const instances = attributes.map((attrs) => new (this as any)(attrs)) as Inst[]; + const instances = attributes.map((attrs) => { + const instance = new (this as any)(attrs); + if (connection) instance.useConnection(connection); + return instance; + }) as Inst[]; if (instances.length === 0) { return instances; } @@ -246,6 +262,19 @@ export abstract class StaticForwarder { return instances; } + /** + * Starts a transaction on this model's database and runs the callback with it. + * Pass the transaction to queries, saves, and creates that should run inside it. + * Commits when the callback resolves; rolls back if it throws. + */ + static async transaction( + this: T, + callback: (trx: Transaction>>) => Promise, + ): Promise { + const dummy = new (this as any)({}); + return dummy.db.transaction().execute(callback); + } + static select>>( this: T, columns: K[] | ((eb: ModelExpressionBuilder>) => K[]), diff --git a/src/model/defineModel.ts b/src/model/defineModel.ts index d007a84..974edc8 100644 --- a/src/model/defineModel.ts +++ b/src/model/defineModel.ts @@ -127,7 +127,10 @@ export function defineModel< type DefaultedInsertable = Pick, DefaultedInsertableKeys>; abstract class BaseModel extends Model { - db = config.db; + // Resolved on every access so a connection set on the instance (e.g. a transaction) wins + get db(): Kysely { + return this.connection ?? config.db; + } table = config.table; // Fallback to "id" if not provided, explicitly cast to keep TypeScript happy primaryKey = (config.primaryKey ?? "id") as PK; diff --git a/test/tests/Transactions.ts b/test/tests/Transactions.ts new file mode 100644 index 0000000..1a1156e --- /dev/null +++ b/test/tests/Transactions.ts @@ -0,0 +1,200 @@ +import { describe, it, expect } from "vitest"; + +import Pet from "@/database/models/Pet"; +import Person from "@/database/models/Person"; +import db from "@/database/db"; + +describe("transactions", () => { + it("commits model activity when the callback resolves", async () => { + await db.transaction().execute(async (trx) => { + await Pet.create({ name: "TrxCommit", type: "cat" }, trx); + }); + + const pet = await Pet.where("name", "TrxCommit").first(); + expect(pet).toBeDefined(); + await pet?.delete(); + }); + + it("rolls back created models when the callback throws", async () => { + await expect( + db.transaction().execute(async (trx) => { + await Pet.create({ name: "TrxRollback", type: "cat" }, trx); + + // The insert is visible inside the transaction + const inside = await Pet.query(trx).where("name", "TrxRollback").first(); + expect(inside).toBeDefined(); + + // ...but not outside of it + const outside = await Pet.where("name", "TrxRollback").first(); + expect(outside).toBeUndefined(); + + throw new Error("abort"); + }), + ).rejects.toThrowError("abort"); + + const pet = await Pet.where("name", "TrxRollback").first(); + expect(pet).toBeUndefined(); + }); + + it("runs save and delete on a connection passed directly", async () => { + const existing = await Pet.create({ name: "TrxVictim", type: "dog" }); + + await expect( + db.transaction().execute(async (trx) => { + existing.attributes.name = "TrxVictimRenamed"; + await existing.save(trx); + + const fresh = new Pet({ name: "TrxFresh", type: "cat" }); + await fresh.save(trx); + + throw new Error("abort"); + }), + ).rejects.toThrowError("abort"); + + expect(await Pet.where("name", "TrxVictim").first()).toBeDefined(); + expect(await Pet.where("name", "TrxVictimRenamed").first()).toBeUndefined(); + expect(await Pet.where("name", "TrxFresh").first()).toBeUndefined(); + + await existing.delete(); + }); + + it("keeps models loaded through a transaction on that transaction", async () => { + const victim = await Pet.create({ name: "TrxSticky", type: "dog" }); + + await expect( + db.transaction().execute(async (trx) => { + const pet = await Pet.query(trx).where("name", "TrxSticky").firstOrFail(); + + // The model remembers the connection it was loaded through + pet.attributes.name = "TrxStickyRenamed"; + await pet.save(); + await pet.delete(); + + throw new Error("abort"); + }), + ).rejects.toThrowError("abort"); + + // Both the rename and the delete were rolled back + expect(await Pet.where("name", "TrxSticky").first()).toBeDefined(); + + await victim.delete(); + }); + + it("binds a connection to a new model with useConnection", async () => { + await expect( + db.transaction().execute(async (trx) => { + const pet = new Pet({ name: "TrxUseConnection", type: "cat" }); + await pet.useConnection(trx).save(); + throw new Error("abort"); + }), + ).rejects.toThrowError("abort"); + + expect(await Pet.where("name", "TrxUseConnection").first()).toBeUndefined(); + }); + + it("rolls back createMany", async () => { + await expect( + db.transaction().execute(async (trx) => { + await Pet.create( + [ + { name: "TrxBulk1", type: "cat" }, + { name: "TrxBulk2", type: "cat" }, + ], + trx, + ); + throw new Error("abort"); + }), + ).rejects.toThrowError("abort"); + + expect(await Pet.where("name", "TrxBulk1").first()).toBeUndefined(); + expect(await Pet.where("name", "TrxBulk2").first()).toBeUndefined(); + }); + + it("runs relations and eager loads on the parent model's connection", async () => { + await expect( + db.transaction().execute(async (trx) => { + const person = await Person.create({ name: "TrxOwner", birthday: new Date("2000-01-01") }, trx); + await Pet.create({ name: "TrxOwned", type: "cat", person_id: person.id }, trx); + + // Lazy relation on a trx-bound model sees the uncommitted pet + const pets = await person.pets; + expect(pets).toHaveLength(1); + expect(pets[0].attributes.name).toBe("TrxOwned"); + + // Eager loading through the transaction sees it too + const reloaded = await Person.query(trx).with("pets").where("name", "TrxOwner").firstOrFail(); + expect(reloaded.loadedRelations.pets).toHaveLength(1); + + throw new Error("abort"); + }), + ).rejects.toThrowError("abort"); + + expect(await Person.where("name", "TrxOwner").first()).toBeUndefined(); + expect(await Pet.where("name", "TrxOwned").first()).toBeUndefined(); + }); + + it("supports useConnection on the query builder", async () => { + await expect( + db.transaction().execute(async (trx) => { + await Pet.create({ name: "TrxBuilder", type: "cat" }, trx); + + const found = await Pet.query().useConnection(trx).where("name", "TrxBuilder").first(); + expect(found).toBeDefined(); + + throw new Error("abort"); + }), + ).rejects.toThrowError("abort"); + + expect(await Pet.where("name", "TrxBuilder").first()).toBeUndefined(); + }); + + it("rolls back destroy through a transaction-bound query", async () => { + const pet = await Pet.create({ name: "TrxDestroy", type: "dog" }); + + await expect( + db.transaction().execute(async (trx) => { + const destroyed = await Pet.query(trx).destroy(pet.id); + expect(destroyed).toBe(1); + throw new Error("abort"); + }), + ).rejects.toThrowError("abort"); + + expect(await Pet.where("name", "TrxDestroy").first()).toBeDefined(); + await pet.delete(); + }); + + it("supports the static Model.transaction() shorthand", async () => { + await expect( + Pet.transaction(async (trx) => { + await Pet.create({ name: "TrxStatic", type: "cat" }, trx); + await Person.create({ name: "TrxStaticPerson", birthday: new Date("2000-01-01") }, trx); + throw new Error("abort"); + }), + ).rejects.toThrowError("abort"); + + expect(await Pet.where("name", "TrxStatic").first()).toBeUndefined(); + expect(await Person.where("name", "TrxStaticPerson").first()).toBeUndefined(); + }); + + it("keeps concurrent transactions isolated", async () => { + const commit = db.transaction().execute(async (trx) => { + await Pet.create({ name: "TrxConcurrentCommit", type: "cat" }, trx); + }); + + const rollback = db.transaction().execute(async (trx) => { + await Pet.create({ name: "TrxConcurrentRollback", type: "cat" }, trx); + throw new Error("abort"); + }); + + await expect(Promise.allSettled([commit, rollback])).resolves.toMatchObject([ + { status: "fulfilled" }, + { status: "rejected" }, + ]); + + const committed = await Pet.where("name", "TrxConcurrentCommit").first(); + expect(committed).toBeDefined(); + expect(await Pet.where("name", "TrxConcurrentRollback").first()).toBeUndefined(); + + await committed?.delete(); + }); +}); From 85e23b1829be70f8096ba27031103ae5cb326c45 Mon Sep 17 00:00:00 2001 From: David Nahodyl Date: Fri, 17 Jul 2026 16:32:49 -0400 Subject: [PATCH 4/5] formatting --- docs/content/2.models/8.transactions.md | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/docs/content/2.models/8.transactions.md b/docs/content/2.models/8.transactions.md index 8f5a60c..99ca2d5 100644 --- a/docs/content/2.models/8.transactions.md +++ b/docs/content/2.models/8.transactions.md @@ -18,10 +18,7 @@ More documentation on Kysely transactions is available in the [Kysely docs](http import db from "./database/db"; await db.transaction().execute(async (trx) => { - const person = await Person.create( - { name: "Aang", birthday: new Date("1993-01-01") }, - trx, - ); + const person = await Person.create({ name: "Aang", birthday: new Date("1993-01-01") }, trx); const pet = new Pet({ name: "Appa", type: "bison", person_id: person.id }); await pet.save(trx); @@ -110,10 +107,6 @@ The transaction is a regular Kysely `Transaction`, so raw queries can run on it await db.transaction().execute(async (trx) => { await Person.create({ name: "Toph" }, trx); - await trx - .updateTable("people") - .set({ favorite_color: "green" }) - .where("name", "=", "Toph") - .execute(); + await trx.updateTable("people").set({ favorite_color: "green" }).where("name", "=", "Toph").execute(); }); ``` From 3f34b9d5eed777c84bc2abee8bec7b3769b34563 Mon Sep 17 00:00:00 2001 From: David Nahodyl Date: Fri, 17 Jul 2026 16:38:52 -0400 Subject: [PATCH 5/5] version bump --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4a57ee0..107104a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vasta-orm", - "version": "0.0.15", + "version": "0.0.16", "description": "Active record ORM built on top of Kysely", "type": "module", "author": "David Nahodyl ",