@owlmeans/mongo-resource 0.1.18-rc.2 → 0.1.18-rc.21
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/README.md +232 -71
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/mongo-resource/SKILL.md +80 -30
- package/build/consts.d.ts +8 -4
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +8 -4
- package/build/consts.js.map +1 -1
- package/build/declarations.d.ts.map +1 -1
- package/build/declarations.js +5 -6
- package/build/declarations.js.map +1 -1
- package/build/index.d.ts +1 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/build/resource.d.ts +2 -2
- package/build/resource.d.ts.map +1 -1
- package/build/resource.js +91 -118
- package/build/resource.js.map +1 -1
- package/build/types.d.ts +16 -5
- package/build/types.d.ts.map +1 -1
- package/build/utils/criteria.d.ts +24 -0
- package/build/utils/criteria.d.ts.map +1 -0
- package/build/utils/criteria.js +221 -0
- package/build/utils/criteria.js.map +1 -0
- package/build/utils/index.d.ts +1 -0
- package/build/utils/index.d.ts.map +1 -1
- package/build/utils/index.js +1 -0
- package/build/utils/index.js.map +1 -1
- package/build/utils/migrations.d.ts.map +1 -1
- package/build/utils/migrations.js +16 -1
- package/build/utils/migrations.js.map +1 -1
- package/build/utils/refs.d.ts +5 -3
- package/build/utils/refs.d.ts.map +1 -1
- package/build/utils/refs.js +4 -1
- package/build/utils/refs.js.map +1 -1
- package/package.json +6 -6
- package/src/consts.ts +8 -4
- package/src/declarations.ts +5 -6
- package/src/index.ts +1 -0
- package/src/resource.ts +115 -144
- package/src/types.ts +17 -5
- package/src/utils/criteria.ts +248 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/migrations.ts +17 -2
- package/src/utils/refs.ts +8 -6
- package/tests/criteria.spec.ts +114 -0
- package/build/.gitkeep +0 -0
package/src/resource.ts
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
import { appendContextual, assertContext } from '@owlmeans/context'
|
|
2
|
-
import type { BasicContext, Contextual } from '@owlmeans/context'
|
|
3
2
|
import { DEFAULT_DB_ALIAS, DEFAULT_PAGE_SIZE } from './consts.js'
|
|
4
3
|
import { MigrationStage } from '@owlmeans/resource'
|
|
5
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
Criteria, FirstOptions, ListOptions, ListResult, ResourceRecord, WriteOptions
|
|
6
|
+
} from '@owlmeans/resource'
|
|
6
7
|
import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
|
|
7
8
|
import type { MongoDbService, MongoReference, MongoRefOptions, MongoResource, MongoTx } from './types.js'
|
|
8
9
|
import { initializeCollection } from './utils/life-cycle.js'
|
|
9
10
|
import { getDeclaration } from './declarations.js'
|
|
10
11
|
import { ObjectId } from 'mongodb'
|
|
11
|
-
import {
|
|
12
|
+
import type { CreateIndexesOptions, Document, IndexSpecification } from 'mongodb'
|
|
13
|
+
import {
|
|
14
|
+
MisshapedRecord, RecordExists, UnknownRecordError, UnsupportedArgumentError, RecordUpdateFailed
|
|
15
|
+
} from '@owlmeans/resource'
|
|
12
16
|
import type { JSONSchemaType } from 'ajv'
|
|
13
17
|
import { getSchemaSecureFeilds } from './helper.js'
|
|
18
|
+
import { criteriaToFilter, sortToMongo } from './utils/criteria.js'
|
|
14
19
|
import {
|
|
15
|
-
demarshalRefs, identityCriteria, makeRefMigration,
|
|
16
|
-
refMigrationName
|
|
20
|
+
demarshalRefs, identityCriteria, makeRefMigration, marshalReference, refMigrationName
|
|
17
21
|
} from './utils/refs.js'
|
|
18
22
|
|
|
19
23
|
type Config = ServerConfig
|
|
@@ -23,13 +27,14 @@ export const makeMongoResource = <
|
|
|
23
27
|
R extends ResourceRecord, T extends MongoResource<R> = MongoResource<R>
|
|
24
28
|
>(
|
|
25
29
|
alias: string, dbAlias: string = DEFAULT_DB_ALIAS, serviceAlias: string = DEFAULT_DB_ALIAS,
|
|
26
|
-
|
|
30
|
+
collectionName?: string
|
|
27
31
|
): T => {
|
|
28
32
|
const location = `mongo-resource:${alias}`
|
|
29
33
|
|
|
30
34
|
/**
|
|
31
35
|
* Live view — references may be declared after the resource is built, and the
|
|
32
|
-
* declarations
|
|
36
|
+
* declarations live at module scope keyed by alias, so a second maker run for the
|
|
37
|
+
* same alias sees everything already declared for it.
|
|
33
38
|
*/
|
|
34
39
|
const refs = (): Map<string, MongoReference> => getDeclaration(alias).references
|
|
35
40
|
|
|
@@ -39,82 +44,112 @@ export const makeMongoResource = <
|
|
|
39
44
|
return demarshalRefs(record, refs())
|
|
40
45
|
}
|
|
41
46
|
|
|
47
|
+
/**
|
|
48
|
+
* A read addressed either way. An id is matched tolerantly: a string that is not a mongo id
|
|
49
|
+
* finds nothing, where handing it to `ObjectId` would raise a driver error at a call site
|
|
50
|
+
* that only asked whether the record exists.
|
|
51
|
+
*/
|
|
52
|
+
const filterOf = (idOrWhere: string | Criteria<R>): Document =>
|
|
53
|
+
typeof idOrWhere === 'string'
|
|
54
|
+
? identityCriteria('id', idOrWhere, refs())
|
|
55
|
+
: criteriaToFilter(idOrWhere, refs())
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The single implementation both overloads of `load` and `get` stand on — an id and a
|
|
59
|
+
* criteria object differ only in how the filter is built.
|
|
60
|
+
*/
|
|
61
|
+
const loadOne = async (
|
|
62
|
+
idOrWhere: string | Criteria<R>, opts?: FirstOptions<R>
|
|
63
|
+
): Promise<R | null> => {
|
|
64
|
+
const sort = sortToMongo(opts?.sort)
|
|
65
|
+
const record = await resource.collection.findOne(
|
|
66
|
+
filterOf(idOrWhere), sort != null ? { sort } : {}
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return record != null ? demarshal(record as unknown as R) : null
|
|
70
|
+
}
|
|
71
|
+
|
|
42
72
|
const resource: T = appendContextual<T>(alias, {
|
|
43
|
-
get: async (
|
|
44
|
-
const record = await
|
|
73
|
+
get: (async (idOrWhere: string | Criteria<R>, opts?: FirstOptions<R>): Promise<R> => {
|
|
74
|
+
const record = await loadOne(idOrWhere, opts)
|
|
45
75
|
if (record == null) {
|
|
46
|
-
throw new UnknownRecordError(
|
|
76
|
+
throw new UnknownRecordError(
|
|
77
|
+
typeof idOrWhere === 'string' ? idOrWhere : JSON.stringify(idOrWhere)
|
|
78
|
+
)
|
|
47
79
|
}
|
|
48
80
|
|
|
49
81
|
return record
|
|
50
|
-
},
|
|
82
|
+
}) as T['get'],
|
|
51
83
|
|
|
52
|
-
load:
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
84
|
+
load: loadOne as T['load'],
|
|
85
|
+
|
|
86
|
+
list: async (where?: Criteria<R>, opts?: ListOptions<R>): Promise<ListResult<R>> => {
|
|
87
|
+
const filter = criteriaToFilter(where, refs())
|
|
88
|
+
const total = await resource.collection.countDocuments(filter)
|
|
89
|
+
|
|
90
|
+
/** `size: 0` is the explicit ask for everything; an omitted size pages by default. */
|
|
91
|
+
const size = opts?.size ?? DEFAULT_PAGE_SIZE
|
|
92
|
+
const page = opts?.page ?? 0
|
|
93
|
+
if (size < 1 && page !== 0) {
|
|
94
|
+
/** Nothing to page through — answering page 0 instead would be a silent wrong answer. */
|
|
95
|
+
throw new UnsupportedArgumentError('page-without-size')
|
|
56
96
|
}
|
|
57
|
-
|
|
58
|
-
if (
|
|
59
|
-
|
|
97
|
+
|
|
98
|
+
if (total === 0) {
|
|
99
|
+
return size > 0 ? { items: [], total, page, size } : { items: [], total }
|
|
60
100
|
}
|
|
61
101
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
102
|
+
let cursor = resource.collection.find(filter)
|
|
103
|
+
const sort = sortToMongo(opts?.sort)
|
|
104
|
+
if (sort != null) {
|
|
105
|
+
cursor = cursor.sort(sort)
|
|
106
|
+
}
|
|
107
|
+
if (size > 0) {
|
|
108
|
+
cursor = cursor.skip(page * size).limit(size)
|
|
65
109
|
}
|
|
66
110
|
|
|
67
|
-
|
|
111
|
+
const items = (await cursor.toArray()).map(item => demarshal({ ...item } as unknown as R))
|
|
112
|
+
|
|
113
|
+
return size > 0 ? { items, total, page, size } : { items, total }
|
|
68
114
|
},
|
|
69
115
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (typeof opts === 'string') {
|
|
73
|
-
field = opts
|
|
74
|
-
} else if (typeof opts === 'object') {
|
|
75
|
-
field = opts.field ?? field
|
|
76
|
-
}
|
|
116
|
+
count: async (where?: Criteria<R>): Promise<number> =>
|
|
117
|
+
await resource.collection.countDocuments(criteriaToFilter(where, refs())),
|
|
77
118
|
|
|
78
|
-
|
|
79
|
-
|
|
119
|
+
update: async (record: Partial<R>, opts?: WriteOptions): Promise<R> => {
|
|
120
|
+
if (opts?.ttl != null) {
|
|
121
|
+
throw new UnsupportedArgumentError('ttl')
|
|
80
122
|
}
|
|
81
|
-
|
|
82
|
-
const id = field == '_id' ? record.id : record[field as keyof typeof record] as string
|
|
123
|
+
const id = record.id
|
|
83
124
|
if (id == null) {
|
|
84
125
|
throw new MisshapedRecord('id')
|
|
85
126
|
}
|
|
86
127
|
|
|
87
|
-
|
|
128
|
+
/** Absence is an error, not a silent no-op — `replaceOne` would just match nothing. */
|
|
129
|
+
const original = await resource.get(id)
|
|
88
130
|
|
|
89
|
-
const replace = { ...record
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
131
|
+
const replace: Document = { ...record }
|
|
132
|
+
/**
|
|
133
|
+
* Documents never store `id`, and a replacement that omits `_id` keeps the one the
|
|
134
|
+
* document already carries — so the whole record is replaced without touching its key.
|
|
135
|
+
*/
|
|
136
|
+
delete replace.id
|
|
93
137
|
|
|
94
138
|
const result = await resource.collection.replaceOne(
|
|
95
|
-
identityCriteria(
|
|
139
|
+
identityCriteria('id', original.id as string, refs()),
|
|
96
140
|
_prepareValues(replace, resource.schema as JSONSchemaType<any>, refs())
|
|
97
141
|
)
|
|
98
142
|
if (!result.acknowledged) {
|
|
99
|
-
throw new RecordUpdateFailed(
|
|
143
|
+
throw new RecordUpdateFailed(`id:${id}`)
|
|
100
144
|
}
|
|
101
145
|
|
|
102
|
-
return resource.get(id
|
|
146
|
+
return await resource.get(original.id as string)
|
|
103
147
|
},
|
|
104
148
|
|
|
105
|
-
save: async (record
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
) || (
|
|
110
|
-
typeof opts === 'object' && ("field" in opts)
|
|
111
|
-
&& record[opts.field as keyof typeof record] != null
|
|
112
|
-
)) {
|
|
113
|
-
return resource.update(record, opts)
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
return resource.create(record, typeof opts !== 'string' ? opts : undefined)
|
|
117
|
-
},
|
|
149
|
+
save: async (record: Partial<R>, opts?: WriteOptions): Promise<R> =>
|
|
150
|
+
record.id != null
|
|
151
|
+
? await resource.update(record, opts)
|
|
152
|
+
: await resource.create(record, opts),
|
|
118
153
|
|
|
119
154
|
getDefaults: () => {
|
|
120
155
|
const schema: JSONSchemaType<unknown> | undefined = resource.schema as JSONSchemaType<unknown>
|
|
@@ -130,14 +165,14 @@ export const makeMongoResource = <
|
|
|
130
165
|
}, {})
|
|
131
166
|
},
|
|
132
167
|
|
|
133
|
-
create: async (record
|
|
168
|
+
create: async (record: Partial<R>, opts?: WriteOptions): Promise<R> => {
|
|
134
169
|
if ("id" in record && record.id == null) {
|
|
135
170
|
delete record.id
|
|
136
171
|
}
|
|
137
172
|
if (record.id != null) {
|
|
138
173
|
throw new RecordExists('id-present')
|
|
139
174
|
}
|
|
140
|
-
if (opts
|
|
175
|
+
if (opts?.ttl != null) {
|
|
141
176
|
throw new UnsupportedArgumentError('ttl')
|
|
142
177
|
}
|
|
143
178
|
const result = await resource.collection.insertOne({
|
|
@@ -149,89 +184,33 @@ export const makeMongoResource = <
|
|
|
149
184
|
throw new RecordUpdateFailed(`creation`)
|
|
150
185
|
}
|
|
151
186
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
return resource.get(id.toString())
|
|
187
|
+
return await resource.get(result.insertedId.toString())
|
|
155
188
|
},
|
|
156
189
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if (id.id == null) {
|
|
161
|
-
throw new MisshapedRecord('id')
|
|
162
|
-
}
|
|
163
|
-
record = await resource.load(id.id)
|
|
164
|
-
} else {
|
|
165
|
-
record = await resource.load(id, opts)
|
|
166
|
-
}
|
|
190
|
+
/** Atomic: the record is handed back by the very operation that removed it. */
|
|
191
|
+
delete: async (id: string): Promise<R | null> => {
|
|
192
|
+
const record = await resource.collection.findOneAndDelete(identityCriteria('id', id, refs()))
|
|
167
193
|
|
|
168
|
-
|
|
169
|
-
return null
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
let field = '_id'
|
|
173
|
-
if (typeof opts === 'string') {
|
|
174
|
-
field = opts
|
|
175
|
-
opts = undefined
|
|
176
|
-
} else if (typeof opts === 'object') {
|
|
177
|
-
field = opts.field ?? field
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
const _id = field == '_id' ? record.id : record[field as keyof typeof record] as string
|
|
181
|
-
if (id == null) {
|
|
182
|
-
throw new MisshapedRecord('id')
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const result = await resource.collection.deleteOne(identityCriteria(field, _id as string, refs()))
|
|
186
|
-
if (!result.acknowledged || result.deletedCount === 0) {
|
|
187
|
-
return null
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
return record
|
|
194
|
+
return record != null ? demarshal(record as unknown as R) : null
|
|
191
195
|
},
|
|
192
196
|
|
|
193
|
-
|
|
194
|
-
const record = await resource.delete(id
|
|
197
|
+
take: async (id: string): Promise<R> => {
|
|
198
|
+
const record = await resource.delete(id)
|
|
195
199
|
if (record == null) {
|
|
196
|
-
throw new UnknownRecordError(
|
|
200
|
+
throw new UnknownRecordError(id)
|
|
197
201
|
}
|
|
198
202
|
|
|
199
203
|
return record
|
|
200
204
|
},
|
|
201
205
|
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const size = pager?.size ?? DEFAULT_PAGE_SIZE
|
|
209
|
-
const total = await resource.collection.countDocuments(criteria as ListCriteria)
|
|
210
|
-
pager.total = total
|
|
211
|
-
|
|
212
|
-
const skip = (pager.page ?? 0) * size
|
|
213
|
-
if (total === 0 && skip >= total) {
|
|
214
|
-
return { items: [], pager }
|
|
206
|
+
purge: async (where: Criteria<R>): Promise<number> => {
|
|
207
|
+
const filter = criteriaToFilter(where, refs())
|
|
208
|
+
if (Object.keys(filter).length < 1) {
|
|
209
|
+
/** An empty filter here would empty the collection. */
|
|
210
|
+
throw new UnsupportedArgumentError('purge:no-criteria')
|
|
215
211
|
}
|
|
216
212
|
|
|
217
|
-
|
|
218
|
-
.skip(skip).limit(size)
|
|
219
|
-
|
|
220
|
-
if (pager.sort != null) {
|
|
221
|
-
cursor = cursor.sort(
|
|
222
|
-
typeof pager.sort === 'string'
|
|
223
|
-
? pager.sort
|
|
224
|
-
: pager.sort.reduce((sort, [field, order]) =>
|
|
225
|
-
({ ...sort, [field as keyof typeof sort]: order ? -1 : 1 }), {}
|
|
226
|
-
)
|
|
227
|
-
)
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const items = await cursor.toArray()
|
|
231
|
-
|
|
232
|
-
return {
|
|
233
|
-
pager, items: items.map(item => demarshal({ ...item } as unknown as R))
|
|
234
|
-
}
|
|
213
|
+
return (await resource.collection.deleteMany(filter)).deletedCount
|
|
235
214
|
},
|
|
236
215
|
|
|
237
216
|
lock: async (record, fields) => {
|
|
@@ -272,17 +251,17 @@ export const makeMongoResource = <
|
|
|
272
251
|
return await mongo.client(dbAlias)
|
|
273
252
|
},
|
|
274
253
|
|
|
275
|
-
|
|
254
|
+
/**
|
|
255
|
+
* `index`, `migration` and `reference` hand the resource itself back so declarations
|
|
256
|
+
* chain — a return an object literal can't express, hence the member level casts: each
|
|
257
|
+
* implementation returns the closed over `resource`, which is that very object.
|
|
258
|
+
*/
|
|
259
|
+
index: ((name: string, index: IndexSpecification, options?: CreateIndexesOptions) => {
|
|
276
260
|
resource.indexes = resource.indexes ?? []
|
|
277
261
|
resource.indexes.push({ name, index, options })
|
|
278
262
|
return resource
|
|
279
|
-
},
|
|
263
|
+
}) as T['index'],
|
|
280
264
|
|
|
281
|
-
/**
|
|
282
|
-
* `migration` and `reference` are `this`-returning in the interface, which an object
|
|
283
|
-
* literal can't express — hence the member level casts: the implementations return
|
|
284
|
-
* the closed over `resource`, which is that very object.
|
|
285
|
-
*/
|
|
286
265
|
migration: ((name: string, apply: (tx: MongoTx) => Promise<void>, stage?: MigrationStage) => {
|
|
287
266
|
getDeclaration(alias).migrations.register(name, apply, stage)
|
|
288
267
|
return resource
|
|
@@ -308,11 +287,12 @@ export const makeMongoResource = <
|
|
|
308
287
|
} as Partial<T>)
|
|
309
288
|
|
|
310
289
|
// Explicit collection name override (decoupled from the registration alias, which may
|
|
311
|
-
// contain characters that aren't valid in a collection name).
|
|
312
|
-
// because it's threaded back into the recursive makeMongoResource call below.
|
|
290
|
+
// contain characters that aren't valid in a collection name).
|
|
313
291
|
if (collectionName != null) {
|
|
314
292
|
resource.name = collectionName
|
|
315
293
|
}
|
|
294
|
+
resource.dbAlias = dbAlias
|
|
295
|
+
resource.serviceAlias = serviceAlias
|
|
316
296
|
|
|
317
297
|
resource.init = async () => {
|
|
318
298
|
const context = assertContext<Config, Context>(resource.ctx as Context, location)
|
|
@@ -325,15 +305,6 @@ export const makeMongoResource = <
|
|
|
325
305
|
)
|
|
326
306
|
}
|
|
327
307
|
|
|
328
|
-
resource.reinitializeContext = <Type extends Contextual>(context: BasicContext<Config>) => {
|
|
329
|
-
const resource = (makeCustomResource?.(dbAlias, serviceAlias)
|
|
330
|
-
?? makeMongoResource<R, T>(alias, dbAlias, serviceAlias, makeCustomResource, collectionName)) as unknown as Type
|
|
331
|
-
|
|
332
|
-
resource.ctx = context
|
|
333
|
-
|
|
334
|
-
return resource as Type
|
|
335
|
-
}
|
|
336
|
-
|
|
337
308
|
return resource
|
|
338
309
|
}
|
|
339
310
|
|
package/src/types.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
Resource, ResourceRecord, ResourceDbService, DbLocker,
|
|
2
|
+
Resource, ResourceRecord, ResourceDbService, DbLocker, LockableResource, MigratableResource
|
|
3
3
|
} from '@owlmeans/resource'
|
|
4
4
|
import type { Collection, CreateIndexesOptions, Db, IndexSpecification, MongoClient } from 'mongodb'
|
|
5
5
|
import type { AnySchema } from 'ajv'
|
|
@@ -44,20 +44,32 @@ export interface MongoRefOptions {
|
|
|
44
44
|
noIndex?: boolean
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
export interface MongoResource<T extends ResourceRecord> extends Resource<T>,
|
|
47
|
+
export interface MongoResource<T extends ResourceRecord> extends Resource<T>, LockableResource<T>,
|
|
48
|
+
MigratableResource<MongoTx, MongoResource<T>> {
|
|
48
49
|
name?: string
|
|
50
|
+
/**
|
|
51
|
+
* The db-config alias this resource was registered against.
|
|
52
|
+
*
|
|
53
|
+
* Exposed because a collection's NAME depends on it: two resources in one database can carry
|
|
54
|
+
* different `resourcePrefix`es, so anything naming a collection on another resource's behalf —
|
|
55
|
+
* a migration reaching across aliases, for one — has to read that resource's own config rather
|
|
56
|
+
* than assume its caller's.
|
|
57
|
+
*/
|
|
58
|
+
dbAlias?: string
|
|
59
|
+
serviceAlias?: string
|
|
49
60
|
schema?: AnySchema
|
|
50
61
|
indexes?: Array<{ name: string, index: IndexSpecification, options?: CreateIndexesOptions }>
|
|
51
62
|
collection: Collection
|
|
52
63
|
db: () => Promise<Db>
|
|
53
64
|
client: () => Promise<MongoClient>
|
|
54
|
-
index:
|
|
65
|
+
index: (name: string, index: IndexSpecification, options?: CreateIndexesOptions) => this
|
|
55
66
|
/**
|
|
56
67
|
* Declare that a field stores another record's id.
|
|
57
68
|
*
|
|
58
69
|
* Chainable and idempotent like {@link MigratableResource.migration}, and stored the same
|
|
59
|
-
* way — per alias at module scope —
|
|
60
|
-
*
|
|
70
|
+
* way — per alias at module scope — so a maker that runs more than once for the same alias
|
|
71
|
+
* re-declares the same entry rather than losing it. A lost declaration would silently stop
|
|
72
|
+
* the string/ObjectId conversion for the field.
|
|
61
73
|
*
|
|
62
74
|
* Declare only fields whose values really are mongo ids (assigned from another record's
|
|
63
75
|
* `id`). Composite keys, external provider ids, DIDs and business slugs must stay
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { UnsupportedArgumentError } from '@owlmeans/resource'
|
|
2
|
+
import type { Criteria, Sort } from '@owlmeans/resource'
|
|
3
|
+
import type { Document } from 'mongodb'
|
|
4
|
+
|
|
5
|
+
import type { MongoReference } from '../types.js'
|
|
6
|
+
import { marshalCriteria } from './refs.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Operators mongo speaks natively and that mean the same thing here as they do in SQL and in
|
|
10
|
+
* the in-memory engine. Everything else in the shared vocabulary is rewritten below into a
|
|
11
|
+
* mongo expression with the same meaning — a criteria object has to answer identically
|
|
12
|
+
* whichever store it reaches.
|
|
13
|
+
*/
|
|
14
|
+
const NATIVE = new Set(['$eq', '$ne', '$gt', '$gte', '$lt', '$lte', '$in', '$nin', '$regex'])
|
|
15
|
+
|
|
16
|
+
const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A SQL `LIKE` pattern as an anchored regular expression: `%` is any run, `_` is one
|
|
20
|
+
* character, and `\` escapes either. The mapping is the one the in-memory engine applies, so
|
|
21
|
+
* the same pattern selects the same records against a collection.
|
|
22
|
+
*/
|
|
23
|
+
const likeToRegExp = (pattern: string): string => {
|
|
24
|
+
let source = ''
|
|
25
|
+
for (let index = 0; index < pattern.length; index++) {
|
|
26
|
+
const char = pattern[index]!
|
|
27
|
+
if (char === '\\' && index + 1 < pattern.length) {
|
|
28
|
+
source += escapeRegExp(pattern[++index]!)
|
|
29
|
+
continue
|
|
30
|
+
}
|
|
31
|
+
if (char === '%') { source += '.*'; continue }
|
|
32
|
+
if (char === '_') { source += '.'; continue }
|
|
33
|
+
source += escapeRegExp(char)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return `^${source}$`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const asArray = (operand: unknown): unknown[] => Array.isArray(operand) ? operand : [operand]
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One operator as the mongo field expression that answers it.
|
|
43
|
+
*
|
|
44
|
+
* `$exists` and `$null` both become a comparison against `null`, not mongo's own `$exists`:
|
|
45
|
+
* the shared vocabulary asks whether a field *has a value*, which the relational and
|
|
46
|
+
* in-memory stores answer as `IS NULL` / `value == null`. Mongo's `$exists` answers a
|
|
47
|
+
* different question — whether the key is present — and a key present but null would part
|
|
48
|
+
* the three stores over one criteria object.
|
|
49
|
+
*
|
|
50
|
+
* @throws {UnsupportedArgumentError} on an operator this store cannot answer.
|
|
51
|
+
*/
|
|
52
|
+
const operatorToFilter = (field: string, operator: string, operand: unknown): Document => {
|
|
53
|
+
if (NATIVE.has(operator)) {
|
|
54
|
+
return { [operator]: operand }
|
|
55
|
+
}
|
|
56
|
+
switch (operator) {
|
|
57
|
+
case '$exists':
|
|
58
|
+
return operand === false ? { $eq: null } : { $ne: null }
|
|
59
|
+
case '$null':
|
|
60
|
+
return operand === false ? { $ne: null } : { $eq: null }
|
|
61
|
+
case '$like':
|
|
62
|
+
return { $regex: likeToRegExp(`${operand}`) }
|
|
63
|
+
case '$ilike':
|
|
64
|
+
return { $regex: likeToRegExp(`${operand}`), $options: 'i' }
|
|
65
|
+
case '$startsWith':
|
|
66
|
+
return { $regex: `^${escapeRegExp(`${operand}`)}` }
|
|
67
|
+
case '$endsWith':
|
|
68
|
+
return { $regex: `${escapeRegExp(`${operand}`)}$` }
|
|
69
|
+
case '$between':
|
|
70
|
+
if (!Array.isArray(operand) || operand.length !== 2) {
|
|
71
|
+
throw new UnsupportedArgumentError(`criteria:$between:${field}`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { $gte: operand[0], $lte: operand[1] }
|
|
75
|
+
/** Array membership, mirroring the postgres array operators `@>`, `<@` and `&&`. */
|
|
76
|
+
case '$contains':
|
|
77
|
+
return { $all: asArray(operand) }
|
|
78
|
+
case '$contained':
|
|
79
|
+
/**
|
|
80
|
+
* Nothing outside the operand list may appear in the field. The `$ne: null` keeps an
|
|
81
|
+
* absent field out of the result — `$not` alone is satisfied by a document that has no
|
|
82
|
+
* such field at all, where the relational store answers NULL and matches nothing.
|
|
83
|
+
*/
|
|
84
|
+
return { $not: { $elemMatch: { $nin: asArray(operand) } }, $ne: null }
|
|
85
|
+
case '$overlaps':
|
|
86
|
+
return { $in: asArray(operand) }
|
|
87
|
+
default:
|
|
88
|
+
throw new UnsupportedArgumentError(`criteria-operator:${operator}`)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* An object naming at least one `$` key is a spec, not a value to compare against. A Date and
|
|
94
|
+
* an array are values even though both are objects.
|
|
95
|
+
*/
|
|
96
|
+
const isOperatorSpec = (value: unknown): value is Record<string, unknown> =>
|
|
97
|
+
value != null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)
|
|
98
|
+
&& Object.keys(value).some(key => key.startsWith('$'))
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* One field's criteria as mongo conditions. Operators that translate into the same mongo key —
|
|
102
|
+
* `$like` and `$startsWith` both produce `$regex` — are emitted as separate conditions rather
|
|
103
|
+
* than merged, so neither silently overwrites the other.
|
|
104
|
+
*/
|
|
105
|
+
const fieldConditions = (field: string, spec: Record<string, unknown>): Document[] => {
|
|
106
|
+
const merged: Document = {}
|
|
107
|
+
const conditions: Document[] = []
|
|
108
|
+
|
|
109
|
+
for (const [operator, operand] of Object.entries(spec)) {
|
|
110
|
+
if (operand === undefined) {
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
const fragment = operatorToFilter(field, operator, operand)
|
|
114
|
+
if (Object.keys(fragment).some(key => key in merged)) {
|
|
115
|
+
conditions.push({ [field]: fragment })
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
Object.assign(merged, fragment)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (Object.keys(merged).length > 0) {
|
|
122
|
+
conditions.unshift({ [field]: merged })
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return conditions
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Flatten the conditions into one filter document. Mongo reads sibling keys as a conjunction,
|
|
130
|
+
* so only the ones that would overwrite an earlier key — two `$regex` expressions over the
|
|
131
|
+
* same field, say — need an explicit `$and`.
|
|
132
|
+
*/
|
|
133
|
+
const flatten = (conditions: Document[]): Document => {
|
|
134
|
+
const filter: Document = {}
|
|
135
|
+
const conflicting: Document[] = []
|
|
136
|
+
|
|
137
|
+
for (const condition of conditions) {
|
|
138
|
+
if (Object.keys(condition).some(key => key in filter)) {
|
|
139
|
+
conflicting.push(condition)
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
Object.assign(filter, condition)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (conflicting.length > 0) {
|
|
146
|
+
filter.$and = [...(Array.isArray(filter.$and) ? filter.$and : []), ...conflicting]
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return filter
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const build = (criteria: Criteria<any> | undefined): Document[] => {
|
|
153
|
+
const conditions: Document[] = []
|
|
154
|
+
|
|
155
|
+
for (const [key, raw] of Object.entries(criteria ?? {})) {
|
|
156
|
+
/**
|
|
157
|
+
* An untouched filter must never empty a list, so `undefined` is skipped rather than
|
|
158
|
+
* compared. `null` asks for the absence of a value.
|
|
159
|
+
*/
|
|
160
|
+
if (raw === undefined) {
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (key === '$and' || key === '$or') {
|
|
165
|
+
const parts = (Array.isArray(raw) ? raw : [raw])
|
|
166
|
+
.map(part => build(part as Criteria<any>))
|
|
167
|
+
.filter(part => part.length > 0)
|
|
168
|
+
.map(part => flatten(part))
|
|
169
|
+
if (parts.length > 0) {
|
|
170
|
+
conditions.push(key === '$and' ? { $and: parts } : { $or: parts })
|
|
171
|
+
}
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
if (key === '$not') {
|
|
175
|
+
const inner = build(raw as Criteria<any>)
|
|
176
|
+
if (inner.length > 0) {
|
|
177
|
+
/** Mongo has no top level `$not`; `$nor` over a single branch is its negation. */
|
|
178
|
+
conditions.push({ $nor: [flatten(inner)] })
|
|
179
|
+
}
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (raw === null) {
|
|
184
|
+
conditions.push({ [key]: { $eq: null } })
|
|
185
|
+
continue
|
|
186
|
+
}
|
|
187
|
+
if (isOperatorSpec(raw)) {
|
|
188
|
+
conditions.push(...fieldConditions(key, raw as Record<string, unknown>))
|
|
189
|
+
continue
|
|
190
|
+
}
|
|
191
|
+
if (Array.isArray(raw)) {
|
|
192
|
+
/**
|
|
193
|
+
* A bare array means "any of these", exactly as it does against a relational store.
|
|
194
|
+
* Exact array equality stays reachable as `{ $eq: [...] }`.
|
|
195
|
+
*/
|
|
196
|
+
conditions.push({ [key]: { $in: raw } })
|
|
197
|
+
continue
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
conditions.push({ [key]: raw })
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return conditions
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Translate `Criteria<T>` into the filter a collection takes.
|
|
208
|
+
*
|
|
209
|
+
* Two passes: the shared operator vocabulary becomes mongo expressions, then
|
|
210
|
+
* {@link marshalCriteria} converts the values addressed at `_id` or at a declared reference
|
|
211
|
+
* into `ObjectId`s and maps the `id` alias onto `_id`. Both halves are needed — a criteria
|
|
212
|
+
* object carries string ids and portable operators, a collection stores neither.
|
|
213
|
+
*
|
|
214
|
+
* An empty result is an empty filter, which matches everything. Callers that must not act on
|
|
215
|
+
* "everything" — `purge` — check for it themselves.
|
|
216
|
+
*
|
|
217
|
+
* @throws {UnsupportedArgumentError}
|
|
218
|
+
*/
|
|
219
|
+
export const criteriaToFilter = (
|
|
220
|
+
criteria: Criteria<any> | undefined, refs: Map<string, MongoReference>
|
|
221
|
+
): Document => {
|
|
222
|
+
const conditions = build(criteria)
|
|
223
|
+
if (conditions.length < 1) {
|
|
224
|
+
return {}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return marshalCriteria(flatten(conditions), refs) ?? {}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Translate `Sort<T>[]` into a mongo sort document. A bare field name is ascending, and `id`
|
|
232
|
+
* addresses `_id` — documents never store an `id` field, so sorting by the name records carry
|
|
233
|
+
* would silently order by nothing.
|
|
234
|
+
*/
|
|
235
|
+
export const sortToMongo = (sort?: Sort<any>[]): Document | undefined => {
|
|
236
|
+
if (sort == null || sort.length < 1) {
|
|
237
|
+
return undefined
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return sort.reduce<Document>((order, entry) => {
|
|
241
|
+
const [field, direction]: [string, number] = typeof entry === 'string'
|
|
242
|
+
? [entry, 1]
|
|
243
|
+
: [entry.field, entry.order === 'desc' ? -1 : 1]
|
|
244
|
+
order[field === 'id' ? '_id' : field] = direction
|
|
245
|
+
|
|
246
|
+
return order
|
|
247
|
+
}, {})
|
|
248
|
+
}
|
package/src/utils/index.ts
CHANGED