core-services-sdk 1.3.23 → 1.3.25
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/package.json +2 -2
- package/src/core/index.js +2 -0
- package/src/core/normalize-array-operators.js +24 -0
- package/src/core/normalize-premitives-types-or-default.js +215 -0
- package/src/mongodb/dsl-to-mongo.js +99 -0
- package/src/mongodb/index.js +1 -0
- package/tests/core/normalize-array-operators.integration.test.js +64 -0
- package/tests/core/normalize-array-operators.unit.test.js +98 -0
- package/tests/core/normalize-premitives-types-or-default.unit.test.js +72 -0
- package/tests/mongodb/dsl-to-mongo.integration.test.js +88 -0
- package/tests/mongodb/dsl-to-mongo.unit.test.js +106 -0
- package/tests/mongodb/dsl-to-mongo2.unit.test.js +115 -0
- package/tests/resources/server-to-mongo.js +27 -0
- package/tests/resources/server.js +23 -0
- package/types/core/index.d.ts +2 -0
- package/types/core/normalize-array-operators.d.ts +1 -0
- package/types/core/normalize-premitives-types-or-default.d.ts +172 -0
- package/types/mongodb/dsl-to-mongo.d.ts +7 -0
- package/types/mongodb/index.d.ts +1 -0
package/package.json
CHANGED
package/src/core/index.js
CHANGED
|
@@ -5,3 +5,5 @@ export * from './normalize-min-max.js'
|
|
|
5
5
|
export * from './normalize-to-array.js'
|
|
6
6
|
export * from './combine-unique-arrays.js'
|
|
7
7
|
export * from './normalize-phone-number.js'
|
|
8
|
+
export * from './normalize-array-operators.js'
|
|
9
|
+
export * from './normalize-premitives-types-or-default.js'
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const normalizeOperators = (obj) => {
|
|
2
|
+
const arrayOps = ['in', 'nin', 'or', 'and']
|
|
3
|
+
|
|
4
|
+
function normalize(value) {
|
|
5
|
+
if (Array.isArray(value)) {
|
|
6
|
+
return value.map(normalize)
|
|
7
|
+
} else if (typeof value === 'object' && value !== null) {
|
|
8
|
+
// Handle objects with numeric keys (e.g. {0: {...}, 1: {...}})
|
|
9
|
+
const keys = Object.keys(value)
|
|
10
|
+
if (keys.every((k) => /^\d+$/.test(k))) {
|
|
11
|
+
return keys.sort().map((k) => normalize(value[k]))
|
|
12
|
+
}
|
|
13
|
+
for (const [k, v] of Object.entries(value)) {
|
|
14
|
+
value[k] = normalize(v)
|
|
15
|
+
if (arrayOps.includes(k)) {
|
|
16
|
+
value[k] = Array.isArray(value[k]) ? value[k] : [value[k]]
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return value
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return normalize(obj)
|
|
24
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize Utilities
|
|
3
|
+
*
|
|
4
|
+
* Small helpers to guarantee stable, predictable values from user/config inputs.
|
|
5
|
+
* When an incoming value is missing, malformed, or in a different-but-supported
|
|
6
|
+
* representation (e.g., number/boolean as string), these utilities either accept
|
|
7
|
+
* it (after safe normalization) or return a default you control.
|
|
8
|
+
*
|
|
9
|
+
* Design highlights:
|
|
10
|
+
* - Keep call sites compact and intention-revealing.
|
|
11
|
+
* - Be strict for strings (no implicit type coercion).
|
|
12
|
+
* - Be permissive for number/boolean when the input is a string in an accepted form.
|
|
13
|
+
* - Fast, side-effect free, no deep cloning — values are returned by reference when valid.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Generic predicate-based normalization.
|
|
18
|
+
*
|
|
19
|
+
* Purpose:
|
|
20
|
+
* Ensure a value conforms to a caller-provided predicate; otherwise return a provided default.
|
|
21
|
+
* Useful when you need a single reusable pattern for custom shapes or policies
|
|
22
|
+
* (e.g., "must be a non-empty array of strings", "must be a plain object", etc.).
|
|
23
|
+
*
|
|
24
|
+
* Behavior:
|
|
25
|
+
* - Calls `isValid(value)`.
|
|
26
|
+
* - If the predicate returns true → returns `value` (as-is).
|
|
27
|
+
* - Otherwise → returns `defaultValue` (as-is).
|
|
28
|
+
*
|
|
29
|
+
* Performance & Safety:
|
|
30
|
+
* - O(1) aside from your predicate cost.
|
|
31
|
+
* - No cloning or sanitization is performed.
|
|
32
|
+
* - Ensure `isValid` is pure and fast; avoid throwing inside it.
|
|
33
|
+
*
|
|
34
|
+
* @template T
|
|
35
|
+
* @param {any} value
|
|
36
|
+
* Candidate input to validate.
|
|
37
|
+
* @param {(v:any)=>boolean} isValid
|
|
38
|
+
* Validation predicate. Return true iff the input is acceptable.
|
|
39
|
+
* @param {T} defaultValue
|
|
40
|
+
* Fallback to return when `value` fails validation.
|
|
41
|
+
* @returns {T}
|
|
42
|
+
* The original `value` when valid; otherwise `defaultValue`.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* // Ensure a finite number
|
|
46
|
+
* normalizeOrDefault(5, v => typeof v === 'number' && Number.isFinite(v), 0) // → 5
|
|
47
|
+
* normalizeOrDefault('x', v => typeof v === 'number', 0) // → 0
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* // Ensure an object (non-null, non-array)
|
|
51
|
+
* const cfg = normalizeOrDefault(
|
|
52
|
+
* maybeCfg,
|
|
53
|
+
* v => v && typeof v === 'object' && !Array.isArray(v),
|
|
54
|
+
* {}
|
|
55
|
+
* )
|
|
56
|
+
*/
|
|
57
|
+
export function normalizeOrDefault(value, isValid, defaultValue) {
|
|
58
|
+
return isValid(value) ? value : defaultValue
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Normalize a value to a non-empty, trimmed string; otherwise return a default (also trimmed).
|
|
63
|
+
*
|
|
64
|
+
* Purpose:
|
|
65
|
+
* Guarantee that downstream code receives a usable, non-empty string
|
|
66
|
+
* without performing implicit type coercion.
|
|
67
|
+
*
|
|
68
|
+
* Acceptance Criteria:
|
|
69
|
+
* - Accept only actual strings whose `trim()` length is > 0.
|
|
70
|
+
* - Return `value.trim()` when valid.
|
|
71
|
+
* - Otherwise return `defaultValue.trim()`.
|
|
72
|
+
*
|
|
73
|
+
* Why strict for strings?
|
|
74
|
+
* - Silent coercion from non-strings to string can hide bugs.
|
|
75
|
+
* - If you need to stringify other types, do it explicitly at the call site.
|
|
76
|
+
*
|
|
77
|
+
* Edge Cases:
|
|
78
|
+
* - If `defaultValue` is empty or whitespace-only, the function returns an empty string.
|
|
79
|
+
* Prefer providing a meaningful, non-empty default for clarity.
|
|
80
|
+
*
|
|
81
|
+
* @param {any} value
|
|
82
|
+
* Candidate to normalize (must be a string to be accepted).
|
|
83
|
+
* @param {string} defaultValue
|
|
84
|
+
* Fallback used when `value` is not a non-empty string. Will be `trim()`ed.
|
|
85
|
+
* @returns {string}
|
|
86
|
+
* A trimmed non-empty string when `value` is valid; otherwise `defaultValue.trim()`.
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* normalizeStringOrDefault(' user-roles-management:edit ', 'fallback')
|
|
90
|
+
* // → 'user-roles-management:edit'
|
|
91
|
+
*
|
|
92
|
+
* @example
|
|
93
|
+
* normalizeStringOrDefault('', 'user-roles-management:edit')
|
|
94
|
+
* // → 'user-roles-management:edit'
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* normalizeStringOrDefault(42, 'user-roles-management:edit')
|
|
98
|
+
* // → 'user-roles-management:edit'
|
|
99
|
+
*/
|
|
100
|
+
export function normalizeStringOrDefault(value, defaultValue) {
|
|
101
|
+
const def = typeof defaultValue === 'string' ? defaultValue.trim() : ''
|
|
102
|
+
if (typeof value === 'string') {
|
|
103
|
+
const trimmed = value.trim()
|
|
104
|
+
if (trimmed.length > 0) {
|
|
105
|
+
return trimmed
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return def
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Normalize a value to a valid number (with safe string coercion); otherwise return a default.
|
|
113
|
+
*
|
|
114
|
+
* Purpose:
|
|
115
|
+
* Accept numeric inputs that may arrive as strings (e.g., from env vars or config files)
|
|
116
|
+
* while keeping semantics explicit and predictable.
|
|
117
|
+
*
|
|
118
|
+
* Acceptance Criteria:
|
|
119
|
+
* - Accepts finite numbers (`typeof value === 'number' && Number.isFinite(value)`).
|
|
120
|
+
* - Accepts strings that become a finite number via `Number(trimmed)`.
|
|
121
|
+
* Examples: "42", " 3.14 ", "1e3", "-7", "0x10" (JS Number semantics).
|
|
122
|
+
* - Rejects non-numeric strings (e.g., "", " ", "abc") and non-number types.
|
|
123
|
+
* - Returns `defaultValue` when not acceptable.
|
|
124
|
+
*
|
|
125
|
+
* Parsing Semantics:
|
|
126
|
+
* - Uses `Number(s)` which requires the whole trimmed string to be numeric.
|
|
127
|
+
* - Honors JavaScript numeric literal rules (including hex and scientific notation).
|
|
128
|
+
* - If you want base-10 only or looser parsing, do it explicitly before calling.
|
|
129
|
+
*
|
|
130
|
+
* @param {any} value
|
|
131
|
+
* Candidate to normalize (number or numeric string).
|
|
132
|
+
* @param {number} defaultValue
|
|
133
|
+
* Fallback used when `value` is neither a finite number nor a numeric string.
|
|
134
|
+
* @returns {number}
|
|
135
|
+
* A finite number derived from `value`, or `defaultValue`.
|
|
136
|
+
*
|
|
137
|
+
* @example
|
|
138
|
+
* normalizeNumberOrDefault(42, 0) // → 42
|
|
139
|
+
* normalizeNumberOrDefault(' 3.14 ', 0) // → 3.14
|
|
140
|
+
* normalizeNumberOrDefault('1e3', 0) // → 1000
|
|
141
|
+
* normalizeNumberOrDefault('-7', 0) // → -7
|
|
142
|
+
*
|
|
143
|
+
* @example
|
|
144
|
+
* normalizeNumberOrDefault('abc', 7) // → 7
|
|
145
|
+
* normalizeNumberOrDefault(NaN, 7) // → 7
|
|
146
|
+
* normalizeNumberOrDefault({}, 7) // → 7
|
|
147
|
+
*/
|
|
148
|
+
export function normalizeNumberOrDefault(value, defaultValue) {
|
|
149
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
150
|
+
return value
|
|
151
|
+
}
|
|
152
|
+
if (typeof value === 'string') {
|
|
153
|
+
const s = value.trim()
|
|
154
|
+
if (s.length > 0) {
|
|
155
|
+
const n = Number(s)
|
|
156
|
+
if (Number.isFinite(n)) {
|
|
157
|
+
return n
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return defaultValue
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Normalize a value to a boolean (with "true"/"false" string support); otherwise return a default.
|
|
166
|
+
*
|
|
167
|
+
* Purpose:
|
|
168
|
+
* Stabilize feature flags and binary config values that might be provided as either booleans
|
|
169
|
+
* or as canonical strings.
|
|
170
|
+
*
|
|
171
|
+
* Acceptance Criteria:
|
|
172
|
+
* - Accepts actual booleans (`true` / `false`) → returned as-is.
|
|
173
|
+
* - Accepts strings equal to "true" or "false" (case-insensitive, trimmed).
|
|
174
|
+
* "true" → true
|
|
175
|
+
* "false" → false
|
|
176
|
+
* - Rejects other strings ("yes", "1", "0", etc.) and other types → returns `defaultValue`.
|
|
177
|
+
*
|
|
178
|
+
* Rationale:
|
|
179
|
+
* - Limiting string forms to the canonical words avoids accidental truthiness/falseyness.
|
|
180
|
+
* - If you need to accept "1"/"0" or other variants, coerce at the call site so intent is explicit.
|
|
181
|
+
*
|
|
182
|
+
* @param {any} value
|
|
183
|
+
* Candidate to normalize (boolean or "true"/"false" string).
|
|
184
|
+
* @param {boolean} defaultValue
|
|
185
|
+
* Fallback used when `value` is neither a boolean nor an accepted string form.
|
|
186
|
+
* @returns {boolean}
|
|
187
|
+
* A boolean derived from `value`, or `defaultValue`.
|
|
188
|
+
*
|
|
189
|
+
* @example
|
|
190
|
+
* normalizeBooleanOrDefault(true, false) // → true
|
|
191
|
+
* normalizeBooleanOrDefault(false, true) // → false
|
|
192
|
+
*
|
|
193
|
+
* @example
|
|
194
|
+
* normalizeBooleanOrDefault('true', false) // → true
|
|
195
|
+
* normalizeBooleanOrDefault(' FALSE ', true) // → false
|
|
196
|
+
*
|
|
197
|
+
* @example
|
|
198
|
+
* normalizeBooleanOrDefault('yes', false) // → false (rejected → default)
|
|
199
|
+
* normalizeBooleanOrDefault(1, true) // → true (rejected → default)
|
|
200
|
+
*/
|
|
201
|
+
export function normalizeBooleanOrDefault(value, defaultValue) {
|
|
202
|
+
if (typeof value === 'boolean') {
|
|
203
|
+
return value
|
|
204
|
+
}
|
|
205
|
+
if (typeof value === 'string') {
|
|
206
|
+
const s = value.trim().toLowerCase()
|
|
207
|
+
if (s === 'true') {
|
|
208
|
+
return true
|
|
209
|
+
}
|
|
210
|
+
if (s === 'false') {
|
|
211
|
+
return false
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return defaultValue
|
|
215
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert a normalized query object into a MongoDB query object.
|
|
3
|
+
*
|
|
4
|
+
* Supported operators: eq, in, nin, gt, gte, lt, lte, and, or.
|
|
5
|
+
*
|
|
6
|
+
* Example input:
|
|
7
|
+
* {
|
|
8
|
+
* userId: { in: ['123','456'] },
|
|
9
|
+
* status: { eq: 'active' },
|
|
10
|
+
* age: { gte: 18 },
|
|
11
|
+
* or: [
|
|
12
|
+
* { role: { eq: 'admin' } },
|
|
13
|
+
* { status: { eq: 'active' } }
|
|
14
|
+
* ]
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* Example output:
|
|
18
|
+
* {
|
|
19
|
+
* userId: { $in: ['123','456'] },
|
|
20
|
+
* status: { $eq: 'active' },
|
|
21
|
+
* age: { $gte: 18 },
|
|
22
|
+
* $or: [
|
|
23
|
+
* { role: { $eq: 'admin' } },
|
|
24
|
+
* { status: { $eq: 'active' } }
|
|
25
|
+
* ]
|
|
26
|
+
* }
|
|
27
|
+
*
|
|
28
|
+
* @param {Record<string, any>} query - The normalized query object
|
|
29
|
+
* @returns {Record<string, any>} - A MongoDB query object
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* Map DSL logical keywords to Mongo operators.
|
|
33
|
+
*/
|
|
34
|
+
const LOGICAL_OPS = new Map([
|
|
35
|
+
['or', '$or'],
|
|
36
|
+
['and', '$and'],
|
|
37
|
+
['nor', '$nor'],
|
|
38
|
+
['not', '$not'],
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Convert a condition object (e.g. { gt: 18, lt: 65 }) into Mongo condition.
|
|
43
|
+
* Recursively applies to nested objects.
|
|
44
|
+
*/
|
|
45
|
+
function convertCondition(condition) {
|
|
46
|
+
if (
|
|
47
|
+
condition === null ||
|
|
48
|
+
typeof condition !== 'object' ||
|
|
49
|
+
Array.isArray(condition)
|
|
50
|
+
) {
|
|
51
|
+
return condition // primitive or array stays as is
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return Object.fromEntries(
|
|
55
|
+
Object.entries(condition).map(([op, value]) => {
|
|
56
|
+
if (op.startsWith('$')) {
|
|
57
|
+
return [op, value] // already Mongo operator
|
|
58
|
+
}
|
|
59
|
+
const mongoOp = `$${op}`
|
|
60
|
+
return [mongoOp, convertCondition(value)]
|
|
61
|
+
}),
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Convert a normalized query object into a MongoDB query object.
|
|
67
|
+
*
|
|
68
|
+
* @param {Record<string, any>} query - normalized DSL query
|
|
69
|
+
* @returns {Record<string, any>} - MongoDB query object
|
|
70
|
+
*/
|
|
71
|
+
export function toMongo(query = {}) {
|
|
72
|
+
return Object.fromEntries(
|
|
73
|
+
Object.entries(query).map(([field, condition]) => {
|
|
74
|
+
// logical operator
|
|
75
|
+
if (LOGICAL_OPS.has(field)) {
|
|
76
|
+
const mongoOp = LOGICAL_OPS.get(field)
|
|
77
|
+
if (Array.isArray(condition)) {
|
|
78
|
+
return [mongoOp, condition.map(toMongo)]
|
|
79
|
+
}
|
|
80
|
+
if (condition && typeof condition === 'object') {
|
|
81
|
+
return [mongoOp, toMongo(condition)]
|
|
82
|
+
}
|
|
83
|
+
return [mongoOp, condition]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// operator object
|
|
87
|
+
if (
|
|
88
|
+
condition !== null &&
|
|
89
|
+
typeof condition === 'object' &&
|
|
90
|
+
!Array.isArray(condition)
|
|
91
|
+
) {
|
|
92
|
+
return [field, convertCondition(condition)]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// plain value or null → $eq
|
|
96
|
+
return [field, { $eq: condition }]
|
|
97
|
+
}),
|
|
98
|
+
)
|
|
99
|
+
}
|
package/src/mongodb/index.js
CHANGED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { buildServer } from '../resources/server.js'
|
|
4
|
+
|
|
5
|
+
describe('Integration: Fastify + normalizeOperators', () => {
|
|
6
|
+
let app
|
|
7
|
+
|
|
8
|
+
beforeAll(async () => {
|
|
9
|
+
app = buildServer()
|
|
10
|
+
await app.listen({ port: 0 })
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
afterAll(async () => {
|
|
14
|
+
await app.close()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('GET with userId[in]=123,456 should normalize to array', async () => {
|
|
18
|
+
const res = await app.inject('/users?userId[in]=123,456&status[eq]=active')
|
|
19
|
+
const body = res.json()
|
|
20
|
+
|
|
21
|
+
expect(body.dsl).toEqual({
|
|
22
|
+
userId: { in: ['123', '456'] },
|
|
23
|
+
status: { eq: 'active' },
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('GET with userId[in]=123 should wrap in array', async () => {
|
|
28
|
+
const res = await app.inject('/users?userId[in]=123')
|
|
29
|
+
const body = res.json()
|
|
30
|
+
|
|
31
|
+
expect(body.dsl).toEqual({ userId: { in: ['123'] } })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('GET with OR query should normalize to array', async () => {
|
|
35
|
+
const res = await app.inject(
|
|
36
|
+
'/users?or[0][status][eq]=active&or[1][role][eq]=admin',
|
|
37
|
+
)
|
|
38
|
+
const body = res.json()
|
|
39
|
+
|
|
40
|
+
expect(body.dsl).toEqual({
|
|
41
|
+
or: [{ status: { eq: 'active' } }, { role: { eq: 'admin' } }],
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('POST with where body should normalize', async () => {
|
|
46
|
+
const res = await app.inject({
|
|
47
|
+
method: 'POST',
|
|
48
|
+
url: '/users/search',
|
|
49
|
+
payload: {
|
|
50
|
+
where: {
|
|
51
|
+
userId: { in: '123' },
|
|
52
|
+
age: { gte: 18 },
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const body = res.json()
|
|
58
|
+
|
|
59
|
+
expect(body.dsl).toEqual({
|
|
60
|
+
userId: { in: ['123'] },
|
|
61
|
+
age: { gte: 18 },
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { normalizeOperators } from '../../src/core/normalize-array-operators.js'
|
|
4
|
+
|
|
5
|
+
describe('normalizeOperators', () => {
|
|
6
|
+
it('should wrap single value in `in` into an array', () => {
|
|
7
|
+
const input = { userId: { in: '123' } }
|
|
8
|
+
const output = normalizeOperators(input)
|
|
9
|
+
expect(output).toEqual({ userId: { in: ['123'] } })
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('should keep array value in `in` as is', () => {
|
|
13
|
+
const input = { userId: { in: ['123', '456'] } }
|
|
14
|
+
const output = normalizeOperators(input)
|
|
15
|
+
expect(output).toEqual({ userId: { in: ['123', '456'] } })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('should wrap single value in `nin` into an array', () => {
|
|
19
|
+
const input = { userId: { nin: '789' } }
|
|
20
|
+
const output = normalizeOperators(input)
|
|
21
|
+
expect(output).toEqual({ userId: { nin: ['789'] } })
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('should normalize object with numeric keys into array (or)', () => {
|
|
25
|
+
const input = {
|
|
26
|
+
or: { 0: { status: { eq: 'active' } }, 1: { role: { eq: 'admin' } } },
|
|
27
|
+
}
|
|
28
|
+
const output = normalizeOperators(input)
|
|
29
|
+
expect(output).toEqual({
|
|
30
|
+
or: [{ status: { eq: 'active' } }, { role: { eq: 'admin' } }],
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('should wrap single object in `or` into array', () => {
|
|
35
|
+
const input = { or: { status: { eq: 'active' } } }
|
|
36
|
+
const output = normalizeOperators(input)
|
|
37
|
+
expect(output).toEqual({
|
|
38
|
+
or: [{ status: { eq: 'active' } }],
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('should handle nested in + or together', () => {
|
|
43
|
+
const input = {
|
|
44
|
+
or: {
|
|
45
|
+
0: { userId: { in: '123' } },
|
|
46
|
+
1: { userId: { in: ['456', '789'] } },
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
const output = normalizeOperators(input)
|
|
50
|
+
expect(output).toEqual({
|
|
51
|
+
or: [{ userId: { in: ['123'] } }, { userId: { in: ['456', '789'] } }],
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('should handle `and` with mixed single and multiple values', () => {
|
|
56
|
+
const input = {
|
|
57
|
+
and: {
|
|
58
|
+
0: { status: { eq: 'active' } },
|
|
59
|
+
1: { userId: { in: '123' } },
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
const output = normalizeOperators(input)
|
|
63
|
+
expect(output).toEqual({
|
|
64
|
+
and: [{ status: { eq: 'active' } }, { userId: { in: ['123'] } }],
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('should do nothing for fields without array operators', () => {
|
|
69
|
+
const input = { age: { gte: 18 } }
|
|
70
|
+
const output = normalizeOperators(input)
|
|
71
|
+
expect(output).toEqual({ age: { gte: 18 } })
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('should work with deeply nested structures', () => {
|
|
75
|
+
const input = {
|
|
76
|
+
or: {
|
|
77
|
+
0: {
|
|
78
|
+
and: {
|
|
79
|
+
0: { userId: { in: '123' } },
|
|
80
|
+
1: { status: { eq: 'active' } },
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
1: { role: { nin: 'guest' } },
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const output = normalizeOperators(input)
|
|
88
|
+
|
|
89
|
+
expect(output).toEqual({
|
|
90
|
+
or: [
|
|
91
|
+
{
|
|
92
|
+
and: [{ userId: { in: ['123'] } }, { status: { eq: 'active' } }],
|
|
93
|
+
},
|
|
94
|
+
{ role: { nin: ['guest'] } },
|
|
95
|
+
],
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
normalizeOrDefault,
|
|
5
|
+
normalizeStringOrDefault,
|
|
6
|
+
normalizeNumberOrDefault,
|
|
7
|
+
normalizeBooleanOrDefault,
|
|
8
|
+
} from '../../src/core/normalize-premitives-types-or-default.js'
|
|
9
|
+
|
|
10
|
+
describe('normalizeOrDefault (generic)', () => {
|
|
11
|
+
it('returns value if predicate is true', () => {
|
|
12
|
+
const out = normalizeOrDefault(5, (v) => typeof v === 'number', 0)
|
|
13
|
+
expect(out).toBe(5)
|
|
14
|
+
})
|
|
15
|
+
it('returns default if predicate is false', () => {
|
|
16
|
+
const out = normalizeOrDefault('x', (v) => typeof v === 'number', 0)
|
|
17
|
+
expect(out).toBe(0)
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('normalizeStringOrDefault (strict, no coercion)', () => {
|
|
22
|
+
it('keeps a non-empty trimmed string', () => {
|
|
23
|
+
expect(normalizeStringOrDefault(' hello ', 'fallback')).toBe('hello')
|
|
24
|
+
})
|
|
25
|
+
it('returns default for empty / whitespace-only', () => {
|
|
26
|
+
expect(normalizeStringOrDefault('', 'fallback')).toBe('fallback')
|
|
27
|
+
expect(normalizeStringOrDefault(' ', 'fallback')).toBe('fallback')
|
|
28
|
+
})
|
|
29
|
+
it('returns default for non-strings', () => {
|
|
30
|
+
expect(normalizeStringOrDefault(123, 'fallback')).toBe('fallback')
|
|
31
|
+
expect(normalizeStringOrDefault(null, 'fallback')).toBe('fallback')
|
|
32
|
+
})
|
|
33
|
+
it('trims default defensively', () => {
|
|
34
|
+
expect(normalizeStringOrDefault('', ' fallback ')).toBe('fallback')
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('normalizeNumberOrDefault (coercive for strings)', () => {
|
|
39
|
+
it('keeps valid number', () => {
|
|
40
|
+
expect(normalizeNumberOrDefault(42, 0)).toBe(42)
|
|
41
|
+
})
|
|
42
|
+
it('coerces valid numeric string', () => {
|
|
43
|
+
expect(normalizeNumberOrDefault('42', 0)).toBe(42)
|
|
44
|
+
expect(normalizeNumberOrDefault(' 3.14 ', 0)).toBe(3.14)
|
|
45
|
+
expect(normalizeNumberOrDefault('1e3', 0)).toBe(1000)
|
|
46
|
+
expect(normalizeNumberOrDefault('-7', 0)).toBe(-7)
|
|
47
|
+
})
|
|
48
|
+
it('returns default for invalid number inputs', () => {
|
|
49
|
+
expect(normalizeNumberOrDefault(NaN, 7)).toBe(7)
|
|
50
|
+
expect(normalizeNumberOrDefault(' ', 7)).toBe(7)
|
|
51
|
+
expect(normalizeNumberOrDefault('abc', 7)).toBe(7)
|
|
52
|
+
expect(normalizeNumberOrDefault({}, 7)).toBe(7)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('normalizeBooleanOrDefault (coercive for "true"/"false" strings)', () => {
|
|
57
|
+
it('keeps actual booleans', () => {
|
|
58
|
+
expect(normalizeBooleanOrDefault(true, false)).toBe(true)
|
|
59
|
+
expect(normalizeBooleanOrDefault(false, true)).toBe(false)
|
|
60
|
+
})
|
|
61
|
+
it('coerces "true"/"false" strings (case-insensitive)', () => {
|
|
62
|
+
expect(normalizeBooleanOrDefault('true', false)).toBe(true)
|
|
63
|
+
expect(normalizeBooleanOrDefault('FALSE', true)).toBe(false)
|
|
64
|
+
expect(normalizeBooleanOrDefault(' TrUe ', false)).toBe(true)
|
|
65
|
+
})
|
|
66
|
+
it('returns default for other strings / types', () => {
|
|
67
|
+
expect(normalizeBooleanOrDefault('yes', false)).toBe(false)
|
|
68
|
+
expect(normalizeBooleanOrDefault('0', true)).toBe(true)
|
|
69
|
+
expect(normalizeBooleanOrDefault(1, false)).toBe(false)
|
|
70
|
+
expect(normalizeBooleanOrDefault(null, true)).toBe(true)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { buildServer } from '../resources/server-to-mongo.js'
|
|
4
|
+
|
|
5
|
+
describe('Integration E2E: Fastify + normalizeOperators + toMongo', () => {
|
|
6
|
+
let app
|
|
7
|
+
|
|
8
|
+
beforeAll(async () => {
|
|
9
|
+
app = buildServer()
|
|
10
|
+
await app.listen({ port: 0 })
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
afterAll(async () => {
|
|
14
|
+
await app.close()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('GET with simple comparison and AND logic', async () => {
|
|
18
|
+
const res = await app.inject(
|
|
19
|
+
'/search?and[0][age][gte]=18&and[1][age][lt]=65&status[eq]=active',
|
|
20
|
+
)
|
|
21
|
+
const body = res.json()
|
|
22
|
+
|
|
23
|
+
expect(body.dsl).toEqual({
|
|
24
|
+
and: [{ age: { gte: '18' } }, { age: { lt: '65' } }],
|
|
25
|
+
status: { eq: 'active' },
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
expect(body.mongo).toEqual({
|
|
29
|
+
$and: [{ age: { $gte: '18' } }, { age: { $lt: '65' } }],
|
|
30
|
+
status: { $eq: 'active' },
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('GET with OR and IN', async () => {
|
|
35
|
+
const res = await app.inject(
|
|
36
|
+
'/search?or[0][role][eq]=admin&or[1][role][eq]=user&userId[in]=123,456',
|
|
37
|
+
)
|
|
38
|
+
const body = res.json()
|
|
39
|
+
|
|
40
|
+
expect(body.dsl).toEqual({
|
|
41
|
+
or: [{ role: { eq: 'admin' } }, { role: { eq: 'user' } }],
|
|
42
|
+
userId: { in: ['123', '456'] },
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
expect(body.mongo).toEqual({
|
|
46
|
+
$or: [{ role: { $eq: 'admin' } }, { role: { $eq: 'user' } }],
|
|
47
|
+
userId: { $in: ['123', '456'] },
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('POST with nested AND/OR, gt/lt, exists', async () => {
|
|
52
|
+
const res = await app.inject({
|
|
53
|
+
method: 'POST',
|
|
54
|
+
url: '/search',
|
|
55
|
+
payload: {
|
|
56
|
+
where: {
|
|
57
|
+
and: [
|
|
58
|
+
{ age: { gt: 18, lt: 65 } },
|
|
59
|
+
{
|
|
60
|
+
or: [{ status: { eq: 'active' } }, { status: { eq: 'pending' } }],
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
deletedAt: { exists: false },
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const body = res.json()
|
|
69
|
+
|
|
70
|
+
expect(body.dsl).toEqual({
|
|
71
|
+
and: [
|
|
72
|
+
{ age: { gt: 18, lt: 65 } },
|
|
73
|
+
{ or: [{ status: { eq: 'active' } }, { status: { eq: 'pending' } }] },
|
|
74
|
+
],
|
|
75
|
+
deletedAt: { exists: false },
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
expect(body.mongo).toEqual({
|
|
79
|
+
$and: [
|
|
80
|
+
{ age: { $gt: 18, $lt: 65 } },
|
|
81
|
+
{
|
|
82
|
+
$or: [{ status: { $eq: 'active' } }, { status: { $eq: 'pending' } }],
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
deletedAt: { $exists: false },
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { toMongo } from '../../src/mongodb/dsl-to-mongo.js'
|
|
4
|
+
|
|
5
|
+
describe('toMongo', () => {
|
|
6
|
+
it('should convert plain values to $eq', () => {
|
|
7
|
+
const input = { status: 'active', role: 'admin' }
|
|
8
|
+
const output = toMongo(input)
|
|
9
|
+
expect(output).toEqual({
|
|
10
|
+
status: { $eq: 'active' },
|
|
11
|
+
role: { $eq: 'admin' },
|
|
12
|
+
})
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('should convert eq operator to $eq', () => {
|
|
16
|
+
const input = { status: { eq: 'active' } }
|
|
17
|
+
const output = toMongo(input)
|
|
18
|
+
expect(output).toEqual({ status: { $eq: 'active' } })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('should convert in operator to $in', () => {
|
|
22
|
+
const input = { userId: { in: ['123', '456'] } }
|
|
23
|
+
const output = toMongo(input)
|
|
24
|
+
expect(output).toEqual({ userId: { $in: ['123', '456'] } })
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('should convert nin operator to $nin', () => {
|
|
28
|
+
const input = { userId: { nin: ['123'] } }
|
|
29
|
+
const output = toMongo(input)
|
|
30
|
+
expect(output).toEqual({ userId: { $nin: ['123'] } })
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('should convert comparison operators correctly', () => {
|
|
34
|
+
const input = {
|
|
35
|
+
age: { gt: 18, lte: 65 },
|
|
36
|
+
}
|
|
37
|
+
const output = toMongo(input)
|
|
38
|
+
expect(output).toEqual({
|
|
39
|
+
age: { $gt: 18, $lte: 65 },
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('should handle $or with multiple conditions', () => {
|
|
44
|
+
const input = {
|
|
45
|
+
or: [{ status: { eq: 'active' } }, { role: { eq: 'admin' } }],
|
|
46
|
+
}
|
|
47
|
+
const output = toMongo(input)
|
|
48
|
+
expect(output).toEqual({
|
|
49
|
+
$or: [{ status: { $eq: 'active' } }, { role: { $eq: 'admin' } }],
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('should handle $and with multiple conditions', () => {
|
|
54
|
+
const input = {
|
|
55
|
+
and: [{ status: { eq: 'active' } }, { age: { gte: 18 } }],
|
|
56
|
+
}
|
|
57
|
+
const output = toMongo(input)
|
|
58
|
+
expect(output).toEqual({
|
|
59
|
+
$and: [{ status: { $eq: 'active' } }, { age: { $gte: 18 } }],
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('should handle nested logical operators', () => {
|
|
64
|
+
const input = {
|
|
65
|
+
or: [
|
|
66
|
+
{ status: { eq: 'active' } },
|
|
67
|
+
{
|
|
68
|
+
and: [{ age: { gte: 18 } }, { age: { lte: 65 } }],
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
}
|
|
72
|
+
const output = toMongo(input)
|
|
73
|
+
expect(output).toEqual({
|
|
74
|
+
$or: [
|
|
75
|
+
{ status: { $eq: 'active' } },
|
|
76
|
+
{
|
|
77
|
+
$and: [{ age: { $gte: 18 } }, { age: { $lte: 65 } }],
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('should handle multiple fields mixed with operators', () => {
|
|
84
|
+
const input = {
|
|
85
|
+
userId: { in: ['123', '456'] },
|
|
86
|
+
status: 'active',
|
|
87
|
+
or: [{ role: { eq: 'admin' } }, { age: { gt: 30 } }],
|
|
88
|
+
}
|
|
89
|
+
const output = toMongo(input)
|
|
90
|
+
expect(output).toEqual({
|
|
91
|
+
userId: { $in: ['123', '456'] },
|
|
92
|
+
status: { $eq: 'active' },
|
|
93
|
+
$or: [{ role: { $eq: 'admin' } }, { age: { $gt: 30 } }],
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('should return empty object if input is empty', () => {
|
|
98
|
+
expect(toMongo({})).toEqual({})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('should handle null values as $eq null', () => {
|
|
102
|
+
const input = { deletedAt: null }
|
|
103
|
+
const output = toMongo(input)
|
|
104
|
+
expect(output).toEqual({ deletedAt: { $eq: null } })
|
|
105
|
+
})
|
|
106
|
+
})
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { toMongo } from '../../src/mongodb/dsl-to-mongo.js'
|
|
4
|
+
|
|
5
|
+
describe('toMongo - extended operators', () => {
|
|
6
|
+
it('should support all comparison operators', () => {
|
|
7
|
+
const input = {
|
|
8
|
+
age: { eq: 30, ne: 40, gt: 10, gte: 20, lt: 100, lte: 90 },
|
|
9
|
+
userId: { in: ['u1', 'u2'], nin: ['u3'] },
|
|
10
|
+
}
|
|
11
|
+
const output = toMongo(input)
|
|
12
|
+
|
|
13
|
+
expect(output).toEqual({
|
|
14
|
+
age: { $eq: 30, $ne: 40, $gt: 10, $gte: 20, $lt: 100, $lte: 90 },
|
|
15
|
+
userId: { $in: ['u1', 'u2'], $nin: ['u3'] },
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('should support logical operators', () => {
|
|
20
|
+
const input = {
|
|
21
|
+
and: [{ age: { gt: 18 } }, { status: { eq: 'active' } }],
|
|
22
|
+
or: [{ role: { eq: 'admin' } }, { role: { eq: 'user' } }],
|
|
23
|
+
nor: [{ country: { eq: 'US' } }],
|
|
24
|
+
}
|
|
25
|
+
const output = toMongo(input)
|
|
26
|
+
|
|
27
|
+
expect(output).toEqual({
|
|
28
|
+
$and: [{ age: { $gt: 18 } }, { status: { $eq: 'active' } }],
|
|
29
|
+
$or: [{ role: { $eq: 'admin' } }, { role: { $eq: 'user' } }],
|
|
30
|
+
$nor: [{ country: { $eq: 'US' } }],
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('should support element operators', () => {
|
|
35
|
+
const input = {
|
|
36
|
+
deletedAt: { exists: false },
|
|
37
|
+
status: { type: 'string' },
|
|
38
|
+
}
|
|
39
|
+
const output = toMongo(input)
|
|
40
|
+
|
|
41
|
+
expect(output).toEqual({
|
|
42
|
+
deletedAt: { $exists: false },
|
|
43
|
+
status: { $type: 'string' },
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('should support evaluation operators', () => {
|
|
48
|
+
const input = {
|
|
49
|
+
name: { regex: '^haim', options: 'i' },
|
|
50
|
+
score: { mod: [4, 0] },
|
|
51
|
+
description: { text: 'fast search' },
|
|
52
|
+
}
|
|
53
|
+
const output = toMongo(input)
|
|
54
|
+
|
|
55
|
+
expect(output).toEqual({
|
|
56
|
+
name: { $regex: '^haim', $options: 'i' },
|
|
57
|
+
score: { $mod: [4, 0] },
|
|
58
|
+
description: { $text: 'fast search' },
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('should support array operators', () => {
|
|
63
|
+
const input = {
|
|
64
|
+
tags: { all: ['tech', 'ai'], size: 3 },
|
|
65
|
+
scores: { elemMatch: { gt: 90 } },
|
|
66
|
+
}
|
|
67
|
+
const output = toMongo(input)
|
|
68
|
+
|
|
69
|
+
expect(output).toEqual({
|
|
70
|
+
tags: { $all: ['tech', 'ai'], $size: 3 },
|
|
71
|
+
scores: { $elemMatch: { $gt: 90 } },
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('should not transform unknown operators (false positive check)', () => {
|
|
76
|
+
const input = {
|
|
77
|
+
age: { weirdOp: 123 },
|
|
78
|
+
}
|
|
79
|
+
const output = toMongo(input)
|
|
80
|
+
|
|
81
|
+
expect(output).toEqual({
|
|
82
|
+
age: { $weirdOp: 123 },
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('should not wrap already Mongo operators with $$ (false positive)', () => {
|
|
87
|
+
const input = {
|
|
88
|
+
age: { $gte: 18 },
|
|
89
|
+
}
|
|
90
|
+
const output = toMongo(input)
|
|
91
|
+
|
|
92
|
+
expect(output).toEqual({
|
|
93
|
+
age: { $gte: 18 },
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('should handle nested structures with mix of operators', () => {
|
|
98
|
+
const input = {
|
|
99
|
+
or: [
|
|
100
|
+
{ and: [{ age: { gte: 18 } }, { age: { lte: 65 } }] },
|
|
101
|
+
{ status: { ne: 'banned' } },
|
|
102
|
+
],
|
|
103
|
+
tags: { all: ['mongo', 'node'] },
|
|
104
|
+
}
|
|
105
|
+
const output = toMongo(input)
|
|
106
|
+
|
|
107
|
+
expect(output).toEqual({
|
|
108
|
+
$or: [
|
|
109
|
+
{ $and: [{ age: { $gte: 18 } }, { age: { $lte: 65 } }] },
|
|
110
|
+
{ status: { $ne: 'banned' } },
|
|
111
|
+
],
|
|
112
|
+
tags: { $all: ['mongo', 'node'] },
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import qs from 'qs'
|
|
2
|
+
import Fastify from 'fastify'
|
|
3
|
+
|
|
4
|
+
import { toMongo } from '../../src/mongodb/dsl-to-mongo.js'
|
|
5
|
+
import { normalizeOperators } from '../../src/core/normalize-array-operators.js'
|
|
6
|
+
|
|
7
|
+
export function buildServer() {
|
|
8
|
+
const app = Fastify({
|
|
9
|
+
querystringParser: (str) => qs.parse(str, { comma: true, depth: 10 }),
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
// GET /search?...
|
|
13
|
+
app.get('/search', async (req) => {
|
|
14
|
+
const dsl = normalizeOperators(req.query)
|
|
15
|
+
const mongo = toMongo(dsl)
|
|
16
|
+
return { dsl, mongo }
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
// POST /search with body { where: {...} }
|
|
20
|
+
app.post('/search', async (req) => {
|
|
21
|
+
const dsl = normalizeOperators(req.body?.where || {})
|
|
22
|
+
const mongo = toMongo(dsl)
|
|
23
|
+
return { dsl, mongo }
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
return app
|
|
27
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import Fastify from 'fastify'
|
|
2
|
+
import qs from 'qs'
|
|
3
|
+
import { normalizeOperators } from '../../src/core/normalize-array-operators.js'
|
|
4
|
+
|
|
5
|
+
export function buildServer() {
|
|
6
|
+
const app = Fastify({
|
|
7
|
+
querystringParser: (str) => qs.parse(str, { comma: true, depth: 10 }),
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
// GET /users
|
|
11
|
+
app.get('/users', async (req) => {
|
|
12
|
+
const dsl = normalizeOperators(req.query)
|
|
13
|
+
return { dsl }
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
// POST /users/search
|
|
17
|
+
app.post('/users/search', async (req) => {
|
|
18
|
+
const dsl = normalizeOperators(req.body?.where || {})
|
|
19
|
+
return { dsl }
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
return app
|
|
23
|
+
}
|
package/types/core/index.d.ts
CHANGED
|
@@ -5,3 +5,5 @@ export * from "./normalize-min-max.js";
|
|
|
5
5
|
export * from "./normalize-to-array.js";
|
|
6
6
|
export * from "./combine-unique-arrays.js";
|
|
7
7
|
export * from "./normalize-phone-number.js";
|
|
8
|
+
export * from "./normalize-array-operators.js";
|
|
9
|
+
export * from "./normalize-premitives-types-or-default.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function normalizeOperators(obj: any): any;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize Utilities
|
|
3
|
+
*
|
|
4
|
+
* Small helpers to guarantee stable, predictable values from user/config inputs.
|
|
5
|
+
* When an incoming value is missing, malformed, or in a different-but-supported
|
|
6
|
+
* representation (e.g., number/boolean as string), these utilities either accept
|
|
7
|
+
* it (after safe normalization) or return a default you control.
|
|
8
|
+
*
|
|
9
|
+
* Design highlights:
|
|
10
|
+
* - Keep call sites compact and intention-revealing.
|
|
11
|
+
* - Be strict for strings (no implicit type coercion).
|
|
12
|
+
* - Be permissive for number/boolean when the input is a string in an accepted form.
|
|
13
|
+
* - Fast, side-effect free, no deep cloning — values are returned by reference when valid.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Generic predicate-based normalization.
|
|
17
|
+
*
|
|
18
|
+
* Purpose:
|
|
19
|
+
* Ensure a value conforms to a caller-provided predicate; otherwise return a provided default.
|
|
20
|
+
* Useful when you need a single reusable pattern for custom shapes or policies
|
|
21
|
+
* (e.g., "must be a non-empty array of strings", "must be a plain object", etc.).
|
|
22
|
+
*
|
|
23
|
+
* Behavior:
|
|
24
|
+
* - Calls `isValid(value)`.
|
|
25
|
+
* - If the predicate returns true → returns `value` (as-is).
|
|
26
|
+
* - Otherwise → returns `defaultValue` (as-is).
|
|
27
|
+
*
|
|
28
|
+
* Performance & Safety:
|
|
29
|
+
* - O(1) aside from your predicate cost.
|
|
30
|
+
* - No cloning or sanitization is performed.
|
|
31
|
+
* - Ensure `isValid` is pure and fast; avoid throwing inside it.
|
|
32
|
+
*
|
|
33
|
+
* @template T
|
|
34
|
+
* @param {any} value
|
|
35
|
+
* Candidate input to validate.
|
|
36
|
+
* @param {(v:any)=>boolean} isValid
|
|
37
|
+
* Validation predicate. Return true iff the input is acceptable.
|
|
38
|
+
* @param {T} defaultValue
|
|
39
|
+
* Fallback to return when `value` fails validation.
|
|
40
|
+
* @returns {T}
|
|
41
|
+
* The original `value` when valid; otherwise `defaultValue`.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* // Ensure a finite number
|
|
45
|
+
* normalizeOrDefault(5, v => typeof v === 'number' && Number.isFinite(v), 0) // → 5
|
|
46
|
+
* normalizeOrDefault('x', v => typeof v === 'number', 0) // → 0
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* // Ensure an object (non-null, non-array)
|
|
50
|
+
* const cfg = normalizeOrDefault(
|
|
51
|
+
* maybeCfg,
|
|
52
|
+
* v => v && typeof v === 'object' && !Array.isArray(v),
|
|
53
|
+
* {}
|
|
54
|
+
* )
|
|
55
|
+
*/
|
|
56
|
+
export function normalizeOrDefault<T>(value: any, isValid: (v: any) => boolean, defaultValue: T): T;
|
|
57
|
+
/**
|
|
58
|
+
* Normalize a value to a non-empty, trimmed string; otherwise return a default (also trimmed).
|
|
59
|
+
*
|
|
60
|
+
* Purpose:
|
|
61
|
+
* Guarantee that downstream code receives a usable, non-empty string
|
|
62
|
+
* without performing implicit type coercion.
|
|
63
|
+
*
|
|
64
|
+
* Acceptance Criteria:
|
|
65
|
+
* - Accept only actual strings whose `trim()` length is > 0.
|
|
66
|
+
* - Return `value.trim()` when valid.
|
|
67
|
+
* - Otherwise return `defaultValue.trim()`.
|
|
68
|
+
*
|
|
69
|
+
* Why strict for strings?
|
|
70
|
+
* - Silent coercion from non-strings to string can hide bugs.
|
|
71
|
+
* - If you need to stringify other types, do it explicitly at the call site.
|
|
72
|
+
*
|
|
73
|
+
* Edge Cases:
|
|
74
|
+
* - If `defaultValue` is empty or whitespace-only, the function returns an empty string.
|
|
75
|
+
* Prefer providing a meaningful, non-empty default for clarity.
|
|
76
|
+
*
|
|
77
|
+
* @param {any} value
|
|
78
|
+
* Candidate to normalize (must be a string to be accepted).
|
|
79
|
+
* @param {string} defaultValue
|
|
80
|
+
* Fallback used when `value` is not a non-empty string. Will be `trim()`ed.
|
|
81
|
+
* @returns {string}
|
|
82
|
+
* A trimmed non-empty string when `value` is valid; otherwise `defaultValue.trim()`.
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* normalizeStringOrDefault(' user-roles-management:edit ', 'fallback')
|
|
86
|
+
* // → 'user-roles-management:edit'
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* normalizeStringOrDefault('', 'user-roles-management:edit')
|
|
90
|
+
* // → 'user-roles-management:edit'
|
|
91
|
+
*
|
|
92
|
+
* @example
|
|
93
|
+
* normalizeStringOrDefault(42, 'user-roles-management:edit')
|
|
94
|
+
* // → 'user-roles-management:edit'
|
|
95
|
+
*/
|
|
96
|
+
export function normalizeStringOrDefault(value: any, defaultValue: string): string;
|
|
97
|
+
/**
|
|
98
|
+
* Normalize a value to a valid number (with safe string coercion); otherwise return a default.
|
|
99
|
+
*
|
|
100
|
+
* Purpose:
|
|
101
|
+
* Accept numeric inputs that may arrive as strings (e.g., from env vars or config files)
|
|
102
|
+
* while keeping semantics explicit and predictable.
|
|
103
|
+
*
|
|
104
|
+
* Acceptance Criteria:
|
|
105
|
+
* - Accepts finite numbers (`typeof value === 'number' && Number.isFinite(value)`).
|
|
106
|
+
* - Accepts strings that become a finite number via `Number(trimmed)`.
|
|
107
|
+
* Examples: "42", " 3.14 ", "1e3", "-7", "0x10" (JS Number semantics).
|
|
108
|
+
* - Rejects non-numeric strings (e.g., "", " ", "abc") and non-number types.
|
|
109
|
+
* - Returns `defaultValue` when not acceptable.
|
|
110
|
+
*
|
|
111
|
+
* Parsing Semantics:
|
|
112
|
+
* - Uses `Number(s)` which requires the whole trimmed string to be numeric.
|
|
113
|
+
* - Honors JavaScript numeric literal rules (including hex and scientific notation).
|
|
114
|
+
* - If you want base-10 only or looser parsing, do it explicitly before calling.
|
|
115
|
+
*
|
|
116
|
+
* @param {any} value
|
|
117
|
+
* Candidate to normalize (number or numeric string).
|
|
118
|
+
* @param {number} defaultValue
|
|
119
|
+
* Fallback used when `value` is neither a finite number nor a numeric string.
|
|
120
|
+
* @returns {number}
|
|
121
|
+
* A finite number derived from `value`, or `defaultValue`.
|
|
122
|
+
*
|
|
123
|
+
* @example
|
|
124
|
+
* normalizeNumberOrDefault(42, 0) // → 42
|
|
125
|
+
* normalizeNumberOrDefault(' 3.14 ', 0) // → 3.14
|
|
126
|
+
* normalizeNumberOrDefault('1e3', 0) // → 1000
|
|
127
|
+
* normalizeNumberOrDefault('-7', 0) // → -7
|
|
128
|
+
*
|
|
129
|
+
* @example
|
|
130
|
+
* normalizeNumberOrDefault('abc', 7) // → 7
|
|
131
|
+
* normalizeNumberOrDefault(NaN, 7) // → 7
|
|
132
|
+
* normalizeNumberOrDefault({}, 7) // → 7
|
|
133
|
+
*/
|
|
134
|
+
export function normalizeNumberOrDefault(value: any, defaultValue: number): number;
|
|
135
|
+
/**
|
|
136
|
+
* Normalize a value to a boolean (with "true"/"false" string support); otherwise return a default.
|
|
137
|
+
*
|
|
138
|
+
* Purpose:
|
|
139
|
+
* Stabilize feature flags and binary config values that might be provided as either booleans
|
|
140
|
+
* or as canonical strings.
|
|
141
|
+
*
|
|
142
|
+
* Acceptance Criteria:
|
|
143
|
+
* - Accepts actual booleans (`true` / `false`) → returned as-is.
|
|
144
|
+
* - Accepts strings equal to "true" or "false" (case-insensitive, trimmed).
|
|
145
|
+
* "true" → true
|
|
146
|
+
* "false" → false
|
|
147
|
+
* - Rejects other strings ("yes", "1", "0", etc.) and other types → returns `defaultValue`.
|
|
148
|
+
*
|
|
149
|
+
* Rationale:
|
|
150
|
+
* - Limiting string forms to the canonical words avoids accidental truthiness/falseyness.
|
|
151
|
+
* - If you need to accept "1"/"0" or other variants, coerce at the call site so intent is explicit.
|
|
152
|
+
*
|
|
153
|
+
* @param {any} value
|
|
154
|
+
* Candidate to normalize (boolean or "true"/"false" string).
|
|
155
|
+
* @param {boolean} defaultValue
|
|
156
|
+
* Fallback used when `value` is neither a boolean nor an accepted string form.
|
|
157
|
+
* @returns {boolean}
|
|
158
|
+
* A boolean derived from `value`, or `defaultValue`.
|
|
159
|
+
*
|
|
160
|
+
* @example
|
|
161
|
+
* normalizeBooleanOrDefault(true, false) // → true
|
|
162
|
+
* normalizeBooleanOrDefault(false, true) // → false
|
|
163
|
+
*
|
|
164
|
+
* @example
|
|
165
|
+
* normalizeBooleanOrDefault('true', false) // → true
|
|
166
|
+
* normalizeBooleanOrDefault(' FALSE ', true) // → false
|
|
167
|
+
*
|
|
168
|
+
* @example
|
|
169
|
+
* normalizeBooleanOrDefault('yes', false) // → false (rejected → default)
|
|
170
|
+
* normalizeBooleanOrDefault(1, true) // → true (rejected → default)
|
|
171
|
+
*/
|
|
172
|
+
export function normalizeBooleanOrDefault(value: any, defaultValue: boolean): boolean;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert a normalized query object into a MongoDB query object.
|
|
3
|
+
*
|
|
4
|
+
* @param {Record<string, any>} query - normalized DSL query
|
|
5
|
+
* @returns {Record<string, any>} - MongoDB query object
|
|
6
|
+
*/
|
|
7
|
+
export function toMongo(query?: Record<string, any>): Record<string, any>;
|
package/types/mongodb/index.d.ts
CHANGED