@open-mercato/shared 0.5.1-develop.2691.d8a0934b37 → 0.5.1-develop.2699.f8b50c8046
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/dist/lib/api/crud.js +1 -1
- package/dist/lib/api/crud.js.map +2 -2
- package/dist/lib/auth/server.js +1 -1
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/data/engine.js +68 -27
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/db/mikro.js +18 -22
- package/dist/lib/db/mikro.js.map +2 -2
- package/dist/lib/indexers/error-log.js +10 -12
- package/dist/lib/indexers/error-log.js.map +2 -2
- package/dist/lib/indexers/status-log.js +14 -16
- package/dist/lib/indexers/status-log.js.map +2 -2
- package/dist/lib/query/engine.js +220 -228
- package/dist/lib/query/engine.js.map +3 -3
- package/dist/lib/query/join-utils.js +28 -23
- package/dist/lib/query/join-utils.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/jest.config.cjs +4 -2
- package/package.json +1 -1
- package/src/lib/api/__tests__/crud.test.ts +5 -3
- package/src/lib/api/crud.ts +1 -1
- package/src/lib/auth/__tests__/server.apiKeyCache.test.ts +10 -4
- package/src/lib/auth/server.ts +1 -1
- package/src/lib/bootstrap/types.ts +2 -2
- package/src/lib/crud/__tests__/crud-factory.test.ts +27 -17
- package/src/lib/data/engine.ts +95 -47
- package/src/lib/db/mikro.ts +26 -25
- package/src/lib/indexers/error-log.ts +23 -23
- package/src/lib/indexers/status-log.ts +36 -33
- package/src/lib/query/__tests__/engine.scope-and-or.test.ts +253 -114
- package/src/lib/query/__tests__/engine.test.ts +206 -139
- package/src/lib/query/engine.ts +306 -263
- package/src/lib/query/join-utils.ts +38 -30
|
@@ -17,7 +17,18 @@ function cloneRows(rows: any[] | undefined): any[] {
|
|
|
17
17
|
return rows.map((row) => ({ ...row }))
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Build a fake Kysely that mimics the fluent API used by BasicQueryEngine.
|
|
22
|
+
* Records operations on each SelectQueryBuilder so tests can inspect:
|
|
23
|
+
* - _ops.table / _ops.alias — starting table (selectFrom target)
|
|
24
|
+
* - _ops.wheres — `[type, ...args]` tuples
|
|
25
|
+
* - _ops.joins — `[{ type, aliasObj, conditions }]`
|
|
26
|
+
* - _ops.orderBys — `[[column, dir]]`
|
|
27
|
+
* - _ops.groups — grouped columns
|
|
28
|
+
* - _ops.selects — select arguments
|
|
29
|
+
* - _ops.limits / _ops.offsets — pagination knobs
|
|
30
|
+
*/
|
|
31
|
+
function createFakeKysely(overrides?: FakeData) {
|
|
21
32
|
const calls: any[] = []
|
|
22
33
|
const defaultData: FakeData = {
|
|
23
34
|
custom_field_defs: [
|
|
@@ -30,115 +41,177 @@ function createFakeKnex(overrides?: FakeData) {
|
|
|
30
41
|
const data: FakeData = Object.fromEntries(
|
|
31
42
|
Object.entries(sourceData).map(([table, rows]) => [table, cloneRows(rows)])
|
|
32
43
|
)
|
|
33
|
-
|
|
34
|
-
function
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
|
|
45
|
+
function parseTableSpec(spec: unknown): { table: string; alias: string | null } {
|
|
46
|
+
if (typeof spec !== 'string') return { table: String(spec || ''), alias: null }
|
|
47
|
+
const asMatch = /^(.+?)\s+as\s+(.+)$/i.exec(spec)
|
|
48
|
+
if (asMatch) return { table: asMatch[1].trim(), alias: asMatch[2].trim() }
|
|
49
|
+
return { table: spec, alias: null }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function createExpressionBuilder() {
|
|
53
|
+
const eb: any = (column: any, op: any, value: any) => ({ kind: 'cmp', column, op, value })
|
|
54
|
+
eb.and = (parts: any[]) => ({ kind: 'and', parts })
|
|
55
|
+
eb.or = (parts: any[]) => ({ kind: 'or', parts })
|
|
56
|
+
eb.not = (part: any) => ({ kind: 'not', part })
|
|
57
|
+
eb.exists = (sub: any) => ({ kind: 'exists', sub })
|
|
58
|
+
eb.val = (value: any) => ({ kind: 'val', value })
|
|
59
|
+
eb.ref = (name: string) => ({ kind: 'ref', name })
|
|
60
|
+
eb.selectFrom = (spec: any) => builderFor(spec)
|
|
61
|
+
return eb
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalizeWhereArgs(args: any[]): any[] {
|
|
65
|
+
if (args.length === 1 && typeof args[0] === 'function') {
|
|
66
|
+
const produced = args[0](createExpressionBuilder())
|
|
67
|
+
if (produced && produced.kind === 'or') return ['or', produced.parts]
|
|
68
|
+
if (produced && produced.kind === 'exists') return ['exists', produced.sub]
|
|
69
|
+
if (produced && produced.kind === 'not' && produced.part?.kind === 'exists') return ['notExists', produced.part.sub]
|
|
70
|
+
return ['expr', produced]
|
|
47
71
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
72
|
+
// (col, op, value) or sql template
|
|
73
|
+
return args
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function recordJoin(ops: any, type: 'left' | 'inner', spec: any, fn: Function) {
|
|
77
|
+
const parsed = parseTableSpec(spec)
|
|
78
|
+
const aliasObj = parsed.alias ? { [parsed.alias]: parsed.table } : { [parsed.table]: parsed.table }
|
|
79
|
+
const entry: any = { type, aliasObj, conditions: [] as any[] }
|
|
80
|
+
const ctx: any = {}
|
|
81
|
+
ctx.on = (left: any, op?: any, right?: any) => {
|
|
82
|
+
if (typeof left === 'function') {
|
|
83
|
+
const expr = left(createExpressionBuilder())
|
|
84
|
+
entry.conditions.push({ method: 'on', expr })
|
|
85
|
+
} else {
|
|
53
86
|
entry.conditions.push({ method: 'on', args: [left, op, right] })
|
|
54
|
-
return ctx
|
|
55
87
|
}
|
|
56
|
-
ctx
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
ops.joins.push(entry)
|
|
88
|
+
return ctx
|
|
89
|
+
}
|
|
90
|
+
ctx.onRef = (left: any, op: any, right: any) => {
|
|
91
|
+
entry.conditions.push({ method: 'on', args: [left, op, right] })
|
|
92
|
+
return ctx
|
|
62
93
|
}
|
|
94
|
+
const result = fn(ctx)
|
|
95
|
+
// onRef/on chain returns ctx; nothing else to do
|
|
96
|
+
void result
|
|
97
|
+
ops.joins.push(entry)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function makeBuilder(ops: any, record: boolean): any {
|
|
63
101
|
const b: any = {
|
|
64
102
|
_ops: ops,
|
|
65
|
-
select:
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
whereIn: function (...args: any[]) { ops.wheres.push(['in', ...args]); return this },
|
|
69
|
-
whereNotIn: function (...args: any[]) { ops.wheres.push(['notIn', ...args]); return this },
|
|
70
|
-
whereNull: function (col: any) { ops.wheres.push(['isNull', col]); return this },
|
|
71
|
-
whereNotNull: function (col: any) { ops.wheres.push(['notNull', col]); return this },
|
|
72
|
-
whereExists: function (sub: any) { ops.wheres.push(['exists', sub]); return this },
|
|
73
|
-
whereNotExists: function (sub: any) { ops.wheres.push(['notExists', sub]); return this },
|
|
74
|
-
whereRaw: function (...args: any[]) { ops.wheres.push(['raw', ...args]); return this },
|
|
75
|
-
orWhereNull: function (col: any) { ops.wheres.push(['orWhereNull', col]); return this },
|
|
76
|
-
leftJoin: function (aliasObj: any, fn: Function) {
|
|
77
|
-
recordJoin('left', aliasObj, fn, () => ({}))
|
|
103
|
+
select(this: any, ...cols: any[]) {
|
|
104
|
+
if (cols.length === 1 && Array.isArray(cols[0])) this._ops.selects.push(...cols[0])
|
|
105
|
+
else this._ops.selects.push(...cols)
|
|
78
106
|
return this
|
|
79
107
|
},
|
|
80
|
-
|
|
81
|
-
|
|
108
|
+
distinct(this: any) { return this },
|
|
109
|
+
where(this: any, ...args: any[]) {
|
|
110
|
+
this._ops.wheres.push(normalizeWhereArgs(args))
|
|
82
111
|
return this
|
|
83
112
|
},
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
offset: function (n: number) { ops.offsets = n; return this },
|
|
88
|
-
clone: function () { return this },
|
|
89
|
-
countDistinct: function () { ops.isCountDistinct = true; return this },
|
|
90
|
-
count: async function () { return [{ count: '0' }] },
|
|
91
|
-
first: async function () {
|
|
92
|
-
// If this is called after countDistinct, return count data
|
|
93
|
-
if (ops.isCountDistinct) {
|
|
94
|
-
return { count: '0' }
|
|
95
|
-
}
|
|
96
|
-
const rows = data[table] || [];
|
|
97
|
-
return rows[0]
|
|
113
|
+
whereRef(this: any, left: any, op: any, right: any) {
|
|
114
|
+
this._ops.wheres.push(['ref', left, op, right])
|
|
115
|
+
return this
|
|
98
116
|
},
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
where: (obj: any) => ({
|
|
105
|
-
orWhereNull: (col: any) => { ops.wheres.push(['andWhereFn', obj, ['orWhereNull', col]]); return inner },
|
|
106
|
-
}),
|
|
107
|
-
}
|
|
108
|
-
arg(inner)
|
|
109
|
-
} else {
|
|
110
|
-
ops.wheres.push(['andWhere', arg])
|
|
111
|
-
}
|
|
112
|
-
return qb
|
|
113
|
-
},
|
|
114
|
-
whereNull: (col: any) => { ops.wheres.push(['isNull', col]); return qb },
|
|
115
|
-
}
|
|
116
|
-
fn(qb)
|
|
117
|
+
leftJoin(this: any, spec: any, fn: Function) { recordJoin(this._ops, 'left', spec, fn); return this },
|
|
118
|
+
innerJoin(this: any, spec: any, fn: Function) { recordJoin(this._ops, 'inner', spec, fn); return this },
|
|
119
|
+
groupBy(this: any, arg: any) {
|
|
120
|
+
if (Array.isArray(arg)) this._ops.groups.push(...arg)
|
|
121
|
+
else this._ops.groups.push(arg)
|
|
117
122
|
return this
|
|
118
123
|
},
|
|
119
|
-
|
|
124
|
+
having(this: any) { return this },
|
|
125
|
+
orderBy(this: any, col: any, dir?: any) { this._ops.orderBys.push([col, dir]); return this },
|
|
126
|
+
limit(this: any, n: number) { this._ops.limits = n; return this },
|
|
127
|
+
offset(this: any, n: number) { this._ops.offsets = n; return this },
|
|
128
|
+
clearSelect(this: any) {
|
|
129
|
+
const nextOps = { ...this._ops, selects: [] }
|
|
130
|
+
return makeBuilder(nextOps, false)
|
|
131
|
+
},
|
|
132
|
+
clearOrderBy(this: any) {
|
|
133
|
+
const nextOps = { ...this._ops, orderBys: [] }
|
|
134
|
+
return makeBuilder(nextOps, false)
|
|
135
|
+
},
|
|
136
|
+
clearGroupBy(this: any) {
|
|
137
|
+
const nextOps = { ...this._ops, groups: [] }
|
|
138
|
+
return makeBuilder(nextOps, false)
|
|
139
|
+
},
|
|
140
|
+
as(this: any, alias: string) { this._ops.alias = alias; return this },
|
|
141
|
+
async execute(this: any) { return cloneRows(data[this._ops.table]) },
|
|
142
|
+
async executeTakeFirst(this: any) {
|
|
143
|
+
const localOps = this._ops
|
|
144
|
+
if (localOps.table === 'information_schema.columns') {
|
|
145
|
+
const infoRows = data['information_schema.columns']
|
|
146
|
+
if (!Array.isArray(infoRows)) return undefined
|
|
147
|
+
const targetTable = extractEqValue(localOps.wheres, 'table_name')
|
|
148
|
+
const targetColumn = extractEqValue(localOps.wheres, 'column_name')
|
|
149
|
+
return infoRows.find((row: any) =>
|
|
150
|
+
(!targetTable || row.table_name === targetTable) &&
|
|
151
|
+
(!targetColumn || row.column_name === targetColumn)
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
if (localOps.table === 'information_schema.tables') {
|
|
155
|
+
const infoRows = data['information_schema.tables']
|
|
156
|
+
if (!Array.isArray(infoRows)) return undefined
|
|
157
|
+
const targetTable = extractEqValue(localOps.wheres, 'table_name')
|
|
158
|
+
return infoRows.find((row: any) => !targetTable || row.table_name === targetTable)
|
|
159
|
+
}
|
|
160
|
+
if (localOps.selects.some((s: any) => s && typeof s === 'object' && (s.__isCount || String(s?.alias || '') === 'count'))) {
|
|
161
|
+
return { count: '0' }
|
|
162
|
+
}
|
|
163
|
+
const rows = data[localOps.table] || []
|
|
164
|
+
if (rows.length === 0) return { count: '0' }
|
|
165
|
+
return rows[0]
|
|
166
|
+
},
|
|
120
167
|
}
|
|
121
|
-
calls.push(b)
|
|
168
|
+
if (record) calls.push(b)
|
|
122
169
|
return b
|
|
123
170
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
171
|
+
|
|
172
|
+
function builderFor(tableArg: any): any {
|
|
173
|
+
const parsed = parseTableSpec(tableArg)
|
|
174
|
+
const ops = {
|
|
175
|
+
table: parsed.table,
|
|
176
|
+
alias: parsed.alias,
|
|
177
|
+
wheres: [] as any[],
|
|
178
|
+
joins: [] as any[],
|
|
179
|
+
selects: [] as any[],
|
|
180
|
+
orderBys: [] as any[],
|
|
181
|
+
groups: [] as any[],
|
|
182
|
+
limits: 0,
|
|
183
|
+
offsets: 0,
|
|
184
|
+
}
|
|
185
|
+
return makeBuilder(ops, true)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function extractEqValue(wheres: any[], column: string): any {
|
|
189
|
+
for (const entry of wheres) {
|
|
190
|
+
if (!Array.isArray(entry)) continue
|
|
191
|
+
if (entry[0] === column && entry[1] === '=') return entry[2]
|
|
192
|
+
}
|
|
193
|
+
return undefined
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const db: any = {
|
|
197
|
+
selectFrom(spec: any) { return builderFor(spec) },
|
|
198
|
+
}
|
|
199
|
+
db._calls = calls
|
|
200
|
+
return db
|
|
128
201
|
}
|
|
129
202
|
|
|
130
|
-
describe('BasicQueryEngine', () => {
|
|
203
|
+
describe('BasicQueryEngine (Kysely)', () => {
|
|
131
204
|
test('pluralizes entity names ending with y correctly', async () => {
|
|
132
|
-
const
|
|
133
|
-
const engine = new BasicQueryEngine({} as any, () =>
|
|
205
|
+
const fakeDb = createFakeKysely()
|
|
206
|
+
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
134
207
|
await engine.query('customers:customer_entity', { tenantId: 't1' })
|
|
135
|
-
const baseCall =
|
|
208
|
+
const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'customer_entities')
|
|
136
209
|
expect(baseCall).toBeTruthy()
|
|
137
210
|
})
|
|
138
211
|
|
|
139
212
|
test('includeCustomFields true discovers keys and allows sort on cf:*; joins extensions', async () => {
|
|
140
|
-
const
|
|
141
|
-
const engine = new BasicQueryEngine({} as any, () =>
|
|
213
|
+
const fakeDb = createFakeKysely()
|
|
214
|
+
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
142
215
|
const res = await engine.query('auth:user', {
|
|
143
216
|
includeCustomFields: true,
|
|
144
217
|
fields: ['id','email','cf:vip'],
|
|
@@ -149,25 +222,29 @@ describe('BasicQueryEngine', () => {
|
|
|
149
222
|
page: { page: 1, pageSize: 10 },
|
|
150
223
|
})
|
|
151
224
|
expect(res).toMatchObject({ page: 1, pageSize: 10, total: 0, items: [] })
|
|
152
|
-
|
|
153
|
-
const defsCall = fakeKnex._calls.find((b: any) => b._ops.table === 'custom_field_defs')
|
|
225
|
+
const defsCall = fakeDb._calls.find((b: any) => b._ops.table === 'custom_field_defs')
|
|
154
226
|
expect(defsCall).toBeTruthy()
|
|
155
|
-
|
|
227
|
+
// Tenant filter (OR tenant_id is null) is expressed as an OR expression in Kysely
|
|
228
|
+
const hasEntityFilter = defsCall._ops.wheres.some((w: any) =>
|
|
229
|
+
Array.isArray(w) && w[0] === 'entity_id' && w[1] === 'in'
|
|
230
|
+
)
|
|
156
231
|
expect(hasEntityFilter).toBe(true)
|
|
157
|
-
|
|
158
|
-
|
|
232
|
+
const hasTenantFilter = defsCall._ops.wheres.some((w: any) => {
|
|
233
|
+
if (!Array.isArray(w)) return false
|
|
234
|
+
const [kind, parts] = w
|
|
235
|
+
if (kind !== 'or' || !Array.isArray(parts)) return false
|
|
236
|
+
return parts.some((part: any) => part?.column === 'tenant_id' && part?.op === '=')
|
|
237
|
+
})
|
|
159
238
|
expect(hasTenantFilter).toBe(true)
|
|
160
|
-
|
|
161
|
-
const baseCall = fakeKnex._calls.find((b: any) => b._ops.table === 'users')
|
|
239
|
+
const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'users')
|
|
162
240
|
const hasCfOrder = baseCall._ops.orderBys.some((o: any) => o[0] === 'cf_vip')
|
|
163
241
|
expect(hasCfOrder).toBe(true)
|
|
164
|
-
// Assert an extension leftJoin was attempted
|
|
165
242
|
const hasExtJoin = baseCall._ops.joins.length > 0
|
|
166
243
|
expect(hasExtJoin).toBe(true)
|
|
167
244
|
})
|
|
168
245
|
|
|
169
246
|
test('customFieldSources join additional profiles for custom fields', async () => {
|
|
170
|
-
const
|
|
247
|
+
const fakeDb = createFakeKysely({
|
|
171
248
|
custom_field_defs: [
|
|
172
249
|
{ key: 'birthday', entity_id: 'customers:customer_person_profile', is_active: true, config_json: JSON.stringify({ listVisible: true }), kind: 'text' },
|
|
173
250
|
{ key: 'sector', entity_id: 'customers:customer_company_profile', is_active: true, config_json: JSON.stringify({ listVisible: true }), kind: 'select' },
|
|
@@ -177,7 +254,7 @@ describe('BasicQueryEngine', () => {
|
|
|
177
254
|
customer_people: [],
|
|
178
255
|
customer_companies: [],
|
|
179
256
|
})
|
|
180
|
-
const engine = new BasicQueryEngine({} as any, () =>
|
|
257
|
+
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
181
258
|
await engine.query('customers:customer_entity', {
|
|
182
259
|
tenantId: 't1',
|
|
183
260
|
includeCustomFields: ['birthday', 'sector'],
|
|
@@ -200,7 +277,7 @@ describe('BasicQueryEngine', () => {
|
|
|
200
277
|
],
|
|
201
278
|
page: { page: 1, pageSize: 10 },
|
|
202
279
|
})
|
|
203
|
-
const baseCall =
|
|
280
|
+
const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'customer_entities')
|
|
204
281
|
expect(baseCall).toBeTruthy()
|
|
205
282
|
const joinAliases = baseCall._ops.joins.map((j: any) => Object.keys(j.aliasObj)[0])
|
|
206
283
|
expect(joinAliases).toEqual(expect.arrayContaining([
|
|
@@ -212,19 +289,22 @@ describe('BasicQueryEngine', () => {
|
|
|
212
289
|
'cfv_company_profile_sector',
|
|
213
290
|
]))
|
|
214
291
|
const personProfileJoin = baseCall._ops.joins.find((j: any) => j.aliasObj.person_profile)
|
|
215
|
-
expect(personProfileJoin?.conditions.some((c: any) => c.args[0] === 'person_profile.entity_id' && c.args[2] === 'customer_entities.id')).toBe(true)
|
|
292
|
+
expect(personProfileJoin?.conditions.some((c: any) => c.args?.[0] === 'person_profile.entity_id' && c.args?.[2] === 'customer_entities.id')).toBe(true)
|
|
216
293
|
const companyProfileJoin = baseCall._ops.joins.find((j: any) => j.aliasObj.company_profile)
|
|
217
|
-
expect(companyProfileJoin?.conditions.some((c: any) => c.args[0] === 'company_profile.entity_id' && c.args[2] === 'customer_entities.id')).toBe(true)
|
|
294
|
+
expect(companyProfileJoin?.conditions.some((c: any) => c.args?.[0] === 'company_profile.entity_id' && c.args?.[2] === 'customer_entities.id')).toBe(true)
|
|
295
|
+
// cfv joins use onRef(`${valAlias}.record_id`, '=', recordIdExpr) where recordIdExpr is a sql template referencing person_profile.id
|
|
218
296
|
const cfvPersonJoin = baseCall._ops.joins.find((j: any) => j.aliasObj.cfv_person_profile_birthday)
|
|
219
|
-
expect(cfvPersonJoin
|
|
297
|
+
expect(cfvPersonJoin).toBeTruthy()
|
|
298
|
+
expect(cfvPersonJoin.conditions.some((c: any) => c.args?.[0] === 'cfv_person_profile_birthday.record_id')).toBe(true)
|
|
220
299
|
const cfvCompanyJoin = baseCall._ops.joins.find((j: any) => j.aliasObj.cfv_company_profile_sector)
|
|
221
|
-
expect(cfvCompanyJoin
|
|
222
|
-
|
|
300
|
+
expect(cfvCompanyJoin).toBeTruthy()
|
|
301
|
+
expect(cfvCompanyJoin.conditions.some((c: any) => c.args?.[0] === 'cfv_company_profile_sector.record_id')).toBe(true)
|
|
302
|
+
const defsInFilter = fakeDb._calls
|
|
223
303
|
.filter((b: any) => b._ops.table === 'custom_field_defs')
|
|
224
304
|
.flatMap((b: any) => b._ops.wheres)
|
|
225
|
-
.find((w: any) => Array.isArray(w) && w[0] === '
|
|
226
|
-
expect(
|
|
227
|
-
const entityTargets =
|
|
305
|
+
.find((w: any) => Array.isArray(w) && w[0] === 'entity_id' && w[1] === 'in')
|
|
306
|
+
expect(defsInFilter).toBeTruthy()
|
|
307
|
+
const entityTargets = defsInFilter?.[2] || []
|
|
228
308
|
expect(entityTargets).toEqual(expect.arrayContaining([
|
|
229
309
|
'customers:customer_entity',
|
|
230
310
|
'customers:customer_person_profile',
|
|
@@ -233,7 +313,7 @@ describe('BasicQueryEngine', () => {
|
|
|
233
313
|
})
|
|
234
314
|
|
|
235
315
|
test('customFieldSources aliases support object equality filters', async () => {
|
|
236
|
-
const
|
|
316
|
+
const fakeDb = createFakeKysely({
|
|
237
317
|
customer_entities: [],
|
|
238
318
|
customer_people: [],
|
|
239
319
|
'information_schema.columns': [
|
|
@@ -242,7 +322,7 @@ describe('BasicQueryEngine', () => {
|
|
|
242
322
|
{ table_name: 'customer_people', column_name: 'tenant_id' },
|
|
243
323
|
],
|
|
244
324
|
})
|
|
245
|
-
const engine = new BasicQueryEngine({} as any, () =>
|
|
325
|
+
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
246
326
|
await engine.query('customers:customer_entity', {
|
|
247
327
|
tenantId: 't1',
|
|
248
328
|
fields: ['id'],
|
|
@@ -259,20 +339,20 @@ describe('BasicQueryEngine', () => {
|
|
|
259
339
|
},
|
|
260
340
|
page: { page: 1, pageSize: 10 },
|
|
261
341
|
})
|
|
262
|
-
const baseCall =
|
|
342
|
+
const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'customer_entities')
|
|
263
343
|
expect(baseCall).toBeTruthy()
|
|
264
344
|
const existsFilter = baseCall._ops.wheres.find((w: any) => Array.isArray(w) && w[0] === 'exists')
|
|
265
345
|
expect(existsFilter).toBeTruthy()
|
|
266
346
|
const subQuery = existsFilter[1]
|
|
267
347
|
expect(subQuery?._ops?.table).toBe('customer_people')
|
|
268
348
|
const hasEqualityFilter = Array.isArray(subQuery?._ops?.wheres)
|
|
269
|
-
? subQuery._ops.wheres.some((w: any) => Array.isArray(w) && w[0] === 'person_profile.id' && w[1] === 'profile-1')
|
|
349
|
+
? subQuery._ops.wheres.some((w: any) => Array.isArray(w) && w[0] === 'person_profile.id' && w[1] === '=' && w[2] === 'profile-1')
|
|
270
350
|
: false
|
|
271
351
|
expect(hasEqualityFilter).toBe(true)
|
|
272
352
|
})
|
|
273
353
|
|
|
274
|
-
test('customFieldSources equality filters
|
|
275
|
-
const
|
|
354
|
+
test('customFieldSources equality filters stay exact when search tokens are available', async () => {
|
|
355
|
+
const fakeDb = createFakeKysely({
|
|
276
356
|
customer_entities: [],
|
|
277
357
|
customer_people: [],
|
|
278
358
|
'information_schema.columns': [
|
|
@@ -281,7 +361,7 @@ describe('BasicQueryEngine', () => {
|
|
|
281
361
|
{ table_name: 'customer_people', column_name: 'tenant_id' },
|
|
282
362
|
],
|
|
283
363
|
})
|
|
284
|
-
const engine = new BasicQueryEngine({} as any, () =>
|
|
364
|
+
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
285
365
|
jest.spyOn(engine as any, 'tableExists').mockResolvedValue(true)
|
|
286
366
|
jest.spyOn(engine as any, 'hasSearchTokens').mockResolvedValue(true)
|
|
287
367
|
const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
|
|
@@ -303,23 +383,10 @@ describe('BasicQueryEngine', () => {
|
|
|
303
383
|
page: { page: 1, pageSize: 10 },
|
|
304
384
|
})
|
|
305
385
|
|
|
306
|
-
|
|
307
|
-
//
|
|
308
|
-
expect(applySearchTokensSpy).
|
|
309
|
-
|
|
310
|
-
expect.objectContaining({
|
|
311
|
-
entity: 'customers:customer_person_profile',
|
|
312
|
-
field: 'id',
|
|
313
|
-
recordIdColumn: 'person_profile.id',
|
|
314
|
-
tenantId: 't1',
|
|
315
|
-
tokens: expect.arrayContaining(['profile']),
|
|
316
|
-
hashes: expect.any(Array),
|
|
317
|
-
}),
|
|
318
|
-
)
|
|
319
|
-
const callArgs = applySearchTokensSpy.mock.calls[0][1]
|
|
320
|
-
expect(callArgs.tokens.length).toBeGreaterThan(0)
|
|
321
|
-
expect(callArgs.hashes.length).toBe(callArgs.tokens.length)
|
|
322
|
-
const baseCall = fakeKnex._calls.find((b: any) => b._ops.table === 'customer_entities')
|
|
386
|
+
// When search tokens are available, equality filters on joined fields should stay exact
|
|
387
|
+
// (not use tokenized matching) and route through EXISTS subquery
|
|
388
|
+
expect(applySearchTokensSpy).not.toHaveBeenCalled()
|
|
389
|
+
const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'customer_entities')
|
|
323
390
|
expect(baseCall).toBeTruthy()
|
|
324
391
|
// The join subquery that the parent whereExists wraps MUST still target customer_people
|
|
325
392
|
const existsFilter = baseCall._ops.wheres.find((w: any) => Array.isArray(w) && w[0] === 'exists')
|
|
@@ -332,7 +399,7 @@ describe('BasicQueryEngine', () => {
|
|
|
332
399
|
// absent (searchEnabled=false), $eq must route through the exact EXISTS
|
|
333
400
|
// subquery path, producing the pre-change `person_profile.id = 'profile-1'`
|
|
334
401
|
// filter — not the tokenized OR across search-tokens columns.
|
|
335
|
-
const
|
|
402
|
+
const fakeDb = createFakeKysely({
|
|
336
403
|
customer_entities: [],
|
|
337
404
|
customer_people: [],
|
|
338
405
|
'information_schema.columns': [
|
|
@@ -341,7 +408,7 @@ describe('BasicQueryEngine', () => {
|
|
|
341
408
|
{ table_name: 'customer_people', column_name: 'tenant_id' },
|
|
342
409
|
],
|
|
343
410
|
})
|
|
344
|
-
const engine = new BasicQueryEngine({} as any, () =>
|
|
411
|
+
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
345
412
|
jest.spyOn(engine as any, 'tableExists').mockResolvedValue(false)
|
|
346
413
|
const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
|
|
347
414
|
|
|
@@ -363,23 +430,23 @@ describe('BasicQueryEngine', () => {
|
|
|
363
430
|
})
|
|
364
431
|
|
|
365
432
|
expect(applySearchTokensSpy).not.toHaveBeenCalled()
|
|
366
|
-
const baseCall =
|
|
433
|
+
const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'customer_entities')
|
|
367
434
|
expect(baseCall).toBeTruthy()
|
|
368
435
|
const existsFilter = baseCall._ops.wheres.find((w: any) => Array.isArray(w) && w[0] === 'exists')
|
|
369
436
|
expect(existsFilter).toBeTruthy()
|
|
370
437
|
const subQuery = existsFilter[1]
|
|
371
438
|
expect(subQuery?._ops?.table).toBe('customer_people')
|
|
372
439
|
const hasEqualityFilter = Array.isArray(subQuery?._ops?.wheres)
|
|
373
|
-
? subQuery._ops.wheres.some((w: any) => Array.isArray(w) && w[0] === 'person_profile.id' && w[1] === 'profile-1')
|
|
440
|
+
? subQuery._ops.wheres.some((w: any) => Array.isArray(w) && w[0] === 'person_profile.id' && w[1] === '=' && w[2] === 'profile-1')
|
|
374
441
|
: false
|
|
375
442
|
expect(hasEqualityFilter).toBe(true)
|
|
376
443
|
})
|
|
377
444
|
|
|
378
445
|
test('uses search tokens for index document fields on base entities', async () => {
|
|
379
|
-
const
|
|
446
|
+
const fakeDb = createFakeKysely({
|
|
380
447
|
todos: [],
|
|
381
448
|
})
|
|
382
|
-
const engine = new BasicQueryEngine({} as any, () =>
|
|
449
|
+
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
383
450
|
const tableExistsSpy = jest.spyOn(engine as any, 'tableExists').mockResolvedValue(true)
|
|
384
451
|
const hasSearchTokensSpy = jest.spyOn(engine as any, 'hasSearchTokens').mockResolvedValue(true)
|
|
385
452
|
const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
|
|
@@ -411,7 +478,7 @@ describe('BasicQueryEngine', () => {
|
|
|
411
478
|
})
|
|
412
479
|
|
|
413
480
|
test('join filters use whereExists with configured alias', async () => {
|
|
414
|
-
const
|
|
481
|
+
const fakeDb = createFakeKysely({
|
|
415
482
|
customer_entities: [],
|
|
416
483
|
customer_tag_assignments: [],
|
|
417
484
|
'information_schema.columns': [
|
|
@@ -420,7 +487,7 @@ describe('BasicQueryEngine', () => {
|
|
|
420
487
|
{ table_name: 'customer_entities', column_name: 'tenant_id' },
|
|
421
488
|
],
|
|
422
489
|
})
|
|
423
|
-
const engine = new BasicQueryEngine({} as any, () =>
|
|
490
|
+
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
424
491
|
await engine.query('customers:customer_entity', {
|
|
425
492
|
tenantId: 't1',
|
|
426
493
|
fields: ['id'],
|
|
@@ -438,14 +505,14 @@ describe('BasicQueryEngine', () => {
|
|
|
438
505
|
},
|
|
439
506
|
page: { page: 1, pageSize: 10 },
|
|
440
507
|
})
|
|
441
|
-
const baseCall =
|
|
508
|
+
const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'customer_entities')
|
|
442
509
|
expect(baseCall).toBeTruthy()
|
|
443
510
|
const existsFilter = baseCall._ops.wheres.find((w: any) => Array.isArray(w) && w[0] === 'exists')
|
|
444
511
|
expect(existsFilter).toBeTruthy()
|
|
445
512
|
const subQuery = existsFilter[1]
|
|
446
513
|
expect(subQuery?._ops?.table).toBe('customer_tag_assignments')
|
|
447
514
|
const hasInFilter = Array.isArray(subQuery?._ops?.wheres)
|
|
448
|
-
? subQuery._ops.wheres.some((w: any) => Array.isArray(w) && w[0] === '
|
|
515
|
+
? subQuery._ops.wheres.some((w: any) => Array.isArray(w) && w[0] === 'tag_assignments.tag_id' && w[1] === 'in')
|
|
449
516
|
: false
|
|
450
517
|
expect(hasInFilter).toBe(true)
|
|
451
518
|
})
|