@declaro/data 2.0.0-beta.12 → 2.0.0-beta.120
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 +26 -0
- package/dist/browser/index.js.map +93 -0
- package/dist/node/index.cjs +13117 -0
- package/dist/node/index.cjs.map +93 -0
- package/dist/node/index.js +13096 -0
- package/dist/node/index.js.map +93 -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 +25 -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 +23 -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 +54 -0
- package/dist/ts/domain/services/model-service.d.ts.map +1 -0
- package/dist/ts/domain/services/model-service.normalization.test.d.ts +2 -0
- package/dist/ts/domain/services/model-service.normalization.test.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 +57 -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/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.assign.test.d.ts +2 -0
- package/dist/ts/test/mock/repositories/mock-memory-repository.assign.test.d.ts.map +1 -0
- package/dist/ts/test/mock/repositories/mock-memory-repository.basic.test.d.ts +2 -0
- package/dist/ts/test/mock/repositories/mock-memory-repository.basic.test.d.ts.map +1 -0
- package/dist/ts/test/mock/repositories/mock-memory-repository.bulk-upsert.test.d.ts +2 -0
- package/dist/ts/test/mock/repositories/mock-memory-repository.bulk-upsert.test.d.ts.map +1 -0
- package/dist/ts/test/mock/repositories/mock-memory-repository.count.test.d.ts +2 -0
- package/dist/ts/test/mock/repositories/mock-memory-repository.count.test.d.ts.map +1 -0
- package/dist/ts/test/mock/repositories/mock-memory-repository.custom-lookup.test.d.ts +1 -0
- package/dist/ts/test/mock/repositories/mock-memory-repository.custom-lookup.test.d.ts.map +1 -0
- package/dist/ts/test/mock/repositories/mock-memory-repository.d.ts +44 -0
- package/dist/ts/test/mock/repositories/mock-memory-repository.d.ts.map +1 -0
- package/dist/ts/test/mock/repositories/mock-memory-repository.search.test.d.ts +2 -0
- package/dist/ts/test/mock/repositories/mock-memory-repository.search.test.d.ts.map +1 -0
- package/dist/ts/test/mock/repositories/mock-memory-repository.upsert.test.d.ts +2 -0
- package/dist/ts/test/mock/repositories/mock-memory-repository.upsert.test.d.ts.map +1 -0
- package/package.json +45 -42
- package/src/application/model-controller.test.ts +503 -0
- package/src/application/model-controller.ts +92 -0
- package/src/application/read-only-model-controller.test.ts +335 -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 +25 -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 +54 -0
- package/src/domain/services/model-service-args.ts +9 -0
- package/src/domain/services/model-service.normalization.test.ts +704 -0
- package/src/domain/services/model-service.test.ts +633 -0
- package/src/domain/services/model-service.ts +345 -0
- package/src/domain/services/read-only-model-service.test.ts +432 -0
- package/src/domain/services/read-only-model-service.ts +158 -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/mock/models/mock-book-models.ts +78 -0
- package/src/test/mock/repositories/mock-memory-repository.assign.test.ts +215 -0
- package/src/test/mock/repositories/mock-memory-repository.basic.test.ts +129 -0
- package/src/test/mock/repositories/mock-memory-repository.bulk-upsert.test.ts +159 -0
- package/src/test/mock/repositories/mock-memory-repository.count.test.ts +98 -0
- package/src/test/mock/repositories/mock-memory-repository.custom-lookup.test.ts +0 -0
- package/src/test/mock/repositories/mock-memory-repository.search.test.ts +265 -0
- package/src/test/mock/repositories/mock-memory-repository.ts +301 -0
- package/src/test/mock/repositories/mock-memory-repository.upsert.test.ts +108 -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,432 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, spyOn, mock, beforeAll } from 'bun:test'
|
|
2
|
+
import { ReadOnlyModelService, type ILoadOptions } from './read-only-model-service'
|
|
3
|
+
import { MockMemoryRepository } from '../../test/mock/repositories/mock-memory-repository'
|
|
4
|
+
import { MockBookSchema, type MockBookDetail, type MockBookLookup } 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
|
+
|
|
297
|
+
describe('Response Normalization', () => {
|
|
298
|
+
class TestRepository extends MockMemoryRepository<typeof mockSchema> {
|
|
299
|
+
constructor() {
|
|
300
|
+
super({
|
|
301
|
+
schema: mockSchema,
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async load(input: MockBookLookup): Promise<MockBookDetail | null> {
|
|
306
|
+
const record = await super.load(input)
|
|
307
|
+
|
|
308
|
+
if (record) {
|
|
309
|
+
record.publishedDate = '2024-01-01' as any
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return record
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async loadMany(inputs: MockBookLookup[]): Promise<MockBookDetail[]> {
|
|
316
|
+
const records = await super.loadMany(inputs)
|
|
317
|
+
|
|
318
|
+
for (const record of records) {
|
|
319
|
+
record.publishedDate = '2024-01-01' as any
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return records
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async search(
|
|
326
|
+
filters: InferFilters<typeof mockSchema>,
|
|
327
|
+
options?: ILoadOptions,
|
|
328
|
+
): Promise<InferSearchResults<typeof mockSchema>> {
|
|
329
|
+
const results = await super.search(filters, options)
|
|
330
|
+
|
|
331
|
+
for (const record of results.results) {
|
|
332
|
+
record.publishedDate = '2024-01-01' as any
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return results
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
class TestServiceWithNormalization extends ReadOnlyModelService<typeof mockSchema> {
|
|
340
|
+
async normalizeDetail(detail: InferDetail<typeof mockSchema>): Promise<InferDetail<typeof mockSchema>> {
|
|
341
|
+
// Handle null case (e.g., when load returns null)
|
|
342
|
+
if (!detail) return detail
|
|
343
|
+
|
|
344
|
+
// Convert string dates back to Date objects
|
|
345
|
+
if (typeof detail.publishedDate === 'string') {
|
|
346
|
+
detail.publishedDate = new Date(detail.publishedDate) as any
|
|
347
|
+
}
|
|
348
|
+
return detail
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async normalizeSummary(summary: InferDetail<typeof mockSchema>): Promise<InferDetail<typeof mockSchema>> {
|
|
352
|
+
// Handle null case (e.g., when load returns null)
|
|
353
|
+
if (!summary) return summary
|
|
354
|
+
|
|
355
|
+
// Convert string dates back to Date objects
|
|
356
|
+
if (typeof summary.publishedDate === 'string') {
|
|
357
|
+
summary.publishedDate = new Date(summary.publishedDate) as any
|
|
358
|
+
}
|
|
359
|
+
return summary
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
let testService: TestServiceWithNormalization
|
|
364
|
+
|
|
365
|
+
beforeEach(() => {
|
|
366
|
+
repository = new TestRepository()
|
|
367
|
+
emitter = new EventManager()
|
|
368
|
+
|
|
369
|
+
testService = new TestServiceWithNormalization({ repository, emitter, schema: mockSchema, namespace })
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it('should allow custom normalization of details in the load response when overridden', async () => {
|
|
373
|
+
const input = { id: 100, title: 'Normalization Test', author: 'Normalizer', publishedDate: new Date() }
|
|
374
|
+
await repository.create(input)
|
|
375
|
+
|
|
376
|
+
const record = await testService.load({ id: 100 })
|
|
377
|
+
|
|
378
|
+
const expectedDate = new Date('2024-01-01')
|
|
379
|
+
const actualDate = record.publishedDate
|
|
380
|
+
|
|
381
|
+
expect(actualDate).toEqual(expectedDate)
|
|
382
|
+
expect(actualDate).toBeInstanceOf(Date)
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
it('should allow custom normalization of details in the loadMany response when overridden', async () => {
|
|
386
|
+
const input1 = { id: 101, title: 'Normalization Test 1', author: 'Normalizer 1', publishedDate: new Date() }
|
|
387
|
+
const input2 = { id: 102, title: 'Normalization Test 2', author: 'Normalizer 2', publishedDate: new Date() }
|
|
388
|
+
await repository.create(input1)
|
|
389
|
+
await repository.create(input2)
|
|
390
|
+
|
|
391
|
+
const records = await testService.loadMany([{ id: 101 }, { id: 102 }])
|
|
392
|
+
|
|
393
|
+
const expectedDate = new Date('2024-01-01')
|
|
394
|
+
|
|
395
|
+
for (const record of records) {
|
|
396
|
+
const actualDate = record.publishedDate
|
|
397
|
+
expect(actualDate).toEqual(expectedDate)
|
|
398
|
+
expect(actualDate).toBeInstanceOf(Date)
|
|
399
|
+
}
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
it('should allow custom normalization of summaries in the search response when overridden', async () => {
|
|
403
|
+
const input1 = { id: 103, title: 'Normalization Test 3', author: 'Normalizer 3', publishedDate: new Date() }
|
|
404
|
+
const input2 = { id: 104, title: 'Normalization Test 4', author: 'Normalizer 4', publishedDate: new Date() }
|
|
405
|
+
await repository.create(input1)
|
|
406
|
+
await repository.create(input2)
|
|
407
|
+
|
|
408
|
+
const results = await testService.search({ text: 'Normalization' })
|
|
409
|
+
|
|
410
|
+
const expectedDate = new Date('2024-01-01')
|
|
411
|
+
|
|
412
|
+
for (const record of results.results) {
|
|
413
|
+
const actualDate = record.publishedDate
|
|
414
|
+
expect(actualDate).toEqual(expectedDate)
|
|
415
|
+
expect(actualDate).toBeInstanceOf(Date)
|
|
416
|
+
}
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
it('should not normalize data by default when normalization methods are not overridden', async () => {
|
|
420
|
+
const defaultService = new ReadOnlyModelService({ repository, emitter, schema: mockSchema, namespace })
|
|
421
|
+
|
|
422
|
+
const input = { id: 105, title: 'Default Test', author: 'Default Author', publishedDate: new Date() }
|
|
423
|
+
await repository.create(input)
|
|
424
|
+
|
|
425
|
+
const record = await defaultService.load({ id: 105 })
|
|
426
|
+
|
|
427
|
+
// Should return the raw string from repository since no normalization is applied
|
|
428
|
+
expect(record.publishedDate as any).toBe('2024-01-01')
|
|
429
|
+
expect(typeof record.publishedDate).toBe('string')
|
|
430
|
+
})
|
|
431
|
+
})
|
|
432
|
+
})
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { AnyModelSchema, Model } from '@declaro/core'
|
|
2
|
+
import type {
|
|
3
|
+
InferDetail,
|
|
4
|
+
InferFilters,
|
|
5
|
+
InferLookup,
|
|
6
|
+
InferSearchResults,
|
|
7
|
+
InferSort,
|
|
8
|
+
} from '../../shared/utils/schema-inference'
|
|
9
|
+
import { ModelQueryEvent } from '../events/event-types'
|
|
10
|
+
import { QueryEvent } from '../events/query-event'
|
|
11
|
+
import { BaseModelService, type IActionOptions } from './base-model-service'
|
|
12
|
+
import type { IPaginationInput } from '../models/pagination'
|
|
13
|
+
|
|
14
|
+
export interface ILoadOptions extends IActionOptions {}
|
|
15
|
+
export interface ISearchOptions<TSchema extends AnyModelSchema> extends IActionOptions {
|
|
16
|
+
pagination?: IPaginationInput
|
|
17
|
+
sort?: InferSort<TSchema>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class ReadOnlyModelService<TSchema extends AnyModelSchema> extends BaseModelService<TSchema> {
|
|
21
|
+
/**
|
|
22
|
+
* Normalize the detail data to match the expected schema.
|
|
23
|
+
* WARNING: This method is called once per detail in load operations.
|
|
24
|
+
* Any intensive operations or queries should be avoided here, and done via bulk operations in the respective methods such as `loadMany` instead.
|
|
25
|
+
* @param detail The detail data to normalize.
|
|
26
|
+
* @returns The normalized detail data.
|
|
27
|
+
*/
|
|
28
|
+
async normalizeDetail(detail: InferDetail<TSchema>): Promise<InferDetail<TSchema>> {
|
|
29
|
+
return detail
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Normalize the summary data to match the expected schema.
|
|
34
|
+
* WARNING: This method is called once per summary in search results, often in parallel.
|
|
35
|
+
* Any intensive operations or queries should be avoided here, and done via bulk operations in the respective methods such as `search` instead.
|
|
36
|
+
*
|
|
37
|
+
* @param summary The summary data to normalize.
|
|
38
|
+
* @returns The normalized summary data.
|
|
39
|
+
*/
|
|
40
|
+
async normalizeSummary(summary: InferDetail<TSchema>): Promise<InferDetail<TSchema>> {
|
|
41
|
+
return summary
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Load a single record by its lookup criteria.
|
|
46
|
+
* @param lookup The lookup criteria to find the record.
|
|
47
|
+
* @param options Additional options for the load operation.
|
|
48
|
+
* @returns The loaded record details.
|
|
49
|
+
*/
|
|
50
|
+
async load(lookup: InferLookup<TSchema>, options?: ILoadOptions): Promise<InferDetail<TSchema>> {
|
|
51
|
+
// Emit the before load event
|
|
52
|
+
const beforeLoadEvent = new QueryEvent<InferDetail<TSchema>, InferLookup<TSchema>>(
|
|
53
|
+
this.getDescriptor(ModelQueryEvent.BeforeLoad, options?.scope),
|
|
54
|
+
lookup,
|
|
55
|
+
)
|
|
56
|
+
await this.emitter.emitAsync(beforeLoadEvent)
|
|
57
|
+
|
|
58
|
+
// Load the details from the repository
|
|
59
|
+
const details = await this.repository.load(lookup)
|
|
60
|
+
|
|
61
|
+
// Emit the after load event
|
|
62
|
+
const afterLoadEvent = new QueryEvent<InferDetail<TSchema>, InferLookup<TSchema>>(
|
|
63
|
+
this.getDescriptor(ModelQueryEvent.AfterLoad, options?.scope),
|
|
64
|
+
lookup,
|
|
65
|
+
).setResult(details)
|
|
66
|
+
await this.emitter.emitAsync(afterLoadEvent)
|
|
67
|
+
|
|
68
|
+
return await this.normalizeDetail(details)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Load multiple records by their lookup criteria.
|
|
73
|
+
* @param lookups The lookup criteria to find the records.
|
|
74
|
+
* @param options Additional options for the load operation.
|
|
75
|
+
* @returns An array of loaded record details.
|
|
76
|
+
*/
|
|
77
|
+
async loadMany(lookups: InferLookup<TSchema>[], options?: ILoadOptions): Promise<InferDetail<TSchema>[]> {
|
|
78
|
+
// Emit the before load many event
|
|
79
|
+
const beforeLoadManyEvent = new QueryEvent<InferDetail<TSchema>[], InferLookup<TSchema>[]>(
|
|
80
|
+
this.getDescriptor(ModelQueryEvent.BeforeLoadMany, options?.scope),
|
|
81
|
+
lookups,
|
|
82
|
+
)
|
|
83
|
+
await this.emitter.emitAsync(beforeLoadManyEvent)
|
|
84
|
+
|
|
85
|
+
// Load the details from the repository
|
|
86
|
+
const details = await this.repository.loadMany(lookups)
|
|
87
|
+
|
|
88
|
+
// Emit the after load many event
|
|
89
|
+
const afterLoadManyEvent = new QueryEvent<InferDetail<TSchema>[], InferLookup<TSchema>[]>(
|
|
90
|
+
this.getDescriptor(ModelQueryEvent.AfterLoadMany, options?.scope),
|
|
91
|
+
lookups,
|
|
92
|
+
).setResult(details)
|
|
93
|
+
await this.emitter.emitAsync(afterLoadManyEvent)
|
|
94
|
+
|
|
95
|
+
return await Promise.all(details.map((detail) => this.normalizeDetail(detail)))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Search for records matching the given filters.
|
|
100
|
+
* @param filters The filters to apply to the search.
|
|
101
|
+
* @param options Additional options for the search operation.
|
|
102
|
+
* @returns The search results.
|
|
103
|
+
*/
|
|
104
|
+
async search(
|
|
105
|
+
filters: InferFilters<TSchema>,
|
|
106
|
+
options?: ISearchOptions<TSchema>,
|
|
107
|
+
): Promise<InferSearchResults<TSchema>> {
|
|
108
|
+
// Emit the before search event
|
|
109
|
+
const beforeSearchEvent = new QueryEvent<InferSearchResults<TSchema>, InferFilters<TSchema>>(
|
|
110
|
+
this.getDescriptor(ModelQueryEvent.BeforeSearch, options?.scope),
|
|
111
|
+
filters,
|
|
112
|
+
)
|
|
113
|
+
await this.emitter.emitAsync(beforeSearchEvent)
|
|
114
|
+
|
|
115
|
+
// Search the repository with the provided filters
|
|
116
|
+
const results = await this.repository.search(filters, options)
|
|
117
|
+
|
|
118
|
+
// Emit the after search event
|
|
119
|
+
const afterSearchEvent = new QueryEvent<InferSearchResults<TSchema>, InferFilters<TSchema>>(
|
|
120
|
+
this.getDescriptor(ModelQueryEvent.AfterSearch, options?.scope),
|
|
121
|
+
filters,
|
|
122
|
+
).setResult(results)
|
|
123
|
+
await this.emitter.emitAsync(afterSearchEvent)
|
|
124
|
+
|
|
125
|
+
// Return the search results
|
|
126
|
+
return {
|
|
127
|
+
...results,
|
|
128
|
+
results: await Promise.all(results.results.map((detail) => this.normalizeSummary(detail))),
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Count the number of records matching the given filters.
|
|
134
|
+
* @param filters The filters to apply to the count operation.
|
|
135
|
+
* @returns The count of matching records.
|
|
136
|
+
*/
|
|
137
|
+
async count(filters: InferFilters<TSchema>, options?: ISearchOptions<TSchema>): Promise<number> {
|
|
138
|
+
// Emit the before count event
|
|
139
|
+
const beforeCountEvent = new QueryEvent<number, InferFilters<TSchema>>(
|
|
140
|
+
this.getDescriptor(ModelQueryEvent.BeforeCount, options?.scope),
|
|
141
|
+
filters,
|
|
142
|
+
)
|
|
143
|
+
await this.emitter.emitAsync(beforeCountEvent)
|
|
144
|
+
|
|
145
|
+
// Count the records in the repository
|
|
146
|
+
const count = await this.repository.count(filters, options)
|
|
147
|
+
|
|
148
|
+
// Emit the after count event
|
|
149
|
+
const afterCountEvent = new QueryEvent<number, InferFilters<TSchema>>(
|
|
150
|
+
this.getDescriptor(ModelQueryEvent.AfterCount, options?.scope),
|
|
151
|
+
filters,
|
|
152
|
+
).setResult(count)
|
|
153
|
+
await this.emitter.emitAsync(afterCountEvent)
|
|
154
|
+
|
|
155
|
+
// Return the count
|
|
156
|
+
return count
|
|
157
|
+
}
|
|
158
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
|
-
export * from './
|
|
2
|
-
export * from './
|
|
3
|
-
export * from './
|
|
4
|
-
export * from './
|
|
1
|
+
export * from './application/model-controller'
|
|
2
|
+
export * from './application/read-only-model-controller'
|
|
3
|
+
export * from './domain/events/domain-event'
|
|
4
|
+
export * from './domain/events/event-types'
|
|
5
|
+
export * from './domain/events/mutation-event'
|
|
6
|
+
export * from './domain/events/query-event'
|
|
7
|
+
export * from './domain/events/request-event'
|
|
8
|
+
export * from './domain/interfaces/repository'
|
|
9
|
+
export * from './domain/models/pagination'
|
|
10
|
+
export * from './domain/services/base-model-service'
|
|
11
|
+
export * from './domain/services/model-service'
|
|
12
|
+
export * from './domain/services/model-service-args'
|
|
13
|
+
export * from './domain/services/read-only-model-service'
|
|
14
|
+
export * from './shared/utils/schema-inference'
|
|
15
|
+
export * from './shared/utils/schema-inheritance'
|
|
16
|
+
export * from './test/mock/models/mock-book-models'
|
|
17
|
+
export * from './test/mock/repositories/mock-memory-repository'
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { AnyModelSchema, InferModelInput, InferModelOutput } from '@declaro/core'
|
|
2
|
+
import type { IPagination } from '../../domain/models/pagination'
|
|
3
|
+
|
|
4
|
+
export interface ISearchResults<T> {
|
|
5
|
+
results: T[]
|
|
6
|
+
pagination: IPagination
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type InferLookup<TSchema extends AnyModelSchema> = InferModelInput<TSchema['definition']['lookup']>
|
|
10
|
+
export type InferDetail<TSchema extends AnyModelSchema> = InferModelOutput<TSchema['definition']['detail']>
|
|
11
|
+
export type InferFilters<TSchema extends AnyModelSchema> = InferModelInput<TSchema['definition']['filters']>
|
|
12
|
+
export type InferSummary<TSchema extends AnyModelSchema> = InferModelOutput<TSchema['definition']['summary']>
|
|
13
|
+
export type InferSort<TSchema extends AnyModelSchema> = InferModelInput<TSchema['definition']['sort']>
|
|
14
|
+
export type InferInput<TSchema extends AnyModelSchema> = InferModelInput<TSchema['definition']['input']>
|
|
15
|
+
export type InferSearchResults<TSchema extends AnyModelSchema> = ISearchResults<InferSummary<TSchema>>
|
|
16
|
+
export type InferEntityMetadata<TSchema extends AnyModelSchema> = ReturnType<TSchema['getEntityMetadata']>
|
|
17
|
+
export type InferPrimaryKeyType<TSchema extends AnyModelSchema> =
|
|
18
|
+
InferLookup<TSchema>[InferEntityMetadata<TSchema>['primaryKey']]
|
|
19
|
+
|
|
20
|
+
export type InferSummarySchema<TSchema extends AnyModelSchema> = TSchema['definition']['summary']['schema']
|
|
21
|
+
export type InferDetailSchema<TSchema extends AnyModelSchema> = TSchema['definition']['detail']['schema']
|
|
22
|
+
export type InferLookupSchema<TSchema extends AnyModelSchema> = TSchema['definition']['lookup']['schema']
|
|
23
|
+
export type InferInputSchema<TSchema extends AnyModelSchema> = TSchema['definition']['input']['schema']
|
|
24
|
+
export type InferFiltersSchema<TSchema extends AnyModelSchema> = TSchema['definition']['filters']['schema']
|
|
25
|
+
export type InferSortSchema<TSchema extends AnyModelSchema> = TSchema['definition']['sort']['schema']
|
|
26
|
+
export type InferSearchResultsSchema<TSchema extends AnyModelSchema> = TSchema['definition']['search']['schema']
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { AnyModelSchema, Model, ModelSchema } from '@declaro/core'
|
|
2
|
+
import type { InferEntityMetadata } from './schema-inference'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Represents a child schema that inherits from a parent schema.
|
|
6
|
+
* This is useful for creating schemas that extend the functionality of an existing schema.
|
|
7
|
+
* It replaces the schema name and all model names with a string type.
|
|
8
|
+
*
|
|
9
|
+
* @warning This type is intended for use in generic types. In most cases, you should use concrete schemas for inheritance for best type inference.
|
|
10
|
+
*
|
|
11
|
+
* @template TSchema - The parent schema type.
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* import { ModelSchema, ChildSchema } from '@declaro/core';
|
|
15
|
+
*
|
|
16
|
+
* export class ParentService<TSchema extends ChildSchema<typeof ParentSchema>> extends ModelService<TSchema> {
|
|
17
|
+
* constructor(args: IModelServiceArgs<TSchema>) {
|
|
18
|
+
* super(args);
|
|
19
|
+
* }
|
|
20
|
+
* }
|
|
21
|
+
*/
|
|
22
|
+
export type ChildSchema<TSchema extends AnyModelSchema> = ModelSchema<
|
|
23
|
+
string,
|
|
24
|
+
{
|
|
25
|
+
[K in keyof TSchema['definition']]: Model<string, TSchema['definition'][K]['schema']>
|
|
26
|
+
},
|
|
27
|
+
InferEntityMetadata<TSchema>
|
|
28
|
+
>
|