@mantiq/database 0.0.1

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.
Files changed (42) hide show
  1. package/README.md +19 -0
  2. package/package.json +77 -0
  3. package/src/DatabaseManager.ts +115 -0
  4. package/src/DatabaseServiceProvider.ts +39 -0
  5. package/src/contracts/Connection.ts +13 -0
  6. package/src/contracts/Grammar.ts +16 -0
  7. package/src/contracts/MongoConnection.ts +122 -0
  8. package/src/contracts/Paginator.ts +10 -0
  9. package/src/drivers/BaseGrammar.ts +220 -0
  10. package/src/drivers/MSSQLConnection.ts +154 -0
  11. package/src/drivers/MSSQLGrammar.ts +106 -0
  12. package/src/drivers/MongoConnection.ts +298 -0
  13. package/src/drivers/MongoQueryBuilderImpl.ts +77 -0
  14. package/src/drivers/MySQLConnection.ts +120 -0
  15. package/src/drivers/MySQLGrammar.ts +19 -0
  16. package/src/drivers/PostgresConnection.ts +125 -0
  17. package/src/drivers/PostgresGrammar.ts +24 -0
  18. package/src/drivers/SQLiteConnection.ts +125 -0
  19. package/src/drivers/SQLiteGrammar.ts +19 -0
  20. package/src/errors/ConnectionError.ts +10 -0
  21. package/src/errors/ModelNotFoundError.ts +14 -0
  22. package/src/errors/QueryError.ts +11 -0
  23. package/src/events/DatabaseEvents.ts +101 -0
  24. package/src/factories/Factory.ts +170 -0
  25. package/src/factories/Faker.ts +382 -0
  26. package/src/helpers/db.ts +37 -0
  27. package/src/index.ts +100 -0
  28. package/src/migrations/Migration.ts +12 -0
  29. package/src/migrations/MigrationRepository.ts +50 -0
  30. package/src/migrations/Migrator.ts +201 -0
  31. package/src/orm/Collection.ts +236 -0
  32. package/src/orm/Document.ts +202 -0
  33. package/src/orm/Model.ts +775 -0
  34. package/src/orm/ModelQueryBuilder.ts +415 -0
  35. package/src/orm/Scope.ts +39 -0
  36. package/src/orm/eagerLoad.ts +300 -0
  37. package/src/query/Builder.ts +456 -0
  38. package/src/query/Expression.ts +18 -0
  39. package/src/schema/Blueprint.ts +196 -0
  40. package/src/schema/ColumnDefinition.ts +93 -0
  41. package/src/schema/SchemaBuilder.ts +376 -0
  42. package/src/seeders/Seeder.ts +28 -0
@@ -0,0 +1,415 @@
1
+ import { QueryBuilder } from '../query/Builder.ts'
2
+ import { ModelNotFoundError } from '../errors/ModelNotFoundError.ts'
3
+ import { eagerLoadRelations, type EagerLoadSpec, normalizeEagerLoads } from './eagerLoad.ts'
4
+ import type { Model } from './Model.ts'
5
+ import type { DatabaseConnection } from '../contracts/Connection.ts'
6
+
7
+ export class ModelQueryBuilder<T> extends QueryBuilder {
8
+ private _eagerLoads: string[] = []
9
+ private _eagerConstraints = new Map<string, ((query: ModelQueryBuilder<any>) => void) | null>()
10
+ private _globalScopes = new Map<string, { apply(builder: ModelQueryBuilder<any>, model: any): void }>()
11
+ private _removedScopes = new Set<string>()
12
+ private _modelClass: any = null
13
+ private _scopesApplied = false
14
+
15
+ constructor(
16
+ connection: DatabaseConnection,
17
+ table: string,
18
+ private readonly _hydrate: (row: Record<string, any>) => T,
19
+ private readonly softDeleteColumn: string | null = null,
20
+ private _withTrashed = false,
21
+ ) {
22
+ super(connection, table)
23
+ if (softDeleteColumn && !_withTrashed) {
24
+ this.whereNull(softDeleteColumn)
25
+ }
26
+ }
27
+
28
+ // ── Eager Loading ───────────────────────────────────────────────────────
29
+
30
+ /**
31
+ * Specify relations to eager-load.
32
+ *
33
+ * @example
34
+ * User.query().with('posts', 'profile').get()
35
+ * User.query().with('posts.comments').get()
36
+ * User.query().with({ posts: q => q.where('published', true) }).get()
37
+ */
38
+ with(...specs: EagerLoadSpec[]): this {
39
+ const normalized = normalizeEagerLoads(...specs)
40
+ for (const [name, constraint] of normalized) {
41
+ if (!this._eagerLoads.includes(name)) {
42
+ this._eagerLoads.push(name)
43
+ }
44
+ if (constraint) {
45
+ this._eagerConstraints.set(name, constraint)
46
+ }
47
+ }
48
+
49
+ // Also track full dot-notation specs for nested loading
50
+ for (const spec of specs) {
51
+ if (typeof spec === 'string' && spec.includes('.')) {
52
+ if (!this._eagerLoads.includes(spec)) {
53
+ this._eagerLoads.push(spec)
54
+ }
55
+ }
56
+ }
57
+
58
+ return this
59
+ }
60
+
61
+ withTrashed(): this {
62
+ this._withTrashed = true
63
+ this.state.wheres = this.state.wheres.filter(
64
+ (w) => !(w.type === 'null' && w.column === this.softDeleteColumn),
65
+ )
66
+ return this
67
+ }
68
+
69
+ onlyTrashed(): this {
70
+ this._withTrashed = true
71
+ this.state.wheres = this.state.wheres.filter(
72
+ (w) => !(w.type === 'null' && w.column === this.softDeleteColumn),
73
+ )
74
+ if (this.softDeleteColumn) {
75
+ this.whereNotNull(this.softDeleteColumn)
76
+ }
77
+ return this
78
+ }
79
+
80
+ // ── Global Scopes ─────────────────────────────────────────────────────────
81
+
82
+ /**
83
+ * Register a global scope to be applied before query execution.
84
+ * Called by Model.query() when building the query.
85
+ */
86
+ registerGlobalScope(name: string, scope: { apply(builder: ModelQueryBuilder<any>, model: any): void }): void {
87
+ this._globalScopes.set(name, scope)
88
+ }
89
+
90
+ /**
91
+ * Set the model class (for passing to scopes).
92
+ */
93
+ setModel(model: any): this {
94
+ this._modelClass = model
95
+ return this
96
+ }
97
+
98
+ /**
99
+ * Remove a specific global scope from this query.
100
+ */
101
+ withoutGlobalScope(name: string): this {
102
+ this._removedScopes.add(name)
103
+ return this
104
+ }
105
+
106
+ /**
107
+ * Remove all (or specific) global scopes from this query.
108
+ */
109
+ withoutGlobalScopes(names?: string[]): this {
110
+ if (names) {
111
+ for (const name of names) this._removedScopes.add(name)
112
+ } else {
113
+ for (const name of this._globalScopes.keys()) this._removedScopes.add(name)
114
+ }
115
+ return this
116
+ }
117
+
118
+ /**
119
+ * Apply registered global scopes that haven't been removed.
120
+ * Called lazily before query execution.
121
+ */
122
+ private applyGlobalScopes(): void {
123
+ if (this._scopesApplied) return
124
+ this._scopesApplied = true
125
+ for (const [name, scope] of this._globalScopes) {
126
+ if (!this._removedScopes.has(name)) {
127
+ scope.apply(this, this._modelClass)
128
+ }
129
+ }
130
+ }
131
+
132
+ // ── Hydrating read methods ─────────────────────────────────────────────────
133
+
134
+ override async get(): Promise<T[]> {
135
+ this.applyGlobalScopes()
136
+ const rows = await this.raw().get()
137
+ const models = rows.map(this._hydrate)
138
+
139
+ // Eager-load relations if any were requested
140
+ if (this._eagerLoads.length > 0 && models.length > 0) {
141
+ await eagerLoadRelations(
142
+ models as unknown as Model[],
143
+ this._eagerLoads,
144
+ this._eagerConstraints,
145
+ )
146
+ }
147
+
148
+ return models
149
+ }
150
+
151
+ override async first(): Promise<T | null> {
152
+ this.applyGlobalScopes()
153
+ const row = await this.raw().first()
154
+ if (!row) return null
155
+
156
+ const model = this._hydrate(row)
157
+
158
+ if (this._eagerLoads.length > 0) {
159
+ await eagerLoadRelations(
160
+ [model as unknown as Model],
161
+ this._eagerLoads,
162
+ this._eagerConstraints,
163
+ )
164
+ }
165
+
166
+ return model
167
+ }
168
+
169
+ async firstOrFail(): Promise<T> {
170
+ const result = await this.first()
171
+ if (!result) throw new ModelNotFoundError(this.state.table)
172
+ return result
173
+ }
174
+
175
+ override async find(id: number | string): Promise<T | null> {
176
+ this.applyGlobalScopes()
177
+ const row = await this.raw().where('id', id).first()
178
+ if (!row) return null
179
+
180
+ const model = this._hydrate(row)
181
+
182
+ if (this._eagerLoads.length > 0) {
183
+ await eagerLoadRelations(
184
+ [model as unknown as Model],
185
+ this._eagerLoads,
186
+ this._eagerConstraints,
187
+ )
188
+ }
189
+
190
+ return model
191
+ }
192
+
193
+ async findOrFail(id: number | string): Promise<T> {
194
+ const result = await this.find(id)
195
+ if (!result) throw new ModelNotFoundError(this.state.table)
196
+ return result
197
+ }
198
+
199
+ // ── Pagination (hydrated) ─────────────────────────────────────────────────
200
+
201
+ override async paginate(page = 1, perPage = 15) {
202
+ const total = await this.count()
203
+ const lastPage = Math.max(1, Math.ceil(total / perPage))
204
+ const currentPage = Math.min(page, lastPage)
205
+ const originalLimit = this.state.limitValue
206
+ const originalOffset = this.state.offsetValue
207
+ this.state.limitValue = perPage
208
+ this.state.offsetValue = (currentPage - 1) * perPage
209
+ const data = await this.get()
210
+ this.state.limitValue = originalLimit
211
+ this.state.offsetValue = originalOffset
212
+ const from = total === 0 ? 0 : (currentPage - 1) * perPage + 1
213
+ const to = Math.min(from + data.length - 1, total)
214
+ return { data, total, perPage, currentPage, lastPage, from, to, hasMore: currentPage < lastPage }
215
+ }
216
+
217
+ // ── Aggregates: delegate to raw QB to bypass hydration ────────────────────
218
+
219
+ override async count(column = '*'): Promise<number> {
220
+ this.applyGlobalScopes()
221
+ return this.raw().count(column)
222
+ }
223
+
224
+ override async sum(column: string): Promise<number> {
225
+ this.applyGlobalScopes()
226
+ return this.raw().sum(column)
227
+ }
228
+
229
+ override async avg(column: string): Promise<number> {
230
+ this.applyGlobalScopes()
231
+ return this.raw().avg(column)
232
+ }
233
+
234
+ override async min(column: string): Promise<any> {
235
+ this.applyGlobalScopes()
236
+ return this.raw().min(column)
237
+ }
238
+
239
+ override async max(column: string): Promise<any> {
240
+ this.applyGlobalScopes()
241
+ return this.raw().max(column)
242
+ }
243
+
244
+ override async exists(): Promise<boolean> {
245
+ this.applyGlobalScopes()
246
+ return this.raw().exists()
247
+ }
248
+
249
+ override async doesntExist(): Promise<boolean> {
250
+ this.applyGlobalScopes()
251
+ return this.raw().doesntExist()
252
+ }
253
+
254
+ override async value(column: string): Promise<any> {
255
+ this.applyGlobalScopes()
256
+ return this.raw().value(column)
257
+ }
258
+
259
+ override async pluck(column: string): Promise<any[]> {
260
+ this.applyGlobalScopes()
261
+ return this.raw().pluck(column)
262
+ }
263
+
264
+ // ── Batch Processing ─────────────────────────────────────────────────────
265
+
266
+ /**
267
+ * Process results in chunks. The callback receives each chunk and can
268
+ * return false to stop processing.
269
+ *
270
+ * @example
271
+ * await User.query().chunk(100, async (users) => {
272
+ * for (const user of users) await user.process()
273
+ * })
274
+ */
275
+ async chunk(size: number, callback: (items: T[], page: number) => Promise<false | void> | false | void): Promise<void> {
276
+ let page = 1
277
+ // eslint-disable-next-line no-constant-condition
278
+ while (true) {
279
+ const originalLimit = this.state.limitValue
280
+ const originalOffset = this.state.offsetValue
281
+ this.state.limitValue = size
282
+ this.state.offsetValue = (page - 1) * size
283
+ const results = await this.get()
284
+ this.state.limitValue = originalLimit
285
+ this.state.offsetValue = originalOffset
286
+
287
+ if (results.length === 0) break
288
+
289
+ const result = await callback(results, page)
290
+ if (result === false) break
291
+ if (results.length < size) break
292
+
293
+ page++
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Process results in chunks ordered by ID. More efficient for large
299
+ * tables because it uses WHERE id > lastId instead of OFFSET.
300
+ *
301
+ * @example
302
+ * await User.query().chunkById(100, async (users) => {
303
+ * for (const user of users) await user.process()
304
+ * })
305
+ */
306
+ async chunkById(
307
+ size: number,
308
+ callback: (items: T[], page: number) => Promise<false | void> | false | void,
309
+ column = 'id',
310
+ ): Promise<void> {
311
+ let lastId: any = null
312
+ let page = 1
313
+ // eslint-disable-next-line no-constant-condition
314
+ while (true) {
315
+ const originalLimit = this.state.limitValue
316
+ const originalOrders = [...this.state.orders]
317
+ this.state.limitValue = size
318
+ this.state.orders = [{ column, direction: 'asc' }]
319
+
320
+ // Clone wheres, add id constraint
321
+ const originalWheres = [...this.state.wheres]
322
+ if (lastId !== null) {
323
+ this.state.wheres.push({ type: 'basic', boolean: 'and', column, operator: '>', value: lastId })
324
+ }
325
+
326
+ const results = await this.get()
327
+
328
+ // Restore state
329
+ this.state.limitValue = originalLimit
330
+ this.state.orders = originalOrders
331
+ this.state.wheres = originalWheres
332
+
333
+ if (results.length === 0) break
334
+
335
+ const lastItem = results[results.length - 1] as any
336
+ lastId = lastItem._attributes?.[column] ?? lastItem[column]
337
+
338
+ const result = await callback(results, page)
339
+ if (result === false) break
340
+ if (results.length < size) break
341
+
342
+ page++
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Lazily iterate over results using an async generator.
348
+ * Fetches rows in batches internally but yields them one at a time.
349
+ *
350
+ * @example
351
+ * for await (const user of User.query().cursor()) {
352
+ * await user.sendEmail()
353
+ * }
354
+ */
355
+ async *cursor(batchSize = 200): AsyncGenerator<T, void, undefined> {
356
+ let offset = 0
357
+ // eslint-disable-next-line no-constant-condition
358
+ while (true) {
359
+ const originalLimit = this.state.limitValue
360
+ const originalOffset = this.state.offsetValue
361
+ this.state.limitValue = batchSize
362
+ this.state.offsetValue = offset
363
+ const results = await this.get()
364
+ this.state.limitValue = originalLimit
365
+ this.state.offsetValue = originalOffset
366
+
367
+ for (const item of results) {
368
+ yield item
369
+ }
370
+
371
+ if (results.length < batchSize) break
372
+ offset += batchSize
373
+ }
374
+ }
375
+
376
+ // ── sole() ──────────────────────────────────────────────────────────────
377
+
378
+ /**
379
+ * Get the only record matching the query. Throws if zero or more than one.
380
+ *
381
+ * @example
382
+ * const user = await User.where('email', 'admin@example.com').sole()
383
+ */
384
+ async sole(): Promise<T> {
385
+ this.applyGlobalScopes()
386
+ const originalLimit = this.state.limitValue
387
+ this.state.limitValue = 2
388
+ const results = await this.get()
389
+ this.state.limitValue = originalLimit
390
+
391
+ if (results.length === 0) throw new ModelNotFoundError(this.state.table)
392
+ if (results.length > 1) throw new Error(`Expected one result for table [${this.state.table}], found multiple.`)
393
+ return results[0]!
394
+ }
395
+
396
+ // ── Internals ─────────────────────────────────────────────────────────────
397
+
398
+ /** Snapshot current state into a plain QueryBuilder (no hydration) */
399
+ private raw(): QueryBuilder {
400
+ const q = new QueryBuilder(this._connection, this.state.table)
401
+ q['state'] = {
402
+ table: this.state.table,
403
+ columns: [...this.state.columns],
404
+ distinct: this.state.distinct,
405
+ wheres: [...this.state.wheres],
406
+ joins: [...this.state.joins],
407
+ orders: [...this.state.orders],
408
+ groups: [...this.state.groups],
409
+ havings: [...this.state.havings],
410
+ limitValue: this.state.limitValue,
411
+ offsetValue: this.state.offsetValue,
412
+ }
413
+ return q
414
+ }
415
+ }
@@ -0,0 +1,39 @@
1
+ import type { ModelQueryBuilder } from './ModelQueryBuilder.ts'
2
+ import type { Model } from './Model.ts'
3
+
4
+ /**
5
+ * Global query scope contract.
6
+ *
7
+ * Global scopes are automatically applied to every query for a model.
8
+ * They can be removed at query time via `withoutGlobalScope()`.
9
+ *
10
+ * Laravel equivalent: `Illuminate\Database\Eloquent\Scope`
11
+ *
12
+ * @example
13
+ * class ActiveScope implements Scope {
14
+ * apply(builder: ModelQueryBuilder<any>, model: typeof Model): void {
15
+ * builder.where('is_active', true)
16
+ * }
17
+ * }
18
+ *
19
+ * class User extends Model {
20
+ * static booted() {
21
+ * this.addGlobalScope('active', new ActiveScope())
22
+ * }
23
+ * }
24
+ */
25
+ export interface Scope {
26
+ apply(builder: ModelQueryBuilder<any>, model: typeof Model): void
27
+ }
28
+
29
+ /**
30
+ * A closure-based scope for simple cases.
31
+ * Wraps a callback in the Scope interface.
32
+ */
33
+ export class ClosureScope implements Scope {
34
+ constructor(private readonly callback: (builder: ModelQueryBuilder<any>, model: typeof Model) => void) {}
35
+
36
+ apply(builder: ModelQueryBuilder<any>, model: typeof Model): void {
37
+ this.callback(builder, model)
38
+ }
39
+ }