@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,265 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from 'bun:test'
|
|
2
|
+
import { MockBookSchema } from '../models/mock-book-models'
|
|
3
|
+
import { MockMemoryRepository } from './mock-memory-repository'
|
|
4
|
+
|
|
5
|
+
describe('MockMemoryRepository - Search Functionality', () => {
|
|
6
|
+
const mockSchema = MockBookSchema
|
|
7
|
+
|
|
8
|
+
let repository: MockMemoryRepository<typeof mockSchema>
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
repository = new MockMemoryRepository({ schema: mockSchema })
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('should search for all items when no filter is provided', async () => {
|
|
15
|
+
const input1 = { title: 'Book One', author: 'Author A', publishedDate: new Date('2023-01-01') }
|
|
16
|
+
const input2 = { title: 'Book Two', author: 'Author B', publishedDate: new Date('2023-02-01') }
|
|
17
|
+
const input3 = { title: 'Book Three', author: 'Author C', publishedDate: new Date('2023-03-01') }
|
|
18
|
+
|
|
19
|
+
const item1 = await repository.create(input1)
|
|
20
|
+
const item2 = await repository.create(input2)
|
|
21
|
+
const item3 = await repository.create(input3)
|
|
22
|
+
|
|
23
|
+
const results = await repository.search({})
|
|
24
|
+
|
|
25
|
+
expect(results.results).toHaveLength(3)
|
|
26
|
+
expect(results.results).toEqual(expect.arrayContaining([item1, item2, item3]))
|
|
27
|
+
expect(results.pagination.total).toBe(3)
|
|
28
|
+
expect(results.pagination.page).toBe(1)
|
|
29
|
+
expect(results.pagination.pageSize).toBe(25)
|
|
30
|
+
expect(results.pagination.totalPages).toBe(1)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('should filter items using custom filter function', async () => {
|
|
34
|
+
const repositoryWithFilter = new MockMemoryRepository({
|
|
35
|
+
schema: mockSchema,
|
|
36
|
+
filter: (data, filters) => {
|
|
37
|
+
if (filters.text) {
|
|
38
|
+
return (
|
|
39
|
+
data.title.toLowerCase().includes(filters.text.toLowerCase()) ||
|
|
40
|
+
data.author.toLowerCase().includes(filters.text.toLowerCase())
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
return true
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const input1 = { title: 'JavaScript Guide', author: 'John Doe', publishedDate: new Date() }
|
|
48
|
+
const input2 = { title: 'Python Handbook', author: 'Jane Smith', publishedDate: new Date() }
|
|
49
|
+
const input3 = { title: 'Java Programming', author: 'John Johnson', publishedDate: new Date() }
|
|
50
|
+
|
|
51
|
+
const item1 = await repositoryWithFilter.create(input1)
|
|
52
|
+
const item2 = await repositoryWithFilter.create(input2)
|
|
53
|
+
const item3 = await repositoryWithFilter.create(input3)
|
|
54
|
+
|
|
55
|
+
// Search by title
|
|
56
|
+
const titleResults = await repositoryWithFilter.search({ text: 'JavaScript' })
|
|
57
|
+
expect(titleResults.results).toEqual([item1])
|
|
58
|
+
|
|
59
|
+
// Search by author
|
|
60
|
+
const authorResults = await repositoryWithFilter.search({ text: 'John' })
|
|
61
|
+
expect(authorResults.results).toHaveLength(2)
|
|
62
|
+
expect(authorResults.results).toEqual(expect.arrayContaining([item1, item3]))
|
|
63
|
+
|
|
64
|
+
// Search with no matches
|
|
65
|
+
const noResults = await repositoryWithFilter.search({ text: 'Ruby' })
|
|
66
|
+
expect(noResults.results).toEqual([])
|
|
67
|
+
expect(noResults.pagination.total).toBe(0)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('should handle pagination correctly', async () => {
|
|
71
|
+
// Create 10 items
|
|
72
|
+
for (let i = 1; i <= 10; i++) {
|
|
73
|
+
const input = { title: `Book ${i}`, author: `Author ${i}`, publishedDate: new Date() }
|
|
74
|
+
await repository.create(input)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Test first page with pageSize 3
|
|
78
|
+
const page1 = await repository.search(
|
|
79
|
+
{},
|
|
80
|
+
{
|
|
81
|
+
pagination: { page: 1, pageSize: 3 },
|
|
82
|
+
},
|
|
83
|
+
)
|
|
84
|
+
expect(page1.results).toHaveLength(3)
|
|
85
|
+
expect(page1.pagination.page).toBe(1)
|
|
86
|
+
expect(page1.pagination.pageSize).toBe(3)
|
|
87
|
+
expect(page1.pagination.total).toBe(10)
|
|
88
|
+
expect(page1.pagination.totalPages).toBe(4)
|
|
89
|
+
|
|
90
|
+
// Test second page
|
|
91
|
+
const page2 = await repository.search(
|
|
92
|
+
{},
|
|
93
|
+
{
|
|
94
|
+
pagination: { page: 2, pageSize: 3 },
|
|
95
|
+
},
|
|
96
|
+
)
|
|
97
|
+
expect(page2.results).toHaveLength(3)
|
|
98
|
+
expect(page2.pagination.page).toBe(2)
|
|
99
|
+
expect(page2.pagination.pageSize).toBe(3)
|
|
100
|
+
|
|
101
|
+
// Test last page (should have 1 item)
|
|
102
|
+
const page4 = await repository.search(
|
|
103
|
+
{},
|
|
104
|
+
{
|
|
105
|
+
pagination: { page: 4, pageSize: 3 },
|
|
106
|
+
},
|
|
107
|
+
)
|
|
108
|
+
expect(page4.results).toHaveLength(1)
|
|
109
|
+
expect(page4.pagination.page).toBe(4)
|
|
110
|
+
|
|
111
|
+
// Test page beyond available data
|
|
112
|
+
const pageEmpty = await repository.search(
|
|
113
|
+
{},
|
|
114
|
+
{
|
|
115
|
+
pagination: { page: 5, pageSize: 3 },
|
|
116
|
+
},
|
|
117
|
+
)
|
|
118
|
+
expect(pageEmpty.results).toHaveLength(0)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('should use default pagination when not provided', async () => {
|
|
122
|
+
// Create 30 items to test default pagination
|
|
123
|
+
for (let i = 1; i <= 30; i++) {
|
|
124
|
+
await repository.create({ title: `Book ${i}`, author: `Author ${i}`, publishedDate: new Date() })
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const results = await repository.search({})
|
|
128
|
+
expect(results.pagination.page).toBe(1)
|
|
129
|
+
expect(results.pagination.pageSize).toBe(25)
|
|
130
|
+
expect(results.pagination.total).toBe(30)
|
|
131
|
+
expect(results.pagination.totalPages).toBe(2)
|
|
132
|
+
expect(results.results).toHaveLength(25)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('should handle edge cases with pagination', async () => {
|
|
136
|
+
// Test with empty repository
|
|
137
|
+
const emptyResults = await repository.search(
|
|
138
|
+
{},
|
|
139
|
+
{
|
|
140
|
+
pagination: { page: 1, pageSize: 10 },
|
|
141
|
+
},
|
|
142
|
+
)
|
|
143
|
+
expect(emptyResults.results).toEqual([])
|
|
144
|
+
expect(emptyResults.pagination.total).toBe(0)
|
|
145
|
+
expect(emptyResults.pagination.totalPages).toBe(0)
|
|
146
|
+
|
|
147
|
+
// Test with page 0 (results in empty due to slice calculation)
|
|
148
|
+
await repository.create({ title: 'Test Book', author: 'Test Author', publishedDate: new Date() })
|
|
149
|
+
const page0Results = await repository.search(
|
|
150
|
+
{},
|
|
151
|
+
{
|
|
152
|
+
pagination: { page: 0, pageSize: 10 },
|
|
153
|
+
},
|
|
154
|
+
)
|
|
155
|
+
expect(page0Results.pagination.page).toBe(0)
|
|
156
|
+
expect(page0Results.results).toHaveLength(0) // slice(-10, 0) returns empty array
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('should handle sorting correctly', async () => {
|
|
160
|
+
const input1 = { title: 'C Book', author: 'Author Z', publishedDate: new Date('2023-03-01') }
|
|
161
|
+
const input2 = { title: 'A Book', author: 'Author Y', publishedDate: new Date('2023-01-01') }
|
|
162
|
+
const input3 = { title: 'B Book', author: 'Author X', publishedDate: new Date('2023-02-01') }
|
|
163
|
+
|
|
164
|
+
const item1 = await repository.create(input1)
|
|
165
|
+
const item2 = await repository.create(input2)
|
|
166
|
+
const item3 = await repository.create(input3)
|
|
167
|
+
|
|
168
|
+
// Sort by title ascending
|
|
169
|
+
const titleAscResults = await repository.search(
|
|
170
|
+
{},
|
|
171
|
+
{
|
|
172
|
+
sort: [{ title: 'asc' }],
|
|
173
|
+
},
|
|
174
|
+
)
|
|
175
|
+
expect(titleAscResults.results.map((r) => r.title)).toEqual(['A Book', 'B Book', 'C Book'])
|
|
176
|
+
|
|
177
|
+
// Sort by title descending
|
|
178
|
+
const titleDescResults = await repository.search(
|
|
179
|
+
{},
|
|
180
|
+
{
|
|
181
|
+
sort: [{ title: 'desc' }],
|
|
182
|
+
},
|
|
183
|
+
)
|
|
184
|
+
expect(titleDescResults.results.map((r) => r.title)).toEqual(['C Book', 'B Book', 'A Book'])
|
|
185
|
+
|
|
186
|
+
// Sort by author ascending
|
|
187
|
+
const authorAscResults = await repository.search(
|
|
188
|
+
{},
|
|
189
|
+
{
|
|
190
|
+
sort: [{ author: 'asc' }],
|
|
191
|
+
},
|
|
192
|
+
)
|
|
193
|
+
expect(authorAscResults.results.map((r) => r.author)).toEqual(['Author X', 'Author Y', 'Author Z'])
|
|
194
|
+
|
|
195
|
+
// Multiple field sort: title asc, then author desc
|
|
196
|
+
const multiSortResults = await repository.search(
|
|
197
|
+
{},
|
|
198
|
+
{
|
|
199
|
+
sort: [{ title: 'asc' }, { author: 'desc' }],
|
|
200
|
+
},
|
|
201
|
+
)
|
|
202
|
+
expect(multiSortResults.results.map((r) => r.title)).toEqual(['A Book', 'B Book', 'C Book'])
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('should handle sorting with pagination', async () => {
|
|
206
|
+
for (let i = 1; i <= 5; i++) {
|
|
207
|
+
await repository.create({
|
|
208
|
+
title: `Book ${String.fromCharCode(69 - i)}`, // D, C, B, A, @
|
|
209
|
+
author: `Author ${i}`,
|
|
210
|
+
publishedDate: new Date(),
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Sort by title ascending and get first page
|
|
215
|
+
const sortedPage1 = await repository.search(
|
|
216
|
+
{},
|
|
217
|
+
{
|
|
218
|
+
sort: [{ title: 'asc' }],
|
|
219
|
+
pagination: { page: 1, pageSize: 2 },
|
|
220
|
+
},
|
|
221
|
+
)
|
|
222
|
+
expect(sortedPage1.results.map((r) => r.title)).toEqual(['Book @', 'Book A'])
|
|
223
|
+
expect(sortedPage1.pagination.totalPages).toBe(3)
|
|
224
|
+
|
|
225
|
+
// Get second page with same sort
|
|
226
|
+
const sortedPage2 = await repository.search(
|
|
227
|
+
{},
|
|
228
|
+
{
|
|
229
|
+
sort: [{ title: 'asc' }],
|
|
230
|
+
pagination: { page: 2, pageSize: 2 },
|
|
231
|
+
},
|
|
232
|
+
)
|
|
233
|
+
expect(sortedPage2.results.map((r) => r.title)).toEqual(['Book B', 'Book C'])
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('should handle combined filtering, sorting, and pagination', async () => {
|
|
237
|
+
const repositoryWithFilter = new MockMemoryRepository({
|
|
238
|
+
schema: mockSchema,
|
|
239
|
+
filter: (data, filters) => {
|
|
240
|
+
if (filters.text) {
|
|
241
|
+
return data.title.toLowerCase().includes(filters.text.toLowerCase())
|
|
242
|
+
}
|
|
243
|
+
return true
|
|
244
|
+
},
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
await repositoryWithFilter.create({ title: 'Test Z Book', author: 'Author 1', publishedDate: new Date() })
|
|
248
|
+
await repositoryWithFilter.create({ title: 'Test A Book', author: 'Author 2', publishedDate: new Date() })
|
|
249
|
+
await repositoryWithFilter.create({ title: 'Other Book', author: 'Author 3', publishedDate: new Date() })
|
|
250
|
+
await repositoryWithFilter.create({ title: 'Test B Book', author: 'Author 4', publishedDate: new Date() })
|
|
251
|
+
|
|
252
|
+
const results = await repositoryWithFilter.search(
|
|
253
|
+
{ text: 'Test' },
|
|
254
|
+
{
|
|
255
|
+
sort: [{ title: 'asc' }],
|
|
256
|
+
pagination: { page: 1, pageSize: 2 },
|
|
257
|
+
},
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
expect(results.results).toHaveLength(2)
|
|
261
|
+
expect(results.results.map((r) => r.title)).toEqual(['Test A Book', 'Test B Book'])
|
|
262
|
+
expect(results.pagination.total).toBe(3) // 3 "Test" books total
|
|
263
|
+
expect(results.pagination.totalPages).toBe(2)
|
|
264
|
+
})
|
|
265
|
+
})
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import type { AnyModelSchema, IModelEntityMetadata, JSONSchema, Model } from '@declaro/core'
|
|
2
|
+
import type { IRepository } from '../../../domain/interfaces/repository'
|
|
3
|
+
import type { IPaginationInput } from '../../../domain/models/pagination'
|
|
4
|
+
import type {
|
|
5
|
+
InferDetail,
|
|
6
|
+
InferFilters,
|
|
7
|
+
InferInput,
|
|
8
|
+
InferLookup,
|
|
9
|
+
InferSearchResults,
|
|
10
|
+
InferSummary,
|
|
11
|
+
} from '../../../shared/utils/schema-inference'
|
|
12
|
+
import { v4 as uuid } from 'uuid'
|
|
13
|
+
import type { ISearchOptions } from '../../../domain/services/read-only-model-service'
|
|
14
|
+
import type { ICreateOptions, IUpdateOptions } from '../../../domain/services/model-service'
|
|
15
|
+
|
|
16
|
+
export interface IMockMemoryRepositoryArgs<TSchema extends AnyModelSchema> {
|
|
17
|
+
schema: TSchema
|
|
18
|
+
lookup?: (data: InferDetail<TSchema>, lookup: InferLookup<TSchema>) => boolean
|
|
19
|
+
filter?: (data: InferSummary<TSchema>, filters: InferFilters<TSchema>) => boolean
|
|
20
|
+
assign?: (data: InferDetail<TSchema>, input: InferInput<TSchema>) => InferDetail<TSchema>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class MockMemoryRepository<TSchema extends AnyModelSchema> implements IRepository<TSchema> {
|
|
24
|
+
protected data = new Map<string, InferDetail<TSchema>>()
|
|
25
|
+
protected trash = new Map<string, InferDetail<TSchema>>()
|
|
26
|
+
protected entityMetadata: IModelEntityMetadata
|
|
27
|
+
protected nextId: number = 0
|
|
28
|
+
|
|
29
|
+
constructor(protected args: IMockMemoryRepositoryArgs<TSchema>) {
|
|
30
|
+
this.entityMetadata = this.args.schema.getEntityMetadata()
|
|
31
|
+
if (!this.entityMetadata?.primaryKey) {
|
|
32
|
+
throw new Error('Primary key must be specified for MockMemoryRepository')
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async load(input: InferLookup<TSchema>): Promise<InferDetail<TSchema> | null> {
|
|
37
|
+
if (!this.entityMetadata?.primaryKey) {
|
|
38
|
+
throw new Error('Primary key is not defined in the schema metadata')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let item: InferDetail<TSchema> | undefined
|
|
42
|
+
if (typeof this.args.lookup === 'function') {
|
|
43
|
+
item = Array.from(this.data.values()).find((data) => this.args.lookup!(data, input))
|
|
44
|
+
} else {
|
|
45
|
+
// Default lookup by primary key
|
|
46
|
+
item = await this.data.get(input[this.entityMetadata.primaryKey])
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return item || null
|
|
50
|
+
}
|
|
51
|
+
async loadMany(inputs: InferLookup<TSchema>[]): Promise<InferDetail<TSchema>[]> {
|
|
52
|
+
if (!this.entityMetadata?.primaryKey) {
|
|
53
|
+
throw new Error('Primary key is not defined in the schema metadata')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const results: InferDetail<TSchema>[] = []
|
|
57
|
+
for (const input of inputs) {
|
|
58
|
+
let item: InferDetail<TSchema> | undefined
|
|
59
|
+
if (typeof this.args.lookup === 'function') {
|
|
60
|
+
item = Array.from(this.data.values()).find((data) => this.args.lookup!(data, input))
|
|
61
|
+
} else {
|
|
62
|
+
// Default lookup by primary key
|
|
63
|
+
item = this.data.get(input[this.entityMetadata.primaryKey!])
|
|
64
|
+
}
|
|
65
|
+
if (item) {
|
|
66
|
+
results.push(item)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return results
|
|
70
|
+
}
|
|
71
|
+
async search(
|
|
72
|
+
input: InferFilters<TSchema>,
|
|
73
|
+
options?: ISearchOptions<TSchema>,
|
|
74
|
+
): Promise<InferSearchResults<TSchema>> {
|
|
75
|
+
const pagination = options?.pagination || { page: 1, pageSize: 25 }
|
|
76
|
+
let items = this.applyFilters(input)
|
|
77
|
+
|
|
78
|
+
// Apply sorting if provided
|
|
79
|
+
if (options?.sort && Array.isArray(options.sort)) {
|
|
80
|
+
items = items.sort((a, b) => {
|
|
81
|
+
for (const sortField of options.sort! as any[]) {
|
|
82
|
+
for (const [field, direction] of Object.entries(sortField)) {
|
|
83
|
+
if (!direction || typeof direction !== 'string') continue
|
|
84
|
+
|
|
85
|
+
const aValue = a[field as keyof typeof a]
|
|
86
|
+
const bValue = b[field as keyof typeof b]
|
|
87
|
+
|
|
88
|
+
let comparison = 0
|
|
89
|
+
if (aValue < bValue) comparison = -1
|
|
90
|
+
else if (aValue > bValue) comparison = 1
|
|
91
|
+
|
|
92
|
+
if (comparison !== 0) {
|
|
93
|
+
// Handle different sort directions
|
|
94
|
+
const isDesc = direction.includes('desc')
|
|
95
|
+
return isDesc ? -comparison : comparison
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return 0
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
results: items.slice(
|
|
105
|
+
((pagination?.page ?? 1) - 1) * (pagination?.pageSize ?? 25),
|
|
106
|
+
(pagination?.page ?? 1) * (pagination?.pageSize ?? 25),
|
|
107
|
+
),
|
|
108
|
+
pagination: {
|
|
109
|
+
total: items.length,
|
|
110
|
+
totalPages: Math.ceil(items.length / (pagination?.pageSize ?? 25)),
|
|
111
|
+
...pagination,
|
|
112
|
+
page: pagination?.page ?? 1,
|
|
113
|
+
pageSize: pagination?.pageSize ?? 25,
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async remove(lookup: InferLookup<TSchema>): Promise<InferSummary<TSchema>> {
|
|
118
|
+
if (!this.entityMetadata?.primaryKey) {
|
|
119
|
+
throw new Error('Primary key is not defined in the schema metadata')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let item: InferDetail<TSchema> | undefined
|
|
123
|
+
let itemKey: string
|
|
124
|
+
|
|
125
|
+
if (typeof this.args.lookup === 'function') {
|
|
126
|
+
item = Array.from(this.data.values()).find((data) => this.args.lookup!(data, lookup))
|
|
127
|
+
if (item) {
|
|
128
|
+
itemKey = item[this.entityMetadata.primaryKey!]
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
// Default lookup by primary key
|
|
132
|
+
itemKey = lookup[this.entityMetadata.primaryKey]
|
|
133
|
+
item = this.data.get(itemKey)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!item) {
|
|
137
|
+
throw new Error('Item not found')
|
|
138
|
+
}
|
|
139
|
+
// Move the item to trash
|
|
140
|
+
this.trash.set(itemKey!, item)
|
|
141
|
+
// Remove the item from data
|
|
142
|
+
this.data.delete(itemKey!)
|
|
143
|
+
return item
|
|
144
|
+
}
|
|
145
|
+
async restore(lookup: InferLookup<TSchema>): Promise<InferSummary<TSchema>> {
|
|
146
|
+
if (!this.entityMetadata?.primaryKey) {
|
|
147
|
+
throw new Error('Primary key is not defined in the schema metadata')
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
let item: InferDetail<TSchema> | undefined
|
|
151
|
+
let itemKey: string
|
|
152
|
+
|
|
153
|
+
if (typeof this.args.lookup === 'function') {
|
|
154
|
+
item = Array.from(this.trash.values()).find((data) => this.args.lookup!(data, lookup))
|
|
155
|
+
if (item) {
|
|
156
|
+
itemKey = item[this.entityMetadata.primaryKey!]
|
|
157
|
+
}
|
|
158
|
+
} else {
|
|
159
|
+
// Default lookup by primary key
|
|
160
|
+
itemKey = lookup[this.entityMetadata.primaryKey]
|
|
161
|
+
item = this.trash.get(itemKey)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (!item) {
|
|
165
|
+
throw new Error('Item not found in trash')
|
|
166
|
+
}
|
|
167
|
+
this.trash.delete(itemKey!)
|
|
168
|
+
this.data.set(itemKey!, item)
|
|
169
|
+
return item
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async create(input: InferInput<TSchema>): Promise<InferDetail<TSchema>> {
|
|
173
|
+
if (!this.entityMetadata?.primaryKey) {
|
|
174
|
+
throw new Error('Primary key is not defined in the schema metadata')
|
|
175
|
+
}
|
|
176
|
+
const primaryKeyValue = input[this.entityMetadata.primaryKey]
|
|
177
|
+
|
|
178
|
+
if (primaryKeyValue && this.data.has(primaryKeyValue)) {
|
|
179
|
+
throw new Error('Item with the same primary key already exists')
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const baseData = {} as InferDetail<TSchema>
|
|
183
|
+
const payload = this.assignInput(baseData, input)
|
|
184
|
+
|
|
185
|
+
if (!payload[this.entityMetadata.primaryKey]) {
|
|
186
|
+
// Generate a new primary key if not provided
|
|
187
|
+
payload[this.entityMetadata.primaryKey!] = await this.generatePrimaryKey()
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
this.data.set(payload[this.entityMetadata.primaryKey!], payload)
|
|
191
|
+
return payload
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async update(lookup: InferLookup<TSchema>, input: InferInput<TSchema>): Promise<InferDetail<TSchema>> {
|
|
195
|
+
if (!this.entityMetadata?.primaryKey) {
|
|
196
|
+
throw new Error('Primary key is not defined in the schema metadata')
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let existingItem: InferDetail<TSchema> | undefined
|
|
200
|
+
let itemKey: string
|
|
201
|
+
|
|
202
|
+
if (typeof this.args.lookup === 'function') {
|
|
203
|
+
existingItem = Array.from(this.data.values()).find((data) => this.args.lookup!(data, lookup))
|
|
204
|
+
if (existingItem) {
|
|
205
|
+
itemKey = existingItem[this.entityMetadata.primaryKey!]
|
|
206
|
+
} else {
|
|
207
|
+
throw new Error('Item not found')
|
|
208
|
+
}
|
|
209
|
+
} else {
|
|
210
|
+
// Default lookup by primary key
|
|
211
|
+
itemKey = lookup[this.entityMetadata.primaryKey]
|
|
212
|
+
if (!itemKey) {
|
|
213
|
+
throw new Error('Primary key value must be provided')
|
|
214
|
+
}
|
|
215
|
+
existingItem = this.data.get(itemKey)
|
|
216
|
+
if (!existingItem) {
|
|
217
|
+
throw new Error('Item not found')
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const updatedItem = this.assignInput(existingItem, input)
|
|
222
|
+
this.data.set(itemKey!, updatedItem)
|
|
223
|
+
return updatedItem
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async count(search: InferFilters<TSchema>, options?: ISearchOptions<TSchema> | undefined): Promise<number> {
|
|
227
|
+
const filteredItems = this.applyFilters(search)
|
|
228
|
+
return filteredItems.length
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async upsert(input: InferInput<TSchema>, options?: ICreateOptions | IUpdateOptions): Promise<InferDetail<TSchema>> {
|
|
232
|
+
const primaryKeyValue = input[this.entityMetadata.primaryKey]
|
|
233
|
+
let existingItem: InferDetail<TSchema> = {} as InferDetail<TSchema>
|
|
234
|
+
|
|
235
|
+
if (primaryKeyValue) {
|
|
236
|
+
existingItem = this.data.get(primaryKeyValue) ?? ({} as InferDetail<TSchema>)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const updatedItem = this.assignInput(existingItem, input)
|
|
240
|
+
if (!updatedItem[this.entityMetadata.primaryKey!]) {
|
|
241
|
+
updatedItem[this.entityMetadata.primaryKey!] = await this.generatePrimaryKey()
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
this.data.set(updatedItem[this.entityMetadata.primaryKey!], updatedItem)
|
|
245
|
+
|
|
246
|
+
return updatedItem
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async bulkUpsert(
|
|
250
|
+
inputs: InferInput<TSchema>[],
|
|
251
|
+
options?: ICreateOptions | IUpdateOptions,
|
|
252
|
+
): Promise<InferDetail<TSchema>[]> {
|
|
253
|
+
return await Promise.all(inputs.map((input) => this.upsert(input, options)))
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Apply filtering logic to all items based on the provided search criteria
|
|
258
|
+
* @param input - The search/filter criteria
|
|
259
|
+
* @returns Filtered array of items
|
|
260
|
+
*/
|
|
261
|
+
protected applyFilters(input: InferFilters<TSchema>): InferDetail<TSchema>[] {
|
|
262
|
+
return Array.from(this.data.values()).filter((item) => {
|
|
263
|
+
// Apply filtering logic based on the input
|
|
264
|
+
if (typeof this.args.filter === 'function') {
|
|
265
|
+
return this.args.filter(item, input)
|
|
266
|
+
} else {
|
|
267
|
+
return true
|
|
268
|
+
}
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Assign input data to existing data using the provided assign function or default Object.assign
|
|
274
|
+
* @param existingData - The existing data to merge with
|
|
275
|
+
* @param input - The input data to assign
|
|
276
|
+
* @returns The merged data
|
|
277
|
+
*/
|
|
278
|
+
protected assignInput(existingData: InferDetail<TSchema>, input: InferInput<TSchema>): InferDetail<TSchema> {
|
|
279
|
+
if (typeof this.args.assign === 'function') {
|
|
280
|
+
return this.args.assign(existingData, input)
|
|
281
|
+
} else {
|
|
282
|
+
// Default implementation using Object.assign
|
|
283
|
+
return Object.assign({}, existingData, input) as InferDetail<TSchema>
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
protected async generatePrimaryKey() {
|
|
288
|
+
const lookupModel: Model<any, any> = this.args.schema.definition.lookup
|
|
289
|
+
const lookupMeta = await lookupModel.toJSONSchema()
|
|
290
|
+
const primaryKeyMeta = lookupMeta.properties?.[this.entityMetadata.primaryKey!] as JSONSchema
|
|
291
|
+
const type = primaryKeyMeta.type as string
|
|
292
|
+
|
|
293
|
+
if (type === 'string') {
|
|
294
|
+
return uuid()
|
|
295
|
+
} else if (['number', 'integer'].includes(type)) {
|
|
296
|
+
return ++this.nextId
|
|
297
|
+
} else {
|
|
298
|
+
throw new Error(`Unsupported primary key type: ${type}`)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from 'bun:test'
|
|
2
|
+
import { MockBookSchema } from '../models/mock-book-models'
|
|
3
|
+
import { MockMemoryRepository } from './mock-memory-repository'
|
|
4
|
+
|
|
5
|
+
describe('MockMemoryRepository - Upsert Functionality', () => {
|
|
6
|
+
const mockSchema = MockBookSchema
|
|
7
|
+
|
|
8
|
+
let repository: MockMemoryRepository<typeof mockSchema>
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
repository = new MockMemoryRepository({ schema: mockSchema })
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('should create a new item when no existing item with primary key exists', async () => {
|
|
15
|
+
const input = { id: 42, title: 'New Book', author: 'Author Name', publishedDate: new Date() }
|
|
16
|
+
|
|
17
|
+
const upsertedItem = await repository.upsert(input)
|
|
18
|
+
|
|
19
|
+
expect(upsertedItem).toEqual(input)
|
|
20
|
+
expect(await repository.load({ id: 42 })).toEqual(input)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('should update an existing item when primary key matches', async () => {
|
|
24
|
+
// Create initial item
|
|
25
|
+
const initial = {
|
|
26
|
+
id: 42,
|
|
27
|
+
title: 'Original Book',
|
|
28
|
+
author: 'Original Author',
|
|
29
|
+
publishedDate: new Date('2023-01-01'),
|
|
30
|
+
}
|
|
31
|
+
await repository.create(initial)
|
|
32
|
+
|
|
33
|
+
// Upsert with same ID but different data
|
|
34
|
+
const update = {
|
|
35
|
+
id: 42,
|
|
36
|
+
title: 'Updated Book',
|
|
37
|
+
author: 'Updated Author',
|
|
38
|
+
publishedDate: new Date('2023-12-01'),
|
|
39
|
+
}
|
|
40
|
+
const upsertedItem = await repository.upsert(update)
|
|
41
|
+
|
|
42
|
+
expect(upsertedItem).toEqual(update)
|
|
43
|
+
expect(await repository.load({ id: 42 })).toEqual(update)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('should generate primary key when upserting without one', async () => {
|
|
47
|
+
const input = { title: 'Book Without ID', author: 'Author Name', publishedDate: new Date() }
|
|
48
|
+
|
|
49
|
+
const upsertedItem = await repository.upsert(input)
|
|
50
|
+
|
|
51
|
+
expect(upsertedItem.id).toBeDefined()
|
|
52
|
+
expect(upsertedItem.title).toBe(input.title)
|
|
53
|
+
expect(upsertedItem.author).toBe(input.author)
|
|
54
|
+
expect(await repository.load({ id: upsertedItem.id })).toEqual(upsertedItem)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('should merge with existing item properties when updating', async () => {
|
|
58
|
+
// Create initial item with multiple properties
|
|
59
|
+
const initial = {
|
|
60
|
+
id: 42,
|
|
61
|
+
title: 'Original Book',
|
|
62
|
+
author: 'Original Author',
|
|
63
|
+
publishedDate: new Date('2023-01-01'),
|
|
64
|
+
}
|
|
65
|
+
await repository.create(initial)
|
|
66
|
+
|
|
67
|
+
// Upsert with partial update (only title) - need to provide required fields
|
|
68
|
+
const partialUpdate = {
|
|
69
|
+
id: 42,
|
|
70
|
+
title: 'Updated Title',
|
|
71
|
+
author: 'Original Author', // Keep original
|
|
72
|
+
publishedDate: new Date('2023-01-01'), // Keep original
|
|
73
|
+
}
|
|
74
|
+
const upsertedItem = await repository.upsert(partialUpdate)
|
|
75
|
+
|
|
76
|
+
// Should have updated title but kept other properties
|
|
77
|
+
expect(upsertedItem.id).toBe(42)
|
|
78
|
+
expect(upsertedItem.title).toBe('Updated Title')
|
|
79
|
+
expect(upsertedItem.author).toBe('Original Author')
|
|
80
|
+
expect(upsertedItem.publishedDate).toEqual(initial.publishedDate)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('should handle upsert with null/undefined primary key', async () => {
|
|
84
|
+
const input = {
|
|
85
|
+
id: undefined,
|
|
86
|
+
title: 'Book With Undefined ID',
|
|
87
|
+
author: 'Author Name',
|
|
88
|
+
publishedDate: new Date(),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const upsertedItem = await repository.upsert(input)
|
|
92
|
+
|
|
93
|
+
expect(upsertedItem.id).toBeDefined()
|
|
94
|
+
expect(typeof upsertedItem.id).toBe('number')
|
|
95
|
+
expect(upsertedItem.title).toBe(input.title)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('should increment auto-generated IDs correctly', async () => {
|
|
99
|
+
const input1 = { title: 'Book 1', author: 'Author 1', publishedDate: new Date() }
|
|
100
|
+
const input2 = { title: 'Book 2', author: 'Author 2', publishedDate: new Date() }
|
|
101
|
+
|
|
102
|
+
const item1 = await repository.upsert(input1)
|
|
103
|
+
const item2 = await repository.upsert(input2)
|
|
104
|
+
|
|
105
|
+
expect(item1.id).toBe(1)
|
|
106
|
+
expect(item2.id).toBe(2)
|
|
107
|
+
})
|
|
108
|
+
})
|