@declaro/data 2.0.0-beta.8 → 2.0.0-beta.81
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/{LICENSE → LICENSE.md} +1 -1
- package/README.md +0 -0
- package/dist/browser/index.js +32 -0
- package/dist/browser/index.js.map +86 -0
- package/dist/node/index.cjs +11547 -0
- package/dist/node/index.cjs.map +86 -0
- package/dist/node/index.js +11526 -0
- package/dist/node/index.js.map +86 -0
- package/dist/ts/application/model-controller.d.ts +29 -0
- package/dist/ts/application/model-controller.d.ts.map +1 -0
- package/dist/ts/application/model-controller.test.d.ts +2 -0
- package/dist/ts/application/model-controller.test.d.ts.map +1 -0
- package/dist/ts/application/read-only-model-controller.d.ts +20 -0
- package/dist/ts/application/read-only-model-controller.d.ts.map +1 -0
- package/dist/ts/application/read-only-model-controller.test.d.ts +2 -0
- package/dist/ts/application/read-only-model-controller.test.d.ts.map +1 -0
- package/dist/ts/domain/events/domain-event.d.ts +41 -0
- package/dist/ts/domain/events/domain-event.d.ts.map +1 -0
- package/dist/ts/domain/events/domain-event.test.d.ts +2 -0
- package/dist/ts/domain/events/domain-event.test.d.ts.map +1 -0
- package/dist/ts/domain/events/event-types.d.ts +21 -0
- package/dist/ts/domain/events/event-types.d.ts.map +1 -0
- package/dist/ts/domain/events/mutation-event.d.ts +6 -0
- package/dist/ts/domain/events/mutation-event.d.ts.map +1 -0
- package/dist/ts/domain/events/mutation-event.test.d.ts +2 -0
- package/dist/ts/domain/events/mutation-event.test.d.ts.map +1 -0
- package/dist/ts/domain/events/query-event.d.ts +6 -0
- package/dist/ts/domain/events/query-event.d.ts.map +1 -0
- package/dist/ts/domain/events/query-event.test.d.ts +2 -0
- package/dist/ts/domain/events/query-event.test.d.ts.map +1 -0
- package/dist/ts/domain/events/request-event.d.ts +11 -0
- package/dist/ts/domain/events/request-event.d.ts.map +1 -0
- package/dist/ts/domain/events/request-event.test.d.ts +2 -0
- package/dist/ts/domain/events/request-event.test.d.ts.map +1 -0
- package/dist/ts/domain/interfaces/repository.d.ts +84 -0
- package/dist/ts/domain/interfaces/repository.d.ts.map +1 -0
- package/dist/ts/domain/models/pagination.d.ts +28 -0
- package/dist/ts/domain/models/pagination.d.ts.map +1 -0
- package/dist/ts/domain/services/base-model-service.d.ts +22 -0
- package/dist/ts/domain/services/base-model-service.d.ts.map +1 -0
- package/dist/ts/domain/services/model-service-args.d.ts +9 -0
- package/dist/ts/domain/services/model-service-args.d.ts.map +1 -0
- package/dist/ts/domain/services/model-service.d.ts +42 -0
- package/dist/ts/domain/services/model-service.d.ts.map +1 -0
- package/dist/ts/domain/services/model-service.test.d.ts +2 -0
- package/dist/ts/domain/services/model-service.test.d.ts.map +1 -0
- package/dist/ts/domain/services/read-only-model-service.d.ts +40 -0
- package/dist/ts/domain/services/read-only-model-service.d.ts.map +1 -0
- package/dist/ts/domain/services/read-only-model-service.test.d.ts +2 -0
- package/dist/ts/domain/services/read-only-model-service.test.d.ts.map +1 -0
- package/dist/ts/index.d.ts +18 -0
- package/dist/ts/index.d.ts.map +1 -0
- package/dist/ts/shared/utils/schema-inference.d.ts +23 -0
- package/dist/ts/shared/utils/schema-inference.d.ts.map +1 -0
- package/dist/ts/shared/utils/schema-inheritance.d.ts +24 -0
- package/dist/ts/shared/utils/schema-inheritance.d.ts.map +1 -0
- package/dist/ts/test/domain/services/model-service.test.d.ts +1 -0
- package/dist/ts/test/domain/services/model-service.test.d.ts.map +1 -0
- package/dist/ts/test/mock/models/mock-book-models.d.ts +42 -0
- package/dist/ts/test/mock/models/mock-book-models.d.ts.map +1 -0
- package/dist/ts/test/mock/repositories/mock-memory-repository.d.ts +36 -0
- package/dist/ts/test/mock/repositories/mock-memory-repository.d.ts.map +1 -0
- package/dist/ts/test/mock/repositories/mock-memory-repository.test.d.ts +2 -0
- package/dist/ts/test/mock/repositories/mock-memory-repository.test.d.ts.map +1 -0
- package/package.json +46 -42
- package/src/application/model-controller.test.ts +488 -0
- package/src/application/model-controller.ts +92 -0
- package/src/application/read-only-model-controller.test.ts +327 -0
- package/src/application/read-only-model-controller.ts +61 -0
- package/src/domain/events/domain-event.test.ts +82 -0
- package/src/domain/events/domain-event.ts +69 -0
- package/src/domain/events/event-types.ts +21 -0
- package/src/domain/events/mutation-event.test.ts +38 -0
- package/src/domain/events/mutation-event.ts +8 -0
- package/src/domain/events/query-event.test.ts +28 -0
- package/src/domain/events/query-event.ts +8 -0
- package/src/domain/events/request-event.test.ts +38 -0
- package/src/domain/events/request-event.ts +32 -0
- package/src/domain/interfaces/repository.ts +107 -0
- package/src/domain/models/pagination.ts +28 -0
- package/src/domain/services/base-model-service.ts +50 -0
- package/src/domain/services/model-service-args.ts +9 -0
- package/src/domain/services/model-service.test.ts +631 -0
- package/src/domain/services/model-service.ts +322 -0
- package/src/domain/services/read-only-model-service.test.ts +296 -0
- package/src/domain/services/read-only-model-service.ts +133 -0
- package/src/index.ts +17 -4
- package/src/shared/utils/schema-inference.ts +26 -0
- package/src/shared/utils/schema-inheritance.ts +28 -0
- package/src/test/domain/services/model-service.test.ts +0 -0
- package/src/test/mock/models/mock-book-models.ts +78 -0
- package/src/test/mock/repositories/mock-memory-repository.test.ts +715 -0
- package/src/test/mock/repositories/mock-memory-repository.ts +235 -0
- package/dist/databaseConnection.d.ts +0 -24
- package/dist/datastoreAbstract.d.ts +0 -37
- package/dist/declaro-data.cjs +0 -1
- package/dist/declaro-data.mjs +0 -250
- package/dist/hydrateEntity.d.ts +0 -8
- package/dist/index.d.ts +0 -4
- package/dist/serverConnection.d.ts +0 -15
- package/dist/trackedStatus.d.ts +0 -15
- package/src/databaseConnection.ts +0 -137
- package/src/datastoreAbstract.ts +0 -190
- package/src/hydrateEntity.ts +0 -36
- package/src/placeholder.test.ts +0 -7
- package/src/serverConnection.ts +0 -74
- package/src/trackedStatus.ts +0 -35
- package/tsconfig.json +0 -10
- package/vite.config.ts +0 -23
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import type { AnyModelSchema } from '@declaro/core'
|
|
2
|
+
import type { InferDetail, InferInput, InferLookup, InferSummary } from '../../shared/utils/schema-inference'
|
|
3
|
+
import { ModelMutationAction, ModelQueryEvent } from '../events/event-types'
|
|
4
|
+
import { MutationEvent } from '../events/mutation-event'
|
|
5
|
+
import type { IModelServiceArgs } from './model-service-args'
|
|
6
|
+
import { ReadOnlyModelService, type ILoadOptions } from './read-only-model-service'
|
|
7
|
+
import type { IActionOptions } from './base-model-service'
|
|
8
|
+
|
|
9
|
+
export interface ICreateOptions extends IActionOptions {}
|
|
10
|
+
export interface IUpdateOptions extends IActionOptions {}
|
|
11
|
+
|
|
12
|
+
export class ModelService<TSchema extends AnyModelSchema> extends ReadOnlyModelService<TSchema> {
|
|
13
|
+
constructor(args: IModelServiceArgs<TSchema>) {
|
|
14
|
+
super(args)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Removes a record by its lookup criteria.
|
|
19
|
+
* @param lookup The lookup criteria to find the record.
|
|
20
|
+
* @returns The removed record.
|
|
21
|
+
*/
|
|
22
|
+
async remove(lookup: InferLookup<TSchema>, options?: ILoadOptions): Promise<InferSummary<TSchema>> {
|
|
23
|
+
// Emit the before remove event
|
|
24
|
+
const beforeRemoveEvent = new MutationEvent<InferSummary<TSchema>, InferLookup<TSchema>>(
|
|
25
|
+
this.getDescriptor(ModelMutationAction.BeforeRemove),
|
|
26
|
+
lookup,
|
|
27
|
+
)
|
|
28
|
+
await this.emitter.emitAsync(beforeRemoveEvent)
|
|
29
|
+
|
|
30
|
+
// Perform the removal
|
|
31
|
+
const result = await this.repository.remove(lookup, options)
|
|
32
|
+
|
|
33
|
+
// Emit the after remove event
|
|
34
|
+
const afterRemoveEvent = new MutationEvent<InferSummary<TSchema>, InferLookup<TSchema>>(
|
|
35
|
+
this.getDescriptor(ModelMutationAction.AfterRemove),
|
|
36
|
+
lookup,
|
|
37
|
+
).setResult(result)
|
|
38
|
+
await this.emitter.emitAsync(afterRemoveEvent)
|
|
39
|
+
|
|
40
|
+
// Return the results of the removal
|
|
41
|
+
return result
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Restores a record by its lookup criteria.
|
|
46
|
+
* If a soft-deleted copy exists, it will be restored.
|
|
47
|
+
* @param lookup The lookup criteria to find the record to restore.
|
|
48
|
+
* @returns
|
|
49
|
+
*/
|
|
50
|
+
async restore(lookup: InferLookup<TSchema>, options?: ILoadOptions): Promise<InferSummary<TSchema>> {
|
|
51
|
+
// Emit the before restore event
|
|
52
|
+
const beforeRestoreEvent = new MutationEvent<InferSummary<TSchema>, InferLookup<TSchema>>(
|
|
53
|
+
this.getDescriptor(ModelMutationAction.BeforeRestore),
|
|
54
|
+
lookup,
|
|
55
|
+
)
|
|
56
|
+
await this.emitter.emitAsync(beforeRestoreEvent)
|
|
57
|
+
|
|
58
|
+
// Perform the restore operation
|
|
59
|
+
const result = await this.repository.restore(lookup, options)
|
|
60
|
+
|
|
61
|
+
// Emit the after restore event
|
|
62
|
+
const afterRestoreEvent = new MutationEvent<InferSummary<TSchema>, InferLookup<TSchema>>(
|
|
63
|
+
this.getDescriptor(ModelMutationAction.AfterRestore),
|
|
64
|
+
lookup,
|
|
65
|
+
).setResult(result)
|
|
66
|
+
await this.emitter.emitAsync(afterRestoreEvent)
|
|
67
|
+
|
|
68
|
+
// Return the results of the restore operation
|
|
69
|
+
return result
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async create(input: InferInput<TSchema>, options?: ICreateOptions): Promise<InferDetail<TSchema>> {
|
|
73
|
+
// Emit the before create event
|
|
74
|
+
const beforeCreateEvent = new MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>(
|
|
75
|
+
this.getDescriptor(ModelMutationAction.BeforeCreate),
|
|
76
|
+
input,
|
|
77
|
+
)
|
|
78
|
+
await this.emitter.emitAsync(beforeCreateEvent)
|
|
79
|
+
|
|
80
|
+
// Perform the creation
|
|
81
|
+
const result = await this.repository.create(input, options)
|
|
82
|
+
|
|
83
|
+
// Emit the after create event
|
|
84
|
+
const afterCreateEvent = new MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>(
|
|
85
|
+
this.getDescriptor(ModelMutationAction.AfterCreate),
|
|
86
|
+
input,
|
|
87
|
+
).setResult(result)
|
|
88
|
+
await this.emitter.emitAsync(afterCreateEvent)
|
|
89
|
+
|
|
90
|
+
// Return the results of the creation
|
|
91
|
+
return result
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async update(
|
|
95
|
+
lookup: InferLookup<TSchema>,
|
|
96
|
+
input: InferInput<TSchema>,
|
|
97
|
+
options?: IUpdateOptions,
|
|
98
|
+
): Promise<InferDetail<TSchema>> {
|
|
99
|
+
// Emit the before update event
|
|
100
|
+
const beforeUpdateEvent = new MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>(
|
|
101
|
+
this.getDescriptor(ModelMutationAction.BeforeUpdate),
|
|
102
|
+
input,
|
|
103
|
+
)
|
|
104
|
+
await this.emitter.emitAsync(beforeUpdateEvent)
|
|
105
|
+
|
|
106
|
+
// Perform the update
|
|
107
|
+
const result = await this.repository.update(lookup, input, options)
|
|
108
|
+
|
|
109
|
+
// Emit the after update event
|
|
110
|
+
const afterUpdateEvent = new MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>(
|
|
111
|
+
this.getDescriptor(ModelMutationAction.AfterUpdate),
|
|
112
|
+
input,
|
|
113
|
+
).setResult(result)
|
|
114
|
+
await this.emitter.emitAsync(afterUpdateEvent)
|
|
115
|
+
|
|
116
|
+
// Return the results of the update
|
|
117
|
+
return result
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Upserts a record (creates if it doesn't exist, updates if it does).
|
|
122
|
+
* @param input The input data for the upsert operation.
|
|
123
|
+
* @param options Optional create or update options.
|
|
124
|
+
* @returns The upserted record.
|
|
125
|
+
*/
|
|
126
|
+
async upsert(input: InferInput<TSchema>, options?: ICreateOptions | IUpdateOptions): Promise<InferDetail<TSchema>> {
|
|
127
|
+
const primaryKeyValue = this.getPrimaryKeyValue(input)
|
|
128
|
+
|
|
129
|
+
let beforeOperation: ModelMutationAction
|
|
130
|
+
let afterOperation: ModelMutationAction
|
|
131
|
+
|
|
132
|
+
if (primaryKeyValue === undefined) {
|
|
133
|
+
beforeOperation = ModelMutationAction.BeforeCreate
|
|
134
|
+
afterOperation = ModelMutationAction.AfterCreate
|
|
135
|
+
} else {
|
|
136
|
+
const existingItem = await this.load(
|
|
137
|
+
{
|
|
138
|
+
[this.entityMetadata.primaryKey]: primaryKeyValue,
|
|
139
|
+
} as InferLookup<TSchema>,
|
|
140
|
+
options,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
if (existingItem) {
|
|
144
|
+
beforeOperation = ModelMutationAction.BeforeUpdate
|
|
145
|
+
afterOperation = ModelMutationAction.AfterUpdate
|
|
146
|
+
} else {
|
|
147
|
+
beforeOperation = ModelMutationAction.BeforeCreate
|
|
148
|
+
afterOperation = ModelMutationAction.AfterCreate
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Emit the before upsert event
|
|
153
|
+
const beforeUpsertEvent = new MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>(
|
|
154
|
+
this.getDescriptor(beforeOperation),
|
|
155
|
+
input,
|
|
156
|
+
)
|
|
157
|
+
await this.emitter.emitAsync(beforeUpsertEvent)
|
|
158
|
+
|
|
159
|
+
// Perform the upsert operation
|
|
160
|
+
const result = await this.repository.upsert(input, options)
|
|
161
|
+
|
|
162
|
+
// Emit the after upsert event
|
|
163
|
+
const afterUpsertEvent = new MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>(
|
|
164
|
+
this.getDescriptor(afterOperation),
|
|
165
|
+
input,
|
|
166
|
+
).setResult(result)
|
|
167
|
+
await this.emitter.emitAsync(afterUpsertEvent)
|
|
168
|
+
|
|
169
|
+
// Return the results of the upsert operation
|
|
170
|
+
return result
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Bulk upserts multiple records (creates if they don't exist, updates if they do).
|
|
175
|
+
* @param inputs Array of input data for the bulk upsert operation.
|
|
176
|
+
* @param options Optional create or update options.
|
|
177
|
+
* @returns Array of upserted records.
|
|
178
|
+
*/
|
|
179
|
+
async bulkUpsert(
|
|
180
|
+
inputs: InferInput<TSchema>[],
|
|
181
|
+
options?: ICreateOptions | IUpdateOptions,
|
|
182
|
+
): Promise<InferDetail<TSchema>[]> {
|
|
183
|
+
if (inputs.length === 0) {
|
|
184
|
+
return []
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Build a map of primary key to input and lookup info
|
|
188
|
+
type EntityInfo = {
|
|
189
|
+
input: InferInput<TSchema>
|
|
190
|
+
lookup: InferLookup<TSchema>
|
|
191
|
+
primaryKeyValue: string | number
|
|
192
|
+
existingEntity?: InferDetail<TSchema>
|
|
193
|
+
operation?: ModelMutationAction
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const entityInfoMap = new Map<string | number, EntityInfo>()
|
|
197
|
+
const inputsWithoutPrimaryKey: InferInput<TSchema>[] = []
|
|
198
|
+
|
|
199
|
+
// Process each input and organize by primary key
|
|
200
|
+
for (const input of inputs) {
|
|
201
|
+
const primaryKeyValue = this.getPrimaryKeyValue(input)
|
|
202
|
+
|
|
203
|
+
if (primaryKeyValue !== undefined) {
|
|
204
|
+
const entityInfo: EntityInfo = {
|
|
205
|
+
input,
|
|
206
|
+
primaryKeyValue,
|
|
207
|
+
lookup: {
|
|
208
|
+
[this.entityMetadata.primaryKey]: primaryKeyValue,
|
|
209
|
+
} as InferLookup<TSchema>,
|
|
210
|
+
}
|
|
211
|
+
entityInfoMap.set(primaryKeyValue, entityInfo)
|
|
212
|
+
} else {
|
|
213
|
+
// Inputs without primary keys are always creates
|
|
214
|
+
inputsWithoutPrimaryKey.push(input)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Extract lookups for existing entities
|
|
219
|
+
const lookups = Array.from(entityInfoMap.values()).map((info) => info.lookup)
|
|
220
|
+
|
|
221
|
+
// Load existing entities and update the map
|
|
222
|
+
if (lookups.length > 0) {
|
|
223
|
+
const existingEntities = await this.loadMany(lookups, options)
|
|
224
|
+
existingEntities.forEach((entity) => {
|
|
225
|
+
if (entity) {
|
|
226
|
+
const pkValue = this.getPrimaryKeyValue(entity)
|
|
227
|
+
if (pkValue !== undefined && entityInfoMap.has(pkValue)) {
|
|
228
|
+
const entityInfo = entityInfoMap.get(pkValue)!
|
|
229
|
+
entityInfo.existingEntity = entity
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Determine operation types and prepare before events
|
|
236
|
+
const beforeEvents: MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>[] = []
|
|
237
|
+
|
|
238
|
+
// Handle entities with primary keys
|
|
239
|
+
for (const entityInfo of entityInfoMap.values()) {
|
|
240
|
+
const operation = entityInfo.existingEntity
|
|
241
|
+
? ModelMutationAction.BeforeUpdate
|
|
242
|
+
: ModelMutationAction.BeforeCreate
|
|
243
|
+
|
|
244
|
+
entityInfo.operation = operation
|
|
245
|
+
|
|
246
|
+
beforeEvents.push(
|
|
247
|
+
new MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>(
|
|
248
|
+
this.getDescriptor(operation),
|
|
249
|
+
entityInfo.input,
|
|
250
|
+
),
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Handle inputs without primary keys (always creates)
|
|
255
|
+
for (const input of inputsWithoutPrimaryKey) {
|
|
256
|
+
beforeEvents.push(
|
|
257
|
+
new MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>(
|
|
258
|
+
this.getDescriptor(ModelMutationAction.BeforeCreate),
|
|
259
|
+
input,
|
|
260
|
+
),
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Emit all before events
|
|
265
|
+
await Promise.all(beforeEvents.map((event) => this.emitter.emitAsync(event)))
|
|
266
|
+
|
|
267
|
+
// Perform the bulk upsert operation
|
|
268
|
+
const results = await this.repository.bulkUpsert(inputs, options)
|
|
269
|
+
|
|
270
|
+
// Create a map of result primary keys to results for matching
|
|
271
|
+
const resultsByPrimaryKey = new Map<string | number, InferDetail<TSchema>>()
|
|
272
|
+
const resultsWithoutPrimaryKey: InferDetail<TSchema>[] = []
|
|
273
|
+
|
|
274
|
+
for (const result of results) {
|
|
275
|
+
const pkValue = this.getPrimaryKeyValue(result)
|
|
276
|
+
if (pkValue !== undefined) {
|
|
277
|
+
resultsByPrimaryKey.set(pkValue, result)
|
|
278
|
+
} else {
|
|
279
|
+
resultsWithoutPrimaryKey.push(result)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Prepare after events by matching results back to original inputs
|
|
284
|
+
const afterEvents: MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>[] = []
|
|
285
|
+
let resultsWithoutPkIndex = 0
|
|
286
|
+
|
|
287
|
+
// Handle entities with primary keys
|
|
288
|
+
for (const entityInfo of entityInfoMap.values()) {
|
|
289
|
+
const matchedResult = resultsByPrimaryKey.get(entityInfo.primaryKeyValue)!
|
|
290
|
+
|
|
291
|
+
const afterOperation =
|
|
292
|
+
entityInfo.operation === ModelMutationAction.BeforeCreate
|
|
293
|
+
? ModelMutationAction.AfterCreate
|
|
294
|
+
: ModelMutationAction.AfterUpdate
|
|
295
|
+
|
|
296
|
+
afterEvents.push(
|
|
297
|
+
new MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>(
|
|
298
|
+
this.getDescriptor(afterOperation),
|
|
299
|
+
entityInfo.input,
|
|
300
|
+
).setResult(matchedResult),
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Handle inputs without primary keys (always creates)
|
|
305
|
+
for (const input of inputsWithoutPrimaryKey) {
|
|
306
|
+
const matchedResult = resultsWithoutPrimaryKey[resultsWithoutPkIndex++]
|
|
307
|
+
|
|
308
|
+
afterEvents.push(
|
|
309
|
+
new MutationEvent<InferDetail<TSchema>, InferInput<TSchema>>(
|
|
310
|
+
this.getDescriptor(ModelMutationAction.AfterCreate),
|
|
311
|
+
input,
|
|
312
|
+
).setResult(matchedResult),
|
|
313
|
+
)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Emit all after events
|
|
317
|
+
await Promise.all(afterEvents.map((event) => this.emitter.emitAsync(event)))
|
|
318
|
+
|
|
319
|
+
// Return the results
|
|
320
|
+
return results
|
|
321
|
+
}
|
|
322
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, spyOn, mock } from 'bun:test'
|
|
2
|
+
import { ReadOnlyModelService } from './read-only-model-service'
|
|
3
|
+
import { MockMemoryRepository } from '../../test/mock/repositories/mock-memory-repository'
|
|
4
|
+
import { MockBookSchema } from '../../test/mock/models/mock-book-models'
|
|
5
|
+
import { EventManager } from '@declaro/core'
|
|
6
|
+
import type { QueryEvent } from '../events/query-event'
|
|
7
|
+
import type { InferDetail, InferFilters, InferLookup, InferSearchResults } from '../../shared/utils/schema-inference'
|
|
8
|
+
|
|
9
|
+
describe('ReadOnlyModelService', () => {
|
|
10
|
+
const namespace = 'books'
|
|
11
|
+
const mockSchema = MockBookSchema
|
|
12
|
+
|
|
13
|
+
let repository: MockMemoryRepository<typeof mockSchema>
|
|
14
|
+
let emitter: EventManager
|
|
15
|
+
let service: ReadOnlyModelService<typeof mockSchema>
|
|
16
|
+
|
|
17
|
+
const beforeLoadSpy = mock(
|
|
18
|
+
(event: QueryEvent<InferDetail<typeof mockSchema>, InferLookup<typeof mockSchema>>) => {},
|
|
19
|
+
)
|
|
20
|
+
const afterLoadSpy = mock((event: QueryEvent<InferDetail<typeof mockSchema>, InferLookup<typeof mockSchema>>) => {})
|
|
21
|
+
|
|
22
|
+
const beforeLoadManySpy = mock(
|
|
23
|
+
(event: QueryEvent<InferDetail<typeof mockSchema>[], InferLookup<typeof mockSchema>[]>) => {},
|
|
24
|
+
)
|
|
25
|
+
const afterLoadManySpy = mock(
|
|
26
|
+
(event: QueryEvent<InferDetail<typeof mockSchema>[], InferLookup<typeof mockSchema>[]>) => {},
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
const beforeSearchSpy = mock(
|
|
30
|
+
(event: QueryEvent<InferSearchResults<typeof mockSchema>[], InferFilters<typeof mockSchema>[]>) => {},
|
|
31
|
+
)
|
|
32
|
+
const afterSearchSpy = mock(
|
|
33
|
+
(event: QueryEvent<InferSearchResults<typeof mockSchema>[], InferFilters<typeof mockSchema>[]>) => {},
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
repository = new MockMemoryRepository({ schema: mockSchema })
|
|
38
|
+
emitter = new EventManager()
|
|
39
|
+
|
|
40
|
+
beforeLoadSpy.mockClear()
|
|
41
|
+
afterLoadSpy.mockClear()
|
|
42
|
+
beforeLoadManySpy.mockClear()
|
|
43
|
+
afterLoadManySpy.mockClear()
|
|
44
|
+
beforeSearchSpy.mockClear()
|
|
45
|
+
afterSearchSpy.mockClear()
|
|
46
|
+
|
|
47
|
+
emitter.on<QueryEvent<InferDetail<typeof mockSchema>, InferLookup<typeof mockSchema>>>(
|
|
48
|
+
'books::book.beforeLoad',
|
|
49
|
+
beforeLoadSpy,
|
|
50
|
+
)
|
|
51
|
+
emitter.on<QueryEvent<InferDetail<typeof mockSchema>, InferLookup<typeof mockSchema>>>(
|
|
52
|
+
'books::book.afterLoad',
|
|
53
|
+
afterLoadSpy,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
emitter.on<QueryEvent<InferDetail<typeof mockSchema>[], InferLookup<typeof mockSchema>[]>>(
|
|
57
|
+
'books::book.beforeLoadMany',
|
|
58
|
+
beforeLoadManySpy,
|
|
59
|
+
)
|
|
60
|
+
emitter.on<QueryEvent<InferDetail<typeof mockSchema>[], InferLookup<typeof mockSchema>[]>>(
|
|
61
|
+
'books::book.afterLoadMany',
|
|
62
|
+
afterLoadManySpy,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
emitter.on<QueryEvent<InferSearchResults<typeof mockSchema>[], InferFilters<typeof mockSchema>[]>>(
|
|
66
|
+
'books::book.beforeSearch',
|
|
67
|
+
beforeSearchSpy,
|
|
68
|
+
)
|
|
69
|
+
emitter.on<QueryEvent<InferSearchResults<typeof mockSchema>[], InferFilters<typeof mockSchema>[]>>(
|
|
70
|
+
'books::book.afterSearch',
|
|
71
|
+
afterSearchSpy,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
service = new ReadOnlyModelService({ repository, emitter, schema: mockSchema, namespace })
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('should load a single record', async () => {
|
|
78
|
+
const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
|
|
79
|
+
await repository.create(input)
|
|
80
|
+
|
|
81
|
+
const record = await service.load({ id: 42 })
|
|
82
|
+
|
|
83
|
+
expect(record).toEqual(input)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('should return null when loading a non-existent record', async () => {
|
|
87
|
+
const record = await service.load({ id: 999 })
|
|
88
|
+
|
|
89
|
+
expect(record).toBeNull()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('should load multiple records', async () => {
|
|
93
|
+
const input1 = { id: 42, title: 'Test Book 1', author: 'Author Name 1', publishedDate: new Date() }
|
|
94
|
+
const input2 = { id: 43, title: 'Test Book 2', author: 'Author Name 2', publishedDate: new Date() }
|
|
95
|
+
await repository.create(input1)
|
|
96
|
+
await repository.create(input2)
|
|
97
|
+
|
|
98
|
+
const records = await service.loadMany([{ id: 42 }, { id: 43 }])
|
|
99
|
+
|
|
100
|
+
expect(records).toEqual([input1, input2])
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('should search for records', async () => {
|
|
104
|
+
const input1 = { id: 42, title: 'Test Book 1', author: 'Author Name 1', publishedDate: new Date() }
|
|
105
|
+
const input2 = { id: 43, title: 'Test Book 2', author: 'Author Name 2', publishedDate: new Date() }
|
|
106
|
+
await repository.create(input1)
|
|
107
|
+
await repository.create(input2)
|
|
108
|
+
|
|
109
|
+
const results = await service.search(
|
|
110
|
+
{ text: 'Test' },
|
|
111
|
+
{
|
|
112
|
+
sort: [
|
|
113
|
+
{
|
|
114
|
+
title: 'asc',
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
author: 'desc',
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
},
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
expect(results.results).toEqual([input1, input2])
|
|
124
|
+
expect(results.pagination.total).toBe(2)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('should return empty results when searching for non-existent records', async () => {
|
|
128
|
+
const results = await service.search({ text: 'Non-existent' })
|
|
129
|
+
|
|
130
|
+
expect(results.results).toEqual([])
|
|
131
|
+
expect(results.pagination.total).toBe(0)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('should handle pagination options correctly', async () => {
|
|
135
|
+
// Create 5 items
|
|
136
|
+
for (let i = 1; i <= 5; i++) {
|
|
137
|
+
await repository.create({
|
|
138
|
+
id: i,
|
|
139
|
+
title: `Test Book ${i}`,
|
|
140
|
+
author: `Author ${i}`,
|
|
141
|
+
publishedDate: new Date(),
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Test first page with pageSize 2
|
|
146
|
+
const page1 = await service.search(
|
|
147
|
+
{},
|
|
148
|
+
{
|
|
149
|
+
pagination: { page: 1, pageSize: 2 },
|
|
150
|
+
},
|
|
151
|
+
)
|
|
152
|
+
expect(page1.results).toHaveLength(2)
|
|
153
|
+
expect(page1.pagination.page).toBe(1)
|
|
154
|
+
expect(page1.pagination.pageSize).toBe(2)
|
|
155
|
+
expect(page1.pagination.total).toBe(5)
|
|
156
|
+
expect(page1.pagination.totalPages).toBe(3)
|
|
157
|
+
|
|
158
|
+
// Test second page
|
|
159
|
+
const page2 = await service.search(
|
|
160
|
+
{},
|
|
161
|
+
{
|
|
162
|
+
pagination: { page: 2, pageSize: 2 },
|
|
163
|
+
},
|
|
164
|
+
)
|
|
165
|
+
expect(page2.results).toHaveLength(2)
|
|
166
|
+
expect(page2.pagination.page).toBe(2)
|
|
167
|
+
|
|
168
|
+
// Test last page
|
|
169
|
+
const page3 = await service.search(
|
|
170
|
+
{},
|
|
171
|
+
{
|
|
172
|
+
pagination: { page: 3, pageSize: 2 },
|
|
173
|
+
},
|
|
174
|
+
)
|
|
175
|
+
expect(page3.results).toHaveLength(1)
|
|
176
|
+
expect(page3.pagination.page).toBe(3)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('should handle sort options correctly', async () => {
|
|
180
|
+
const input1 = { id: 1, title: 'Z Book', author: 'Author A', publishedDate: new Date('2023-01-01') }
|
|
181
|
+
const input2 = { id: 2, title: 'A Book', author: 'Author B', publishedDate: new Date('2023-02-01') }
|
|
182
|
+
const input3 = { id: 3, title: 'M Book', author: 'Author C', publishedDate: new Date('2023-03-01') }
|
|
183
|
+
|
|
184
|
+
await repository.create(input1)
|
|
185
|
+
await repository.create(input2)
|
|
186
|
+
await repository.create(input3)
|
|
187
|
+
|
|
188
|
+
// Sort by title ascending
|
|
189
|
+
const titleAscResults = await service.search(
|
|
190
|
+
{},
|
|
191
|
+
{
|
|
192
|
+
sort: [{ title: 'asc' }],
|
|
193
|
+
},
|
|
194
|
+
)
|
|
195
|
+
expect(titleAscResults.results.map((r) => r.title)).toEqual(['A Book', 'M Book', 'Z Book'])
|
|
196
|
+
|
|
197
|
+
// Sort by title descending
|
|
198
|
+
const titleDescResults = await service.search(
|
|
199
|
+
{},
|
|
200
|
+
{
|
|
201
|
+
sort: [{ title: 'desc' }],
|
|
202
|
+
},
|
|
203
|
+
)
|
|
204
|
+
expect(titleDescResults.results.map((r) => r.title)).toEqual(['Z Book', 'M Book', 'A Book'])
|
|
205
|
+
|
|
206
|
+
// Sort by author ascending
|
|
207
|
+
const authorAscResults = await service.search(
|
|
208
|
+
{},
|
|
209
|
+
{
|
|
210
|
+
sort: [{ author: 'asc' }],
|
|
211
|
+
},
|
|
212
|
+
)
|
|
213
|
+
expect(authorAscResults.results.map((r) => r.author)).toEqual(['Author A', 'Author B', 'Author C'])
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('should handle combined filtering, sorting, and pagination', async () => {
|
|
217
|
+
const repositoryWithFilter = new MockMemoryRepository({
|
|
218
|
+
schema: mockSchema,
|
|
219
|
+
filter: (data, filters) => {
|
|
220
|
+
if (filters.text) {
|
|
221
|
+
return data.title.toLowerCase().includes(filters.text.toLowerCase())
|
|
222
|
+
}
|
|
223
|
+
return true
|
|
224
|
+
},
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
const serviceWithFilter = new ReadOnlyModelService({
|
|
228
|
+
repository: repositoryWithFilter,
|
|
229
|
+
emitter,
|
|
230
|
+
namespace,
|
|
231
|
+
schema: mockSchema,
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
await repositoryWithFilter.create({ title: 'Test Z Book', author: 'Author 1', publishedDate: new Date() })
|
|
235
|
+
await repositoryWithFilter.create({ title: 'Test A Book', author: 'Author 2', publishedDate: new Date() })
|
|
236
|
+
await repositoryWithFilter.create({ title: 'Other Book', author: 'Author 3', publishedDate: new Date() })
|
|
237
|
+
await repositoryWithFilter.create({ title: 'Test M Book', author: 'Author 4', publishedDate: new Date() })
|
|
238
|
+
|
|
239
|
+
const results = await serviceWithFilter.search(
|
|
240
|
+
{ text: 'Test' },
|
|
241
|
+
{
|
|
242
|
+
sort: [{ title: 'asc' }],
|
|
243
|
+
pagination: { page: 1, pageSize: 2 },
|
|
244
|
+
},
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
expect(results.results).toHaveLength(2)
|
|
248
|
+
expect(results.results.map((r) => r.title)).toEqual(['Test A Book', 'Test M Book'])
|
|
249
|
+
expect(results.pagination.total).toBe(3) // 3 "Test" books total
|
|
250
|
+
expect(results.pagination.totalPages).toBe(2)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('should trigger before and after events for load', async () => {
|
|
254
|
+
const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
|
|
255
|
+
await repository.create(input)
|
|
256
|
+
|
|
257
|
+
const record = await service.load({ id: 42 })
|
|
258
|
+
|
|
259
|
+
expect(record).toEqual(input)
|
|
260
|
+
expect(beforeLoadSpy).toHaveBeenCalledTimes(1)
|
|
261
|
+
expect(beforeLoadSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.beforeLoad' }))
|
|
262
|
+
expect(afterLoadSpy).toHaveBeenCalledTimes(1)
|
|
263
|
+
expect(afterLoadSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.afterLoad' }))
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('should trigger before and after events for loadMany', async () => {
|
|
267
|
+
const input1 = { id: 42, title: 'Test Book 1', author: 'Author Name 1', publishedDate: new Date() }
|
|
268
|
+
const input2 = { id: 43, title: 'Test Book 2', author: 'Author Name 2', publishedDate: new Date() }
|
|
269
|
+
await repository.create(input1)
|
|
270
|
+
await repository.create(input2)
|
|
271
|
+
|
|
272
|
+
const records = await service.loadMany([{ id: 42 }, { id: 43 }])
|
|
273
|
+
|
|
274
|
+
expect(records).toEqual([input1, input2])
|
|
275
|
+
expect(beforeLoadManySpy).toHaveBeenCalledTimes(1)
|
|
276
|
+
expect(beforeLoadManySpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.beforeLoadMany' }))
|
|
277
|
+
expect(afterLoadManySpy).toHaveBeenCalledTimes(1)
|
|
278
|
+
expect(afterLoadManySpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.afterLoadMany' }))
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('should trigger before and after events for search', async () => {
|
|
282
|
+
const input1 = { id: 42, title: 'Test Book 1', author: 'Author Name 1', publishedDate: new Date() }
|
|
283
|
+
const input2 = { id: 43, title: 'Test Book 2', author: 'Author Name 2', publishedDate: new Date() }
|
|
284
|
+
await repository.create(input1)
|
|
285
|
+
await repository.create(input2)
|
|
286
|
+
|
|
287
|
+
const results = await service.search({ text: 'Test' })
|
|
288
|
+
|
|
289
|
+
expect(results.results).toEqual([input1, input2])
|
|
290
|
+
expect(results.pagination.total).toBe(2)
|
|
291
|
+
expect(beforeSearchSpy).toHaveBeenCalledTimes(1)
|
|
292
|
+
expect(beforeSearchSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.beforeSearch' }))
|
|
293
|
+
expect(afterSearchSpy).toHaveBeenCalledTimes(1)
|
|
294
|
+
expect(afterSearchSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.afterSearch' }))
|
|
295
|
+
})
|
|
296
|
+
})
|