@open-mercato/shared 0.6.8-develop.7029.1.a1bb3363af → 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
package/dist/lib/openapi/crud.js
CHANGED
|
@@ -8,7 +8,10 @@ function createPagedListResponseSchema(itemSchema, options = {}) {
|
|
|
8
8
|
total: z.number(),
|
|
9
9
|
page: paginationMetaOptional ? z.number().optional() : z.number(),
|
|
10
10
|
pageSize: paginationMetaOptional ? z.number().optional() : z.number(),
|
|
11
|
-
totalPages: z.number()
|
|
11
|
+
totalPages: z.number(),
|
|
12
|
+
// Present (true) only when the list count was bounded at OM_LIST_COUNT_CAP:
|
|
13
|
+
// `total` is then a floor, not an exact value.
|
|
14
|
+
totalIsCapped: z.boolean().optional()
|
|
12
15
|
});
|
|
13
16
|
}
|
|
14
17
|
function withIdsQueryParam(schema) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/openapi/crud.ts"],
|
|
4
|
-
"sourcesContent": ["import { z, type ZodTypeAny } from 'zod'\nimport type { OpenApiResponseDoc, OpenApiRouteDoc } from './types'\n\nexport const defaultCreateResponseSchema = z.object({ id: z.string().uuid().nullable() })\nexport const defaultOkResponseSchema = z.object({ ok: z.literal(true) })\n\nexport type PagedListResponseOptions = {\n paginationMetaOptional?: boolean\n}\n\nexport function createPagedListResponseSchema(itemSchema: ZodTypeAny, options: PagedListResponseOptions = {}) {\n const paginationMetaOptional = options.paginationMetaOptional ?? false\n\n return z.object({\n items: z.array(itemSchema),\n total: z.number(),\n page: paginationMetaOptional ? z.number().optional() : z.number(),\n pageSize: paginationMetaOptional ? z.number().optional() : z.number(),\n totalPages: z.number(),\n })\n}\n\ntype CrudMethodConfig = {\n schema: ZodTypeAny\n description?: string\n responseSchema?: ZodTypeAny\n}\n\ntype CrudCreateConfig = CrudMethodConfig & {\n status?: number\n}\n\ntype CrudDeleteConfig = {\n schema?: ZodTypeAny\n description?: string\n responseSchema?: ZodTypeAny\n errors?: OpenApiResponseDoc[]\n}\n\nexport type CrudOpenApiOptions = {\n tag?: string\n resourceName: string\n pluralName?: string\n description?: string\n querySchema?: ZodTypeAny\n listResponseSchema: ZodTypeAny\n create?: CrudCreateConfig\n update?: CrudMethodConfig\n del?: CrudDeleteConfig\n}\n\nexport type CrudTextContext = {\n resourceName: string\n resourceLower: string\n pluralName: string\n pluralLower: string\n}\n\nexport type CrudOpenApiFactoryConfig = {\n defaultTag: string\n defaultCreateResponseSchema?: ZodTypeAny\n defaultOkResponseSchema?: ZodTypeAny\n makeListDescription?: (ctx: CrudTextContext) => string\n makeCreateDescription?: (ctx: CrudTextContext) => string\n makeCreateRequestBodyDescription?: (ctx: CrudTextContext) => string\n makeUpdateDescription?: (ctx: CrudTextContext) => string\n makeUpdateRequestBodyDescription?: (ctx: CrudTextContext) => string\n makeDeleteDescription?: (ctx: CrudTextContext) => string\n makeDeleteRequestBodyDescription?: (ctx: CrudTextContext) => string\n}\n\nfunction withIdsQueryParam(schema: ZodTypeAny | undefined): ZodTypeAny | undefined {\n if (!schema) return schema\n if (!(schema instanceof z.ZodObject)) return schema\n return schema.extend({\n ids: z\n .string()\n .optional()\n .describe('Comma-separated list of record UUIDs to filter by (max 200).'),\n })\n}\n\nfunction resolveDefault(\n factory: ((ctx: CrudTextContext) => string) | undefined,\n ctx: CrudTextContext,\n fallback: string,\n) {\n if (typeof factory === 'function') return factory(ctx)\n return fallback\n}\n\nexport function createCrudOpenApiFactory(config: CrudOpenApiFactoryConfig) {\n return function createCrudOpenApi(options: CrudOpenApiOptions): OpenApiRouteDoc {\n const {\n resourceName,\n pluralName,\n tag,\n description,\n querySchema,\n listResponseSchema,\n create,\n update,\n del,\n } = options\n\n const plural = pluralName ?? `${resourceName}s`\n const resourceLower = resourceName.toLowerCase()\n const pluralLower = plural.toLowerCase()\n const context: CrudTextContext = {\n resourceName,\n resourceLower,\n pluralName: plural,\n pluralLower,\n }\n\n const fallbackCreateResponseSchema = config.defaultCreateResponseSchema ?? defaultCreateResponseSchema\n const fallbackOkResponseSchema = config.defaultOkResponseSchema ?? defaultOkResponseSchema\n\n const methods: NonNullable<OpenApiRouteDoc['methods']> = {}\n\n methods.GET = {\n summary: `List ${pluralLower}`,\n description:\n description ?? resolveDefault(config.makeListDescription, context, `Returns a paginated collection of ${pluralLower}.`),\n query: withIdsQueryParam(querySchema),\n responses: [\n {\n status: 200,\n description: `Paginated ${pluralLower}`,\n schema: listResponseSchema,\n },\n ],\n }\n\n if (create) {\n const createDescription =\n create.description ??\n resolveDefault(config.makeCreateDescription, context, `Creates a new ${resourceLower}.`)\n\n const createBodyDescription =\n resolveDefault(\n config.makeCreateRequestBodyDescription,\n context,\n create.description ?? `Payload describing the ${resourceLower} to create.`,\n )\n\n methods.POST = {\n summary: `Create ${resourceLower}`,\n description: createDescription,\n requestBody: {\n schema: create.schema,\n description: createBodyDescription,\n },\n responses: [\n {\n status: create.status ?? 201,\n description: `${resourceName} created`,\n schema: create.responseSchema ?? fallbackCreateResponseSchema,\n },\n ],\n }\n }\n\n if (update) {\n const updateDescription =\n update.description ??\n resolveDefault(config.makeUpdateDescription, context, `Updates an existing ${resourceLower} by id.`)\n\n const updateBodyDescription =\n resolveDefault(\n config.makeUpdateRequestBodyDescription,\n context,\n update.description ?? `Fields to update on the ${resourceLower}.`,\n )\n\n methods.PUT = {\n summary: `Update ${resourceLower}`,\n description: updateDescription,\n requestBody: {\n schema: update.schema,\n description: updateBodyDescription,\n },\n responses: [\n {\n status: 200,\n description: `${resourceName} updated`,\n schema: update.responseSchema ?? fallbackOkResponseSchema,\n },\n ],\n }\n }\n\n if (del) {\n const deleteDescription =\n del.description ??\n resolveDefault(config.makeDeleteDescription, context, `Deletes a ${resourceLower} identified by id.`)\n\n const deleteBodyDescription =\n resolveDefault(\n config.makeDeleteRequestBodyDescription,\n context,\n del.description ?? 'Identifier payload.',\n )\n\n methods.DELETE = {\n summary: `Delete ${resourceLower}`,\n description: deleteDescription,\n requestBody: del.schema\n ? {\n schema: del.schema,\n description: deleteBodyDescription,\n }\n : undefined,\n responses: [\n {\n status: 200,\n description: `${resourceName} deleted`,\n schema: del.responseSchema ?? fallbackOkResponseSchema,\n },\n ],\n ...(del.errors && del.errors.length > 0 ? { errors: del.errors } : {}),\n }\n }\n\n return {\n tag: tag ?? config.defaultTag,\n summary: `${resourceName} management`,\n methods,\n }\n }\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,SAA0B;AAG5B,MAAM,8BAA8B,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC;AACjF,MAAM,0BAA0B,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,IAAI,EAAE,CAAC;AAMhE,SAAS,8BAA8B,YAAwB,UAAoC,CAAC,GAAG;AAC5G,QAAM,yBAAyB,QAAQ,0BAA0B;AAEjE,SAAO,EAAE,OAAO;AAAA,IACd,OAAO,EAAE,MAAM,UAAU;AAAA,IACzB,OAAO,EAAE,OAAO;AAAA,IAChB,MAAM,yBAAyB,EAAE,OAAO,EAAE,SAAS,IAAI,EAAE,OAAO;AAAA,IAChE,UAAU,yBAAyB,EAAE,OAAO,EAAE,SAAS,IAAI,EAAE,OAAO;AAAA,IACpE,YAAY,EAAE,OAAO;AAAA,
|
|
4
|
+
"sourcesContent": ["import { z, type ZodTypeAny } from 'zod'\nimport type { OpenApiResponseDoc, OpenApiRouteDoc } from './types'\n\nexport const defaultCreateResponseSchema = z.object({ id: z.string().uuid().nullable() })\nexport const defaultOkResponseSchema = z.object({ ok: z.literal(true) })\n\nexport type PagedListResponseOptions = {\n paginationMetaOptional?: boolean\n}\n\nexport function createPagedListResponseSchema(itemSchema: ZodTypeAny, options: PagedListResponseOptions = {}) {\n const paginationMetaOptional = options.paginationMetaOptional ?? false\n\n return z.object({\n items: z.array(itemSchema),\n total: z.number(),\n page: paginationMetaOptional ? z.number().optional() : z.number(),\n pageSize: paginationMetaOptional ? z.number().optional() : z.number(),\n totalPages: z.number(),\n // Present (true) only when the list count was bounded at OM_LIST_COUNT_CAP:\n // `total` is then a floor, not an exact value.\n totalIsCapped: z.boolean().optional(),\n })\n}\n\ntype CrudMethodConfig = {\n schema: ZodTypeAny\n description?: string\n responseSchema?: ZodTypeAny\n}\n\ntype CrudCreateConfig = CrudMethodConfig & {\n status?: number\n}\n\ntype CrudDeleteConfig = {\n schema?: ZodTypeAny\n description?: string\n responseSchema?: ZodTypeAny\n errors?: OpenApiResponseDoc[]\n}\n\nexport type CrudOpenApiOptions = {\n tag?: string\n resourceName: string\n pluralName?: string\n description?: string\n querySchema?: ZodTypeAny\n listResponseSchema: ZodTypeAny\n create?: CrudCreateConfig\n update?: CrudMethodConfig\n del?: CrudDeleteConfig\n}\n\nexport type CrudTextContext = {\n resourceName: string\n resourceLower: string\n pluralName: string\n pluralLower: string\n}\n\nexport type CrudOpenApiFactoryConfig = {\n defaultTag: string\n defaultCreateResponseSchema?: ZodTypeAny\n defaultOkResponseSchema?: ZodTypeAny\n makeListDescription?: (ctx: CrudTextContext) => string\n makeCreateDescription?: (ctx: CrudTextContext) => string\n makeCreateRequestBodyDescription?: (ctx: CrudTextContext) => string\n makeUpdateDescription?: (ctx: CrudTextContext) => string\n makeUpdateRequestBodyDescription?: (ctx: CrudTextContext) => string\n makeDeleteDescription?: (ctx: CrudTextContext) => string\n makeDeleteRequestBodyDescription?: (ctx: CrudTextContext) => string\n}\n\nfunction withIdsQueryParam(schema: ZodTypeAny | undefined): ZodTypeAny | undefined {\n if (!schema) return schema\n if (!(schema instanceof z.ZodObject)) return schema\n return schema.extend({\n ids: z\n .string()\n .optional()\n .describe('Comma-separated list of record UUIDs to filter by (max 200).'),\n })\n}\n\nfunction resolveDefault(\n factory: ((ctx: CrudTextContext) => string) | undefined,\n ctx: CrudTextContext,\n fallback: string,\n) {\n if (typeof factory === 'function') return factory(ctx)\n return fallback\n}\n\nexport function createCrudOpenApiFactory(config: CrudOpenApiFactoryConfig) {\n return function createCrudOpenApi(options: CrudOpenApiOptions): OpenApiRouteDoc {\n const {\n resourceName,\n pluralName,\n tag,\n description,\n querySchema,\n listResponseSchema,\n create,\n update,\n del,\n } = options\n\n const plural = pluralName ?? `${resourceName}s`\n const resourceLower = resourceName.toLowerCase()\n const pluralLower = plural.toLowerCase()\n const context: CrudTextContext = {\n resourceName,\n resourceLower,\n pluralName: plural,\n pluralLower,\n }\n\n const fallbackCreateResponseSchema = config.defaultCreateResponseSchema ?? defaultCreateResponseSchema\n const fallbackOkResponseSchema = config.defaultOkResponseSchema ?? defaultOkResponseSchema\n\n const methods: NonNullable<OpenApiRouteDoc['methods']> = {}\n\n methods.GET = {\n summary: `List ${pluralLower}`,\n description:\n description ?? resolveDefault(config.makeListDescription, context, `Returns a paginated collection of ${pluralLower}.`),\n query: withIdsQueryParam(querySchema),\n responses: [\n {\n status: 200,\n description: `Paginated ${pluralLower}`,\n schema: listResponseSchema,\n },\n ],\n }\n\n if (create) {\n const createDescription =\n create.description ??\n resolveDefault(config.makeCreateDescription, context, `Creates a new ${resourceLower}.`)\n\n const createBodyDescription =\n resolveDefault(\n config.makeCreateRequestBodyDescription,\n context,\n create.description ?? `Payload describing the ${resourceLower} to create.`,\n )\n\n methods.POST = {\n summary: `Create ${resourceLower}`,\n description: createDescription,\n requestBody: {\n schema: create.schema,\n description: createBodyDescription,\n },\n responses: [\n {\n status: create.status ?? 201,\n description: `${resourceName} created`,\n schema: create.responseSchema ?? fallbackCreateResponseSchema,\n },\n ],\n }\n }\n\n if (update) {\n const updateDescription =\n update.description ??\n resolveDefault(config.makeUpdateDescription, context, `Updates an existing ${resourceLower} by id.`)\n\n const updateBodyDescription =\n resolveDefault(\n config.makeUpdateRequestBodyDescription,\n context,\n update.description ?? `Fields to update on the ${resourceLower}.`,\n )\n\n methods.PUT = {\n summary: `Update ${resourceLower}`,\n description: updateDescription,\n requestBody: {\n schema: update.schema,\n description: updateBodyDescription,\n },\n responses: [\n {\n status: 200,\n description: `${resourceName} updated`,\n schema: update.responseSchema ?? fallbackOkResponseSchema,\n },\n ],\n }\n }\n\n if (del) {\n const deleteDescription =\n del.description ??\n resolveDefault(config.makeDeleteDescription, context, `Deletes a ${resourceLower} identified by id.`)\n\n const deleteBodyDescription =\n resolveDefault(\n config.makeDeleteRequestBodyDescription,\n context,\n del.description ?? 'Identifier payload.',\n )\n\n methods.DELETE = {\n summary: `Delete ${resourceLower}`,\n description: deleteDescription,\n requestBody: del.schema\n ? {\n schema: del.schema,\n description: deleteBodyDescription,\n }\n : undefined,\n responses: [\n {\n status: 200,\n description: `${resourceName} deleted`,\n schema: del.responseSchema ?? fallbackOkResponseSchema,\n },\n ],\n ...(del.errors && del.errors.length > 0 ? { errors: del.errors } : {}),\n }\n }\n\n return {\n tag: tag ?? config.defaultTag,\n summary: `${resourceName} management`,\n methods,\n }\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { parseNumberWithDefault } from "../number.js";
|
|
2
|
+
const DEFAULT_LIST_COUNT_CAP = 1e4;
|
|
3
|
+
function resolveListCountCap() {
|
|
4
|
+
const parsed = parseNumberWithDefault(process.env.OM_LIST_COUNT_CAP, DEFAULT_LIST_COUNT_CAP, { integer: true });
|
|
5
|
+
return parsed <= 0 ? null : parsed;
|
|
6
|
+
}
|
|
7
|
+
export {
|
|
8
|
+
DEFAULT_LIST_COUNT_CAP,
|
|
9
|
+
resolveListCountCap
|
|
10
|
+
};
|
|
11
|
+
//# sourceMappingURL=count-cap.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/lib/query/count-cap.ts"],
|
|
4
|
+
"sourcesContent": ["import { parseNumberWithDefault } from '../number'\n\nexport const DEFAULT_LIST_COUNT_CAP = 10_000\n\n/**\n * Cap on how many matching rows a list COUNT may visit before reporting\n * `total: cap` with `meta.listCountCapWarning` (surfaced to clients as\n * `totalIsCapped`). Returns the cap as a number, or `null` when capping is\n * disabled.\n *\n * Resolution of `OM_LIST_COUNT_CAP`:\n * - unset / blank / unparseable \u2192 `DEFAULT_LIST_COUNT_CAP` (the cap is on by\n * default, and bad input must not silently disable it)\n * - `0` (or negative) \u2192 `null` \u2014 capping disabled, exact counts everywhere\n */\nexport function resolveListCountCap(): number | null {\n const parsed = parseNumberWithDefault(process.env.OM_LIST_COUNT_CAP, DEFAULT_LIST_COUNT_CAP, { integer: true })\n return parsed <= 0 ? null : parsed\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,8BAA8B;AAEhC,MAAM,yBAAyB;AAa/B,SAAS,sBAAqC;AACnD,QAAM,SAAS,uBAAuB,QAAQ,IAAI,mBAAmB,wBAAwB,EAAE,SAAS,KAAK,CAAC;AAC9G,SAAO,UAAU,IAAI,OAAO;AAC9B;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/dist/lib/query/engine.js
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
} from "../crud/custom-field-definition-index.js";
|
|
21
21
|
import { warnOnCiphertextLikeFallback } from "./ciphertext-search-warning.js";
|
|
22
22
|
import { resolveEncryptedSortFields, resolveEncryptedSortMaxRows, sortRowsInMemory } from "./encrypted-sort.js";
|
|
23
|
+
import { resolveListCountCap } from "./count-cap.js";
|
|
23
24
|
import { mapWithConcurrency } from "./bounded-decrypt.js";
|
|
24
25
|
import { createLogger } from "../logger/index.js";
|
|
25
26
|
const logger = createLogger("shared").child({ component: "query" });
|
|
@@ -440,6 +441,7 @@ class BasicQueryEngine {
|
|
|
440
441
|
const sanitize = (s) => s.replace(/[^a-zA-Z0-9_]/g, "_");
|
|
441
442
|
const buildQuery = async (projection) => {
|
|
442
443
|
const isSortKeysProjection = projection === "sortKeys";
|
|
444
|
+
const isCountProjection = projection === "count";
|
|
443
445
|
let q = db.selectFrom(table);
|
|
444
446
|
if (!skipAutoScope && orgScope && await this.columnExists(table, "organization_id")) {
|
|
445
447
|
q = this.applyOrganizationScope(q, qualify("organization_id"), orgScope);
|
|
@@ -513,7 +515,8 @@ class BasicQueryEngine {
|
|
|
513
515
|
applyJoinFilterOp,
|
|
514
516
|
columnExists: (tbl, column) => this.columnExists(tbl, column)
|
|
515
517
|
});
|
|
516
|
-
if (
|
|
518
|
+
if (isCountProjection) {
|
|
519
|
+
} else if (isSortKeysProjection) {
|
|
517
520
|
q = q.select(sql.ref(qualify("id")).as("id"));
|
|
518
521
|
if (await this.columnExists(table, "tenant_id")) {
|
|
519
522
|
q = q.select(sql.ref(qualify("tenant_id")).as("tenant_id"));
|
|
@@ -535,18 +538,18 @@ class BasicQueryEngine {
|
|
|
535
538
|
} else {
|
|
536
539
|
q = q.select(sql`${sql.ref(table)}.*`.as("__all"));
|
|
537
540
|
}
|
|
538
|
-
const cfSourcesResult = this.configureCustomFieldSources(q, table, entity, db, opts, qualify);
|
|
541
|
+
const cfSourcesResult = this.configureCustomFieldSources(q, table, entity, db, opts, qualify, !isCountProjection);
|
|
539
542
|
q = cfSourcesResult.builder;
|
|
540
543
|
const cfSources = cfSourcesResult.sources;
|
|
541
544
|
const entityIdToSource = /* @__PURE__ */ new Map();
|
|
542
545
|
for (const source of cfSources) {
|
|
543
546
|
entityIdToSource.set(String(source.entityId), source);
|
|
544
547
|
}
|
|
545
|
-
const requestedCustomFieldKeys =
|
|
548
|
+
const requestedCustomFieldKeys = projection === "full" && Array.isArray(opts.includeCustomFields) ? opts.includeCustomFields.map((key) => String(key)) : [];
|
|
546
549
|
const cfKeys = /* @__PURE__ */ new Set();
|
|
547
550
|
const keySource = /* @__PURE__ */ new Map();
|
|
548
551
|
let resolvedCustomFieldDefinitions2;
|
|
549
|
-
if (
|
|
552
|
+
if (projection === "full") {
|
|
550
553
|
for (const f of opts.fields || []) {
|
|
551
554
|
if (typeof f === "string" && f.startsWith("cf:")) cfKeys.add(f.slice(3));
|
|
552
555
|
}
|
|
@@ -554,7 +557,7 @@ class BasicQueryEngine {
|
|
|
554
557
|
for (const f of cfFilters) {
|
|
555
558
|
if (typeof f.field === "string" && f.field.startsWith("cf:")) cfKeys.add(f.field.slice(3));
|
|
556
559
|
}
|
|
557
|
-
if (
|
|
560
|
+
if (projection === "full" && opts.includeCustomFields === true) {
|
|
558
561
|
if (entityIdToSource.size > 0) {
|
|
559
562
|
const entityIdList = Array.from(entityIdToSource.keys());
|
|
560
563
|
const entityOrder = /* @__PURE__ */ new Map();
|
|
@@ -653,6 +656,7 @@ class BasicQueryEngine {
|
|
|
653
656
|
for (const key of cfKeys) {
|
|
654
657
|
const source = keySource.get(key);
|
|
655
658
|
if (!source) continue;
|
|
659
|
+
if (isCountProjection) continue;
|
|
656
660
|
const entityIdForKey = source.entityId;
|
|
657
661
|
const recordIdExpr = source.recordIdExpr;
|
|
658
662
|
const sourceAliasSafe = sanitize(source.alias || "src");
|
|
@@ -700,8 +704,10 @@ class BasicQueryEngine {
|
|
|
700
704
|
for (const f of regularCfFilters) {
|
|
701
705
|
if (!f.field.startsWith("cf:")) continue;
|
|
702
706
|
const key = f.field.slice(3);
|
|
707
|
+
const filterSource = keySource.get(key);
|
|
708
|
+
if (!filterSource) continue;
|
|
703
709
|
const expr = cfValueExprByKey[key];
|
|
704
|
-
if (!expr) continue;
|
|
710
|
+
if (!isCountProjection && !expr) continue;
|
|
705
711
|
if ((f.op === "like" || f.op === "ilike") && searchActive && typeof f.value === "string") {
|
|
706
712
|
const tokens = tokenizeText(String(f.value), searchConfig);
|
|
707
713
|
const hashes = tokens.hashes;
|
|
@@ -736,15 +742,39 @@ class BasicQueryEngine {
|
|
|
736
742
|
});
|
|
737
743
|
}
|
|
738
744
|
}
|
|
745
|
+
if (isCountProjection) {
|
|
746
|
+
q = this.applyCfValueExistsFilter(q, {
|
|
747
|
+
source: filterSource,
|
|
748
|
+
qualify,
|
|
749
|
+
tenantId: tenantId ?? null,
|
|
750
|
+
key,
|
|
751
|
+
op: f.op,
|
|
752
|
+
value: f.value
|
|
753
|
+
});
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
739
756
|
q = this.applyColumnOp(q, expr, f.op, f.value);
|
|
740
757
|
}
|
|
741
|
-
const
|
|
758
|
+
const cfLeafApplicable = (key) => isCountProjection ? keySource.has(key) : Boolean(cfValueExprByKey[key]);
|
|
759
|
+
const applicableGroupFilters = resolvedGroupFilters.map((group) => group.filter((rf) => rf.kind !== "cf" || cfLeafApplicable(rf.key))).filter((group) => group.length > 0);
|
|
742
760
|
if (applicableGroupFilters.length > 0) {
|
|
743
761
|
q = q.where((eb) => {
|
|
744
762
|
const disjuncts = applicableGroupFilters.map((group) => {
|
|
745
763
|
const parts = group.map((rf) => {
|
|
746
764
|
if (rf.kind === "column") return this.buildColumnOpExpression(eb, rf.qualified, rf.op, rf.value);
|
|
747
|
-
if (rf.kind === "cf")
|
|
765
|
+
if (rf.kind === "cf") {
|
|
766
|
+
if (isCountProjection) {
|
|
767
|
+
return this.buildCfValueExistsExpression(eb, {
|
|
768
|
+
source: keySource.get(rf.key),
|
|
769
|
+
qualify,
|
|
770
|
+
tenantId: tenantId ?? null,
|
|
771
|
+
key: rf.key,
|
|
772
|
+
op: rf.op,
|
|
773
|
+
value: rf.value
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
return this.buildColumnOpExpression(eb, cfValueExprByKey[rf.key], rf.op, rf.value);
|
|
777
|
+
}
|
|
748
778
|
return this.buildIndexDocOpExpression(eb, {
|
|
749
779
|
entity: String(entity),
|
|
750
780
|
field: rf.field,
|
|
@@ -761,7 +791,7 @@ class BasicQueryEngine {
|
|
|
761
791
|
return disjuncts.length === 1 ? disjuncts[0] : eb.or(disjuncts);
|
|
762
792
|
});
|
|
763
793
|
}
|
|
764
|
-
if (opts.includeExtensions) {
|
|
794
|
+
if (opts.includeExtensions && !isCountProjection) {
|
|
765
795
|
const { getModules } = await import("@open-mercato/shared/lib/i18n/server");
|
|
766
796
|
const allMods = getModules();
|
|
767
797
|
const allExts = allMods.flatMap((m) => m.entityExtensions || []);
|
|
@@ -778,7 +808,7 @@ class BasicQueryEngine {
|
|
|
778
808
|
);
|
|
779
809
|
}
|
|
780
810
|
}
|
|
781
|
-
for (const s of resolvedSorts) {
|
|
811
|
+
for (const s of isCountProjection ? [] : resolvedSorts) {
|
|
782
812
|
if (s.field.startsWith("cf:")) {
|
|
783
813
|
const key = s.field.slice(3);
|
|
784
814
|
const alias = sanitize(`cf:${key}`);
|
|
@@ -794,7 +824,7 @@ class BasicQueryEngine {
|
|
|
794
824
|
if (!requiresPlaintextSort) q = q.orderBy(qualify(s.field), s.dir ?? "asc");
|
|
795
825
|
}
|
|
796
826
|
}
|
|
797
|
-
const hasJoinedAggregates2 = opts.includeExtensions && (Array.isArray(opts.includeExtensions) ? opts.includeExtensions.length > 0 : true) || Object.keys(cfValueExprByKey).length > 0;
|
|
827
|
+
const hasJoinedAggregates2 = !isCountProjection && (opts.includeExtensions && (Array.isArray(opts.includeExtensions) ? opts.includeExtensions.length > 0 : true) || Object.keys(cfValueExprByKey).length > 0);
|
|
798
828
|
if (hasJoinedAggregates2) {
|
|
799
829
|
q = q.groupBy(`${table}.id`);
|
|
800
830
|
}
|
|
@@ -809,11 +839,24 @@ class BasicQueryEngine {
|
|
|
809
839
|
cfMultiAliasByAlias,
|
|
810
840
|
resolvedCustomFieldDefinitions
|
|
811
841
|
} = await buildQuery("full");
|
|
812
|
-
const
|
|
813
|
-
const
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
842
|
+
const countCap = resolveListCountCap();
|
|
843
|
+
const { builder: countShape } = await buildQuery("count");
|
|
844
|
+
let total;
|
|
845
|
+
let listCountCapWarning;
|
|
846
|
+
if (countCap !== null) {
|
|
847
|
+
const probe = countShape.select(sql`1`.as("one")).limit(countCap + 1);
|
|
848
|
+
const countRow = await db.selectFrom(probe.as("om_count_probe")).select(sql`count(*)`.as("count")).executeTakeFirst();
|
|
849
|
+
const probed = Number(countRow?.count ?? 0);
|
|
850
|
+
if (probed > countCap) {
|
|
851
|
+
total = countCap;
|
|
852
|
+
listCountCapWarning = { entity, cap: countCap };
|
|
853
|
+
} else {
|
|
854
|
+
total = probed;
|
|
855
|
+
}
|
|
856
|
+
} else {
|
|
857
|
+
const countRow = await countShape.select(sql`count(*)`.as("count")).executeTakeFirst();
|
|
858
|
+
total = Number(countRow?.count ?? 0);
|
|
859
|
+
}
|
|
817
860
|
const svc = encryptionService;
|
|
818
861
|
const decryptPayload = svc?.decryptEntityPayload?.bind(svc);
|
|
819
862
|
const decryptRow = async (item) => {
|
|
@@ -862,13 +905,15 @@ class BasicQueryEngine {
|
|
|
862
905
|
const cap = resolveEncryptedSortMaxRows();
|
|
863
906
|
let qSort = (await buildQuery("sortKeys")).builder;
|
|
864
907
|
if (cap !== null) {
|
|
865
|
-
qSort = qSort.limit(cap).orderBy(qualify("id"), "asc");
|
|
908
|
+
qSort = qSort.limit(cap + 1).orderBy(qualify("id"), "asc");
|
|
866
909
|
}
|
|
867
|
-
const
|
|
910
|
+
const candidateRowsRaw = await qSort.execute();
|
|
911
|
+
const sortTruncated = cap !== null && candidateRowsRaw.length > cap;
|
|
912
|
+
const candidateRows = sortTruncated && cap !== null ? candidateRowsRaw.slice(0, cap) : candidateRowsRaw;
|
|
868
913
|
const decryptedCandidates = decryptPayload ? await mapWithConcurrency(candidateRows, DECRYPT_CONCURRENCY, decryptRow) : candidateRows;
|
|
869
914
|
const orderedCandidates = sortRowsInMemory(decryptedCandidates, resolvedSorts);
|
|
870
915
|
const pageIds = orderedCandidates.slice((page - 1) * pageSize, page * pageSize).map((row) => row.id);
|
|
871
|
-
if (cap !== null
|
|
916
|
+
if (sortTruncated && cap !== null) {
|
|
872
917
|
encryptedSortRowCapWarning = {
|
|
873
918
|
entity,
|
|
874
919
|
sortFields: resolvedSorts.map((s) => s.field),
|
|
@@ -892,8 +937,10 @@ class BasicQueryEngine {
|
|
|
892
937
|
pagedItems = decryptPayload ? await mapWithConcurrency(items, DECRYPT_CONCURRENCY, decryptRow) : items;
|
|
893
938
|
}
|
|
894
939
|
let queryResult = { items: pagedItems, page, pageSize, total };
|
|
895
|
-
if (encryptedSortRowCapWarning) {
|
|
896
|
-
const meta = {
|
|
940
|
+
if (encryptedSortRowCapWarning || listCountCapWarning) {
|
|
941
|
+
const meta = {};
|
|
942
|
+
if (encryptedSortRowCapWarning) meta.encryptedSortRowCapWarning = encryptedSortRowCapWarning;
|
|
943
|
+
if (listCountCapWarning) meta.listCountCapWarning = listCountCapWarning;
|
|
897
944
|
queryResult.meta = meta;
|
|
898
945
|
}
|
|
899
946
|
if (ext && extensionCtx) {
|
|
@@ -938,6 +985,101 @@ class BasicQueryEngine {
|
|
|
938
985
|
return builder;
|
|
939
986
|
}
|
|
940
987
|
}
|
|
988
|
+
/**
|
|
989
|
+
* Apply a `cf:*` filter as a correlated EXISTS semi-join over
|
|
990
|
+
* `custom_field_values` (+ `custom_field_defs` for kind-based coercion) —
|
|
991
|
+
* the count shape's equivalent of the projection path's leftJoin + WHERE.
|
|
992
|
+
* A semi-join returns each base row at most once, so the count query needs
|
|
993
|
+
* no DISTINCT or GROUP BY and stays boundable by an outer LIMIT.
|
|
994
|
+
*
|
|
995
|
+
* Predicates satisfied by the *absence* of a value row (`eq null`,
|
|
996
|
+
* `exists: false`) become `NOT EXISTS(value) OR EXISTS(null value)`,
|
|
997
|
+
* matching the leftJoin form where a missing row yields a NULL expression.
|
|
998
|
+
*/
|
|
999
|
+
applyCfValueExistsFilter(q, opts) {
|
|
1000
|
+
return q.where((eb) => this.buildCfValueExistsExpression(eb, opts));
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* Expression-returning core of `applyCfValueExistsFilter`, so a cf leaf
|
|
1004
|
+
* inside an OR group can compile to an EXISTS predicate on the count shape
|
|
1005
|
+
* instead of being dropped for lacking a `cfValueExprByKey` entry.
|
|
1006
|
+
*/
|
|
1007
|
+
buildCfValueExistsExpression(eb, opts) {
|
|
1008
|
+
const { source, qualify, tenantId, key, op, value } = opts;
|
|
1009
|
+
const seq = this.searchAliasSeq++;
|
|
1010
|
+
const valAlias = `cfev_${seq}`;
|
|
1011
|
+
const defAlias = `cfed_${seq}`;
|
|
1012
|
+
const srcAlias = `cfes_${seq}`;
|
|
1013
|
+
const caseExpr = sql`CASE ${sql.ref(`${defAlias}.kind`)}
|
|
1014
|
+
WHEN 'integer' THEN (${sql.ref(`${valAlias}.value_int`)})::text
|
|
1015
|
+
WHEN 'float' THEN (${sql.ref(`${valAlias}.value_float`)})::text
|
|
1016
|
+
WHEN 'boolean' THEN (${sql.ref(`${valAlias}.value_bool`)})::text
|
|
1017
|
+
WHEN 'multiline' THEN (${sql.ref(`${valAlias}.value_multiline`)})::text
|
|
1018
|
+
ELSE (${sql.ref(`${valAlias}.value_text`)})::text
|
|
1019
|
+
END`;
|
|
1020
|
+
const buildSub = (eb2) => {
|
|
1021
|
+
let sub = eb2.selectFrom(`custom_field_values as ${valAlias}`).select(sql`1`.as("one")).leftJoin(`custom_field_defs as ${defAlias}`, (jb) => jb.on(`${defAlias}.entity_id`, "=", String(source.entityId)).on(`${defAlias}.key`, "=", key).on(`${defAlias}.is_active`, "=", true).on((jeb) => jeb.or([
|
|
1022
|
+
jeb(`${defAlias}.tenant_id`, "=", tenantId),
|
|
1023
|
+
jeb(`${defAlias}.tenant_id`, "is", null)
|
|
1024
|
+
]))).where(`${valAlias}.entity_id`, "=", String(source.entityId)).where(`${valAlias}.field_key`, "=", key).where((web) => web.or([
|
|
1025
|
+
web(`${valAlias}.tenant_id`, "=", tenantId),
|
|
1026
|
+
web(`${valAlias}.tenant_id`, "is", null)
|
|
1027
|
+
]));
|
|
1028
|
+
if (source.hop) {
|
|
1029
|
+
sub = sub.innerJoin(`${source.table} as ${srcAlias}`, (jb) => jb.on(sql`${sql.ref(`${valAlias}.record_id`)} = (${sql.ref(`${srcAlias}.${source.hop.recordIdColumn}`)})::text`)).whereRef(`${srcAlias}.${source.hop.toField}`, "=", qualify(source.hop.fromField));
|
|
1030
|
+
} else {
|
|
1031
|
+
sub = sub.where(sql`${sql.ref(`${valAlias}.record_id`)} = ${source.recordIdExpr}`);
|
|
1032
|
+
}
|
|
1033
|
+
return sub;
|
|
1034
|
+
};
|
|
1035
|
+
const absenceSatisfiable = op === "eq" && value === null || op === "exists" && !value;
|
|
1036
|
+
if (absenceSatisfiable) {
|
|
1037
|
+
return eb.or([
|
|
1038
|
+
eb.not(eb.exists(buildSub(eb))),
|
|
1039
|
+
eb.exists(buildSub(eb).where(sql`${caseExpr} is null`))
|
|
1040
|
+
]);
|
|
1041
|
+
}
|
|
1042
|
+
let predicate = null;
|
|
1043
|
+
switch (op) {
|
|
1044
|
+
case "eq":
|
|
1045
|
+
predicate = sql`${caseExpr} = ${value}`;
|
|
1046
|
+
break;
|
|
1047
|
+
case "ne":
|
|
1048
|
+
predicate = value === null ? sql`${caseExpr} is not null` : sql`${caseExpr} != ${value}`;
|
|
1049
|
+
break;
|
|
1050
|
+
case "gt":
|
|
1051
|
+
case "gte":
|
|
1052
|
+
case "lt":
|
|
1053
|
+
case "lte": {
|
|
1054
|
+
const operator = sql.raw(op === "gt" ? ">" : op === "gte" ? ">=" : op === "lt" ? "<" : "<=");
|
|
1055
|
+
predicate = sql`${caseExpr} ${operator} ${value}`;
|
|
1056
|
+
break;
|
|
1057
|
+
}
|
|
1058
|
+
case "in": {
|
|
1059
|
+
const vals = Array.isArray(value) ? value : [value];
|
|
1060
|
+
predicate = sql`${caseExpr} in (${sql.join(vals.map((v) => sql`${v}`), sql`, `)})`;
|
|
1061
|
+
break;
|
|
1062
|
+
}
|
|
1063
|
+
case "nin": {
|
|
1064
|
+
const vals = Array.isArray(value) ? value : [value];
|
|
1065
|
+
predicate = sql`${caseExpr} not in (${sql.join(vals.map((v) => sql`${v}`), sql`, `)})`;
|
|
1066
|
+
break;
|
|
1067
|
+
}
|
|
1068
|
+
case "like":
|
|
1069
|
+
predicate = sql`${caseExpr} like ${value}`;
|
|
1070
|
+
break;
|
|
1071
|
+
case "ilike":
|
|
1072
|
+
predicate = sql`${caseExpr} ilike ${value}`;
|
|
1073
|
+
break;
|
|
1074
|
+
case "exists":
|
|
1075
|
+
predicate = sql`${caseExpr} is not null`;
|
|
1076
|
+
break;
|
|
1077
|
+
default:
|
|
1078
|
+
return eb.val(true);
|
|
1079
|
+
}
|
|
1080
|
+
const captured = predicate;
|
|
1081
|
+
return eb.exists(buildSub(eb).where(captured));
|
|
1082
|
+
}
|
|
941
1083
|
buildColumnOpExpression(eb, column, op, value) {
|
|
942
1084
|
switch (op) {
|
|
943
1085
|
case "eq":
|
|
@@ -1119,7 +1261,7 @@ class BasicQueryEngine {
|
|
|
1119
1261
|
return sub;
|
|
1120
1262
|
})());
|
|
1121
1263
|
}
|
|
1122
|
-
configureCustomFieldSources(q, baseTable, baseEntity, db, opts, qualify) {
|
|
1264
|
+
configureCustomFieldSources(q, baseTable, baseEntity, db, opts, qualify, attachJoins = true) {
|
|
1123
1265
|
const sources = [
|
|
1124
1266
|
{
|
|
1125
1267
|
entityId: baseEntity,
|
|
@@ -1137,14 +1279,22 @@ class BasicQueryEngine {
|
|
|
1137
1279
|
if (!join) {
|
|
1138
1280
|
throw new Error(`QueryEngine: customFieldSources entry for ${String(srcOpt.entityId)} requires a join configuration`);
|
|
1139
1281
|
}
|
|
1140
|
-
const
|
|
1141
|
-
|
|
1282
|
+
const joinType = (join.type ?? "left") === "inner" ? "inner" : "left";
|
|
1283
|
+
if (attachJoins) {
|
|
1284
|
+
const joinFn = joinType === "inner" ? "innerJoin" : "leftJoin";
|
|
1285
|
+
next = next[joinFn](`${joinTable} as ${alias}`, (jb) => jb.onRef(`${alias}.${join.toField}`, "=", qualify(join.fromField)));
|
|
1286
|
+
} else if (joinType === "inner") {
|
|
1287
|
+
next = next.where((eb) => eb.exists(
|
|
1288
|
+
eb.selectFrom(`${joinTable} as ${alias}`).select(sql`1`.as("one")).whereRef(`${alias}.${join.toField}`, "=", qualify(join.fromField))
|
|
1289
|
+
));
|
|
1290
|
+
}
|
|
1142
1291
|
const recordColumn = srcOpt.recordIdColumn ?? "id";
|
|
1143
1292
|
sources.push({
|
|
1144
1293
|
entityId: srcOpt.entityId,
|
|
1145
1294
|
alias,
|
|
1146
1295
|
table: joinTable,
|
|
1147
|
-
recordIdExpr: sql`${sql.ref(`${alias}.${recordColumn}`)}::text
|
|
1296
|
+
recordIdExpr: sql`${sql.ref(`${alias}.${recordColumn}`)}::text`,
|
|
1297
|
+
hop: { fromField: join.fromField, toField: join.toField, recordIdColumn: recordColumn, type: joinType }
|
|
1148
1298
|
});
|
|
1149
1299
|
});
|
|
1150
1300
|
return { builder: next, sources };
|