@open-mercato/shared 0.6.8-develop.7030.1.b84302d973 → 0.6.8-develop.7031.1.005201cd70
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/.turbo/turbo-build.log +1 -1
- package/dist/lib/crud/factory.js +1 -0
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/openapi/crud.js +4 -1
- package/dist/lib/openapi/crud.js.map +2 -2
- package/dist/lib/query/count-cap.js +11 -0
- package/dist/lib/query/count-cap.js.map +7 -0
- package/dist/lib/query/engine.js +175 -25
- package/dist/lib/query/engine.js.map +3 -3
- package/dist/lib/query/types.js.map +1 -1
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/crud/__tests__/crud-factory.test.ts +22 -0
- package/src/lib/crud/factory.ts +1 -0
- package/src/lib/openapi/crud.ts +3 -0
- package/src/lib/query/__tests__/count-cap-plan.test.ts +240 -0
- package/src/lib/query/__tests__/count-cap.test.ts +41 -0
- package/src/lib/query/__tests__/engine.count-distinct.test.ts +154 -14
- package/src/lib/query/__tests__/engine.test.ts +131 -7
- package/src/lib/query/count-cap.ts +19 -0
- package/src/lib/query/engine.ts +271 -44
- package/src/lib/query/types.ts +15 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/query/types.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityId } from '@open-mercato/shared/modules/entities'\nimport type { Profiler } from '../profiler'\nimport type { ResolvedCustomFieldDefinitions } from '../crud/custom-field-definition-index'\n\nexport type FilterOp = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'nin' | 'like' | 'ilike' | 'exists'\n\nexport enum SortDir {\n Asc = 'asc',\n Desc = 'desc',\n}\n\nexport type FieldSelector = string // base field or custom field key (prefixed with 'cf:')\n\nexport type Filter = {\n field: FieldSelector\n op: FilterOp\n value?: any\n}\n\nexport type Sort = { field: FieldSelector; dir?: SortDir }\n\nexport type Page = { page?: number; pageSize?: number }\n\n// Mongo/Medusa-style filter operators (typed)\nexport type WhereOps<T> = {\n $eq?: T\n $ne?: T | null\n $gt?: T extends number | Date ? T : never\n $gte?: T extends number | Date ? T : never\n $lt?: T extends number | Date ? T : never\n $lte?: T extends number | Date ? T : never\n $in?: T[]\n $nin?: T[]\n $like?: T extends string ? string : never\n $ilike?: T extends string ? string : never\n $exists?: boolean\n}\n\n// A field filter can be a direct value (equals) or ops object\nexport type WhereValue<T = any> = T | WhereOps<T>\n\n// Generic shape for object filters. If you have a typed map of field\u2192type,\n// pass it as the generic to get end-to-end typing.\n// Example: Where<{\n// id: string; title: string; created_at: Date; 'cf:severity': number\n// }>\nexport type Where<Fields extends Record<string, any> = Record<string, any>> =\n Partial<{ [K in keyof Fields]: WhereValue<Fields[K]> }> & Record<string, WhereValue>\n\nexport type QueryCustomFieldJoin = {\n fromField: string\n toField: string\n type?: 'left' | 'inner'\n}\n\nexport type QueryCustomFieldSource = {\n entityId: EntityId\n table?: string\n alias?: string\n recordIdColumn?: string\n join?: QueryCustomFieldJoin\n tenantField?: string\n organizationField?: string\n}\n\nexport type QueryJoinEdge = {\n alias: string\n table?: string\n entityId?: EntityId\n from: {\n alias?: string\n field: string\n }\n to: {\n field: string\n }\n type?: 'left' | 'inner'\n}\n\n/**\n * Optional context for query-level UMES extensions.\n * When provided, the query engine will execute sync lifecycle events\n * (querying/queried) and apply query-enabled enrichers.\n */\nexport type QueryExtensionsConfig = {\n userId?: string\n container?: unknown\n userFeatures?: string[]\n resolve?: <T = unknown>(name: string) => T\n}\n\nexport type QueryOptions = {\n fields?: FieldSelector[] // base fields and/or 'cf:<key>' for custom fields\n includeExtensions?: boolean | string[] // include all registered extensions or only specific ones by entity id\n includeCustomFields?: boolean | string[] // include all CFs or specific keys\n // Accept classic array syntax or Mongo-style object syntax\n filters?: Filter[] | Where\n sort?: Sort[]\n page?: Page\n organizationId?: string // enforce multi-tenant scope\n tenantId?: string // enforce tenant scope\n // Optional list of organization ids to scope results. Takes precedence over organizationId.\n organizationIds?: string[]\n /**\n * When true, the engine does not apply default `organization_id` / `tenant_id` equality guards.\n *\n * Callers MUST encode full visibility in `filters` (for example with `$or` of scoped branches)\n * and MUST fail closed when the authenticated principal lacks a resolvable tenant/org, otherwise\n * queries return cross-tenant rows.\n *\n * When this flag is set, the hybrid query engine delegates to the basic engine. The basic engine\n * still applies `cf:*` filters/sorts, but `search_tokens` fulltext filtering, the JSONB index read\n * path, and the vector-search branch are BYPASSED. Only use this on entities whose scoping does\n * not match the standard `organization_id = X AND tenant_id = Y` shape.\n */\n omitAutomaticTenantOrgScope?: boolean\n // Soft-delete behavior: when false (default), rows with non-null deleted_at\n // are excluded if the base table has that column. Set true to include them.\n withDeleted?: boolean\n customFieldSources?: QueryCustomFieldSource[]\n joins?: QueryJoinEdge[]\n profiler?: Profiler\n // When true, suppress automatic reindex scheduling triggered by coverage gap detection.\n // Used by the search indexing pipeline to prevent feedback loops where indexing triggers\n // re-indexing indefinitely.\n skipAutoReindex?: boolean\n /**\n * Force routing this query to custom-entity doc storage (`custom_entities_storage`)\n * instead of classifying the entity automatically. Automatic classification routes\n * ids backed by a registered ORM table to that base table, so surfaces that manage\n * doc records for ids that are ALSO table-backed (e.g. the entities records browser\n * reading a module-declared custom entity such as `example:todo`) must set this flag.\n * Honored by the hybrid query engine only; `BasicQueryEngine` has no doc-storage\n * reader and ignores it.\n */\n forceCustomEntityStorage?: boolean\n // Optional UMES query extensions context. When provided, the engine will\n // emit sync lifecycle events and apply query-level enrichers.\n extensions?: QueryExtensionsConfig\n}\n\nexport type PartialIndexWarning = {\n entity: EntityId\n entityLabel?: string | null\n baseCount?: number | null\n indexedCount?: number | null\n scope?: 'scoped' | 'global'\n}\n\nexport type EncryptedSortRowCapWarning = {\n entity: EntityId\n sortFields: string[]\n maxRows: number\n totalMatched: number\n}\n\nexport type QueryResultMeta = {\n partialIndexWarning?: PartialIndexWarning\n encryptedSortRowCapWarning?: EncryptedSortRowCapWarning\n}\n\nexport type QueryResult<T = any> = {\n items: T[]\n page: number\n pageSize: number\n total: number\n meta?: QueryResultMeta\n /**\n * Custom-field definitions the engine resolved while building this result\n * (only present when `includeCustomFields: true`). Lets the CRUD factory\n * decorate list rows without reloading definitions from the DB (issue #2133).\n * Internal contract \u2014 additive and optional; callers must treat absence as a\n * cue to load definitions themselves.\n */\n customFieldDefinitions?: ResolvedCustomFieldDefinitions\n}\n\nexport interface QueryEngine {\n query<T = any>(entity: EntityId, opts?: QueryOptions): Promise<QueryResult<T>>\n}\n"],
|
|
4
|
+
"sourcesContent": ["import type { EntityId } from '@open-mercato/shared/modules/entities'\nimport type { Profiler } from '../profiler'\nimport type { ResolvedCustomFieldDefinitions } from '../crud/custom-field-definition-index'\n\nexport type FilterOp = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'nin' | 'like' | 'ilike' | 'exists'\n\nexport enum SortDir {\n Asc = 'asc',\n Desc = 'desc',\n}\n\nexport type FieldSelector = string // base field or custom field key (prefixed with 'cf:')\n\nexport type Filter = {\n field: FieldSelector\n op: FilterOp\n value?: any\n}\n\nexport type Sort = { field: FieldSelector; dir?: SortDir }\n\nexport type Page = { page?: number; pageSize?: number }\n\n// Mongo/Medusa-style filter operators (typed)\nexport type WhereOps<T> = {\n $eq?: T\n $ne?: T | null\n $gt?: T extends number | Date ? T : never\n $gte?: T extends number | Date ? T : never\n $lt?: T extends number | Date ? T : never\n $lte?: T extends number | Date ? T : never\n $in?: T[]\n $nin?: T[]\n $like?: T extends string ? string : never\n $ilike?: T extends string ? string : never\n $exists?: boolean\n}\n\n// A field filter can be a direct value (equals) or ops object\nexport type WhereValue<T = any> = T | WhereOps<T>\n\n// Generic shape for object filters. If you have a typed map of field\u2192type,\n// pass it as the generic to get end-to-end typing.\n// Example: Where<{\n// id: string; title: string; created_at: Date; 'cf:severity': number\n// }>\nexport type Where<Fields extends Record<string, any> = Record<string, any>> =\n Partial<{ [K in keyof Fields]: WhereValue<Fields[K]> }> & Record<string, WhereValue>\n\nexport type QueryCustomFieldJoin = {\n fromField: string\n toField: string\n type?: 'left' | 'inner'\n}\n\nexport type QueryCustomFieldSource = {\n entityId: EntityId\n table?: string\n alias?: string\n recordIdColumn?: string\n join?: QueryCustomFieldJoin\n tenantField?: string\n organizationField?: string\n}\n\nexport type QueryJoinEdge = {\n alias: string\n table?: string\n entityId?: EntityId\n from: {\n alias?: string\n field: string\n }\n to: {\n field: string\n }\n type?: 'left' | 'inner'\n}\n\n/**\n * Optional context for query-level UMES extensions.\n * When provided, the query engine will execute sync lifecycle events\n * (querying/queried) and apply query-enabled enrichers.\n */\nexport type QueryExtensionsConfig = {\n userId?: string\n container?: unknown\n userFeatures?: string[]\n resolve?: <T = unknown>(name: string) => T\n}\n\nexport type QueryOptions = {\n fields?: FieldSelector[] // base fields and/or 'cf:<key>' for custom fields\n includeExtensions?: boolean | string[] // include all registered extensions or only specific ones by entity id\n includeCustomFields?: boolean | string[] // include all CFs or specific keys\n // Accept classic array syntax or Mongo-style object syntax\n filters?: Filter[] | Where\n sort?: Sort[]\n page?: Page\n organizationId?: string // enforce multi-tenant scope\n tenantId?: string // enforce tenant scope\n // Optional list of organization ids to scope results. Takes precedence over organizationId.\n organizationIds?: string[]\n /**\n * When true, the engine does not apply default `organization_id` / `tenant_id` equality guards.\n *\n * Callers MUST encode full visibility in `filters` (for example with `$or` of scoped branches)\n * and MUST fail closed when the authenticated principal lacks a resolvable tenant/org, otherwise\n * queries return cross-tenant rows.\n *\n * When this flag is set, the hybrid query engine delegates to the basic engine. The basic engine\n * still applies `cf:*` filters/sorts, but `search_tokens` fulltext filtering, the JSONB index read\n * path, and the vector-search branch are BYPASSED. Only use this on entities whose scoping does\n * not match the standard `organization_id = X AND tenant_id = Y` shape.\n */\n omitAutomaticTenantOrgScope?: boolean\n // Soft-delete behavior: when false (default), rows with non-null deleted_at\n // are excluded if the base table has that column. Set true to include them.\n withDeleted?: boolean\n customFieldSources?: QueryCustomFieldSource[]\n joins?: QueryJoinEdge[]\n profiler?: Profiler\n // When true, suppress automatic reindex scheduling triggered by coverage gap detection.\n // Used by the search indexing pipeline to prevent feedback loops where indexing triggers\n // re-indexing indefinitely.\n skipAutoReindex?: boolean\n /**\n * Force routing this query to custom-entity doc storage (`custom_entities_storage`)\n * instead of classifying the entity automatically. Automatic classification routes\n * ids backed by a registered ORM table to that base table, so surfaces that manage\n * doc records for ids that are ALSO table-backed (e.g. the entities records browser\n * reading a module-declared custom entity such as `example:todo`) must set this flag.\n * Honored by the hybrid query engine only; `BasicQueryEngine` has no doc-storage\n * reader and ignores it.\n */\n forceCustomEntityStorage?: boolean\n // Optional UMES query extensions context. When provided, the engine will\n // emit sync lifecycle events and apply query-level enrichers.\n extensions?: QueryExtensionsConfig\n}\n\nexport type PartialIndexWarning = {\n entity: EntityId\n entityLabel?: string | null\n baseCount?: number | null\n indexedCount?: number | null\n scope?: 'scoped' | 'global'\n}\n\nexport type EncryptedSortRowCapWarning = {\n entity: EntityId\n sortFields: string[]\n maxRows: number\n /**\n * Exact when `meta.listCountCapWarning` is absent; a floor when it is\n * present (the list total itself was bounded at the cap).\n */\n totalMatched: number\n}\n\n/**\n * Present when the list COUNT was bounded at `cap` matching rows\n * (`OM_LIST_COUNT_CAP`): `total` is a floor, not an exact value. Surfaced on\n * CRUD list payloads as `totalIsCapped: true`.\n */\nexport type ListCountCapWarning = {\n entity: EntityId\n cap: number\n}\n\nexport type QueryResultMeta = {\n partialIndexWarning?: PartialIndexWarning\n encryptedSortRowCapWarning?: EncryptedSortRowCapWarning\n listCountCapWarning?: ListCountCapWarning\n}\n\nexport type QueryResult<T = any> = {\n items: T[]\n page: number\n pageSize: number\n total: number\n meta?: QueryResultMeta\n /**\n * Custom-field definitions the engine resolved while building this result\n * (only present when `includeCustomFields: true`). Lets the CRUD factory\n * decorate list rows without reloading definitions from the DB (issue #2133).\n * Internal contract \u2014 additive and optional; callers must treat absence as a\n * cue to load definitions themselves.\n */\n customFieldDefinitions?: ResolvedCustomFieldDefinitions\n}\n\nexport interface QueryEngine {\n query<T = any>(entity: EntityId, opts?: QueryOptions): Promise<QueryResult<T>>\n}\n"],
|
|
5
5
|
"mappings": "AAMO,IAAK,UAAL,kBAAKA,aAAL;AACL,EAAAA,SAAA,SAAM;AACN,EAAAA,SAAA,UAAO;AAFG,SAAAA;AAAA,GAAA;",
|
|
6
6
|
"names": ["SortDir"]
|
|
7
7
|
}
|
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/version.ts"],
|
|
4
|
-
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.8-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.8-develop.7031.1.005201cd70';\nexport const appVersion = APP_VERSION;\n"],
|
|
5
5
|
"mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/shared",
|
|
3
|
-
"version": "0.6.8-develop.
|
|
3
|
+
"version": "0.6.8-develop.7031.1.005201cd70",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -109,7 +109,7 @@
|
|
|
109
109
|
"@mikro-orm/core": "^7.1.8",
|
|
110
110
|
"@mikro-orm/decorators": "^7.1.8",
|
|
111
111
|
"@mikro-orm/postgresql": "^7.1.8",
|
|
112
|
-
"@open-mercato/cache": "0.6.8-develop.
|
|
112
|
+
"@open-mercato/cache": "0.6.8-develop.7031.1.005201cd70",
|
|
113
113
|
"@types/html-to-text": "^9.0.4",
|
|
114
114
|
"@types/sanitize-html": "^2.16.1",
|
|
115
115
|
"dotenv": "^17.4.2",
|
|
@@ -268,6 +268,28 @@ describe('CRUD Factory', () => {
|
|
|
268
268
|
}))
|
|
269
269
|
})
|
|
270
270
|
|
|
271
|
+
it('GET spreads totalIsCapped only when the engine reports a capped count', async () => {
|
|
272
|
+
queryEngine.query.mockResolvedValueOnce({
|
|
273
|
+
items: [{ id: 'id-1', title: 'A', is_done: false }],
|
|
274
|
+
total: 10_000,
|
|
275
|
+
page: 1,
|
|
276
|
+
pageSize: 10,
|
|
277
|
+
meta: { listCountCapWarning: { entity: 'example.todo', cap: 10_000 } },
|
|
278
|
+
})
|
|
279
|
+
const res = await route.GET(new Request('http://x/api/example/todos?page=1&pageSize=10&sortField=id&sortDir=asc'))
|
|
280
|
+
expect(res.status).toBe(200)
|
|
281
|
+
const body = await res.json()
|
|
282
|
+
expect(body.total).toBe(10_000)
|
|
283
|
+
expect(body.totalIsCapped).toBe(true)
|
|
284
|
+
expect(body.meta.listCountCapWarning).toEqual({ entity: 'example.todo', cap: 10_000 })
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('GET omits totalIsCapped entirely for exact totals', async () => {
|
|
288
|
+
const res = await route.GET(new Request('http://x/api/example/todos?page=1&pageSize=10&sortField=id&sortDir=asc'))
|
|
289
|
+
const body = await res.json()
|
|
290
|
+
expect('totalIsCapped' in body).toBe(false)
|
|
291
|
+
})
|
|
292
|
+
|
|
271
293
|
const makeDecoratedRoute = () => makeCrudRoute({
|
|
272
294
|
metadata: { GET: { requireAuth: true } },
|
|
273
295
|
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
package/src/lib/crud/factory.ts
CHANGED
|
@@ -1941,6 +1941,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
1941
1941
|
page: page.page || requestedPage,
|
|
1942
1942
|
pageSize: page.pageSize || requestedPageSize,
|
|
1943
1943
|
totalPages: Math.ceil(res.total / (Number(page.pageSize) || 1)),
|
|
1944
|
+
...(res.meta?.listCountCapWarning ? { totalIsCapped: true } : {}),
|
|
1944
1945
|
...(res.meta ? { meta: res.meta } : {}),
|
|
1945
1946
|
}
|
|
1946
1947
|
await opts.hooks?.afterList?.(payload, { ...ctx, query: validated as any })
|
package/src/lib/openapi/crud.ts
CHANGED
|
@@ -17,6 +17,9 @@ export function createPagedListResponseSchema(itemSchema: ZodTypeAny, options: P
|
|
|
17
17
|
page: paginationMetaOptional ? z.number().optional() : z.number(),
|
|
18
18
|
pageSize: paginationMetaOptional ? z.number().optional() : z.number(),
|
|
19
19
|
totalPages: z.number(),
|
|
20
|
+
// Present (true) only when the list count was bounded at OM_LIST_COUNT_CAP:
|
|
21
|
+
// `total` is then a floor, not an exact value.
|
|
22
|
+
totalIsCapped: z.boolean().optional(),
|
|
20
23
|
})
|
|
21
24
|
}
|
|
22
25
|
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan-level guard for the capped list COUNT (#4552 Phase 2), run against a real
|
|
3
|
+
* PostgreSQL. This is the only test that fails if a future refactor reintroduces
|
|
4
|
+
* a blocking node (Aggregate/Sort/Unique) between the probe `Limit` and the base
|
|
5
|
+
* scan — a mocked total cannot catch a bound that does not bind. It doubles as
|
|
6
|
+
* the count-parity check: with the cap disabled, the rebuilt count must equal
|
|
7
|
+
* ground truth across the cf-filter / or-group / plain matrices.
|
|
8
|
+
*
|
|
9
|
+
* Gated on OM_COUNT_CAP_PG_URL (a throwaway database — the suite creates and
|
|
10
|
+
* drops its own tables). Example:
|
|
11
|
+
* docker run --rm -d -p 54329:5432 -e POSTGRES_PASSWORD=t postgres:16
|
|
12
|
+
* OM_COUNT_CAP_PG_URL=postgres://postgres:t@localhost:54329/postgres yarn jest count-cap-plan
|
|
13
|
+
*/
|
|
14
|
+
import { Kysely, PostgresDialect, sql } from 'kysely'
|
|
15
|
+
import { BasicQueryEngine } from '../engine'
|
|
16
|
+
|
|
17
|
+
const PG_URL = process.env.OM_COUNT_CAP_PG_URL
|
|
18
|
+
const maybe = PG_URL ? describe : describe.skip
|
|
19
|
+
|
|
20
|
+
const TENANT = '11111111-1111-4111-8111-111111111111'
|
|
21
|
+
const ORG = '22222222-2222-4222-8222-222222222222'
|
|
22
|
+
const ENTITY = 'capcheck:om_capcheck_order'
|
|
23
|
+
const TABLE = 'om_capcheck_orders'
|
|
24
|
+
const ROWS = 120
|
|
25
|
+
const RED_ROWS = 30
|
|
26
|
+
|
|
27
|
+
maybe('capped list COUNT against PostgreSQL', () => {
|
|
28
|
+
jest.setTimeout(60_000)
|
|
29
|
+
let db: Kysely<any>
|
|
30
|
+
let sqlLog: string[] = []
|
|
31
|
+
let paramLog: unknown[][] = []
|
|
32
|
+
const originalCap = process.env.OM_LIST_COUNT_CAP
|
|
33
|
+
|
|
34
|
+
beforeAll(async () => {
|
|
35
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
36
|
+
const { Pool } = require('pg')
|
|
37
|
+
db = new Kysely<any>({
|
|
38
|
+
dialect: new PostgresDialect({ pool: new Pool({ connectionString: PG_URL }) }),
|
|
39
|
+
log(event) {
|
|
40
|
+
if (event.level === 'query' || event.level === 'error') {
|
|
41
|
+
sqlLog.push(event.query.sql)
|
|
42
|
+
paramLog.push([...event.query.parameters])
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
await sql`drop table if exists om_capcheck_orders`.execute(db)
|
|
47
|
+
await sql`create table om_capcheck_orders (
|
|
48
|
+
id uuid primary key,
|
|
49
|
+
tenant_id uuid not null,
|
|
50
|
+
organization_id uuid not null,
|
|
51
|
+
deleted_at timestamptz,
|
|
52
|
+
status text not null
|
|
53
|
+
)`.execute(db)
|
|
54
|
+
await sql`create table if not exists custom_field_defs (
|
|
55
|
+
id serial primary key,
|
|
56
|
+
key text not null,
|
|
57
|
+
entity_id text not null,
|
|
58
|
+
kind text,
|
|
59
|
+
config_json jsonb,
|
|
60
|
+
is_active boolean not null default true,
|
|
61
|
+
organization_id uuid,
|
|
62
|
+
tenant_id uuid,
|
|
63
|
+
updated_at timestamptz,
|
|
64
|
+
deleted_at timestamptz
|
|
65
|
+
)`.execute(db)
|
|
66
|
+
await sql`create table if not exists custom_field_values (
|
|
67
|
+
id serial primary key,
|
|
68
|
+
entity_id text not null,
|
|
69
|
+
field_key text not null,
|
|
70
|
+
record_id text not null,
|
|
71
|
+
value_text text,
|
|
72
|
+
value_multiline text,
|
|
73
|
+
value_int int,
|
|
74
|
+
value_float float,
|
|
75
|
+
value_bool boolean,
|
|
76
|
+
organization_id uuid,
|
|
77
|
+
tenant_id uuid
|
|
78
|
+
)`.execute(db)
|
|
79
|
+
await sql`delete from custom_field_defs where entity_id = ${ENTITY}`.execute(db)
|
|
80
|
+
await sql`delete from custom_field_values where entity_id = ${ENTITY}`.execute(db)
|
|
81
|
+
await sql`insert into custom_field_defs (key, entity_id, kind, is_active, tenant_id)
|
|
82
|
+
values ('color', ${ENTITY}, 'text', true, null)`.execute(db)
|
|
83
|
+
for (let i = 0; i < ROWS; i++) {
|
|
84
|
+
const id = `33333333-3333-4333-8333-${String(i).padStart(12, '0')}`
|
|
85
|
+
await sql`insert into om_capcheck_orders (id, tenant_id, organization_id, deleted_at, status)
|
|
86
|
+
values (${id}::uuid, ${TENANT}::uuid, ${ORG}::uuid, null, ${i % 2 === 0 ? 'open' : 'closed'})`.execute(db)
|
|
87
|
+
if (i < RED_ROWS) {
|
|
88
|
+
await sql`insert into custom_field_values (entity_id, field_key, record_id, value_text, tenant_id)
|
|
89
|
+
values (${ENTITY}, 'color', ${id}, 'red', ${TENANT}::uuid)`.execute(db)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
afterAll(async () => {
|
|
95
|
+
await sql`drop table if exists om_capcheck_orders`.execute(db)
|
|
96
|
+
await sql`delete from custom_field_defs where entity_id = ${ENTITY}`.execute(db)
|
|
97
|
+
await sql`delete from custom_field_values where entity_id = ${ENTITY}`.execute(db)
|
|
98
|
+
await db.destroy()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
afterEach(() => {
|
|
102
|
+
if (originalCap === undefined) delete process.env.OM_LIST_COUNT_CAP
|
|
103
|
+
else process.env.OM_LIST_COUNT_CAP = originalCap
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const makeEngine = () => new BasicQueryEngine({} as any, () => db as any)
|
|
107
|
+
const baseOpts = {
|
|
108
|
+
tenantId: TENANT,
|
|
109
|
+
organizationId: ORG,
|
|
110
|
+
fields: ['id'],
|
|
111
|
+
page: { page: 1, pageSize: 10 },
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function lastCountSql(): { text: string; params: unknown[] } {
|
|
115
|
+
for (let i = sqlLog.length - 1; i >= 0; i--) {
|
|
116
|
+
if (/count\(\*\)/i.test(sqlLog[i])) return { text: sqlLog[i], params: paramLog[i] }
|
|
117
|
+
}
|
|
118
|
+
throw new Error('no count SQL captured')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function collectNodes(node: any, out: any[] = []): any[] {
|
|
122
|
+
if (!node) return out
|
|
123
|
+
out.push(node)
|
|
124
|
+
for (const child of node.Plans ?? []) collectNodes(child, out)
|
|
125
|
+
return out
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
test('probe plan: no blocking node between the Limit and the base scan (cf-filtered)', async () => {
|
|
129
|
+
process.env.OM_LIST_COUNT_CAP = '50'
|
|
130
|
+
sqlLog = []; paramLog = []
|
|
131
|
+
const result = await makeEngine().query(ENTITY, {
|
|
132
|
+
...baseOpts,
|
|
133
|
+
filters: { status: { $eq: 'open' } },
|
|
134
|
+
})
|
|
135
|
+
expect(result.total).toBe(50)
|
|
136
|
+
expect(result.meta?.listCountCapWarning).toEqual({ entity: ENTITY, cap: 50 })
|
|
137
|
+
|
|
138
|
+
const { text, params } = lastCountSql()
|
|
139
|
+
expect(text.toLowerCase()).toContain('limit')
|
|
140
|
+
const explained = await sql
|
|
141
|
+
.raw(`explain (format json) ${text.replace(/\$(\d+)/g, (_, n) => {
|
|
142
|
+
const value = params[Number(n) - 1]
|
|
143
|
+
return typeof value === 'number' ? String(value) : `'${String(value).replace(/'/g, "''")}'`
|
|
144
|
+
})}`)
|
|
145
|
+
.execute(db)
|
|
146
|
+
const plan = (explained.rows[0] as any)['QUERY PLAN'][0].Plan
|
|
147
|
+
const all = collectNodes(plan)
|
|
148
|
+
const limitNode = all.find((n) => n['Node Type'] === 'Limit')
|
|
149
|
+
expect(limitNode).toBeTruthy()
|
|
150
|
+
// Below the Limit: only row-producing nodes down to the scan. An Aggregate,
|
|
151
|
+
// Sort, or Unique here means the bound does not bind.
|
|
152
|
+
const belowLimit = collectNodes(limitNode).slice(1)
|
|
153
|
+
const blocking = belowLimit.filter((n) => /Aggregate|Sort|Unique/.test(String(n['Node Type'])))
|
|
154
|
+
expect(blocking).toEqual([])
|
|
155
|
+
// The base scan is inside the Limit subtree.
|
|
156
|
+
expect(belowLimit.some((n) => n['Relation Name'] === TABLE)).toBe(true)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('cf filter compiles to EXISTS and stays cappable', async () => {
|
|
160
|
+
process.env.OM_LIST_COUNT_CAP = '10'
|
|
161
|
+
sqlLog = []; paramLog = []
|
|
162
|
+
const result = await makeEngine().query(ENTITY, {
|
|
163
|
+
...baseOpts,
|
|
164
|
+
filters: { cf_color: { $eq: 'red' } },
|
|
165
|
+
})
|
|
166
|
+
expect(result.total).toBe(10)
|
|
167
|
+
expect(result.meta?.listCountCapWarning).toEqual({ entity: ENTITY, cap: 10 })
|
|
168
|
+
const { text } = lastCountSql()
|
|
169
|
+
expect(text.toLowerCase()).toContain('exists')
|
|
170
|
+
expect(text.toLowerCase()).not.toContain('group by')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('parity with the cap disabled: rebuilt count matches the display query across the filter matrix', async () => {
|
|
174
|
+
process.env.OM_LIST_COUNT_CAP = '0'
|
|
175
|
+
const engine = makeEngine()
|
|
176
|
+
const wide = { page: { page: 1, pageSize: 1000 } }
|
|
177
|
+
|
|
178
|
+
// Each case asserts the rebuilt count against ground truth where the filter
|
|
179
|
+
// semantics are well-defined, and always against the display query's own
|
|
180
|
+
// row set — the regression guard for "count rebuild changing a total".
|
|
181
|
+
const expectParity = async (filters: any, expectedTotal?: number) => {
|
|
182
|
+
const result = await engine.query(ENTITY, { ...baseOpts, ...wide, filters })
|
|
183
|
+
expect(result.total).toBe(result.items.length)
|
|
184
|
+
if (expectedTotal !== undefined) expect(result.total).toBe(expectedTotal)
|
|
185
|
+
expect(result.meta?.listCountCapWarning).toBeUndefined()
|
|
186
|
+
return result
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
await expectParity(undefined, ROWS)
|
|
190
|
+
await expectParity({ status: { $eq: 'open' } }, ROWS / 2)
|
|
191
|
+
await expectParity({ cf_color: { $eq: 'red' } }, RED_ROWS)
|
|
192
|
+
await expectParity({ $and: [{ status: { $eq: 'open' } }, { cf_color: { $eq: 'red' } }] }, RED_ROWS / 2)
|
|
193
|
+
// Base-column-only $or exercises the or-group path in the count shape.
|
|
194
|
+
await expectParity({ $or: [{ status: { $eq: 'open' } }, { status: { $eq: 'closed' } }] }, ROWS)
|
|
195
|
+
// Mixed base + cf disjunction (#5039): the count shape must compile the cf
|
|
196
|
+
// leaf to an EXISTS rather than dropping it — a dropped leaf narrows the OR
|
|
197
|
+
// and undercounts. closed (60) ∪ red (30, half of which are closed) = 75.
|
|
198
|
+
await expectParity({ $or: [{ status: { $eq: 'closed' } }, { cf_color: { $eq: 'red' } }] }, ROWS / 2 + RED_ROWS / 2)
|
|
199
|
+
// cf-only disjunction: red (30) ∪ blue (0) = 30.
|
|
200
|
+
await expectParity({ $or: [{ cf_color: { $eq: 'red' } }, { cf_color: { $eq: 'blue' } }] }, RED_ROWS)
|
|
201
|
+
await expectParity({ cf_color: { $eq: 'blue' } }, 0)
|
|
202
|
+
await expectParity({ cf_color: { $eq: null } }, ROWS - RED_ROWS)
|
|
203
|
+
await expectParity({ cf_color: { $ne: 'red' } }, 0)
|
|
204
|
+
await expectParity({ cf_color: { $in: ['red', 'blue'] } }, RED_ROWS)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('kill switch (cap=0): the cf-filtered count still plans set-oriented, not per-row', async () => {
|
|
208
|
+
// OM_LIST_COUNT_CAP=0 restores exact totals but runs them through the
|
|
209
|
+
// rebuilt shape. The documented escape hatch must not be slower than the
|
|
210
|
+
// problem: the uncapped EXISTS should plan as a semi-join or a hashed
|
|
211
|
+
// subplan, never an un-hashed per-row subplan re-executed for every row.
|
|
212
|
+
process.env.OM_LIST_COUNT_CAP = '0'
|
|
213
|
+
sqlLog = []; paramLog = []
|
|
214
|
+
const result = await makeEngine().query(ENTITY, {
|
|
215
|
+
...baseOpts,
|
|
216
|
+
filters: { cf_color: { $eq: 'red' } },
|
|
217
|
+
})
|
|
218
|
+
expect(result.total).toBe(RED_ROWS)
|
|
219
|
+
|
|
220
|
+
const { text, params } = lastCountSql()
|
|
221
|
+
const explained = await sql
|
|
222
|
+
.raw(`explain (format json) ${text.replace(/\$(\d+)/g, (_, n) => {
|
|
223
|
+
const value = params[Number(n) - 1]
|
|
224
|
+
return typeof value === 'number' ? String(value) : `'${String(value).replace(/'/g, "''")}'`
|
|
225
|
+
})}`)
|
|
226
|
+
.execute(db)
|
|
227
|
+
const plan = (explained.rows[0] as any)['QUERY PLAN'][0].Plan
|
|
228
|
+
const all = collectNodes(plan)
|
|
229
|
+
const semiJoin = all.some((n) => /Semi/.test(String(n['Join Type'] ?? '')))
|
|
230
|
+
const hashedSubplan = all.some((n) => /hashed/i.test(String(n['Subplan Name'] ?? '')))
|
|
231
|
+
expect(semiJoin || hashedSubplan).toBe(true)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
test('sub-cap totals stay exact with the cap active', async () => {
|
|
235
|
+
process.env.OM_LIST_COUNT_CAP = '1000'
|
|
236
|
+
const result = await makeEngine().query(ENTITY, { ...baseOpts, filters: { cf_color: { $eq: 'red' } } })
|
|
237
|
+
expect(result.total).toBe(RED_ROWS)
|
|
238
|
+
expect(result.meta?.listCountCapWarning).toBeUndefined()
|
|
239
|
+
})
|
|
240
|
+
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { resolveListCountCap, DEFAULT_LIST_COUNT_CAP } from '../count-cap'
|
|
2
|
+
|
|
3
|
+
describe('resolveListCountCap', () => {
|
|
4
|
+
const original = process.env.OM_LIST_COUNT_CAP
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
if (original === undefined) delete process.env.OM_LIST_COUNT_CAP
|
|
7
|
+
else process.env.OM_LIST_COUNT_CAP = original
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
test('unset: the default cap — the cap is on by default', () => {
|
|
11
|
+
delete process.env.OM_LIST_COUNT_CAP
|
|
12
|
+
expect(resolveListCountCap()).toBe(DEFAULT_LIST_COUNT_CAP)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('blank: the default cap', () => {
|
|
16
|
+
process.env.OM_LIST_COUNT_CAP = ' '
|
|
17
|
+
expect(resolveListCountCap()).toBe(DEFAULT_LIST_COUNT_CAP)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('0 disables capping', () => {
|
|
21
|
+
process.env.OM_LIST_COUNT_CAP = '0'
|
|
22
|
+
expect(resolveListCountCap()).toBeNull()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('negative values disable capping like 0', () => {
|
|
26
|
+
process.env.OM_LIST_COUNT_CAP = '-5'
|
|
27
|
+
expect(resolveListCountCap()).toBeNull()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('a positive integer is used as-is; floats floor', () => {
|
|
31
|
+
process.env.OM_LIST_COUNT_CAP = '500'
|
|
32
|
+
expect(resolveListCountCap()).toBe(500)
|
|
33
|
+
process.env.OM_LIST_COUNT_CAP = '500.9'
|
|
34
|
+
expect(resolveListCountCap()).toBe(500)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('unparseable input falls back to the default — bad input must not silently disable the cap', () => {
|
|
38
|
+
process.env.OM_LIST_COUNT_CAP = 'unbounded'
|
|
39
|
+
expect(resolveListCountCap()).toBe(DEFAULT_LIST_COUNT_CAP)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
@@ -139,6 +139,22 @@ function createFakeKysely(selectsSink: any[], overrides?: FakeData) {
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
function builderFor(tableArg: any): any {
|
|
142
|
+
if (tableArg && typeof tableArg === 'object' && tableArg._ops) {
|
|
143
|
+
// selectFrom(subquery.as(alias)) — the capped-count probe shape.
|
|
144
|
+
const ops = {
|
|
145
|
+
table: '__subquery__',
|
|
146
|
+
alias: tableArg._ops.alias ?? null,
|
|
147
|
+
subquery: tableArg._ops,
|
|
148
|
+
wheres: [] as any[],
|
|
149
|
+
joins: [] as any[],
|
|
150
|
+
selects: [] as any[],
|
|
151
|
+
orderBys: [] as any[],
|
|
152
|
+
groups: [] as any[],
|
|
153
|
+
limits: 0,
|
|
154
|
+
offsets: 0,
|
|
155
|
+
}
|
|
156
|
+
return makeBuilder(ops, true)
|
|
157
|
+
}
|
|
142
158
|
const parsed = parseTableSpec(tableArg)
|
|
143
159
|
const ops = {
|
|
144
160
|
table: parsed.table,
|
|
@@ -173,8 +189,28 @@ function findCountSql(selectsSink: any[]): string {
|
|
|
173
189
|
return rawSqlText(countExprs[countExprs.length - 1]).toLowerCase()
|
|
174
190
|
}
|
|
175
191
|
|
|
176
|
-
|
|
177
|
-
|
|
192
|
+
// The count query is rebuilt from scope + filters only (#4552 Phase 2): cf
|
|
193
|
+
// filters compile to correlated EXISTS semi-joins, projection joins are
|
|
194
|
+
// dropped, so the count never needs DISTINCT or GROUP BY — completing the
|
|
195
|
+
// direction #2227 started — and the LIMIT cap+1 probe actually bounds the scan.
|
|
196
|
+
describe('BasicQueryEngine — rebuilt list COUNT query (#4552 Phase 2)', () => {
|
|
197
|
+
const originalCap = process.env.OM_LIST_COUNT_CAP
|
|
198
|
+
afterEach(() => {
|
|
199
|
+
if (originalCap === undefined) delete process.env.OM_LIST_COUNT_CAP
|
|
200
|
+
else process.env.OM_LIST_COUNT_CAP = originalCap
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
// The count-shape builder is the base-table call carrying the probe LIMIT
|
|
204
|
+
// (cap + 1); with the cap disabled it is the base call whose recorded select
|
|
205
|
+
// is the bare count aggregate.
|
|
206
|
+
function findCountShapeCall(fakeDb: any, table: string) {
|
|
207
|
+
const baseCalls = fakeDb._calls.filter((b: any) => b._ops.table === table)
|
|
208
|
+
const probed = baseCalls.find((b: any) => b._ops.limits === 10_001)
|
|
209
|
+
if (probed) return probed
|
|
210
|
+
return baseCalls.find((b: any) => b._ops.selects.some((s: any) => aliasName(s) === 'count'))
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
test('uses count(*) with no DISTINCT and no GROUP BY when nothing joins', async () => {
|
|
178
214
|
const selects: any[] = []
|
|
179
215
|
const fakeDb = createFakeKysely(selects)
|
|
180
216
|
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
@@ -184,11 +220,11 @@ describe('BasicQueryEngine — list COUNT query (issue #2227)', () => {
|
|
|
184
220
|
expect(countSql).toContain('count(*)')
|
|
185
221
|
expect(countSql).not.toContain('distinct')
|
|
186
222
|
|
|
187
|
-
const
|
|
188
|
-
expect(
|
|
223
|
+
const countCall = findCountShapeCall(fakeDb, 'scheduled_jobs')
|
|
224
|
+
expect(countCall._ops.groups.length).toBe(0)
|
|
189
225
|
})
|
|
190
226
|
|
|
191
|
-
test('
|
|
227
|
+
test('drops extension projection joins from the count: count(*) over the bare base table', async () => {
|
|
192
228
|
const selects: any[] = []
|
|
193
229
|
const fakeDb = createFakeKysely(selects)
|
|
194
230
|
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
@@ -201,14 +237,20 @@ describe('BasicQueryEngine — list COUNT query (issue #2227)', () => {
|
|
|
201
237
|
})
|
|
202
238
|
|
|
203
239
|
const countSql = findCountSql(selects)
|
|
204
|
-
expect(countSql).toContain('count(
|
|
205
|
-
expect(countSql).not.toContain('
|
|
240
|
+
expect(countSql).toContain('count(*)')
|
|
241
|
+
expect(countSql).not.toContain('distinct')
|
|
206
242
|
|
|
207
|
-
|
|
208
|
-
|
|
243
|
+
// The display query keeps the extension join + GROUP BY …
|
|
244
|
+
const dataCall = fakeDb._calls.find((b: any) => b._ops.table === 'users' && b._ops.joins.length > 0)
|
|
245
|
+
expect(dataCall._ops.groups.length).toBeGreaterThan(0)
|
|
246
|
+
// … while the count shape carries neither: no join can multiply base rows,
|
|
247
|
+
// so no barrier sits between the probe LIMIT and the scan.
|
|
248
|
+
const countCall = findCountShapeCall(fakeDb, 'users')
|
|
249
|
+
expect(countCall._ops.joins.length).toBe(0)
|
|
250
|
+
expect(countCall._ops.groups.length).toBe(0)
|
|
209
251
|
})
|
|
210
252
|
|
|
211
|
-
test('
|
|
253
|
+
test('explicit relation joins never reach the count: count(*), join filters stay EXISTS', async () => {
|
|
212
254
|
const selects: any[] = []
|
|
213
255
|
const fakeDb = createFakeKysely(selects)
|
|
214
256
|
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
@@ -216,14 +258,112 @@ describe('BasicQueryEngine — list COUNT query (issue #2227)', () => {
|
|
|
216
258
|
tenantId: 't1',
|
|
217
259
|
fields: ['id'],
|
|
218
260
|
joins: [{ alias: 'owner', table: 'users', from: { field: 'owner_id' }, to: { field: 'id' } }],
|
|
261
|
+
filters: { 'owner.email': { $eq: 'a@b.c' } },
|
|
262
|
+
page: { page: 1, pageSize: 20 },
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
const countSql = findCountSql(selects)
|
|
266
|
+
expect(countSql).toContain('count(*)')
|
|
267
|
+
expect(countSql).not.toContain('distinct')
|
|
268
|
+
|
|
269
|
+
const countCall = findCountShapeCall(fakeDb, 'scheduled_jobs')
|
|
270
|
+
expect(countCall._ops.groups.length).toBe(0)
|
|
271
|
+
expect(countCall._ops.joins.length).toBe(0)
|
|
272
|
+
// The join filter is a semi-join on the count shape, not a join.
|
|
273
|
+
expect(countCall._ops.wheres.some((w: any) => Array.isArray(w) && w[0] === 'exists')).toBe(true)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
test('cf filters compile to EXISTS over custom_field_values on the count shape', async () => {
|
|
277
|
+
const selects: any[] = []
|
|
278
|
+
const fakeDb = createFakeKysely(selects, {
|
|
279
|
+
custom_field_defs: [
|
|
280
|
+
{ key: 'color', entity_id: 'scheduler:scheduled_job', is_active: true, tenant_id: null, kind: 'text', config_json: '{}' },
|
|
281
|
+
],
|
|
282
|
+
})
|
|
283
|
+
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
284
|
+
await engine.query('scheduler:scheduled_job', {
|
|
285
|
+
tenantId: 't1',
|
|
286
|
+
fields: ['id'],
|
|
287
|
+
filters: { cf_color: { $eq: 'red' } },
|
|
288
|
+
page: { page: 1, pageSize: 20 },
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
const countSql = findCountSql(selects)
|
|
292
|
+
expect(countSql).toContain('count(*)')
|
|
293
|
+
expect(countSql).not.toContain('distinct')
|
|
294
|
+
|
|
295
|
+
const countCall = findCountShapeCall(fakeDb, 'scheduled_jobs')
|
|
296
|
+
expect(countCall._ops.groups.length).toBe(0)
|
|
297
|
+
// No custom_field_values join on the count shape — the filter is an EXISTS
|
|
298
|
+
// whose subquery selects from custom_field_values.
|
|
299
|
+
expect(countCall._ops.joins.length).toBe(0)
|
|
300
|
+
const existsEntries = countCall._ops.wheres.filter((w: any) => Array.isArray(w) && w[0] === 'exists')
|
|
301
|
+
expect(existsEntries.length).toBeGreaterThan(0)
|
|
302
|
+
const existsSub = existsEntries[0][1]
|
|
303
|
+
expect(existsSub._ops.table).toBe('custom_field_values')
|
|
304
|
+
// The display query still resolves the same filter through its value join.
|
|
305
|
+
const dataCall = fakeDb._calls.find((b: any) =>
|
|
306
|
+
b._ops.table === 'scheduled_jobs' &&
|
|
307
|
+
b._ops.joins.some((j: any) => Object.values(j.aliasObj).includes('custom_field_values')))
|
|
308
|
+
expect(dataCall).toBeTruthy()
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
test('a cf leaf inside $or compiles to EXISTS on the count shape instead of being dropped (#5039)', async () => {
|
|
312
|
+
const selects: any[] = []
|
|
313
|
+
const fakeDb = createFakeKysely(selects, {
|
|
314
|
+
custom_field_defs: [
|
|
315
|
+
{ key: 'color', entity_id: 'scheduler:scheduled_job', is_active: true, tenant_id: null, kind: 'text', config_json: '{}' },
|
|
316
|
+
],
|
|
317
|
+
'information_schema.columns': [
|
|
318
|
+
{ table_name: 'scheduled_jobs', column_name: 'id' },
|
|
319
|
+
{ table_name: 'scheduled_jobs', column_name: 'tenant_id' },
|
|
320
|
+
{ table_name: 'scheduled_jobs', column_name: 'status' },
|
|
321
|
+
],
|
|
322
|
+
})
|
|
323
|
+
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
324
|
+
await engine.query('scheduler:scheduled_job', {
|
|
325
|
+
tenantId: 't1',
|
|
326
|
+
fields: ['id'],
|
|
327
|
+
filters: { $or: [{ status: { $eq: 'closed' } }, { cf_color: { $eq: 'red' } }] },
|
|
219
328
|
page: { page: 1, pageSize: 20 },
|
|
220
329
|
})
|
|
221
330
|
|
|
331
|
+
const countCall = findCountShapeCall(fakeDb, 'scheduled_jobs')
|
|
332
|
+
expect(countCall._ops.joins.length).toBe(0)
|
|
333
|
+
// The whole disjunction lands in one OR where-entry whose parts include an
|
|
334
|
+
// EXISTS (the cf leaf) — dropping it would narrow the OR and undercount.
|
|
335
|
+
const orEntries = countCall._ops.wheres.filter((w: any) => Array.isArray(w) && w[0] === 'or')
|
|
336
|
+
expect(orEntries.length).toBeGreaterThan(0)
|
|
337
|
+
const hasExistsPart = orEntries.some((entry: any) =>
|
|
338
|
+
(entry[1] ?? []).some((part: any) => part?.kind === 'exists' || (part?.kind === 'and' && part.parts?.some((p: any) => p?.kind === 'exists'))))
|
|
339
|
+
expect(hasExistsPart).toBe(true)
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
test('the probe LIMIT is cap + 1 on the row-producing inner query, counted by an outer aggregate', async () => {
|
|
343
|
+
process.env.OM_LIST_COUNT_CAP = '100'
|
|
344
|
+
const selects: any[] = []
|
|
345
|
+
const fakeDb = createFakeKysely(selects)
|
|
346
|
+
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
347
|
+
await engine.query('scheduler:scheduled_job', { tenantId: 't1', fields: ['id'], page: { page: 1, pageSize: 20 } })
|
|
348
|
+
|
|
349
|
+
const outer = fakeDb._calls.find((b: any) => b._ops.table === '__subquery__')
|
|
350
|
+
expect(outer).toBeTruthy()
|
|
351
|
+
expect(outer._ops.subquery.limits).toBe(101)
|
|
352
|
+
expect(outer._ops.subquery.groups.length).toBe(0)
|
|
222
353
|
const countSql = findCountSql(selects)
|
|
223
|
-
expect(countSql).toContain('count(
|
|
224
|
-
|
|
354
|
+
expect(countSql).toContain('count(*)')
|
|
355
|
+
})
|
|
225
356
|
|
|
226
|
-
|
|
227
|
-
|
|
357
|
+
test('OM_LIST_COUNT_CAP=0 disables the probe: a direct unbounded count(*)', async () => {
|
|
358
|
+
process.env.OM_LIST_COUNT_CAP = '0'
|
|
359
|
+
const selects: any[] = []
|
|
360
|
+
const fakeDb = createFakeKysely(selects)
|
|
361
|
+
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
362
|
+
const result = await engine.query('scheduler:scheduled_job', { tenantId: 't1', fields: ['id'], page: { page: 1, pageSize: 20 } })
|
|
363
|
+
|
|
364
|
+
expect(fakeDb._calls.some((b: any) => b._ops.table === '__subquery__')).toBe(false)
|
|
365
|
+
const countSql = findCountSql(selects)
|
|
366
|
+
expect(countSql).toContain('count(*)')
|
|
367
|
+
expect(result.meta?.listCountCapWarning).toBeUndefined()
|
|
228
368
|
})
|
|
229
369
|
})
|