mevn-orm 3.2.2 → 4.0.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.
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ configureDatabase,
4
+ setMigrationConfig,
5
+ makeMigration,
6
+ migrateLatest,
7
+ migrateRollback,
8
+ migrateList,
9
+ migrateCurrentVersion,
10
+ } from '../index.js'
11
+
12
+ const [command = 'latest', name] = process.argv.slice(2)
13
+
14
+ const databaseConfig: Parameters<typeof configureDatabase>[0] = {
15
+ dialect: (process.env.DB_DIALECT as
16
+ | 'sqlite'
17
+ | 'better-sqlite3'
18
+ | 'mysql'
19
+ | 'mysql2'
20
+ | 'postgres'
21
+ | 'postgresql'
22
+ | 'pg'
23
+ | 'pgnative'
24
+ | 'cockroachdb'
25
+ | 'redshift'
26
+ | 'mssql'
27
+ | 'oracledb'
28
+ | 'oracle') ?? 'sqlite',
29
+ filename: process.env.DB_FILENAME ?? './dev.sqlite',
30
+ }
31
+
32
+ if (process.env.DATABASE_URL) {
33
+ databaseConfig.connectionString = process.env.DATABASE_URL
34
+ }
35
+ if (process.env.DB_HOST) {
36
+ databaseConfig.host = process.env.DB_HOST
37
+ }
38
+ if (process.env.DB_PORT) {
39
+ databaseConfig.port = Number(process.env.DB_PORT)
40
+ }
41
+ if (process.env.DB_USER) {
42
+ databaseConfig.user = process.env.DB_USER
43
+ }
44
+ if (process.env.DB_PASSWORD) {
45
+ databaseConfig.password = process.env.DB_PASSWORD
46
+ }
47
+ if (process.env.DB_NAME) {
48
+ databaseConfig.database = process.env.DB_NAME
49
+ }
50
+ if (process.env.DB_SSL === 'true') {
51
+ databaseConfig.ssl = true
52
+ }
53
+
54
+ configureDatabase(databaseConfig)
55
+
56
+ setMigrationConfig({
57
+ directory: process.env.MIGRATIONS_DIR ?? './migrations',
58
+ extension: process.env.MIGRATIONS_EXT ?? 'ts',
59
+ })
60
+
61
+ const run = async (): Promise<void> => {
62
+ switch (command) {
63
+ case 'make': {
64
+ if (!name) {
65
+ throw new Error('Usage: node --import tsx scripts/migrate.ts make <name>')
66
+ }
67
+ const file = await makeMigration(name)
68
+ console.log(file)
69
+ return
70
+ }
71
+ case 'latest': {
72
+ const result = await migrateLatest()
73
+ console.log(JSON.stringify(result, null, 2))
74
+ return
75
+ }
76
+ case 'rollback': {
77
+ const all = process.argv.includes('--all')
78
+ const result = await migrateRollback(undefined, all)
79
+ console.log(JSON.stringify(result, null, 2))
80
+ return
81
+ }
82
+ case 'list': {
83
+ const result = await migrateList()
84
+ console.log(JSON.stringify(result, null, 2))
85
+ return
86
+ }
87
+ case 'version': {
88
+ const version = await migrateCurrentVersion()
89
+ console.log(version)
90
+ return
91
+ }
92
+ default:
93
+ throw new Error(`Unknown command "${command}". Use make|latest|rollback|list|version`)
94
+ }
95
+ }
96
+
97
+ await run()
package/src/config.ts ADDED
@@ -0,0 +1,270 @@
1
+ import type { Knex } from 'knex'
2
+ import knexModule from 'knex'
3
+
4
+ type KnexFactory = (config: Knex.Config) => Knex
5
+
6
+ const knexFactory: KnexFactory =
7
+ (knexModule as unknown as { knex?: KnexFactory }).knex ??
8
+ (knexModule as unknown as KnexFactory)
9
+
10
+ const toError = (error: unknown): Error => {
11
+ if (error instanceof Error) {
12
+ return error
13
+ }
14
+
15
+ return new Error(String(error))
16
+ }
17
+
18
+ let DB: Knex | undefined
19
+ let defaultMigrationConfig: Knex.MigratorConfig = {}
20
+
21
+ type SimpleDialect =
22
+ | 'sqlite'
23
+ | 'better-sqlite3'
24
+ | 'mysql'
25
+ | 'mysql2'
26
+ | 'postgres'
27
+ | 'postgresql'
28
+ | 'pg'
29
+ | 'pgnative'
30
+ | 'cockroachdb'
31
+ | 'redshift'
32
+ | 'mssql'
33
+ | 'oracledb'
34
+ | 'oracle'
35
+
36
+ interface SimpleDatabaseConfig {
37
+ dialect: SimpleDialect
38
+ connectionString?: string
39
+ filename?: string
40
+ host?: string
41
+ port?: number
42
+ user?: string
43
+ password?: string
44
+ database?: string
45
+ ssl?: boolean | Record<string, unknown>
46
+ debug?: boolean
47
+ pool?: Knex.PoolConfig
48
+ }
49
+
50
+ interface MigrationResult {
51
+ batch: number
52
+ log: string[]
53
+ }
54
+
55
+ /** Returns the active Knex instance or throws if the ORM has not been configured. */
56
+ const getDB = (): Knex => {
57
+ if (!DB) {
58
+ throw new Error('Mevn ORM is not configured. Call configure({ client, connection, ... }) before using Model.')
59
+ }
60
+
61
+ return DB
62
+ }
63
+
64
+ /** Configures the ORM using a Knex config object or an existing Knex instance. */
65
+ const configure = (config: Knex.Config | Knex): Knex => {
66
+ if (typeof config === 'function') {
67
+ DB = config
68
+ return DB
69
+ }
70
+
71
+ DB = knexFactory(config)
72
+ return DB
73
+ }
74
+
75
+ /** Sets default migration options used by migration helpers. */
76
+ const setMigrationConfig = (config: Knex.MigratorConfig): Knex.MigratorConfig => {
77
+ defaultMigrationConfig = { ...config }
78
+ return { ...defaultMigrationConfig }
79
+ }
80
+
81
+ /** Returns the currently configured default migration options. */
82
+ const getMigrationConfig = (): Knex.MigratorConfig => ({ ...defaultMigrationConfig })
83
+
84
+ const resolveMigrationConfig = (config?: Knex.MigratorConfig): Knex.MigratorConfig => ({
85
+ ...defaultMigrationConfig,
86
+ ...(config ?? {}),
87
+ })
88
+
89
+ const normalizeDialect = (dialect: SimpleDialect): string => {
90
+ switch (dialect) {
91
+ case 'sqlite':
92
+ return 'sqlite3'
93
+ case 'mysql':
94
+ return 'mysql2'
95
+ case 'postgres':
96
+ case 'postgresql':
97
+ case 'pg':
98
+ return 'pg'
99
+ case 'oracle':
100
+ return 'oracledb'
101
+ default:
102
+ return dialect
103
+ }
104
+ }
105
+
106
+ const requireField = (value: unknown, field: string, dialect: string): void => {
107
+ if (value === undefined || value === null || value === '') {
108
+ throw new Error(`Missing required field "${field}" for dialect "${dialect}".`)
109
+ }
110
+ }
111
+
112
+ const buildConnection = (config: SimpleDatabaseConfig): NonNullable<Knex.Config['connection']> => {
113
+ if (config.connectionString) {
114
+ return config.connectionString
115
+ }
116
+
117
+ const client = normalizeDialect(config.dialect)
118
+
119
+ if (client === 'sqlite3' || client === 'better-sqlite3') {
120
+ requireField(config.filename, 'filename', config.dialect)
121
+ return { filename: config.filename as string }
122
+ }
123
+
124
+ if (client === 'mssql') {
125
+ const server = config.host
126
+ requireField(server, 'host', config.dialect)
127
+ requireField(config.user, 'user', config.dialect)
128
+ requireField(config.database, 'database', config.dialect)
129
+ const connection: Record<string, unknown> = {
130
+ server: server as string,
131
+ user: config.user as string,
132
+ database: config.database as string,
133
+ }
134
+ if (typeof config.port === 'number') {
135
+ connection.port = config.port
136
+ }
137
+ if (config.password !== undefined) {
138
+ connection.password = config.password
139
+ }
140
+ if (config.ssl) {
141
+ connection.options = { encrypt: true }
142
+ }
143
+ return connection
144
+ }
145
+
146
+ requireField(config.host, 'host', config.dialect)
147
+ requireField(config.user, 'user', config.dialect)
148
+ requireField(config.database, 'database', config.dialect)
149
+ const connection: Record<string, unknown> = {
150
+ host: config.host as string,
151
+ user: config.user as string,
152
+ database: config.database as string,
153
+ }
154
+ if (typeof config.port === 'number') {
155
+ connection.port = config.port
156
+ }
157
+ if (config.password !== undefined) {
158
+ connection.password = config.password
159
+ }
160
+ if (config.ssl !== undefined) {
161
+ connection.ssl = config.ssl
162
+ }
163
+ return connection
164
+ }
165
+
166
+ /** Builds a Knex config from a simplified, dialect-first configuration object. */
167
+ const createKnexConfig = (config: SimpleDatabaseConfig): Knex.Config => {
168
+ const client = normalizeDialect(config.dialect)
169
+ const base: Knex.Config = {
170
+ client,
171
+ connection: buildConnection(config),
172
+ }
173
+ if (config.pool) {
174
+ base.pool = config.pool
175
+ }
176
+ if (typeof config.debug === 'boolean') {
177
+ base.debug = config.debug
178
+ }
179
+
180
+ if (client === 'sqlite3') {
181
+ base.useNullAsDefault = true
182
+ }
183
+
184
+ return base
185
+ }
186
+
187
+ /** Configures the ORM from simplified database options. */
188
+ const configureDatabase = (config: SimpleDatabaseConfig): Knex => configure(createKnexConfig(config))
189
+
190
+ /** Generates a migration file and returns its path. */
191
+ const makeMigration = async (name: string, config?: Knex.MigratorConfig): Promise<string> => {
192
+ try {
193
+ return await getDB().migrate.make(name, resolveMigrationConfig(config))
194
+ } catch (error) {
195
+ throw toError(error)
196
+ }
197
+ }
198
+
199
+ /** Runs pending migrations and returns the batch number and migration filenames. */
200
+ const migrateLatest = async (config?: Knex.MigratorConfig): Promise<MigrationResult> => {
201
+ try {
202
+ const [batch, log] = await getDB().migrate.latest(resolveMigrationConfig(config))
203
+ return { batch, log }
204
+ } catch (error) {
205
+ throw toError(error)
206
+ }
207
+ }
208
+
209
+ /** Rolls back migrations. Set `all` to true to rollback all completed batches. */
210
+ const migrateRollback = async (config?: Knex.MigratorConfig, all = false): Promise<MigrationResult> => {
211
+ try {
212
+ const [batch, log] = all
213
+ ? await getDB().migrate.rollback(resolveMigrationConfig(config), true)
214
+ : await getDB().migrate.rollback(resolveMigrationConfig(config))
215
+ return { batch, log }
216
+ } catch (error) {
217
+ throw toError(error)
218
+ }
219
+ }
220
+
221
+ /** Returns the current migration version recorded by Knex. */
222
+ const migrateCurrentVersion = async (config?: Knex.MigratorConfig): Promise<string> => {
223
+ try {
224
+ return await getDB().migrate.currentVersion(resolveMigrationConfig(config))
225
+ } catch (error) {
226
+ throw toError(error)
227
+ }
228
+ }
229
+
230
+ /** Returns completed and pending migration filenames. */
231
+ const migrateList = async (config?: Knex.MigratorConfig): Promise<{ completed: string[]; pending: string[] }> => {
232
+ try {
233
+ const [completed, pending] = await getDB().migrate.list(resolveMigrationConfig(config))
234
+ const toName = (entry: unknown): string => {
235
+ if (typeof entry === 'string') {
236
+ return entry
237
+ }
238
+ if (entry && typeof entry === 'object') {
239
+ if ('name' in entry) {
240
+ return String((entry as { name: unknown }).name)
241
+ }
242
+ if ('file' in entry) {
243
+ return String((entry as { file: unknown }).file)
244
+ }
245
+ }
246
+ return String(entry)
247
+ }
248
+ return {
249
+ completed: completed.map(toName),
250
+ pending: pending.map(toName),
251
+ }
252
+ } catch (error) {
253
+ throw toError(error)
254
+ }
255
+ }
256
+
257
+ export {
258
+ DB,
259
+ getDB,
260
+ configure,
261
+ createKnexConfig,
262
+ configureDatabase,
263
+ setMigrationConfig,
264
+ getMigrationConfig,
265
+ makeMigration,
266
+ migrateLatest,
267
+ migrateRollback,
268
+ migrateCurrentVersion,
269
+ migrateList,
270
+ }
package/src/model.ts ADDED
@@ -0,0 +1,301 @@
1
+ import type { Knex } from 'knex'
2
+ import pluralize from 'pluralize'
3
+ import { getDB } from './config.js'
4
+ import { createRelationshipMethods } from './relationships.js'
5
+
6
+ type Row = Record<string, unknown>
7
+
8
+ const toError = (error: unknown): Error => {
9
+ if (error instanceof Error) {
10
+ return error
11
+ }
12
+
13
+ return new Error(String(error))
14
+ }
15
+
16
+ class Model {
17
+ [key: string]: any
18
+
19
+ #private: string[]
20
+
21
+ static currentTable: string = pluralize(this.name.toLowerCase())
22
+ // `where()` stores a static scoped query consumed by `first()`.
23
+ static currentQuery: Knex.QueryBuilder<Row, Row[]> | undefined
24
+
25
+ fillable: string[]
26
+ hidden: string[]
27
+ modelName: string
28
+ table: string
29
+ id?: number
30
+
31
+ constructor(properties: Row = {}) {
32
+ Object.assign(this, properties)
33
+ this.fillable = []
34
+ this.hidden = []
35
+ this.#private = ['fillable', 'hidden']
36
+ this.modelName = this.constructor.name.toLowerCase()
37
+ this.table = pluralize(this.constructor.name.toLowerCase())
38
+ }
39
+
40
+ /** Inserts the current model using `fillable` attributes and reloads it from the database. */
41
+ async save(): Promise<this> {
42
+ try {
43
+ const rows: Row = {}
44
+ for (const field of this.fillable) {
45
+ rows[field] = this[field]
46
+ }
47
+
48
+ const inserted = await getDB()(this.table).insert(rows)
49
+ const idValue = Array.isArray(inserted) ? inserted[0] : inserted
50
+ const id = typeof idValue === 'bigint' ? Number(idValue) : Number(idValue)
51
+ const fields = await getDB()(this.table).where({ id }).first<Row>()
52
+
53
+ if (!fields) {
54
+ throw new Error(`Failed to load inserted record for table "${this.table}"`)
55
+ }
56
+
57
+ Object.assign(this, fields)
58
+ this.id = id
59
+ return this.stripColumns(this, true)
60
+ } catch (error) {
61
+ throw toError(error)
62
+ }
63
+ }
64
+
65
+ /** Updates the current row by primary key and returns a refreshed model instance. */
66
+ async update(properties: Row): Promise<this> {
67
+ if (this.id === undefined) {
68
+ throw new Error('Cannot update model without id')
69
+ }
70
+
71
+ try {
72
+ await getDB()(this.table).where({ id: this.id }).update(properties)
73
+ const fields = await getDB()(this.table).where({ id: this.id }).first<Row>()
74
+
75
+ if (!fields) {
76
+ throw new Error(`Failed to load updated record for table "${this.table}"`)
77
+ }
78
+
79
+ const next = new (this.constructor as new (props: Row) => this)(fields)
80
+ return this.stripColumns(next)
81
+ } catch (error) {
82
+ throw toError(error)
83
+ }
84
+ }
85
+
86
+ /** Deletes the current row by primary key. */
87
+ async delete(): Promise<void> {
88
+ if (this.id === undefined) {
89
+ throw new Error('Cannot delete model without id')
90
+ }
91
+
92
+ try {
93
+ await getDB()(this.table).where({ id: this.id }).del()
94
+ } catch (error) {
95
+ throw toError(error)
96
+ }
97
+ }
98
+
99
+ /** Updates rows in the model table, optionally scoped by `where()`. */
100
+ static async update(properties: Row): Promise<number | undefined> {
101
+ try {
102
+ const table = pluralize(this.name.toLowerCase())
103
+ const query = this.currentQuery ?? getDB()(table)
104
+ return await query.update(properties)
105
+ } catch (error) {
106
+ throw toError(error)
107
+ } finally {
108
+ this.currentQuery = undefined
109
+ }
110
+ }
111
+
112
+ /** Deletes rows in the model table, optionally scoped by `where()`. */
113
+ static async destroy(): Promise<number | undefined> {
114
+ try {
115
+ const table = pluralize(this.name.toLowerCase())
116
+ const query = this.currentQuery ?? getDB()(table)
117
+ return await query.delete()
118
+ } catch (error) {
119
+ throw toError(error)
120
+ } finally {
121
+ this.currentQuery = undefined
122
+ }
123
+ }
124
+
125
+ /** Finds a single model by primary key. */
126
+ static async find(this: typeof Model, id: number | string, columns: string | string[] = '*'): Promise<Model | null> {
127
+ const table = pluralize(this.name.toLowerCase())
128
+
129
+ try {
130
+ const fields = await getDB()(table).where({ id }).first<Row>(columns as never)
131
+ return fields ? new this(fields) : null
132
+ } catch (error) {
133
+ throw toError(error)
134
+ }
135
+ }
136
+
137
+ /** Finds a model by primary key or throws if it does not exist. */
138
+ static async findOrFail(this: typeof Model, id: number | string, columns: string | string[] = '*'): Promise<Model> {
139
+ const found = await this.find(id, columns)
140
+ if (!found) {
141
+ throw new Error(`${this.name} with id "${id}" not found`)
142
+ }
143
+
144
+ return found
145
+ }
146
+
147
+ /** Creates and returns a single model record. */
148
+ static async create(this: typeof Model, properties: Row): Promise<Model> {
149
+ const table = pluralize(this.name.toLowerCase())
150
+
151
+ try {
152
+ const inserted = await getDB()(table).insert(properties)
153
+ const idValue = Array.isArray(inserted) ? inserted[0] : inserted
154
+ const id = typeof idValue === 'bigint' ? Number(idValue) : Number(idValue)
155
+ const record = await getDB()(table).where({ id }).first<Row>()
156
+
157
+ if (!record) {
158
+ throw new Error(`Failed to load created record for table "${table}"`)
159
+ }
160
+
161
+ const model = new this(record)
162
+ return model.stripColumns(model)
163
+ } catch (error) {
164
+ throw toError(error)
165
+ }
166
+ }
167
+
168
+ /** Creates multiple model records and returns created model instances. */
169
+ static async createMany(this: typeof Model, properties: Row[]): Promise<Model[]> {
170
+ if (properties.length === 0) {
171
+ return []
172
+ }
173
+
174
+ try {
175
+ const records: Model[] = []
176
+ for (const property of properties) {
177
+ records.push(await this.create(property))
178
+ }
179
+ return records
180
+ } catch (error) {
181
+ throw toError(error)
182
+ }
183
+ }
184
+
185
+ /** Returns the first matching row or creates it with merged values when missing. */
186
+ static async firstOrCreate(this: typeof Model, attributes: Row, values: Row = {}): Promise<Model> {
187
+ const table = pluralize(this.name.toLowerCase())
188
+ try {
189
+ const record = await getDB()(table).where(attributes).first<Row>()
190
+ if (record) {
191
+ const model = new this(record)
192
+ return model.stripColumns(model)
193
+ }
194
+
195
+ return this.create({ ...attributes, ...values })
196
+ } catch (error) {
197
+ throw toError(error)
198
+ }
199
+ }
200
+
201
+ /** Applies a query scope used by chained static query methods. */
202
+ static where(this: typeof Model, conditions: Row = {}): typeof Model {
203
+ const table = pluralize(this.name.toLowerCase())
204
+ this.currentQuery = getDB()(table).where(conditions) as Knex.QueryBuilder<Row, Row[]>
205
+ return this
206
+ }
207
+
208
+ /** Returns the first model for the current scope (or table if unscoped). */
209
+ static async first(this: typeof Model, columns: string | string[] = '*'): Promise<Model | null> {
210
+ try {
211
+ const table = pluralize(this.name.toLowerCase())
212
+ const query = this.currentQuery ?? getDB()(table)
213
+ const rows = await query.first<Row>(columns as never)
214
+ return rows ? new this(rows) : null
215
+ } catch (error) {
216
+ throw toError(error)
217
+ } finally {
218
+ this.currentQuery = undefined
219
+ }
220
+ }
221
+
222
+ /** Returns all models for the current scope (or table if unscoped). */
223
+ static async all(this: typeof Model, columns: string | string[] = '*'): Promise<Model[]> {
224
+ try {
225
+ const table = pluralize(this.name.toLowerCase())
226
+ const query = this.currentQuery ?? getDB()(table)
227
+ const rows = await query.select<Row[]>(columns as never)
228
+ return rows.map((row) => new this(row))
229
+ } catch (error) {
230
+ throw toError(error)
231
+ } finally {
232
+ this.currentQuery = undefined
233
+ }
234
+ }
235
+
236
+ /** Returns a row count for the current scope (or table if unscoped). */
237
+ static async count(this: typeof Model, column = '*'): Promise<number> {
238
+ try {
239
+ const table = pluralize(this.name.toLowerCase())
240
+ const query = this.currentQuery ?? getDB()(table)
241
+ const result = await query.count<{ count: string | number }>({ count: column }).first()
242
+ if (!result) {
243
+ return 0
244
+ }
245
+
246
+ return Number(result.count)
247
+ } catch (error) {
248
+ throw toError(error)
249
+ } finally {
250
+ this.currentQuery = undefined
251
+ }
252
+ }
253
+
254
+ /** Removes internal and hidden fields from a model instance. */
255
+ stripColumns<T extends Model>(model: T, keepInternalState = false): T {
256
+ // Hide internal ORM fields and caller-defined hidden attributes.
257
+ const privateKeys = keepInternalState ? [] : this.#private
258
+ const hiddenKeys = Array.isArray(this.hidden) ? this.hidden : []
259
+ for (const key of [...privateKeys, ...hiddenKeys]) {
260
+ delete model[key]
261
+ }
262
+
263
+ return model
264
+ }
265
+ }
266
+
267
+ interface Model {
268
+ hasOne(
269
+ Related: typeof Model,
270
+ localKey?: number | string,
271
+ foreignKey?: string,
272
+ ): Promise<Model | null>
273
+ hasMany(
274
+ Related: typeof Model,
275
+ localKey?: number | string,
276
+ foreignKey?: string,
277
+ ): Promise<Model[]>
278
+ belongsTo(
279
+ Related: typeof Model,
280
+ foreignKey?: string,
281
+ ownerKey?: string,
282
+ ): Promise<Model | null>
283
+ }
284
+
285
+ Object.assign(Model.prototype, createRelationshipMethods(getDB) as Pick<Model, 'hasOne' | 'hasMany' | 'belongsTo'>)
286
+
287
+ export { Model }
288
+ export {
289
+ DB,
290
+ getDB,
291
+ configure,
292
+ createKnexConfig,
293
+ configureDatabase,
294
+ setMigrationConfig,
295
+ getMigrationConfig,
296
+ makeMigration,
297
+ migrateLatest,
298
+ migrateRollback,
299
+ migrateCurrentVersion,
300
+ migrateList,
301
+ } from './config.js'