@bunnykit/orm 0.1.23 → 0.1.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,10 +18,13 @@ export declare class Connection {
18
18
  getDriverName(): "sqlite" | "mysql" | "postgres";
19
19
  getGrammar(): Grammar;
20
20
  getSchema(): string | undefined;
21
+ static isSafeIdentifier(value: string): boolean;
22
+ static assertSafeIdentifier(value: string, label?: string): void;
23
+ static assertSafeQualifiedIdentifier(value: string, label?: string): void;
21
24
  withSchema(schema: string): Connection;
22
25
  withoutSchema(): Connection;
23
26
  qualifyTable(table: string): string;
24
- private quoteIdentifier;
27
+ quoteIdentifier(value: string): string;
25
28
  query(sqlString: string, bindings?: any[]): Promise<any[]>;
26
29
  run(sqlString: string, bindings?: any[]): Promise<any>;
27
30
  beginTransaction(): Promise<void>;
@@ -59,7 +59,22 @@ export class Connection {
59
59
  getSchema() {
60
60
  return this.schema;
61
61
  }
62
+ static isSafeIdentifier(value) {
63
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
64
+ }
65
+ static assertSafeIdentifier(value, label = "identifier") {
66
+ if (!this.isSafeIdentifier(value)) {
67
+ throw new Error(`Invalid ${label}: ${value}`);
68
+ }
69
+ }
70
+ static assertSafeQualifiedIdentifier(value, label = "identifier") {
71
+ const parts = value.split(".");
72
+ if (parts.length === 0 || parts.some((part) => !this.isSafeIdentifier(part))) {
73
+ throw new Error(`Invalid ${label}: ${value}`);
74
+ }
75
+ }
62
76
  withSchema(schema) {
77
+ Connection.assertSafeIdentifier(schema, "schema name");
63
78
  if (this.schema === schema)
64
79
  return this;
65
80
  return new Connection(this.config, { driver: this.driver, schema, ownsDriver: false });
@@ -70,8 +85,14 @@ export class Connection {
70
85
  return new Connection(this.config, { driver: this.driver, ownsDriver: false });
71
86
  }
72
87
  qualifyTable(table) {
73
- if (!this.schema || this.driverName === "sqlite" || table.includes("."))
88
+ if (table.includes(".")) {
89
+ Connection.assertSafeQualifiedIdentifier(table, "qualified table name");
90
+ return table;
91
+ }
92
+ if (!this.schema || this.driverName === "sqlite")
74
93
  return table;
94
+ Connection.assertSafeIdentifier(this.schema, "schema name");
95
+ Connection.assertSafeIdentifier(table, "table name");
75
96
  return `${this.schema}.${table}`;
76
97
  }
77
98
  quoteIdentifier(value) {
@@ -144,6 +165,7 @@ export class Connection {
144
165
  if (this.driverName !== "postgres") {
145
166
  throw new Error("search_path schema switching is only supported for PostgreSQL connections.");
146
167
  }
168
+ Connection.assertSafeIdentifier(schema, "schema name");
147
169
  return await this.transaction(async (connection) => {
148
170
  await connection.run(`SET LOCAL search_path TO ${connection.quoteIdentifier(schema)}`);
149
171
  return await callback(connection.withoutSchema());
@@ -1,25 +1,32 @@
1
1
  import { Connection } from "./Connection.js";
2
2
  import type { ConnectionConfig } from "../types/index.js";
3
3
  import type { ActiveTenantContext } from "./TenantContext.js";
4
- export type TenantResolution = {
4
+ export interface TenantCachePolicy {
5
+ ttl?: number;
6
+ closeOnPurge?: boolean;
7
+ }
8
+ type TenantResolutionOptions = TenantCachePolicy & {
9
+ cache?: TenantCachePolicy;
10
+ };
11
+ export type TenantResolution = ({
5
12
  strategy: "database";
6
13
  name: string;
7
14
  config: ConnectionConfig;
8
- } | {
15
+ } & TenantResolutionOptions) | ({
9
16
  strategy: "schema";
10
17
  name: string;
11
18
  config?: ConnectionConfig;
12
19
  connection?: string | Connection;
13
20
  schema: string;
14
21
  mode?: "qualify" | "search_path";
15
- } | {
22
+ } & TenantResolutionOptions) | ({
16
23
  strategy: "rls";
17
24
  name: string;
18
25
  config?: ConnectionConfig;
19
26
  connection?: string | Connection;
20
27
  tenantId?: string;
21
28
  setting?: string;
22
- };
29
+ } & TenantResolutionOptions);
23
30
  export type TenantResolver = (tenantId: string) => TenantResolution | Promise<TenantResolution>;
24
31
  export interface PoolConfig {
25
32
  maxConnections?: number;
@@ -50,6 +57,10 @@ export declare class ConnectionManager {
50
57
  static resolveTenant(tenantId: string): Promise<ActiveTenantContext>;
51
58
  static getResolvedTenant(tenantId: string): ActiveTenantContext | undefined;
52
59
  static purgeTenant(tenantId: string): void;
60
+ static purgeExpiredTenants(options?: {
61
+ close?: boolean;
62
+ }): Promise<string[]>;
53
63
  static closeTenant(tenantId: string): Promise<void>;
54
64
  static closeAll(): Promise<void>;
55
65
  }
66
+ export {};
@@ -139,14 +139,20 @@ export class ConnectionManager {
139
139
  }
140
140
  static async resolveTenant(tenantId) {
141
141
  const cached = this.tenantCache.get(tenantId);
142
- if (cached)
143
- return cached;
142
+ if (cached) {
143
+ if (!cached.expiresAt || cached.expiresAt > Date.now())
144
+ return cached;
145
+ await this.closeTenant(tenantId);
146
+ }
144
147
  if (!this.tenantResolver) {
145
148
  throw new Error("No tenant resolver configured.");
146
149
  }
147
150
  const resolution = await this.tenantResolver(tenantId);
151
+ const policy = { ...resolution.cache, ttl: resolution.ttl ?? resolution.cache?.ttl, closeOnPurge: resolution.closeOnPurge ?? resolution.cache?.closeOnPurge };
152
+ const resolvedAt = Date.now();
148
153
  const schema = resolution.strategy === "schema" ? resolution.schema : undefined;
149
154
  const schemaMode = resolution.strategy === "schema" ? resolution.mode || "qualify" : undefined;
155
+ let ownsConnection = false;
150
156
  let connection = (resolution.strategy === "schema" || resolution.strategy === "rls") && resolution.connection instanceof Connection
151
157
  ? resolution.connection
152
158
  : (resolution.strategy === "schema" || resolution.strategy === "rls") && typeof resolution.connection === "string"
@@ -167,6 +173,7 @@ export class ConnectionManager {
167
173
  }
168
174
  connection = new Connection(config, { schema });
169
175
  this.connections.set(resolution.name, connection);
176
+ ownsConnection = true;
170
177
  }
171
178
  else if (schema && schemaMode === "qualify") {
172
179
  connection = connection.withSchema(schema);
@@ -176,6 +183,10 @@ export class ConnectionManager {
176
183
  connection,
177
184
  connectionName: resolution.name,
178
185
  strategy: resolution.strategy,
186
+ resolvedAt,
187
+ expiresAt: policy.ttl ? resolvedAt + policy.ttl : undefined,
188
+ closeOnPurge: policy.closeOnPurge ?? ownsConnection,
189
+ ownsConnection,
179
190
  schema,
180
191
  schemaMode,
181
192
  rlsTenantId: resolution.strategy === "rls" ? resolution.tenantId || tenantId : undefined,
@@ -185,19 +196,53 @@ export class ConnectionManager {
185
196
  return context;
186
197
  }
187
198
  static getResolvedTenant(tenantId) {
188
- return this.tenantCache.get(tenantId);
199
+ const context = this.tenantCache.get(tenantId);
200
+ if (!context || !context.expiresAt || context.expiresAt > Date.now())
201
+ return context;
202
+ this.tenantCache.delete(tenantId);
203
+ return undefined;
189
204
  }
190
205
  static purgeTenant(tenantId) {
191
206
  this.tenantCache.delete(tenantId);
192
207
  }
208
+ static async purgeExpiredTenants(options = {}) {
209
+ const now = Date.now();
210
+ const purged = [];
211
+ for (const [tenantId, context] of [...this.tenantCache.entries()]) {
212
+ if (!context.expiresAt || context.expiresAt > now)
213
+ continue;
214
+ purged.push(tenantId);
215
+ if (options.close ?? context.closeOnPurge) {
216
+ await this.closeTenant(tenantId);
217
+ }
218
+ else {
219
+ this.tenantCache.delete(tenantId);
220
+ }
221
+ }
222
+ return purged;
223
+ }
193
224
  static async closeTenant(tenantId) {
194
225
  const context = this.tenantCache.get(tenantId);
195
226
  if (!context)
196
227
  return;
197
228
  this.tenantCache.delete(tenantId);
229
+ if (!context.closeOnPurge)
230
+ return;
198
231
  const connection = this.connections.get(context.connectionName);
199
- this.connections.delete(context.connectionName);
200
- await connection?.close();
232
+ if (connection === context.connection) {
233
+ this.connections.delete(context.connectionName);
234
+ await connection.close();
235
+ }
236
+ else if (context.ownsConnection) {
237
+ await context.connection.close();
238
+ }
239
+ const pool = this.pools.get(context.connectionName);
240
+ if (pool) {
241
+ this.pools.delete(context.connectionName);
242
+ for (const { connection } of pool) {
243
+ await connection.close().catch(() => null);
244
+ }
245
+ }
201
246
  }
202
247
  static async closeAll() {
203
248
  const connections = new Set(this.connections.values());
@@ -4,6 +4,10 @@ export interface ActiveTenantContext {
4
4
  connection: Connection;
5
5
  connectionName: string;
6
6
  strategy: "database" | "schema" | "rls";
7
+ resolvedAt: number;
8
+ expiresAt?: number;
9
+ closeOnPurge: boolean;
10
+ ownsConnection: boolean;
7
11
  schema?: string;
8
12
  schemaMode?: "qualify" | "search_path";
9
13
  rlsTenantId?: string;
@@ -1,6 +1,6 @@
1
1
  export { Connection } from "./connection/Connection.js";
2
2
  export { ConnectionManager } from "./connection/ConnectionManager.js";
3
- export type { TenantResolution, TenantResolver } from "./connection/ConnectionManager.js";
3
+ export type { TenantCachePolicy, TenantResolution, TenantResolver } from "./connection/ConnectionManager.js";
4
4
  export { TenantContext } from "./connection/TenantContext.js";
5
5
  export type { ActiveTenantContext } from "./connection/TenantContext.js";
6
6
  export { configureBunny } from "./config/BunnyConfig.js";
@@ -23,7 +23,7 @@ export { BelongsToMany } from "./model/BelongsToMany.js";
23
23
  export { IdentityMap } from "./model/IdentityMap.js";
24
24
  export { Migration } from "./migration/Migration.js";
25
25
  export { Migrator } from "./migration/Migrator.js";
26
- export type { MigrationEvent, MigrationEventListener, MigrationEventPayload } from "./migration/Migrator.js";
26
+ export type { MigrationEvent, MigrationEventListener, MigrationEventPayload, MigrationStatusRow, MigratorOptions } from "./migration/Migrator.js";
27
27
  export { MigrationCreator } from "./migration/MigrationCreator.js";
28
28
  export { TypeGenerator } from "./typegen/TypeGenerator.js";
29
29
  export { TypeMapper } from "./typegen/TypeMapper.js";
@@ -1,5 +1,15 @@
1
1
  import { Connection } from "../connection/Connection.js";
2
2
  import type { TypeGeneratorOptions } from "../typegen/TypeGenerator.js";
3
+ export interface MigrationStatusRow {
4
+ migration: string;
5
+ status: string;
6
+ tenant: string | null;
7
+ }
8
+ export interface MigratorOptions {
9
+ tenantId?: string | null;
10
+ lock?: boolean;
11
+ lockTimeoutMs?: number;
12
+ }
3
13
  export type MigrationEvent = "migrating" | "migrated" | "rollingBack" | "rolledBack" | "schemaDumped" | "schemaSquashed";
4
14
  export interface MigrationEventPayload {
5
15
  migration?: string;
@@ -12,10 +22,18 @@ export declare class Migrator {
12
22
  private path;
13
23
  private typesOutDir?;
14
24
  private typeGeneratorOptions;
25
+ private options;
15
26
  private static listeners;
16
- constructor(connection: Connection, path: string | string[], typesOutDir?: string | undefined, typeGeneratorOptions?: Omit<TypeGeneratorOptions, "outDir">);
27
+ constructor(connection: Connection, path: string | string[], typesOutDir?: string | undefined, typeGeneratorOptions?: Omit<TypeGeneratorOptions, "outDir">, options?: MigratorOptions);
17
28
  private getPaths;
18
29
  private ensureMigrationsTable;
30
+ private getTenantId;
31
+ private scopedMigrations;
32
+ private ensureMigrationLocksTable;
33
+ private getLockName;
34
+ private shouldLock;
35
+ private acquireLock;
36
+ private releaseLock;
19
37
  static on(event: MigrationEvent, listener: MigrationEventListener): () => void;
20
38
  static clearListeners(event?: MigrationEvent): void;
21
39
  private emit;
@@ -24,10 +42,7 @@ export declare class Migrator {
24
42
  run(): Promise<void>;
25
43
  rollback(): Promise<void>;
26
44
  private generateTypesIfNeeded;
27
- status(): Promise<{
28
- migration: string;
29
- status: string;
30
- }[]>;
45
+ status(): Promise<MigrationStatusRow[]>;
31
46
  dumpSchema(path: string): Promise<string>;
32
47
  squash(path: string): Promise<string>;
33
48
  private getSchemaDumpSql;
@@ -10,12 +10,14 @@ export class Migrator {
10
10
  path;
11
11
  typesOutDir;
12
12
  typeGeneratorOptions;
13
+ options;
13
14
  static listeners = new Map();
14
- constructor(connection, path, typesOutDir, typeGeneratorOptions = {}) {
15
+ constructor(connection, path, typesOutDir, typeGeneratorOptions = {}, options = {}) {
15
16
  this.connection = connection;
16
17
  this.path = path;
17
18
  this.typesOutDir = typesOutDir;
18
19
  this.typeGeneratorOptions = typeGeneratorOptions;
20
+ this.options = options;
19
21
  Schema.setConnection(connection);
20
22
  }
21
23
  getPaths() {
@@ -27,10 +29,73 @@ export class Migrator {
27
29
  await Schema.create("migrations", (table) => {
28
30
  table.increments("id");
29
31
  table.string("migration");
32
+ table.string("tenant").nullable().index();
30
33
  table.integer("batch");
31
34
  });
35
+ return;
36
+ }
37
+ if (!(await Schema.hasColumn("migrations", "tenant"))) {
38
+ await Schema.table("migrations", (table) => {
39
+ table.string("tenant").nullable().index();
40
+ });
41
+ }
42
+ }
43
+ getTenantId() {
44
+ return this.options.tenantId ?? null;
45
+ }
46
+ scopedMigrations() {
47
+ const builder = new Builder(this.connection, "migrations");
48
+ const tenantId = this.getTenantId();
49
+ return tenantId === null ? builder.whereNull("tenant") : builder.where("tenant", tenantId);
50
+ }
51
+ async ensureMigrationLocksTable() {
52
+ if (await Schema.hasTable("migration_locks"))
53
+ return;
54
+ await Schema.create("migration_locks", (table) => {
55
+ table.string("name").primary();
56
+ table.string("owner");
57
+ table.string("created_at");
58
+ });
59
+ }
60
+ getLockName() {
61
+ const tenantId = this.getTenantId();
62
+ return tenantId === null ? "migrations:default" : `migrations:tenant:${tenantId}`;
63
+ }
64
+ shouldLock() {
65
+ return this.options.lock !== false;
66
+ }
67
+ async acquireLock() {
68
+ if (!this.shouldLock())
69
+ return false;
70
+ await this.ensureMigrationLocksTable();
71
+ const lockName = this.getLockName();
72
+ const timeoutMs = this.options.lockTimeoutMs ?? 30000;
73
+ const owner = `${process.pid}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
74
+ const started = Date.now();
75
+ while (true) {
76
+ try {
77
+ await new Builder(this.connection, "migration_locks").insert({
78
+ name: lockName,
79
+ owner,
80
+ created_at: new Date().toISOString(),
81
+ });
82
+ return true;
83
+ }
84
+ catch {
85
+ if (Date.now() - started >= timeoutMs) {
86
+ throw new Error(`Could not acquire migration lock "${lockName}" within ${timeoutMs}ms.`);
87
+ }
88
+ await new Promise((resolve) => setTimeout(resolve, 50));
89
+ }
32
90
  }
33
91
  }
92
+ async releaseLock() {
93
+ if (!this.shouldLock())
94
+ return;
95
+ await new Builder(this.connection, "migration_locks")
96
+ .where("name", this.getLockName())
97
+ .delete();
98
+ }
34
99
  static on(event, listener) {
35
100
  const listeners = this.listeners.get(event) || new Set();
36
101
  listeners.add(listener);
@@ -49,7 +114,8 @@ export class Migrator {
49
114
  }
50
115
  }
51
116
  async getLastBatchNumber() {
52
- const result = await new Builder(this.connection, "migrations")
117
+ await this.ensureMigrationsTable();
118
+ const result = await this.scopedMigrations()
53
119
  .select("MAX(batch) as batch")
54
120
  .first();
55
121
  return result?.batch || 0;
@@ -74,16 +140,18 @@ export class Migrator {
74
140
  return files.sort((a, b) => a.fileName.localeCompare(b.fileName) || a.id.localeCompare(b.id));
75
141
  }
76
142
  async run() {
77
- const ran = await this.getRan();
78
- const files = await this.getMigrationFiles();
79
- const pending = files.filter((f) => !ran.has(f.id) && !ran.has(f.fileName));
80
- if (pending.length === 0) {
81
- console.log("Nothing to migrate.");
82
- return;
83
- }
84
- const batch = (await this.getLastBatchNumber()) + 1;
85
- await this.connection.beginTransaction();
143
+ await this.ensureMigrationsTable();
144
+ const locked = await this.acquireLock();
86
145
  try {
146
+ const ran = await this.getRan();
147
+ const files = await this.getMigrationFiles();
148
+ const pending = files.filter((f) => !ran.has(f.id) && !ran.has(f.fileName));
149
+ if (pending.length === 0) {
150
+ console.log("Nothing to migrate.");
151
+ return;
152
+ }
153
+ const batch = (await this.getLastBatchNumber()) + 1;
154
+ await this.connection.beginTransaction();
87
155
  for (const file of pending) {
88
156
  const migration = await this.resolve(file.id);
89
157
  console.log(`Migrating: ${file.id}`);
@@ -91,6 +159,7 @@ export class Migrator {
91
159
  await migration.up();
92
160
  await new Builder(this.connection, "migrations").insert({
93
161
  migration: file.id,
162
+ tenant: this.getTenantId(),
94
163
  batch,
95
164
  });
96
165
  await this.emit("migrated", { migration: file.id, batch });
@@ -103,23 +172,29 @@ export class Migrator {
103
172
  await this.connection.rollback();
104
173
  throw error;
105
174
  }
175
+ finally {
176
+ if (locked)
177
+ await this.releaseLock();
178
+ }
106
179
  }
107
180
  async rollback() {
108
- const batch = await this.getLastBatchNumber();
109
- if (batch === 0) {
110
- console.log("Nothing to rollback.");
111
- return;
112
- }
113
- const records = (await new Builder(this.connection, "migrations")
114
- .where("batch", batch)
115
- .orderBy("id", "desc")
116
- .get());
117
- if (records.length === 0) {
118
- console.log("Nothing to rollback.");
119
- return;
120
- }
121
- await this.connection.beginTransaction();
181
+ await this.ensureMigrationsTable();
182
+ const locked = await this.acquireLock();
122
183
  try {
184
+ const batch = await this.getLastBatchNumber();
185
+ if (batch === 0) {
186
+ console.log("Nothing to rollback.");
187
+ return;
188
+ }
189
+ const records = (await this.scopedMigrations()
190
+ .where("batch", batch)
191
+ .orderBy("id", "desc")
192
+ .get());
193
+ if (records.length === 0) {
194
+ console.log("Nothing to rollback.");
195
+ return;
196
+ }
197
+ await this.connection.beginTransaction();
123
198
  for (const record of records) {
124
199
  const migration = await this.resolve(record.migration);
125
200
  console.log(`Rolling back: ${record.migration}`);
@@ -138,6 +213,10 @@ export class Migrator {
138
213
  await this.connection.rollback();
139
214
  throw error;
140
215
  }
216
+ finally {
217
+ if (locked)
218
+ await this.releaseLock();
219
+ }
141
220
  }
142
221
  async generateTypesIfNeeded() {
143
222
  const modelDirectories = normalizePathList(this.typeGeneratorOptions.modelDirectories || this.typeGeneratorOptions.modelDirectory);
@@ -154,11 +233,14 @@ export class Migrator {
154
233
  console.log(`Regenerated types in ${label}`);
155
234
  }
156
235
  async status() {
236
+ await this.ensureMigrationsTable();
157
237
  const ran = await this.getRan();
158
238
  const files = await this.getMigrationFiles();
239
+ const tenant = this.getTenantId();
159
240
  return files.map((file) => ({
160
241
  migration: file.id,
161
242
  status: ran.has(file.id) || ran.has(file.fileName) ? "Ran" : "Pending",
243
+ tenant,
162
244
  }));
163
245
  }
164
246
  async dumpSchema(path) {
@@ -173,12 +255,20 @@ export class Migrator {
173
255
  const files = await this.getMigrationFiles();
174
256
  await this.ensureMigrationsTable();
175
257
  const batch = (await this.getLastBatchNumber()) + 1;
176
- await new Builder(this.connection, "migrations").delete();
177
- for (const file of files) {
178
- await new Builder(this.connection, "migrations").insert({
179
- migration: file.id,
180
- batch,
181
- });
258
+ const locked = await this.acquireLock();
259
+ try {
260
+ await this.scopedMigrations().delete();
261
+ for (const file of files) {
262
+ await new Builder(this.connection, "migrations").insert({
263
+ migration: file.id,
264
+ tenant: this.getTenantId(),
265
+ batch,
266
+ });
267
+ }
268
+ }
269
+ finally {
270
+ if (locked)
271
+ await this.releaseLock();
182
272
  }
183
273
  await this.emit("schemaSquashed", { path, batch });
184
274
  return sql;
@@ -269,7 +359,7 @@ export class Migrator {
269
359
  }
270
360
  async getRan() {
271
361
  await this.ensureMigrationsTable();
272
- const results = await new Builder(this.connection, "migrations")
362
+ const results = await this.scopedMigrations()
273
363
  .orderBy("id", "asc")
274
364
  .get();
275
365
  const ran = new Set();
@@ -5,7 +5,7 @@ import { BelongsToMany } from "./BelongsToMany.js";
5
5
  export type ModelConstructor<T extends Model = Model> = (new (...args: any[]) => T) & Omit<typeof Model, "prototype">;
6
6
  export type GlobalScope = (builder: Builder<any>, model: ModelConstructor) => void;
7
7
  export type LiteralUnion<T extends string> = T | (string & {});
8
- type BaseModelInstanceKey = "$attributes" | "$original" | "$exists" | "$relations" | "$casts" | "$connection" | "fill" | "setConnection" | "getConnection" | "isFillable" | "getAttribute" | "setAttribute" | "castAttribute" | "serializeCastAttribute" | "mergeCasts" | "getDirty" | "isDirty" | "save" | "updateTimestamps" | "touch" | "increment" | "decrement" | "load" | "delete" | "restore" | "forceDelete" | "refresh" | "toJSON" | "toString" | "freshTimestamp" | "setRelation" | "getRelation" | "hasMany" | "belongsTo" | "hasOne" | "hasManyThrough" | "hasOneThrough" | "belongsToMany" | "morphTo" | "morphOne" | "morphMany" | "morphToMany" | "morphedByMany";
8
+ type BaseModelInstanceKey = "$attributes" | "$original" | "$exists" | "$relations" | "$casts" | "$castCache" | "$connection" | "fill" | "setConnection" | "getConnection" | "isFillable" | "getAttribute" | "setAttribute" | "castAttribute" | "serializeCastAttribute" | "mergeCasts" | "getDirty" | "isDirty" | "save" | "updateTimestamps" | "touch" | "increment" | "decrement" | "load" | "delete" | "restore" | "forceDelete" | "refresh" | "toJSON" | "toString" | "freshTimestamp" | "setRelation" | "getRelation" | "hasMany" | "belongsTo" | "hasOne" | "hasManyThrough" | "hasOneThrough" | "belongsToMany" | "morphTo" | "morphOne" | "morphMany" | "morphToMany" | "morphedByMany";
9
9
  export type ModelInstanceAttributeKeys<T> = Extract<Exclude<keyof T, BaseModelInstanceKey>, string>;
10
10
  export type ModelAttributes<T> = T extends {
11
11
  $attributes: Record<string, any>;
@@ -111,10 +111,9 @@ export declare class Model<T extends Record<string, any> = Record<string, any>>
111
111
  $exists: boolean;
112
112
  $relations: Record<string, any>;
113
113
  $casts: Record<string, CastDefinition>;
114
+ $castCache: Record<string, any>;
114
115
  $connection?: Connection;
115
116
  constructor(attributes?: Partial<T>);
116
- private defineAttributeProperty;
117
- private syncAttributeProperties;
118
117
  static getTable(): string;
119
118
  static getConnection(): Connection;
120
119
  static setConnection(connection: Connection): void;
@@ -127,6 +126,7 @@ export declare class Model<T extends Record<string, any> = Record<string, any>>
127
126
  static applyGlobalScopes(builder: Builder<any>): void;
128
127
  static getQualifiedDeletedAtColumn(): string;
129
128
  static shouldAutoGeneratePrimaryKey(): Promise<boolean>;
129
+ static hydrate<M extends ModelConstructor>(this: M, row: Record<string, any>, connection?: Connection): InstanceType<M>;
130
130
  static create<M extends ModelConstructor>(this: M, attributes: ModelAttributeInput<InstanceType<M>>): Promise<InstanceType<M>>;
131
131
  static find<M extends ModelConstructor>(this: M, id: any): Promise<InstanceType<M> | null>;
132
132
  static findOrFail<M extends ModelConstructor>(this: M, id: any): Promise<InstanceType<M>>;
@@ -8,6 +8,44 @@ import { ModelNotFoundError } from "./ModelNotFoundError.js";
8
8
  import { ConnectionManager } from "../connection/ConnectionManager.js";
9
9
  import { TenantContext } from "../connection/TenantContext.js";
10
10
  import { IdentityMap } from "./IdentityMap.js";
11
+ const modelProxyHandler = {
12
+ get(target, prop, receiver) {
13
+ if (typeof prop === "string" && !(prop in target) && prop in target.$attributes) {
14
+ return target.getAttribute(prop);
15
+ }
16
+ return Reflect.get(target, prop, receiver);
17
+ },
18
+ set(target, prop, value, receiver) {
19
+ if (typeof prop === "string" && !prop.startsWith("$") && !(prop in target)) {
20
+ target.setAttribute(prop, value);
21
+ return true;
22
+ }
23
+ return Reflect.set(target, prop, value, receiver);
24
+ },
25
+ has(target, prop) {
26
+ if (typeof prop === "string" && prop in target.$attributes)
27
+ return true;
28
+ return Reflect.has(target, prop);
29
+ },
30
+ getOwnPropertyDescriptor(target, prop) {
31
+ if (typeof prop === "string" && prop in target.$attributes) {
32
+ return {
33
+ enumerable: true,
34
+ configurable: true,
35
+ value: target.getAttribute(prop),
36
+ };
37
+ }
38
+ return Reflect.getOwnPropertyDescriptor(target, prop);
39
+ },
40
+ ownKeys(target) {
41
+ const keys = new Set(Reflect.ownKeys(target));
42
+ for (const key of Object.keys(target.$attributes)) {
43
+ if (!key.startsWith("$"))
44
+ keys.add(key);
45
+ }
46
+ return Array.from(keys);
47
+ },
48
+ };
11
49
  const globalScopes = new WeakMap();
12
50
  function getGlobalScopes(model) {
13
51
  const scopes = new Map();
@@ -306,6 +344,7 @@ export class Model {
306
344
  $exists = false;
307
345
  $relations = {};
308
346
  $casts = {};
347
+ $castCache = {};
309
348
  $connection;
310
349
  constructor(attributes) {
311
350
  const defaults = this.constructor.attributes;
@@ -315,72 +354,7 @@ export class Model {
315
354
  if (attributes) {
316
355
  this.fill(attributes);
317
356
  }
318
- this.syncAttributeProperties();
319
- // Minimal Proxy fallback for dynamic property access on undefined keys.
320
- // Pre-defined attribute getters/setters bypass the Proxy entirely.
321
- return new Proxy(this, {
322
- get(target, prop, receiver) {
323
- if (typeof prop === "string" && !(prop in target) && prop in target.$attributes) {
324
- return target.getAttribute(prop);
325
- }
326
- return Reflect.get(target, prop, receiver);
327
- },
328
- set(target, prop, value, receiver) {
329
- if (typeof prop === "string" && !prop.startsWith("$") && !(prop in target)) {
330
- target.setAttribute(prop, value);
331
- return true;
332
- }
333
- return Reflect.set(target, prop, value, receiver);
334
- },
335
- has(target, prop) {
336
- if (typeof prop === "string" && prop in target.$attributes)
337
- return true;
338
- return Reflect.has(target, prop);
339
- },
340
- getOwnPropertyDescriptor(target, prop) {
341
- if (typeof prop === "string" && prop in target.$attributes) {
342
- return {
343
- enumerable: true,
344
- configurable: true,
345
- value: target.getAttribute(prop),
346
- };
347
- }
348
- return Reflect.getOwnPropertyDescriptor(target, prop);
349
- },
350
- ownKeys(target) {
351
- const keys = new Set(Reflect.ownKeys(target));
352
- for (const key of Object.keys(target.$attributes)) {
353
- if (!key.startsWith("$"))
354
- keys.add(key);
355
- }
356
- return Array.from(keys);
357
- },
358
- });
359
- }
360
- defineAttributeProperty(key) {
361
- if (key in this)
362
- return;
363
- Object.defineProperty(this, key, {
364
- get: () => this.getAttribute(key),
365
- set: (value) => this.setAttribute(key, value),
366
- enumerable: true,
367
- configurable: true,
368
- });
369
- }
370
- syncAttributeProperties() {
371
- const currentKeys = new Set(Object.keys(this.$attributes));
372
- // Remove stale attribute properties
373
- for (const key of Reflect.ownKeys(this)) {
374
- if (key.startsWith("$") || typeof key !== "string")
375
- continue;
376
- const desc = Object.getOwnPropertyDescriptor(this, key);
377
- if (desc && desc.get && desc.configurable && !currentKeys.has(key)) {
378
- delete this[key];
379
- }
380
- }
381
- for (const key of currentKeys) {
382
- this.defineAttributeProperty(key);
383
- }
357
+ return new Proxy(this, modelProxyHandler);
384
358
  }
385
359
  static getTable() {
386
360
  return this.table || snakeCase(this.name) + "s";
@@ -459,6 +433,17 @@ export class Model {
459
433
  const numericTypes = new Set(["integer", "int", "bigint", "smallint", "tinyint", "real", "float", "double", "decimal", "numeric"]);
460
434
  return !numericTypes.has(type);
461
435
  }
436
+ static hydrate(row, connection) {
437
+ const instance = new this();
438
+ instance.$attributes = { ...instance.$attributes, ...row };
439
+ instance.$original = { ...row };
440
+ instance.$castCache = {};
441
+ instance.$exists = true;
442
+ if (connection) {
443
+ instance.setConnection(connection);
444
+ }
445
+ return instance;
446
+ }
462
447
  static async create(attributes) {
463
448
  const instance = new this();
464
449
  instance.fill(attributes);
@@ -701,12 +686,19 @@ export class Model {
701
686
  return true;
702
687
  }
703
688
  getAttribute(key) {
689
+ if (Object.prototype.hasOwnProperty.call(this.$castCache, key)) {
690
+ return this.$castCache[key];
691
+ }
704
692
  const value = this.$attributes[key];
705
- return this.castAttribute(key, value);
693
+ const casted = this.castAttribute(key, value);
694
+ if (this.getCastDefinition(key) && value !== null && value !== undefined) {
695
+ this.$castCache[key] = casted;
696
+ }
697
+ return casted;
706
698
  }
707
699
  setAttribute(key, value) {
708
700
  this.$attributes[key] = this.serializeCastAttribute(key, value);
709
- this.defineAttributeProperty(key);
701
+ delete this.$castCache[key];
710
702
  }
711
703
  castAttribute(key, value) {
712
704
  const cast = this.getCastDefinition(key);
@@ -785,6 +777,7 @@ export class Model {
785
777
  }
786
778
  mergeCasts(casts) {
787
779
  this.$casts = { ...this.$casts, ...casts };
780
+ this.$castCache = {};
788
781
  return this;
789
782
  }
790
783
  getCastDefinition(key) {
@@ -819,6 +812,7 @@ export class Model {
819
812
  let dirty = this.getDirty();
820
813
  if (Object.keys(dirty).length > 0 && constructor.timestamps) {
821
814
  this.$attributes["updated_at"] = this.freshTimestamp();
815
+ delete this.$castCache.updated_at;
822
816
  dirty = this.getDirty();
823
817
  }
824
818
  if (Object.keys(dirty).length > 0) {
@@ -840,6 +834,8 @@ export class Model {
840
834
  const now = this.freshTimestamp();
841
835
  this.$attributes["created_at"] = now;
842
836
  this.$attributes["updated_at"] = now;
837
+ delete this.$castCache.created_at;
838
+ delete this.$castCache.updated_at;
843
839
  }
844
840
  const primaryKey = constructor.primaryKey;
845
841
  const primaryKeyValue = this.getAttribute(primaryKey);
@@ -847,6 +843,7 @@ export class Model {
847
843
  if ((primaryKeyValue === null || primaryKeyValue === undefined || primaryKeyValue === "") && shouldGeneratePrimaryKey) {
848
844
  const generated = crypto.randomUUID();
849
845
  this.$attributes[primaryKey] = generated;
846
+ delete this.$castCache[primaryKey];
850
847
  }
851
848
  const connection = this.getConnection();
852
849
  if (shouldGeneratePrimaryKey || primaryKeyValue !== null && primaryKeyValue !== undefined && primaryKeyValue !== "") {
@@ -856,6 +853,7 @@ export class Model {
856
853
  const result = await new Builder(connection, connection.qualifyTable(constructor.getTable())).insertGetId(this.$attributes);
857
854
  if (result) {
858
855
  this.$attributes[constructor.primaryKey] = result;
856
+ delete this.$castCache[constructor.primaryKey];
859
857
  }
860
858
  }
861
859
  this.$exists = true;
@@ -863,7 +861,6 @@ export class Model {
863
861
  await ObserverRegistry.dispatch("created", this);
864
862
  await ObserverRegistry.dispatch("saved", this);
865
863
  }
866
- this.syncAttributeProperties();
867
864
  const identityMap = IdentityMap.current();
868
865
  if (identityMap) {
869
866
  const pk = this.getAttribute(constructor.primaryKey);
@@ -879,10 +876,11 @@ export class Model {
879
876
  return;
880
877
  const now = this.freshTimestamp();
881
878
  this.$attributes["updated_at"] = now;
879
+ delete this.$castCache.updated_at;
882
880
  if (!this.$exists) {
883
881
  this.$attributes["created_at"] = now;
882
+ delete this.$castCache.created_at;
884
883
  }
885
- this.syncAttributeProperties();
886
884
  }
887
885
  async touch() {
888
886
  if (!this.$exists)
@@ -897,8 +895,8 @@ export class Model {
897
895
  .where(constructor.primaryKey, pk)
898
896
  .update({ updated_at: now });
899
897
  this.$attributes["updated_at"] = now;
898
+ delete this.$castCache.updated_at;
900
899
  this.$original = { ...this.$attributes };
901
- this.syncAttributeProperties();
902
900
  return true;
903
901
  }
904
902
  async increment(column, amount = 1, extra = {}) {
@@ -914,11 +912,12 @@ export class Model {
914
912
  }
915
913
  await builder.increment(column, amount, extra);
916
914
  this.$attributes[column] = (this.$attributes[column] || 0) + amount;
915
+ delete this.$castCache[column];
917
916
  for (const [key, value] of Object.entries(extra)) {
918
917
  this.$attributes[key] = value;
918
+ delete this.$castCache[key];
919
919
  }
920
920
  this.$original = { ...this.$attributes };
921
- this.syncAttributeProperties();
922
921
  return this;
923
922
  }
924
923
  async decrement(column, amount = 1, extra = {}) {
@@ -942,8 +941,8 @@ export class Model {
942
941
  .where(constructor.primaryKey, pk)
943
942
  .update({ [constructor.deletedAtColumn]: deletedAt });
944
943
  this.$attributes[constructor.deletedAtColumn] = deletedAt;
944
+ delete this.$castCache[constructor.deletedAtColumn];
945
945
  this.$original = { ...this.$attributes };
946
- this.syncAttributeProperties();
947
946
  }
948
947
  else {
949
948
  const connection = this.getConnection();
@@ -971,9 +970,9 @@ export class Model {
971
970
  .where(constructor.primaryKey, pk)
972
971
  .update({ [constructor.deletedAtColumn]: null });
973
972
  this.$attributes[constructor.deletedAtColumn] = null;
973
+ delete this.$castCache[constructor.deletedAtColumn];
974
974
  this.$original = { ...this.$attributes };
975
975
  this.$exists = true;
976
- this.syncAttributeProperties();
977
976
  return true;
978
977
  }
979
978
  async forceDelete() {
@@ -1006,7 +1005,7 @@ export class Model {
1006
1005
  if (result) {
1007
1006
  this.$attributes = result.$attributes;
1008
1007
  this.$original = { ...result.$attributes };
1009
- this.syncAttributeProperties();
1008
+ this.$castCache = {};
1010
1009
  // Ensure this instance is the canonical one in the identity map
1011
1010
  if (identityMap) {
1012
1011
  IdentityMap.set(constructor.getTable(), pk, this);
@@ -32,8 +32,10 @@ export declare class Builder<T = Record<string, any>> {
32
32
  updateJoins: string[];
33
33
  bindings: any[];
34
34
  private parameterize;
35
+ private sqlCache?;
35
36
  constructor(connection: Connection, table: string);
36
37
  private get grammar();
38
+ private invalidateSqlCache;
37
39
  setModel(model: ModelConstructor): this;
38
40
  table(table: string): this;
39
41
  select(...columns: ModelColumn<T>[]): this;
@@ -21,6 +21,7 @@ export class Builder {
21
21
  updateJoins = [];
22
22
  bindings = [];
23
23
  parameterize = false;
24
+ sqlCache;
24
25
  constructor(connection, table) {
25
26
  this.connection = connection;
26
27
  this.tableName = table;
@@ -28,19 +29,25 @@ export class Builder {
28
29
  get grammar() {
29
30
  return this.connection.getGrammar();
30
31
  }
32
+ invalidateSqlCache() {
33
+ this.sqlCache = undefined;
34
+ }
31
35
  setModel(model) {
32
36
  this.model = model;
33
37
  return this;
34
38
  }
35
39
  table(table) {
40
+ this.invalidateSqlCache();
36
41
  this.tableName = table;
37
42
  return this;
38
43
  }
39
44
  select(...columns) {
45
+ this.invalidateSqlCache();
40
46
  this.columns = columns;
41
47
  return this;
42
48
  }
43
49
  distinct() {
50
+ this.invalidateSqlCache();
44
51
  this.distinctFlag = true;
45
52
  return this;
46
53
  }
@@ -58,6 +65,7 @@ export class Builder {
58
65
  value = operator;
59
66
  operator = "=";
60
67
  }
68
+ this.invalidateSqlCache();
61
69
  this.wheres.push({ type: "basic", column, operator, value, boolean, scope });
62
70
  return this;
63
71
  }
@@ -65,6 +73,7 @@ export class Builder {
65
73
  const nested = new Builder(this.connection, this.tableName);
66
74
  callback(nested);
67
75
  if (nested.wheres.length > 0) {
76
+ this.invalidateSqlCache();
68
77
  this.wheres.push({ type: "nested", column: "", query: nested.wheres, boolean, scope: undefined });
69
78
  }
70
79
  return this;
@@ -85,26 +94,32 @@ export class Builder {
85
94
  return this.whereNot(column, value, "or");
86
95
  }
87
96
  whereIn(column, values, boolean = "and", scope) {
97
+ this.invalidateSqlCache();
88
98
  this.wheres.push({ type: "in", column, value: values, boolean, scope });
89
99
  return this;
90
100
  }
91
101
  whereNotIn(column, values, boolean = "and", scope) {
102
+ this.invalidateSqlCache();
92
103
  this.wheres.push({ type: "in", column, value: values, boolean, operator: "NOT IN", scope });
93
104
  return this;
94
105
  }
95
106
  whereNull(column, boolean = "and", scope) {
107
+ this.invalidateSqlCache();
96
108
  this.wheres.push({ type: "null", column, boolean, scope });
97
109
  return this;
98
110
  }
99
111
  whereNotNull(column, boolean = "and", scope) {
112
+ this.invalidateSqlCache();
100
113
  this.wheres.push({ type: "null", column, boolean, operator: "NOT NULL", scope });
101
114
  return this;
102
115
  }
103
116
  whereBetween(column, values, boolean = "and", scope) {
117
+ this.invalidateSqlCache();
104
118
  this.wheres.push({ type: "between", column, value: values, boolean, scope });
105
119
  return this;
106
120
  }
107
121
  whereNotBetween(column, values, boolean = "and", scope) {
122
+ this.invalidateSqlCache();
108
123
  this.wheres.push({ type: "between", column, value: values, boolean, operator: "NOT BETWEEN", scope });
109
124
  return this;
110
125
  }
@@ -139,14 +154,17 @@ export class Builder {
139
154
  return this.whereTime(column, operator, value, "or");
140
155
  }
141
156
  whereRaw(sql, boolean = "and", scope) {
157
+ this.invalidateSqlCache();
142
158
  this.wheres.push({ type: "raw", column: sql, boolean, scope });
143
159
  return this;
144
160
  }
145
161
  whereColumn(first, operator, second, boolean = "and") {
162
+ this.invalidateSqlCache();
146
163
  this.wheres.push({ type: "column", column: first, operator, value: second, boolean });
147
164
  return this;
148
165
  }
149
166
  whereExists(sql, boolean = "and", not = false) {
167
+ this.invalidateSqlCache();
150
168
  this.wheres.push({ type: "exists", column: sql, boolean, operator: not ? "NOT EXISTS" : "EXISTS" });
151
169
  return this;
152
170
  }
@@ -181,6 +199,7 @@ export class Builder {
181
199
  return this.whereRaw(sql, "or", scope);
182
200
  }
183
201
  whereJsonContains(column, value, boolean = "and", not = false) {
202
+ this.invalidateSqlCache();
184
203
  this.wheres.push({ type: "json_contains", column, value, boolean, scope: undefined, not });
185
204
  return this;
186
205
  }
@@ -189,10 +208,12 @@ export class Builder {
189
208
  value = operator;
190
209
  operator = "=";
191
210
  }
211
+ this.invalidateSqlCache();
192
212
  this.wheres.push({ type: "json_length", column, operator: String(operator), value, boolean, scope: undefined, not });
193
213
  return this;
194
214
  }
195
215
  whereLike(column, value, boolean = "and", not = false) {
216
+ this.invalidateSqlCache();
196
217
  this.wheres.push({ type: "like", column, value, boolean, scope: undefined, not });
197
218
  return this;
198
219
  }
@@ -200,23 +221,28 @@ export class Builder {
200
221
  return this.whereLike(column, value, "and", true);
201
222
  }
202
223
  whereRegexp(column, value, boolean = "and", not = false) {
224
+ this.invalidateSqlCache();
203
225
  this.wheres.push({ type: "regexp", column, value, boolean, scope: undefined, not });
204
226
  return this;
205
227
  }
206
228
  whereFullText(columns, value, boolean = "and", not = false) {
207
229
  const cols = Array.isArray(columns) ? columns : [columns];
230
+ this.invalidateSqlCache();
208
231
  this.wheres.push({ type: "fulltext", column: "", columns: cols, value, boolean, scope: undefined, not });
209
232
  return this;
210
233
  }
211
234
  whereAll(columns, operator, value, boolean = "and") {
235
+ this.invalidateSqlCache();
212
236
  this.wheres.push({ type: "all", column: "", columns: columns, operator, value, boolean, scope: undefined });
213
237
  return this;
214
238
  }
215
239
  whereAny(columns, operator, value, boolean = "and") {
240
+ this.invalidateSqlCache();
216
241
  this.wheres.push({ type: "any", column: "", columns: columns, operator, value, boolean, scope: undefined });
217
242
  return this;
218
243
  }
219
244
  orderBy(column, direction = "asc") {
245
+ this.invalidateSqlCache();
220
246
  this.orders.push({ column, direction });
221
247
  return this;
222
248
  }
@@ -227,6 +253,7 @@ export class Builder {
227
253
  return this.orderBy(column, "asc");
228
254
  }
229
255
  inRandomOrder() {
256
+ this.invalidateSqlCache();
230
257
  this.randomOrderFlag = true;
231
258
  return this;
232
259
  }
@@ -234,6 +261,7 @@ export class Builder {
234
261
  return this.orderBy(column, "desc");
235
262
  }
236
263
  reorder(column, direction = "asc") {
264
+ this.invalidateSqlCache();
237
265
  this.orders = [];
238
266
  this.randomOrderFlag = false;
239
267
  if (column) {
@@ -242,18 +270,22 @@ export class Builder {
242
270
  return this;
243
271
  }
244
272
  groupBy(...columns) {
273
+ this.invalidateSqlCache();
245
274
  this.groups.push(...columns);
246
275
  return this;
247
276
  }
248
277
  having(column, operator, value) {
278
+ this.invalidateSqlCache();
249
279
  this.havings.push({ column, operator, value, boolean: "and" });
250
280
  return this;
251
281
  }
252
282
  orHaving(column, operator, value) {
283
+ this.invalidateSqlCache();
253
284
  this.havings.push({ column, operator, value, boolean: "or" });
254
285
  return this;
255
286
  }
256
287
  havingRaw(sql, boolean = "and") {
288
+ this.invalidateSqlCache();
257
289
  this.havings.push({ sql, boolean });
258
290
  return this;
259
291
  }
@@ -261,10 +293,12 @@ export class Builder {
261
293
  return this.havingRaw(sql, "or");
262
294
  }
263
295
  limit(count) {
296
+ this.invalidateSqlCache();
264
297
  this.limitValue = count;
265
298
  return this;
266
299
  }
267
300
  offset(count) {
301
+ this.invalidateSqlCache();
268
302
  this.offsetValue = count;
269
303
  return this;
270
304
  }
@@ -273,6 +307,7 @@ export class Builder {
273
307
  }
274
308
  join(table, first, operator, second, type = "INNER") {
275
309
  const joinSql = `${type} JOIN ${this.grammar.wrap(table)} ON ${this.grammar.wrap(first)} ${operator} ${this.grammar.wrap(second)}`;
310
+ this.invalidateSqlCache();
276
311
  this.joins.push(joinSql);
277
312
  return this;
278
313
  }
@@ -283,11 +318,13 @@ export class Builder {
283
318
  return this.join(table, first, operator, second, "RIGHT");
284
319
  }
285
320
  crossJoin(table) {
321
+ this.invalidateSqlCache();
286
322
  this.joins.push(`CROSS JOIN ${this.grammar.wrap(table)}`);
287
323
  return this;
288
324
  }
289
325
  union(query, all = false) {
290
326
  const sql = typeof query === "string" ? query : query.toSql();
327
+ this.invalidateSqlCache();
291
328
  this.unions.push({ query: sql, all });
292
329
  return this;
293
330
  }
@@ -299,10 +336,12 @@ export class Builder {
299
336
  return this;
300
337
  }
301
338
  withoutGlobalScope(scope) {
339
+ this.invalidateSqlCache();
302
340
  this.wheres = this.wheres.filter((where) => where.scope !== scope);
303
341
  return this;
304
342
  }
305
343
  withoutGlobalScopes() {
344
+ this.invalidateSqlCache();
306
345
  this.wheres = this.wheres.filter((where) => !where.scope);
307
346
  return this;
308
347
  }
@@ -407,6 +446,7 @@ export class Builder {
407
446
  return this.withAggregate(relationName, column, "MAX", alias);
408
447
  }
409
448
  addSelect(...columns) {
449
+ this.invalidateSqlCache();
410
450
  if (this.columns.length === 1 && this.columns[0] === "*") {
411
451
  this.columns = [`${this.tableName}.*`];
412
452
  }
@@ -414,15 +454,18 @@ export class Builder {
414
454
  return this;
415
455
  }
416
456
  selectRaw(sql) {
457
+ this.invalidateSqlCache();
417
458
  this.columns.push(sql);
418
459
  return this;
419
460
  }
420
461
  fromSub(query, as) {
421
462
  const sql = typeof query === "string" ? query : query.toSql();
463
+ this.invalidateSqlCache();
422
464
  this.fromRaw = `(${sql}) AS ${this.grammar.wrap(as)}`;
423
465
  return this;
424
466
  }
425
467
  updateFrom(table, first, operator, second) {
468
+ this.invalidateSqlCache();
426
469
  this.updateJoins.push(`INNER JOIN ${this.grammar.wrap(table)} ON ${this.grammar.wrap(first)} ${operator} ${this.grammar.wrap(second)}`);
427
470
  return this;
428
471
  }
@@ -598,6 +641,8 @@ export class Builder {
598
641
  return column.includes("(") || /\s+as\s+/i.test(column) || /^[0-9]+$/.test(column);
599
642
  }
600
643
  toSql() {
644
+ if (!this.parameterize && this.sqlCache)
645
+ return this.sqlCache;
601
646
  const distinct = this.distinctFlag ? "DISTINCT " : "";
602
647
  const from = this.fromRaw || this.grammar.wrap(this.tableName);
603
648
  let sql = `SELECT ${distinct}${this.compileColumns()} FROM ${from}`;
@@ -613,7 +658,10 @@ export class Builder {
613
658
  for (const union of this.unions) {
614
659
  sql += ` UNION${union.all ? " ALL" : ""} ${union.query}`;
615
660
  }
616
- return sql.replace(/\s+/g, " ").trim();
661
+ const compiled = sql.replace(/\s+/g, " ").trim();
662
+ if (!this.parameterize)
663
+ this.sqlCache = compiled;
664
+ return compiled;
617
665
  }
618
666
  async get() {
619
667
  this.bindings = [];
@@ -636,12 +684,7 @@ export class Builder {
636
684
  }
637
685
  }
638
686
  }
639
- const instance = new this.model(row);
640
- instance.$exists = true;
641
- instance.$original = { ...row };
642
- if (typeof instance.setConnection === "function") {
643
- instance.setConnection(this.connection);
644
- }
687
+ const instance = this.model.hydrate(row, this.connection);
645
688
  if (identityMap) {
646
689
  const pk = row[primaryKey];
647
690
  if (pk !== null && pk !== undefined) {
@@ -725,6 +768,7 @@ export class Builder {
725
768
  query.offsetValue = undefined;
726
769
  query.eagerLoads = [];
727
770
  query.lockMode = undefined;
771
+ query.invalidateSqlCache();
728
772
  const result = await query.first();
729
773
  return result ? result[alias] : null;
730
774
  }
@@ -748,6 +792,7 @@ export class Builder {
748
792
  countQuery.limitValue = undefined;
749
793
  countQuery.offsetValue = undefined;
750
794
  countQuery.orders = [];
795
+ countQuery.invalidateSqlCache();
751
796
  const total = await countQuery.count();
752
797
  const data = await this.clone().forPage(page, perPage).get();
753
798
  return {
@@ -1023,6 +1068,7 @@ export class Builder {
1023
1068
  lockForUpdate() {
1024
1069
  const driver = this.connection.getDriverName();
1025
1070
  if (driver !== "sqlite") {
1071
+ this.invalidateSqlCache();
1026
1072
  this.lockMode = "FOR UPDATE";
1027
1073
  }
1028
1074
  return this;
@@ -1030,21 +1076,25 @@ export class Builder {
1030
1076
  sharedLock() {
1031
1077
  const driver = this.connection.getDriverName();
1032
1078
  if (driver === "mysql") {
1079
+ this.invalidateSqlCache();
1033
1080
  this.lockMode = "LOCK IN SHARE MODE";
1034
1081
  }
1035
1082
  else if (driver === "postgres") {
1083
+ this.invalidateSqlCache();
1036
1084
  this.lockMode = "FOR SHARE";
1037
1085
  }
1038
1086
  return this;
1039
1087
  }
1040
1088
  skipLocked() {
1041
1089
  if (this.lockMode) {
1090
+ this.invalidateSqlCache();
1042
1091
  this.lockMode += " SKIP LOCKED";
1043
1092
  }
1044
1093
  return this;
1045
1094
  }
1046
1095
  noWait() {
1047
1096
  if (this.lockMode) {
1097
+ this.invalidateSqlCache();
1048
1098
  this.lockMode += " NOWAIT";
1049
1099
  }
1050
1100
  return this;
@@ -1054,6 +1104,7 @@ export class Builder {
1054
1104
  value = operator;
1055
1105
  operator = "=";
1056
1106
  }
1107
+ this.invalidateSqlCache();
1057
1108
  this.wheres.push({ type: "date", column: column, operator, value, boolean, scope: undefined, dateType: type });
1058
1109
  return this;
1059
1110
  }
@@ -7,6 +7,10 @@ export declare class Schema {
7
7
  private static getGrammar;
8
8
  static create(table: string, callback: (blueprint: Blueprint) => void): Promise<void>;
9
9
  static createIfNotExists(table: string, callback: (blueprint: Blueprint) => void): Promise<void>;
10
+ static createSchema(schema: string): Promise<void>;
11
+ static dropSchema(schema: string, options?: {
12
+ cascade?: boolean;
13
+ }): Promise<void>;
10
14
  static table(table: string, callback: (blueprint: Blueprint) => void): Promise<void>;
11
15
  static drop(table: string): Promise<void>;
12
16
  static dropIfExists(table: string): Promise<void>;
@@ -1,3 +1,4 @@
1
+ import { Connection } from "../connection/Connection.js";
1
2
  import { Blueprint } from "./Blueprint.js";
2
3
  import { SQLiteGrammar } from "./grammars/SQLiteGrammar.js";
3
4
  import { MySqlGrammar } from "./grammars/MySqlGrammar.js";
@@ -61,6 +62,29 @@ export class Schema {
61
62
  await this.getConnection().run(fkSql);
62
63
  }
63
64
  }
65
+ static async createSchema(schema) {
66
+ Connection.assertSafeIdentifier(schema, "schema name");
67
+ const connection = this.getConnection();
68
+ const driver = connection.getDriverName();
69
+ if (driver === "sqlite") {
70
+ throw new Error("Schema creation is not supported for SQLite connections.");
71
+ }
72
+ const grammar = this.getGrammar();
73
+ const keyword = driver === "mysql" ? "DATABASE" : "SCHEMA";
74
+ await connection.run(`CREATE ${keyword} IF NOT EXISTS ${grammar.wrap(schema)}`);
75
+ }
76
+ static async dropSchema(schema, options = {}) {
77
+ Connection.assertSafeIdentifier(schema, "schema name");
78
+ const connection = this.getConnection();
79
+ const driver = connection.getDriverName();
80
+ if (driver === "sqlite") {
81
+ throw new Error("Schema dropping is not supported for SQLite connections.");
82
+ }
83
+ const grammar = this.getGrammar();
84
+ const keyword = driver === "mysql" ? "DATABASE" : "SCHEMA";
85
+ const cascade = driver === "postgres" && options.cascade ? " CASCADE" : "";
86
+ await connection.run(`DROP ${keyword} IF EXISTS ${grammar.wrap(schema)}${cascade}`);
87
+ }
64
88
  static async table(table, callback) {
65
89
  const blueprint = new Blueprint(table);
66
90
  callback(blueprint);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bunnykit/orm",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "description": "An Eloquent-inspired ORM for Bun's native SQL client supporting SQLite, MySQL, and PostgreSQL.",
5
5
  "license": "MIT",
6
6
  "packageManager": "bun@1.3.12",