@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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
-
import type
|
|
2
|
+
import { type Kysely, sql } from 'kysely'
|
|
3
3
|
import type { IndexerErrorSource } from './error-log'
|
|
4
4
|
|
|
5
5
|
export type IndexerLogLevel = 'info' | 'warn'
|
|
@@ -18,7 +18,7 @@ export type RecordIndexerLogInput = {
|
|
|
18
18
|
|
|
19
19
|
type RecordIndexerLogDeps = {
|
|
20
20
|
em?: EntityManager
|
|
21
|
-
|
|
21
|
+
db?: Kysely<any>
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
const MAX_MESSAGE_LENGTH = 4_096
|
|
@@ -43,14 +43,11 @@ function safeJson(value: unknown): unknown {
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
function
|
|
47
|
-
if (deps.
|
|
46
|
+
function pickDb(deps: RecordIndexerLogDeps): Kysely<any> | null {
|
|
47
|
+
if (deps.db) return deps.db
|
|
48
48
|
if (deps.em) {
|
|
49
49
|
try {
|
|
50
|
-
|
|
51
|
-
if (connection && typeof connection.getKnex === 'function') {
|
|
52
|
-
return connection.getKnex()
|
|
53
|
-
}
|
|
50
|
+
return deps.em.getKysely<any>()
|
|
54
51
|
} catch {
|
|
55
52
|
return null
|
|
56
53
|
}
|
|
@@ -58,30 +55,33 @@ function pickKnex(deps: RecordIndexerLogDeps): Knex | null {
|
|
|
58
55
|
return null
|
|
59
56
|
}
|
|
60
57
|
|
|
61
|
-
async function pruneExcessLogs(
|
|
62
|
-
const rows = await
|
|
63
|
-
.
|
|
64
|
-
.
|
|
65
|
-
.
|
|
66
|
-
.orderBy('
|
|
58
|
+
async function pruneExcessLogs(db: Kysely<any>, source: IndexerErrorSource): Promise<void> {
|
|
59
|
+
const rows = await db
|
|
60
|
+
.selectFrom('indexer_status_logs' as any)
|
|
61
|
+
.select('id' as any)
|
|
62
|
+
.where('source' as any, '=', source)
|
|
63
|
+
.orderBy('occurred_at' as any, 'desc')
|
|
64
|
+
.orderBy('id' as any, 'desc')
|
|
67
65
|
.offset(MAX_LOGS_PER_SOURCE)
|
|
68
66
|
.limit(MAX_DELETE_BATCH)
|
|
67
|
+
.execute()
|
|
69
68
|
|
|
70
69
|
if (!rows.length) return
|
|
71
70
|
const ids = rows.map((row: any) => row.id).filter(Boolean)
|
|
72
71
|
if (!ids.length) return
|
|
73
|
-
await
|
|
74
|
-
.
|
|
75
|
-
.
|
|
72
|
+
await db
|
|
73
|
+
.deleteFrom('indexer_status_logs' as any)
|
|
74
|
+
.where('id' as any, 'in', ids)
|
|
75
|
+
.execute()
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
export async function recordIndexerLog(
|
|
79
79
|
deps: RecordIndexerLogDeps,
|
|
80
80
|
input: RecordIndexerLogInput,
|
|
81
81
|
): Promise<void> {
|
|
82
|
-
const
|
|
83
|
-
if (!
|
|
84
|
-
console.warn('[indexers] Unable to record indexer log (missing
|
|
82
|
+
const db = pickDb(deps)
|
|
83
|
+
if (!db) {
|
|
84
|
+
console.warn('[indexers] Unable to record indexer log (missing db connection)', {
|
|
85
85
|
source: input.source,
|
|
86
86
|
handler: input.handler,
|
|
87
87
|
})
|
|
@@ -94,25 +94,28 @@ export async function recordIndexerLog(
|
|
|
94
94
|
const occurredAt = new Date()
|
|
95
95
|
|
|
96
96
|
try {
|
|
97
|
-
await
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
97
|
+
await db
|
|
98
|
+
.insertInto('indexer_status_logs' as any)
|
|
99
|
+
.values({
|
|
100
|
+
source: input.source,
|
|
101
|
+
handler: input.handler,
|
|
102
|
+
level,
|
|
103
|
+
entity_type: input.entityType ?? null,
|
|
104
|
+
record_id: input.recordId ?? null,
|
|
105
|
+
tenant_id: input.tenantId ?? null,
|
|
106
|
+
organization_id: input.organizationId ?? null,
|
|
107
|
+
message,
|
|
108
|
+
details: details === null ? null : sql`${JSON.stringify(details)}::jsonb`,
|
|
109
|
+
occurred_at: occurredAt,
|
|
110
|
+
} as any)
|
|
111
|
+
.execute()
|
|
109
112
|
} catch (error) {
|
|
110
113
|
console.error('[indexers] Failed to persist indexer log', error)
|
|
111
114
|
return
|
|
112
115
|
}
|
|
113
116
|
|
|
114
117
|
try {
|
|
115
|
-
await pruneExcessLogs(
|
|
118
|
+
await pruneExcessLogs(db, input.source)
|
|
116
119
|
} catch (pruneError) {
|
|
117
120
|
console.warn('[indexers] Failed to prune indexer logs', pruneError)
|
|
118
121
|
}
|
|
@@ -8,88 +8,182 @@ function cloneRows(rows: any[] | undefined): any[] {
|
|
|
8
8
|
return rows.map((row) => ({ ...row }))
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
function
|
|
11
|
+
function createFakeKysely(overrides?: FakeData) {
|
|
12
12
|
const calls: any[] = []
|
|
13
13
|
const defaultData: FakeData = { custom_field_defs: [], custom_field_values: [] }
|
|
14
14
|
const sourceData = { ...defaultData, ...(overrides || {}) }
|
|
15
15
|
const data: FakeData = Object.fromEntries(
|
|
16
16
|
Object.entries(sourceData).map(([table, rows]) => [table, cloneRows(rows)]),
|
|
17
17
|
)
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
|
|
19
|
+
function parseTableSpec(spec: unknown): { table: string; alias: string | null } {
|
|
20
|
+
if (typeof spec !== 'string') return { table: String(spec || ''), alias: null }
|
|
21
|
+
const asMatch = /^(.+?)\s+as\s+(.+)$/i.exec(spec)
|
|
22
|
+
if (asMatch) return { table: asMatch[1].trim(), alias: asMatch[2].trim() }
|
|
23
|
+
return { table: spec, alias: null }
|
|
20
24
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
25
|
+
|
|
26
|
+
function createExpressionBuilder() {
|
|
27
|
+
const eb: any = (column: any, op: any, value: any) => ({ kind: 'cmp', column, op, value })
|
|
28
|
+
eb.and = (parts: any[]) => ({ kind: 'and', parts })
|
|
29
|
+
eb.or = (parts: any[]) => ({ kind: 'or', parts })
|
|
30
|
+
eb.not = (part: any) => ({ kind: 'not', part })
|
|
31
|
+
eb.exists = (sub: any) => ({ kind: 'exists', sub })
|
|
32
|
+
eb.val = (value: any) => ({ kind: 'val', value })
|
|
33
|
+
eb.ref = (name: string) => ({ kind: 'ref', name })
|
|
34
|
+
eb.selectFrom = (spec: any) => builderFor(spec)
|
|
35
|
+
return eb
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizeWhereArgs(args: any[]): any[] {
|
|
39
|
+
if (args.length === 1 && typeof args[0] === 'function') {
|
|
40
|
+
const produced = args[0](createExpressionBuilder())
|
|
41
|
+
if (produced && produced.kind === 'or') return ['or', produced.parts]
|
|
42
|
+
if (produced && produced.kind === 'and') return ['and', produced.parts]
|
|
43
|
+
if (produced && produced.kind === 'exists') return ['exists', produced.sub]
|
|
44
|
+
if (produced && produced.kind === 'not' && produced.part?.kind === 'exists') return ['notExists', produced.part.sub]
|
|
45
|
+
return ['expr', produced]
|
|
46
|
+
}
|
|
47
|
+
return args
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function recordJoin(ops: any, type: 'left' | 'inner', spec: any, fn: Function) {
|
|
51
|
+
const parsed = parseTableSpec(spec)
|
|
52
|
+
const aliasObj = parsed.alias ? { [parsed.alias]: parsed.table } : { [parsed.table]: parsed.table }
|
|
53
|
+
const entry: any = { type, aliasObj, conditions: [] as any[] }
|
|
54
|
+
const ctx: any = {}
|
|
55
|
+
ctx.on = (left: any, op?: any, right?: any) => {
|
|
56
|
+
if (typeof left === 'function') {
|
|
57
|
+
const expr = left(createExpressionBuilder())
|
|
58
|
+
entry.conditions.push({ method: 'on', expr })
|
|
59
|
+
} else {
|
|
60
|
+
entry.conditions.push({ method: 'on', args: [left, op, right] })
|
|
61
|
+
}
|
|
62
|
+
return ctx
|
|
32
63
|
}
|
|
64
|
+
ctx.onRef = (left: any, op: any, right: any) => {
|
|
65
|
+
entry.conditions.push({ method: 'on', args: [left, op, right] })
|
|
66
|
+
return ctx
|
|
67
|
+
}
|
|
68
|
+
fn(ctx)
|
|
69
|
+
ops.joins.push(entry)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function makeBuilder(ops: any, record: boolean): any {
|
|
33
73
|
const b: any = {
|
|
34
74
|
_ops: ops,
|
|
35
|
-
select:
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const nested = makeBuilder(`${table}::where-fn`)
|
|
39
|
-
args[0].call(nested, nested)
|
|
40
|
-
ops.wheres.push(['whereFn', nested._ops])
|
|
41
|
-
return this
|
|
42
|
-
}
|
|
43
|
-
ops.wheres.push(args)
|
|
75
|
+
select(this: any, ...cols: any[]) {
|
|
76
|
+
if (cols.length === 1 && Array.isArray(cols[0])) this._ops.selects.push(...cols[0])
|
|
77
|
+
else this._ops.selects.push(...cols)
|
|
44
78
|
return this
|
|
45
79
|
},
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
80
|
+
distinct(this: any) { return this },
|
|
81
|
+
where(this: any, ...args: any[]) {
|
|
82
|
+
this._ops.wheres.push(normalizeWhereArgs(args))
|
|
83
|
+
return this
|
|
84
|
+
},
|
|
85
|
+
whereRef(this: any, left: any, op: any, right: any) {
|
|
86
|
+
this._ops.wheres.push(['ref', left, op, right])
|
|
87
|
+
return this
|
|
88
|
+
},
|
|
89
|
+
leftJoin(this: any, spec: any, fn: Function) { recordJoin(this._ops, 'left', spec, fn); return this },
|
|
90
|
+
innerJoin(this: any, spec: any, fn: Function) { recordJoin(this._ops, 'inner', spec, fn); return this },
|
|
91
|
+
groupBy(this: any, arg: any) {
|
|
92
|
+
if (Array.isArray(arg)) this._ops.groups.push(...arg)
|
|
93
|
+
else this._ops.groups.push(arg)
|
|
55
94
|
return this
|
|
56
95
|
},
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
96
|
+
having(this: any) { return this },
|
|
97
|
+
orderBy(this: any, col: any, dir?: any) { this._ops.orderBys.push([col, dir]); return this },
|
|
98
|
+
limit(this: any, n: number) { this._ops.limits = n; return this },
|
|
99
|
+
offset(this: any, n: number) { this._ops.offsets = n; return this },
|
|
100
|
+
clearSelect(this: any) {
|
|
101
|
+
const nextOps = { ...this._ops, selects: [] }
|
|
102
|
+
return makeBuilder(nextOps, false)
|
|
103
|
+
},
|
|
104
|
+
clearOrderBy(this: any) {
|
|
105
|
+
const nextOps = { ...this._ops, orderBys: [] }
|
|
106
|
+
return makeBuilder(nextOps, false)
|
|
107
|
+
},
|
|
108
|
+
clearGroupBy(this: any) {
|
|
109
|
+
const nextOps = { ...this._ops, groups: [] }
|
|
110
|
+
return makeBuilder(nextOps, false)
|
|
111
|
+
},
|
|
112
|
+
as(this: any, alias: string) { this._ops.alias = alias; return this },
|
|
113
|
+
async execute(this: any) { return cloneRows(data[this._ops.table]) },
|
|
114
|
+
async executeTakeFirst(this: any) {
|
|
115
|
+
const localOps = this._ops
|
|
116
|
+
if (localOps.table === 'information_schema.columns') {
|
|
117
|
+
const infoRows = data['information_schema.columns']
|
|
118
|
+
if (!Array.isArray(infoRows)) return undefined
|
|
119
|
+
const targetTable = extractEqValue(localOps.wheres, 'table_name')
|
|
120
|
+
const targetColumn = extractEqValue(localOps.wheres, 'column_name')
|
|
121
|
+
return infoRows.find((row: any) =>
|
|
122
|
+
(!targetTable || row.table_name === targetTable) &&
|
|
123
|
+
(!targetColumn || row.column_name === targetColumn)
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
if (localOps.table === 'information_schema.tables') {
|
|
127
|
+
const infoRows = data['information_schema.tables']
|
|
128
|
+
if (!Array.isArray(infoRows)) return undefined
|
|
129
|
+
const targetTable = extractEqValue(localOps.wheres, 'table_name')
|
|
130
|
+
return infoRows.find((row: any) => !targetTable || row.table_name === targetTable)
|
|
131
|
+
}
|
|
132
|
+
if (localOps.selects.some((s: any) => s && typeof s === 'object' && (s.__isCount || String(s?.alias || '') === 'count'))) {
|
|
133
|
+
return { count: '0' }
|
|
134
|
+
}
|
|
135
|
+
const rows = data[localOps.table] || []
|
|
136
|
+
if (rows.length === 0) return { count: '0' }
|
|
137
|
+
return rows[0]
|
|
138
|
+
},
|
|
77
139
|
}
|
|
78
|
-
calls.push(b)
|
|
140
|
+
if (record) calls.push(b)
|
|
79
141
|
return b
|
|
80
142
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
143
|
+
|
|
144
|
+
function builderFor(tableArg: any): any {
|
|
145
|
+
const parsed = parseTableSpec(tableArg)
|
|
146
|
+
const ops = {
|
|
147
|
+
table: parsed.table,
|
|
148
|
+
alias: parsed.alias,
|
|
149
|
+
wheres: [] as any[],
|
|
150
|
+
joins: [] as any[],
|
|
151
|
+
selects: [] as any[],
|
|
152
|
+
orderBys: [] as any[],
|
|
153
|
+
groups: [] as any[],
|
|
154
|
+
limits: 0,
|
|
155
|
+
offsets: 0,
|
|
156
|
+
}
|
|
157
|
+
return makeBuilder(ops, true)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function extractEqValue(wheres: any[], column: string): any {
|
|
161
|
+
for (const entry of wheres) {
|
|
162
|
+
if (!Array.isArray(entry)) continue
|
|
163
|
+
if (entry[0] === column && entry[1] === '=') return entry[2]
|
|
164
|
+
}
|
|
165
|
+
return undefined
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const db: any = {
|
|
169
|
+
selectFrom(spec: any) { return builderFor(spec) },
|
|
84
170
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
return fn
|
|
171
|
+
db._calls = calls
|
|
172
|
+
return db
|
|
88
173
|
}
|
|
89
174
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
175
|
+
type Cmp = { kind: 'cmp'; column: string; op: string; value: unknown }
|
|
176
|
+
type And = { kind: 'and'; parts: Node[] }
|
|
177
|
+
type Or = { kind: 'or'; parts: Node[] }
|
|
178
|
+
type Node = Cmp | And | Or | { kind: string; [k: string]: any }
|
|
179
|
+
|
|
180
|
+
function flattenCmps(node: Node | unknown, out: Cmp[] = []): Cmp[] {
|
|
181
|
+
if (!node || typeof node !== 'object') return out
|
|
182
|
+
const n = node as Node
|
|
183
|
+
if (n.kind === 'cmp') out.push(n as Cmp)
|
|
184
|
+
else if (n.kind === 'and' || n.kind === 'or') {
|
|
185
|
+
for (const p of (n as And | Or).parts) flattenCmps(p, out)
|
|
186
|
+
}
|
|
93
187
|
return out
|
|
94
188
|
}
|
|
95
189
|
|
|
@@ -135,63 +229,68 @@ describe('normalizeFilters $or clause grouping', () => {
|
|
|
135
229
|
})
|
|
136
230
|
|
|
137
231
|
describe('BasicQueryEngine — null equality', () => {
|
|
138
|
-
test('$eq null compiles to
|
|
139
|
-
const
|
|
232
|
+
test('$eq null compiles to where(col, "is", null)', async () => {
|
|
233
|
+
const fakeDb = createFakeKysely({
|
|
140
234
|
scheduled_jobs: [],
|
|
141
235
|
'information_schema.columns': [
|
|
142
236
|
{ table_name: 'scheduled_jobs', column_name: 'organization_id' },
|
|
143
237
|
],
|
|
144
238
|
})
|
|
145
|
-
const engine = new BasicQueryEngine({} as any, () =>
|
|
239
|
+
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
146
240
|
await engine.query('scheduler:scheduled_job', {
|
|
147
241
|
tenantId: 't1',
|
|
148
242
|
fields: ['id'],
|
|
149
243
|
omitAutomaticTenantOrgScope: true,
|
|
150
244
|
filters: { organization_id: { $eq: null } },
|
|
151
245
|
})
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
246
|
+
const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'scheduled_jobs')
|
|
247
|
+
expect(baseCall).toBeTruthy()
|
|
248
|
+
const wheres: any[] = baseCall._ops.wheres
|
|
249
|
+
const hasIsNullOrgId = wheres.some(
|
|
250
|
+
(w: any) => Array.isArray(w) && String(w[0]).endsWith('organization_id') && w[1] === 'is' && w[2] === null,
|
|
155
251
|
)
|
|
156
|
-
expect(
|
|
252
|
+
expect(hasIsNullOrgId).toBe(true)
|
|
157
253
|
const hasEqualsLiteralNull = wheres.some(
|
|
158
|
-
(w: any) => Array.isArray(w) &&
|
|
254
|
+
(w: any) => Array.isArray(w) && String(w[0]).endsWith('organization_id') && w[1] === '=' && w[2] === null,
|
|
159
255
|
)
|
|
160
256
|
expect(hasEqualsLiteralNull).toBe(false)
|
|
161
257
|
})
|
|
162
258
|
|
|
163
|
-
test('$ne null compiles to
|
|
164
|
-
const
|
|
259
|
+
test('$ne null compiles to where(col, "is not", null)', async () => {
|
|
260
|
+
const fakeDb = createFakeKysely({
|
|
165
261
|
scheduled_jobs: [],
|
|
166
262
|
'information_schema.columns': [
|
|
167
263
|
{ table_name: 'scheduled_jobs', column_name: 'organization_id' },
|
|
168
264
|
],
|
|
169
265
|
})
|
|
170
|
-
const engine = new BasicQueryEngine({} as any, () =>
|
|
266
|
+
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
171
267
|
await engine.query('scheduler:scheduled_job', {
|
|
172
268
|
tenantId: 't1',
|
|
173
269
|
fields: ['id'],
|
|
174
270
|
omitAutomaticTenantOrgScope: true,
|
|
175
271
|
filters: { organization_id: { $ne: null } },
|
|
176
272
|
})
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
273
|
+
const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'scheduled_jobs')
|
|
274
|
+
expect(baseCall).toBeTruthy()
|
|
275
|
+
const wheres: any[] = baseCall._ops.wheres
|
|
276
|
+
const hasIsNotNullOrgId = wheres.some(
|
|
277
|
+
(w: any) => Array.isArray(w) && String(w[0]).endsWith('organization_id') && w[1] === 'is not' && w[2] === null,
|
|
180
278
|
)
|
|
181
|
-
expect(
|
|
279
|
+
expect(hasIsNotNullOrgId).toBe(true)
|
|
182
280
|
})
|
|
183
281
|
})
|
|
184
282
|
|
|
185
283
|
describe('BasicQueryEngine — omitAutomaticTenantOrgScope', () => {
|
|
186
284
|
test('skips automatic tenant and organization guards when flag is set', async () => {
|
|
187
|
-
const
|
|
285
|
+
const fakeDb = createFakeKysely({
|
|
188
286
|
scheduled_jobs: [],
|
|
189
287
|
'information_schema.columns': [
|
|
288
|
+
{ table_name: 'scheduled_jobs', column_name: 'id' },
|
|
190
289
|
{ table_name: 'scheduled_jobs', column_name: 'organization_id' },
|
|
191
290
|
{ table_name: 'scheduled_jobs', column_name: 'tenant_id' },
|
|
192
291
|
],
|
|
193
292
|
})
|
|
194
|
-
const engine = new BasicQueryEngine({} as any, () =>
|
|
293
|
+
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
195
294
|
await engine.query('scheduler:scheduled_job', {
|
|
196
295
|
tenantId: 't1',
|
|
197
296
|
organizationId: 'org-1',
|
|
@@ -199,35 +298,62 @@ describe('BasicQueryEngine — omitAutomaticTenantOrgScope', () => {
|
|
|
199
298
|
omitAutomaticTenantOrgScope: true,
|
|
200
299
|
filters: { id: { $eq: '11111111-1111-1111-1111-111111111111' } },
|
|
201
300
|
})
|
|
202
|
-
const baseCall =
|
|
301
|
+
const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'scheduled_jobs')
|
|
203
302
|
expect(baseCall).toBeTruthy()
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
303
|
+
const wheres: any[] = baseCall._ops.wheres
|
|
304
|
+
|
|
305
|
+
const hasTenantGuard = wheres.some(
|
|
306
|
+
(w: any) => Array.isArray(w) && w[0] === 'scheduled_jobs.tenant_id' && w[1] === '=' && w[2] === 't1',
|
|
307
|
+
)
|
|
308
|
+
expect(hasTenantGuard).toBe(false)
|
|
309
|
+
|
|
310
|
+
const allCmps = wheres.flatMap((w: any) => {
|
|
311
|
+
if (!Array.isArray(w)) return []
|
|
312
|
+
if (w[0] === 'expr' || w[0] === 'and' || w[0] === 'or') {
|
|
313
|
+
const payload = w[0] === 'expr' ? w[1] : { kind: w[0], parts: w[1] }
|
|
314
|
+
return flattenCmps(payload)
|
|
315
|
+
}
|
|
316
|
+
if (w.length >= 3 && typeof w[0] === 'string' && typeof w[1] === 'string') {
|
|
317
|
+
return [{ kind: 'cmp', column: w[0], op: w[1], value: w[2] } as Cmp]
|
|
318
|
+
}
|
|
319
|
+
return []
|
|
320
|
+
})
|
|
321
|
+
const hasOrgGuard = allCmps.some(
|
|
322
|
+
(c) => String(c.column).endsWith('organization_id') && c.op === 'in' && Array.isArray(c.value) && (c.value as any[]).includes('org-1'),
|
|
323
|
+
)
|
|
324
|
+
expect(hasOrgGuard).toBe(false)
|
|
325
|
+
|
|
326
|
+
const hasIdFilter = wheres.some(
|
|
327
|
+
(w: any) => Array.isArray(w) && w[0] === 'scheduled_jobs.id' && w[1] === '=' && w[2] === '11111111-1111-1111-1111-111111111111',
|
|
328
|
+
)
|
|
329
|
+
expect(hasIdFilter).toBe(true)
|
|
207
330
|
})
|
|
208
331
|
|
|
209
332
|
test('applies automatic tenant guard when flag is absent (baseline)', async () => {
|
|
210
|
-
const
|
|
333
|
+
const fakeDb = createFakeKysely({
|
|
211
334
|
scheduled_jobs: [],
|
|
212
335
|
'information_schema.columns': [
|
|
213
336
|
{ table_name: 'scheduled_jobs', column_name: 'tenant_id' },
|
|
214
337
|
],
|
|
215
338
|
})
|
|
216
|
-
const engine = new BasicQueryEngine({} as any, () =>
|
|
339
|
+
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
217
340
|
await engine.query('scheduler:scheduled_job', {
|
|
218
341
|
tenantId: 't1',
|
|
219
342
|
fields: ['id'],
|
|
220
343
|
})
|
|
221
|
-
const baseCall =
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
344
|
+
const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'scheduled_jobs')
|
|
345
|
+
expect(baseCall).toBeTruthy()
|
|
346
|
+
const wheres: any[] = baseCall._ops.wheres
|
|
347
|
+
const hasTenantGuard = wheres.some(
|
|
348
|
+
(w: any) => Array.isArray(w) && w[0] === 'scheduled_jobs.tenant_id' && w[1] === '=' && w[2] === 't1',
|
|
349
|
+
)
|
|
350
|
+
expect(hasTenantGuard).toBe(true)
|
|
225
351
|
})
|
|
226
352
|
})
|
|
227
353
|
|
|
228
354
|
describe('BasicQueryEngine — multi-field $or grouping', () => {
|
|
229
355
|
test('AND within each $or clause, OR between clauses', async () => {
|
|
230
|
-
const
|
|
356
|
+
const fakeDb = createFakeKysely({
|
|
231
357
|
scheduled_jobs: [],
|
|
232
358
|
'information_schema.columns': [
|
|
233
359
|
{ table_name: 'scheduled_jobs', column_name: 'organization_id' },
|
|
@@ -235,7 +361,7 @@ describe('BasicQueryEngine — multi-field $or grouping', () => {
|
|
|
235
361
|
{ table_name: 'scheduled_jobs', column_name: 'scope_type' },
|
|
236
362
|
],
|
|
237
363
|
})
|
|
238
|
-
const engine = new BasicQueryEngine({} as any, () =>
|
|
364
|
+
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
239
365
|
await engine.query('scheduler:scheduled_job', {
|
|
240
366
|
tenantId: 't1',
|
|
241
367
|
fields: ['id'],
|
|
@@ -247,32 +373,45 @@ describe('BasicQueryEngine — multi-field $or grouping', () => {
|
|
|
247
373
|
],
|
|
248
374
|
},
|
|
249
375
|
})
|
|
250
|
-
const baseCall =
|
|
376
|
+
const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'scheduled_jobs')
|
|
251
377
|
expect(baseCall).toBeTruthy()
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
expect(
|
|
258
|
-
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
378
|
+
|
|
379
|
+
const orEntry = baseCall._ops.wheres.find((w: any) => Array.isArray(w) && w[0] === 'or')
|
|
380
|
+
expect(orEntry).toBeTruthy()
|
|
381
|
+
const orParts: Node[] = orEntry[1]
|
|
382
|
+
expect(Array.isArray(orParts)).toBe(true)
|
|
383
|
+
expect(orParts.length).toBe(2)
|
|
384
|
+
|
|
385
|
+
const groupOne = orParts[0] as And
|
|
386
|
+
expect(groupOne.kind).toBe('and')
|
|
387
|
+
const groupOneCmps = flattenCmps(groupOne)
|
|
388
|
+
const groupOneCols = groupOneCmps
|
|
389
|
+
.map((c) => String(c.column))
|
|
390
|
+
.filter((col) => col.endsWith('organization_id') || col.endsWith('tenant_id'))
|
|
391
|
+
expect(groupOneCols.length).toBeGreaterThanOrEqual(2)
|
|
392
|
+
const groupOneHasOrgEq = groupOneCmps.some(
|
|
393
|
+
(c) => String(c.column).endsWith('organization_id') && c.op === '=' && c.value === 'org-1',
|
|
394
|
+
)
|
|
395
|
+
const groupOneHasTenantEq = groupOneCmps.some(
|
|
396
|
+
(c) => String(c.column).endsWith('tenant_id') && c.op === '=' && c.value === 't1',
|
|
397
|
+
)
|
|
398
|
+
expect(groupOneHasOrgEq).toBe(true)
|
|
399
|
+
expect(groupOneHasTenantEq).toBe(true)
|
|
400
|
+
|
|
401
|
+
const groupTwo = orParts[1] as And
|
|
402
|
+
expect(groupTwo.kind).toBe('and')
|
|
403
|
+
const groupTwoCmps = flattenCmps(groupTwo)
|
|
404
|
+
const hasSystemScope = groupTwoCmps.some(
|
|
405
|
+
(c) => String(c.column).endsWith('scope_type') && c.op === '=' && c.value === 'system',
|
|
267
406
|
)
|
|
268
|
-
const
|
|
269
|
-
(
|
|
407
|
+
const hasNullOrg = groupTwoCmps.some(
|
|
408
|
+
(c) => String(c.column).endsWith('organization_id') && c.op === 'is' && c.value === null,
|
|
270
409
|
)
|
|
271
|
-
const
|
|
272
|
-
(
|
|
410
|
+
const hasNullTenant = groupTwoCmps.some(
|
|
411
|
+
(c) => String(c.column).endsWith('tenant_id') && c.op === 'is' && c.value === null,
|
|
273
412
|
)
|
|
274
|
-
expect(
|
|
275
|
-
expect(
|
|
276
|
-
expect(
|
|
413
|
+
expect(hasSystemScope).toBe(true)
|
|
414
|
+
expect(hasNullOrg).toBe(true)
|
|
415
|
+
expect(hasNullTenant).toBe(true)
|
|
277
416
|
})
|
|
278
417
|
})
|