@strav/database 0.1.0
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.
- package/package.json +46 -0
- package/src/database/database.ts +181 -0
- package/src/database/domain/context.ts +84 -0
- package/src/database/domain/index.ts +17 -0
- package/src/database/domain/manager.ts +274 -0
- package/src/database/domain/wrapper.ts +105 -0
- package/src/database/index.ts +32 -0
- package/src/database/introspector.ts +446 -0
- package/src/database/migration/differ.ts +308 -0
- package/src/database/migration/file_generator.ts +125 -0
- package/src/database/migration/index.ts +18 -0
- package/src/database/migration/runner.ts +145 -0
- package/src/database/migration/sql_generator.ts +378 -0
- package/src/database/migration/tracker.ts +86 -0
- package/src/database/migration/types.ts +189 -0
- package/src/database/query_builder.ts +1034 -0
- package/src/database/seeder.ts +31 -0
- package/src/helpers/identity.ts +12 -0
- package/src/helpers/index.ts +1 -0
- package/src/index.ts +5 -0
- package/src/orm/base_model.ts +427 -0
- package/src/orm/decorators.ts +290 -0
- package/src/orm/index.ts +3 -0
- package/src/providers/database_provider.ts +25 -0
- package/src/providers/index.ts +1 -0
- package/src/schema/database_representation.ts +124 -0
- package/src/schema/define_association.ts +60 -0
- package/src/schema/define_schema.ts +46 -0
- package/src/schema/domain_discovery.ts +83 -0
- package/src/schema/field_builder.ts +160 -0
- package/src/schema/field_definition.ts +69 -0
- package/src/schema/index.ts +22 -0
- package/src/schema/naming.ts +19 -0
- package/src/schema/postgres.ts +109 -0
- package/src/schema/registry.ts +187 -0
- package/src/schema/representation_builder.ts +482 -0
- package/src/schema/type_builder.ts +115 -0
- package/src/schema/types.ts +35 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import Database from './database'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Base class for database seeders.
|
|
5
|
+
*
|
|
6
|
+
* Extend this class and implement {@link run} to populate the database
|
|
7
|
+
* with dev/test data. Use {@link call} to compose sub-seeders from a
|
|
8
|
+
* main `DatabaseSeeder`.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* import { Seeder } from '@stravigor/database/database'
|
|
12
|
+
*
|
|
13
|
+
* export default class DatabaseSeeder extends Seeder {
|
|
14
|
+
* async run(): Promise<void> {
|
|
15
|
+
* await this.call(UserSeeder)
|
|
16
|
+
* await this.call(PostSeeder)
|
|
17
|
+
* }
|
|
18
|
+
* }
|
|
19
|
+
*/
|
|
20
|
+
export abstract class Seeder {
|
|
21
|
+
constructor(protected db: Database) {}
|
|
22
|
+
|
|
23
|
+
/** Insert seed data. */
|
|
24
|
+
abstract run(): Promise<void>
|
|
25
|
+
|
|
26
|
+
/** Invoke another seeder. */
|
|
27
|
+
async call(SeederClass: new (db: Database) => Seeder): Promise<void> {
|
|
28
|
+
const seeder = new SeederClass(this.db)
|
|
29
|
+
await seeder.run()
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import BaseModel from '../orm/base_model'
|
|
2
|
+
|
|
3
|
+
/** Extract a user ID from a BaseModel instance or a raw string/number. */
|
|
4
|
+
export function extractUserId(user: unknown): string {
|
|
5
|
+
if (typeof user === 'string') return user
|
|
6
|
+
if (typeof user === 'number') return String(user)
|
|
7
|
+
if (user instanceof BaseModel) {
|
|
8
|
+
const ctor = user.constructor as typeof BaseModel
|
|
9
|
+
return String((user as unknown as Record<string, unknown>)[ctor.primaryKeyProperty])
|
|
10
|
+
}
|
|
11
|
+
throw new Error('Pass a BaseModel instance or a string/number user ID.')
|
|
12
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { extractUserId } from './identity'
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import { DateTime } from 'luxon'
|
|
2
|
+
import { toSnakeCase, toCamelCase } from '@stravigor/kernel/helpers/strings'
|
|
3
|
+
import { ulid as generateUlid } from '@stravigor/kernel/helpers'
|
|
4
|
+
import { inject } from '@stravigor/kernel/core/inject'
|
|
5
|
+
import {
|
|
6
|
+
getPrimaryKey,
|
|
7
|
+
getReferences,
|
|
8
|
+
getReferenceMeta,
|
|
9
|
+
getAssociates,
|
|
10
|
+
getCasts,
|
|
11
|
+
getEncrypted,
|
|
12
|
+
getUlids,
|
|
13
|
+
} from './decorators'
|
|
14
|
+
import type { ReferenceMetadata, AssociateMetadata } from './decorators'
|
|
15
|
+
import Database from '../database/database'
|
|
16
|
+
import { ConfigurationError, ModelNotFoundError } from '@stravigor/kernel/exceptions/errors'
|
|
17
|
+
|
|
18
|
+
type ModelStatic<T extends BaseModel> = (new (...args: any[]) => T) & typeof BaseModel
|
|
19
|
+
|
|
20
|
+
@inject
|
|
21
|
+
export default class BaseModel {
|
|
22
|
+
private static _db: Database
|
|
23
|
+
|
|
24
|
+
/** Whether this model supports soft deletes. Override in subclass. */
|
|
25
|
+
static softDeletes: boolean = false
|
|
26
|
+
|
|
27
|
+
constructor(db?: Database) {
|
|
28
|
+
if (db) BaseModel._db = db
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The underlying database connection. */
|
|
32
|
+
static get db(): Database {
|
|
33
|
+
if (!BaseModel._db) {
|
|
34
|
+
throw new ConfigurationError(
|
|
35
|
+
'Database not configured. Resolve BaseModel through the container first.'
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
return BaseModel._db
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Derive table name from class name: User → user, OrderItem → order_item */
|
|
42
|
+
static get tableName(): string {
|
|
43
|
+
return toSnakeCase(this.name)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The primary key column name in snake_case (from @primary metadata). */
|
|
47
|
+
static get primaryKeyColumn(): string {
|
|
48
|
+
return toSnakeCase(getPrimaryKey(this))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The primary key property name in camelCase (from @primary metadata). */
|
|
52
|
+
static get primaryKeyProperty(): string {
|
|
53
|
+
return getPrimaryKey(this)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Whether this record was loaded from (or saved to) the database. */
|
|
57
|
+
_exists: boolean = false
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Static CRUD
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/** Find a record by primary key. Returns null if not found or soft-deleted. */
|
|
64
|
+
static async find<T extends BaseModel>(
|
|
65
|
+
this: ModelStatic<T>,
|
|
66
|
+
id: number | string | bigint
|
|
67
|
+
): Promise<T | null> {
|
|
68
|
+
const db = BaseModel.db
|
|
69
|
+
const table = this.tableName
|
|
70
|
+
const pkCol = this.primaryKeyColumn
|
|
71
|
+
const softClause = this.softDeletes ? ' AND "deleted_at" IS NULL' : ''
|
|
72
|
+
const rows = await db.sql.unsafe(
|
|
73
|
+
`SELECT * FROM "${table}" WHERE "${pkCol}" = $1${softClause} LIMIT 1`,
|
|
74
|
+
[id]
|
|
75
|
+
)
|
|
76
|
+
if (rows.length === 0) return null
|
|
77
|
+
return this.hydrate<T>(rows[0] as Record<string, unknown>)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Find a record by primary key or throw. */
|
|
81
|
+
static async findOrFail<T extends BaseModel>(
|
|
82
|
+
this: ModelStatic<T>,
|
|
83
|
+
id: number | string | bigint
|
|
84
|
+
): Promise<T> {
|
|
85
|
+
const result = (await (this as any).find(id)) as T | null
|
|
86
|
+
if (!result) {
|
|
87
|
+
throw new ModelNotFoundError(this.name, id)
|
|
88
|
+
}
|
|
89
|
+
return result
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Retrieve all records (excluding soft-deleted). */
|
|
93
|
+
static async all<T extends BaseModel>(this: ModelStatic<T>): Promise<T[]> {
|
|
94
|
+
const db = BaseModel.db
|
|
95
|
+
const table = this.tableName
|
|
96
|
+
const softClause = this.softDeletes ? ' WHERE "deleted_at" IS NULL' : ''
|
|
97
|
+
const rows = await db.sql.unsafe(`SELECT * FROM "${table}"${softClause}`)
|
|
98
|
+
return rows.map((row: Record<string, unknown>) => this.hydrate<T>(row))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Create a new record, assign attributes, save, and return it. */
|
|
102
|
+
static async create<T extends BaseModel>(
|
|
103
|
+
this: ModelStatic<T>,
|
|
104
|
+
attrs: Record<string, unknown>,
|
|
105
|
+
trx?: any
|
|
106
|
+
): Promise<T> {
|
|
107
|
+
const instance = new this()
|
|
108
|
+
instance.merge(attrs)
|
|
109
|
+
await instance.save(trx)
|
|
110
|
+
return instance
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Instance helpers
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
/** Assign properties from a plain object onto this model instance. */
|
|
118
|
+
merge(data: Record<string, unknown>): this {
|
|
119
|
+
for (const [key, value] of Object.entries(data)) {
|
|
120
|
+
;(this as any)[key] = value
|
|
121
|
+
}
|
|
122
|
+
return this
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Serialize this model for JSON.stringify() (Vue islands, API responses, etc.).
|
|
127
|
+
* Skips internal (_-prefixed), @reference, and @associate properties.
|
|
128
|
+
* Converts DateTime to ISO strings. Leaves @cast-parsed values as-is.
|
|
129
|
+
*/
|
|
130
|
+
toJSON(): Record<string, unknown> {
|
|
131
|
+
const ctor = this.constructor as typeof BaseModel
|
|
132
|
+
const refProps = new Set(getReferences(ctor))
|
|
133
|
+
const assocProps = new Set(getAssociates(ctor).map(a => a.property))
|
|
134
|
+
const encryptedProps = new Set(getEncrypted(ctor))
|
|
135
|
+
const result: Record<string, unknown> = {}
|
|
136
|
+
|
|
137
|
+
for (const key of Object.keys(this)) {
|
|
138
|
+
if (key.startsWith('_')) continue
|
|
139
|
+
if (refProps.has(key)) continue
|
|
140
|
+
if (assocProps.has(key)) continue
|
|
141
|
+
if (encryptedProps.has(key)) continue
|
|
142
|
+
|
|
143
|
+
const value = (this as any)[key]
|
|
144
|
+
if (value instanceof DateTime) {
|
|
145
|
+
result[key] = value.toISO()
|
|
146
|
+
} else if (typeof value === 'bigint') {
|
|
147
|
+
result[key] =
|
|
148
|
+
value <= Number.MAX_SAFE_INTEGER && value >= Number.MIN_SAFE_INTEGER
|
|
149
|
+
? Number(value)
|
|
150
|
+
: String(value)
|
|
151
|
+
} else {
|
|
152
|
+
result[key] = value
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return result
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Instance CRUD
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
/** INSERT or UPDATE depending on whether the record exists. */
|
|
164
|
+
async save(trx?: any): Promise<this> {
|
|
165
|
+
const ctor = this.constructor as typeof BaseModel
|
|
166
|
+
const conn = trx ?? BaseModel.db.sql
|
|
167
|
+
const table = ctor.tableName
|
|
168
|
+
|
|
169
|
+
if (this._exists) {
|
|
170
|
+
return this.performUpdate(conn, table)
|
|
171
|
+
} else {
|
|
172
|
+
return this.performInsert(conn, table)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Soft-delete (if model supports it) or hard-delete. */
|
|
177
|
+
async delete(trx?: any): Promise<void> {
|
|
178
|
+
const ctor = this.constructor as typeof BaseModel
|
|
179
|
+
const conn = trx ?? BaseModel.db.sql
|
|
180
|
+
const table = ctor.tableName
|
|
181
|
+
const pkCol = ctor.primaryKeyColumn
|
|
182
|
+
const pkProp = ctor.primaryKeyProperty
|
|
183
|
+
const pkValue = (this as any)[pkProp]
|
|
184
|
+
|
|
185
|
+
if (ctor.softDeletes && (this as any).deletedAt === null) {
|
|
186
|
+
const now = DateTime.now()
|
|
187
|
+
;(this as any).deletedAt = now
|
|
188
|
+
await conn.unsafe(`UPDATE "${table}" SET "deleted_at" = $1 WHERE "${pkCol}" = $2`, [
|
|
189
|
+
now.toJSDate(),
|
|
190
|
+
pkValue,
|
|
191
|
+
])
|
|
192
|
+
} else {
|
|
193
|
+
await conn.unsafe(`DELETE FROM "${table}" WHERE "${pkCol}" = $1`, [pkValue])
|
|
194
|
+
this._exists = false
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Always hard-delete, regardless of soft-delete support. */
|
|
199
|
+
async forceDelete(trx?: any): Promise<void> {
|
|
200
|
+
const conn = trx ?? BaseModel.db.sql
|
|
201
|
+
const ctor = this.constructor as typeof BaseModel
|
|
202
|
+
const table = ctor.tableName
|
|
203
|
+
const pkCol = ctor.primaryKeyColumn
|
|
204
|
+
const pkProp = ctor.primaryKeyProperty
|
|
205
|
+
const pkValue = (this as any)[pkProp]
|
|
206
|
+
await conn.unsafe(`DELETE FROM "${table}" WHERE "${pkCol}" = $1`, [pkValue])
|
|
207
|
+
this._exists = false
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
// Relationship loading
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Eagerly load one or more relationships by name.
|
|
216
|
+
*
|
|
217
|
+
* Supports both `@reference` (belongs-to) and `@associate` (many-to-many)
|
|
218
|
+
* relationships. Returns `this` for chaining.
|
|
219
|
+
*
|
|
220
|
+
* @example
|
|
221
|
+
* const team = await Team.find(1)
|
|
222
|
+
* await team.load('members') // many-to-many
|
|
223
|
+
* await user.load('profile', 'teams') // multiple relations
|
|
224
|
+
*/
|
|
225
|
+
async load(...relations: string[]): Promise<this> {
|
|
226
|
+
const ctor = this.constructor as typeof BaseModel
|
|
227
|
+
const refMetas = getReferenceMeta(ctor)
|
|
228
|
+
const assocMetas = getAssociates(ctor)
|
|
229
|
+
|
|
230
|
+
for (const relation of relations) {
|
|
231
|
+
const refMeta = refMetas.find(r => r.property === relation)
|
|
232
|
+
if (refMeta) {
|
|
233
|
+
await this.loadReference(refMeta)
|
|
234
|
+
continue
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const assocMeta = assocMetas.find(a => a.property === relation)
|
|
238
|
+
if (assocMeta) {
|
|
239
|
+
await this.loadAssociation(assocMeta)
|
|
240
|
+
continue
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
throw new Error(`Unknown relation "${relation}" on ${ctor.name}`)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return this
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private async loadReference(meta: ReferenceMetadata): Promise<void> {
|
|
250
|
+
const db = BaseModel.db
|
|
251
|
+
const fkValue = (this as any)[meta.foreignKey]
|
|
252
|
+
|
|
253
|
+
if (fkValue === null || fkValue === undefined) {
|
|
254
|
+
;(this as any)[meta.property] = null
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const targetTable = toSnakeCase(meta.model)
|
|
259
|
+
const targetPKCol = toSnakeCase(meta.targetPK)
|
|
260
|
+
const rows = await db.sql.unsafe(
|
|
261
|
+
`SELECT * FROM "${targetTable}" WHERE "${targetPKCol}" = $1 LIMIT 1`,
|
|
262
|
+
[fkValue]
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
;(this as any)[meta.property] =
|
|
266
|
+
rows.length > 0 ? hydrateRow(rows[0] as Record<string, unknown>) : null
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private async loadAssociation(meta: AssociateMetadata): Promise<void> {
|
|
270
|
+
const db = BaseModel.db
|
|
271
|
+
const ctor = this.constructor as typeof BaseModel
|
|
272
|
+
const pkValue = (this as any)[ctor.primaryKeyProperty]
|
|
273
|
+
|
|
274
|
+
const targetTable = toSnakeCase(meta.model)
|
|
275
|
+
const rows = await db.sql.unsafe(
|
|
276
|
+
`SELECT t.* FROM "${targetTable}" t ` +
|
|
277
|
+
`INNER JOIN "${meta.through}" p ON p."${meta.otherKey}" = t."${toSnakeCase(meta.targetPK)}" ` +
|
|
278
|
+
`WHERE p."${meta.foreignKey}" = $1`,
|
|
279
|
+
[pkValue]
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
;(this as any)[meta.property] = (rows as Record<string, unknown>[]).map(row => hydrateRow(row))
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// Private helpers
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
private async performInsert(conn: any, table: string): Promise<this> {
|
|
290
|
+
// Auto-generate ULIDs for fields marked with @ulid decorator
|
|
291
|
+
const ctor = this.constructor as typeof BaseModel
|
|
292
|
+
const ulidFields = getUlids(ctor)
|
|
293
|
+
for (const field of ulidFields) {
|
|
294
|
+
const snakeField = toSnakeCase(field)
|
|
295
|
+
if (!(this as any)[field]) {
|
|
296
|
+
(this as any)[field] = generateUlid()
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const data = this.dehydrate()
|
|
301
|
+
const columns = Object.keys(data)
|
|
302
|
+
const values = Object.values(data)
|
|
303
|
+
|
|
304
|
+
let sql: string
|
|
305
|
+
if (columns.length === 0) {
|
|
306
|
+
sql = `INSERT INTO "${table}" DEFAULT VALUES RETURNING *`
|
|
307
|
+
} else {
|
|
308
|
+
const colNames = columns.map(c => `"${c}"`).join(', ')
|
|
309
|
+
const placeholders = columns.map((_, i) => `$${i + 1}`).join(', ')
|
|
310
|
+
sql = `INSERT INTO "${table}" (${colNames}) VALUES (${placeholders}) RETURNING *`
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const rows = await conn.unsafe(sql, values)
|
|
314
|
+
|
|
315
|
+
if (rows.length > 0) {
|
|
316
|
+
this.hydrateFrom(rows[0] as Record<string, unknown>)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
this._exists = true
|
|
320
|
+
return this
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private async performUpdate(conn: any, table: string): Promise<this> {
|
|
324
|
+
if ('updatedAt' in this) (this as any).updatedAt = DateTime.now()
|
|
325
|
+
|
|
326
|
+
const ctor = this.constructor as typeof BaseModel
|
|
327
|
+
const pkCol = ctor.primaryKeyColumn
|
|
328
|
+
|
|
329
|
+
const data = this.dehydrate()
|
|
330
|
+
const pkValue = data[pkCol]
|
|
331
|
+
delete data[pkCol]
|
|
332
|
+
|
|
333
|
+
const columns = Object.keys(data)
|
|
334
|
+
const values = Object.values(data)
|
|
335
|
+
const setClauses = columns.map((col, i) => `"${col}" = $${i + 1}`).join(', ')
|
|
336
|
+
values.push(pkValue)
|
|
337
|
+
|
|
338
|
+
await conn.unsafe(
|
|
339
|
+
`UPDATE "${table}" SET ${setClauses} WHERE "${pkCol}" = $${values.length}`,
|
|
340
|
+
values
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
return this
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Convert a DB row to a model instance.
|
|
348
|
+
* snake_case columns → camelCase properties. Date → DateTime.
|
|
349
|
+
*/
|
|
350
|
+
/** @internal Used by QueryBuilder to create model instances from DB rows. */
|
|
351
|
+
static hydrate<T extends BaseModel>(this: new () => T, row: Record<string, unknown>): T {
|
|
352
|
+
const instance = new this()
|
|
353
|
+
instance.hydrateFrom(row)
|
|
354
|
+
instance._exists = true
|
|
355
|
+
return instance
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Populate this instance's properties from a DB row. */
|
|
359
|
+
private hydrateFrom(row: Record<string, unknown>): void {
|
|
360
|
+
const casts = getCasts(this.constructor as typeof BaseModel)
|
|
361
|
+
|
|
362
|
+
for (const [column, value] of Object.entries(row)) {
|
|
363
|
+
const prop = toCamelCase(column)
|
|
364
|
+
|
|
365
|
+
if (value == null) {
|
|
366
|
+
;(this as any)[prop] = value
|
|
367
|
+
continue
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const castDef = casts.get(prop)
|
|
371
|
+
if (castDef) {
|
|
372
|
+
;(this as any)[prop] = castDef.get(value)
|
|
373
|
+
} else if (value instanceof Date) {
|
|
374
|
+
;(this as any)[prop] = DateTime.fromJSDate(value)
|
|
375
|
+
} else {
|
|
376
|
+
;(this as any)[prop] = value
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Convert model properties to DB columns.
|
|
383
|
+
* Skips _-prefixed props and @reference-decorated properties.
|
|
384
|
+
*/
|
|
385
|
+
private dehydrate(): Record<string, unknown> {
|
|
386
|
+
const ctor = this.constructor as typeof BaseModel
|
|
387
|
+
const refProps = new Set(getReferences(ctor))
|
|
388
|
+
const assocProps = new Set(getAssociates(ctor).map(a => a.property))
|
|
389
|
+
const casts = getCasts(ctor)
|
|
390
|
+
const data: Record<string, unknown> = {}
|
|
391
|
+
|
|
392
|
+
for (const key of Object.keys(this)) {
|
|
393
|
+
if (key.startsWith('_')) continue
|
|
394
|
+
if (refProps.has(key)) continue
|
|
395
|
+
if (assocProps.has(key)) continue
|
|
396
|
+
|
|
397
|
+
const value = (this as any)[key]
|
|
398
|
+
const column = toSnakeCase(key)
|
|
399
|
+
|
|
400
|
+
if (value == null) {
|
|
401
|
+
data[column] = value
|
|
402
|
+
continue
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const castDef = casts.get(key)
|
|
406
|
+
if (castDef) {
|
|
407
|
+
data[column] = castDef.set(value)
|
|
408
|
+
} else if (value instanceof DateTime) {
|
|
409
|
+
data[column] = value.toJSDate()
|
|
410
|
+
} else {
|
|
411
|
+
data[column] = value
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return data
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Convert a raw DB row to a plain object with camelCase keys and DateTime hydration. */
|
|
420
|
+
export function hydrateRow(row: Record<string, unknown>): Record<string, unknown> {
|
|
421
|
+
const obj: Record<string, unknown> = {}
|
|
422
|
+
for (const [column, value] of Object.entries(row)) {
|
|
423
|
+
const prop = toCamelCase(column)
|
|
424
|
+
obj[prop] = value instanceof Date ? DateTime.fromJSDate(value) : value
|
|
425
|
+
}
|
|
426
|
+
return obj
|
|
427
|
+
}
|