@open-mercato/shared 0.6.1-develop.3276.1.1fcd145078 → 0.6.1-develop.3278.1.a4d26475e4

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.
@@ -1,83 +1,129 @@
1
1
  import { sql } from "kysely";
2
+ const VALID_OPS = ["eq", "ne", "gt", "gte", "lt", "lte", "in", "nin", "like", "ilike", "exists"];
3
+ const normalizeField = (key) => key.startsWith("cf_") ? `cf:${key.slice(3)}` : key;
4
+ const MAX_DNF_DISJUNCTS = 1e3;
5
+ class AdvancedFilterComplexityError extends Error {
6
+ constructor(disjunctCount) {
7
+ super(
8
+ `Advanced filter expanded to ${disjunctCount} disjuncts, exceeding the limit of ${MAX_DNF_DISJUNCTS}. Reduce the number of OR branches or simplify nested groups.`
9
+ );
10
+ this.code = "ADVANCED_FILTER_TOO_COMPLEX";
11
+ this.name = "AdvancedFilterComplexityError";
12
+ this.disjunctCount = disjunctCount;
13
+ }
14
+ }
15
+ function compileToDnf(filter) {
16
+ if (filter === null || filter === void 0) return [[]];
17
+ if (Array.isArray(filter) || typeof filter !== "object") return [[]];
18
+ const obj = filter;
19
+ const partialDnfs = [];
20
+ for (const [rawKey, rawVal] of Object.entries(obj)) {
21
+ if (rawKey === "$and" && Array.isArray(rawVal)) {
22
+ partialDnfs.push(cartesianAnd(rawVal.map(compileToDnf)));
23
+ continue;
24
+ }
25
+ if (rawKey === "$or" && Array.isArray(rawVal)) {
26
+ const expanded = rawVal.flatMap(compileToDnf);
27
+ if (expanded.length > MAX_DNF_DISJUNCTS) {
28
+ throw new AdvancedFilterComplexityError(expanded.length);
29
+ }
30
+ partialDnfs.push(expanded);
31
+ continue;
32
+ }
33
+ const field = normalizeField(rawKey);
34
+ if (rawVal !== null && typeof rawVal === "object" && !Array.isArray(rawVal)) {
35
+ const clauses = [];
36
+ for (const [opKey, opVal] of Object.entries(rawVal)) {
37
+ const op = opKey.startsWith("$") ? opKey.slice(1) : opKey;
38
+ if (VALID_OPS.includes(op)) {
39
+ clauses.push({ field, op, value: opVal });
40
+ }
41
+ }
42
+ if (clauses.length > 0) partialDnfs.push([clauses]);
43
+ } else {
44
+ partialDnfs.push([[{ field, op: "eq", value: rawVal }]]);
45
+ }
46
+ }
47
+ return cartesianAnd(partialDnfs);
48
+ }
49
+ function cartesianAnd(dnfs) {
50
+ if (dnfs.length === 0) return [[]];
51
+ let result = [[]];
52
+ for (const dnf of dnfs) {
53
+ if (dnf.length === 0) {
54
+ return [];
55
+ }
56
+ const projected = result.length * dnf.length;
57
+ if (projected > MAX_DNF_DISJUNCTS) {
58
+ throw new AdvancedFilterComplexityError(projected);
59
+ }
60
+ const next = [];
61
+ for (const left of result) {
62
+ for (const right of dnf) {
63
+ next.push([...left, ...right]);
64
+ }
65
+ }
66
+ result = next;
67
+ }
68
+ return result;
69
+ }
70
+ function clauseKey(c) {
71
+ return JSON.stringify([c.field, c.op, c.value]);
72
+ }
73
+ function liftCommonClauses(dnf) {
74
+ if (dnf.length <= 1) return { common: [], remaining: dnf };
75
+ const counts = /* @__PURE__ */ new Map();
76
+ for (const disjunct of dnf) {
77
+ const seen = /* @__PURE__ */ new Set();
78
+ for (const c of disjunct) {
79
+ const key = clauseKey(c);
80
+ if (seen.has(key)) continue;
81
+ seen.add(key);
82
+ const entry = counts.get(key);
83
+ if (entry) entry.count += 1;
84
+ else counts.set(key, { clause: c, count: 1 });
85
+ }
86
+ }
87
+ const total = dnf.length;
88
+ const commonKeys = /* @__PURE__ */ new Set();
89
+ const common = [];
90
+ for (const [key, { clause, count }] of counts) {
91
+ if (count === total) {
92
+ commonKeys.add(key);
93
+ common.push(clause);
94
+ }
95
+ }
96
+ if (common.length === 0) return { common: [], remaining: dnf };
97
+ const remaining = dnf.map((d) => d.filter((c) => !commonKeys.has(clauseKey(c))));
98
+ return { common, remaining };
99
+ }
2
100
  function normalizeFilters(filters) {
3
101
  if (!filters) return [];
4
- const normalizeField = (key) => key.startsWith("cf_") ? `cf:${key.slice(3)}` : key;
5
102
  if (Array.isArray(filters)) {
6
103
  return filters.map((f) => ({
7
104
  ...f,
8
105
  field: normalizeField(String(f.field))
9
106
  }));
10
107
  }
108
+ const dnf = compileToDnf(filters);
109
+ if (dnf.length === 0) return [];
110
+ const nonEmpty = dnf.filter((disjunct) => disjunct.length > 0);
111
+ if (nonEmpty.length === 0) return [];
112
+ if (nonEmpty.length === 1) {
113
+ return nonEmpty[0].map((c) => ({ field: c.field, op: c.op, value: c.value }));
114
+ }
115
+ const { common, remaining } = liftCommonClauses(nonEmpty);
11
116
  const out = [];
12
- const obj = filters;
13
- const push = (field, op, value, orGroup) => {
14
- out.push({ field, op, value, orGroup });
15
- };
16
- if (Array.isArray(obj.$or)) {
17
- const clauses = obj.$or;
18
- for (let clauseIndex = 0; clauseIndex < clauses.length; clauseIndex++) {
19
- const clause = clauses[clauseIndex];
20
- const orGroupId = `or_${clauseIndex}`;
21
- if (clause && typeof clause === "object") {
22
- for (const [rawKey, rawVal] of Object.entries(clause)) {
23
- const field = normalizeField(rawKey);
24
- if (rawVal !== null && typeof rawVal === "object" && !Array.isArray(rawVal)) {
25
- for (const [opKey, opVal] of Object.entries(rawVal)) {
26
- const op = opKey.replace("$", "");
27
- if (["eq", "ne", "gt", "gte", "lt", "lte", "in", "nin", "like", "ilike", "exists"].includes(op)) {
28
- push(field, op, opVal, orGroupId);
29
- }
30
- }
31
- } else {
32
- push(field, "eq", rawVal, orGroupId);
33
- }
34
- }
35
- }
36
- }
117
+ for (const c of common) out.push({ field: c.field, op: c.op, value: c.value });
118
+ const remainingNonEmpty = remaining.filter((d) => d.length > 0);
119
+ if (remainingNonEmpty.length === 0) return out;
120
+ if (remainingNonEmpty.length === 1) {
121
+ for (const c of remainingNonEmpty[0]) out.push({ field: c.field, op: c.op, value: c.value });
122
+ return out;
37
123
  }
38
- for (const [rawKey, rawVal] of Object.entries(obj)) {
39
- if (rawKey === "$or") continue;
40
- const field = normalizeField(rawKey);
41
- if (rawVal !== null && typeof rawVal === "object" && !Array.isArray(rawVal)) {
42
- for (const [opKey, opVal] of Object.entries(rawVal)) {
43
- switch (opKey) {
44
- case "$eq":
45
- push(field, "eq", opVal);
46
- break;
47
- case "$ne":
48
- push(field, "ne", opVal);
49
- break;
50
- case "$gt":
51
- push(field, "gt", opVal);
52
- break;
53
- case "$gte":
54
- push(field, "gte", opVal);
55
- break;
56
- case "$lt":
57
- push(field, "lt", opVal);
58
- break;
59
- case "$lte":
60
- push(field, "lte", opVal);
61
- break;
62
- case "$in":
63
- push(field, "in", opVal);
64
- break;
65
- case "$nin":
66
- push(field, "nin", opVal);
67
- break;
68
- case "$like":
69
- push(field, "like", opVal);
70
- break;
71
- case "$ilike":
72
- push(field, "ilike", opVal);
73
- break;
74
- case "$exists":
75
- push(field, "exists", opVal);
76
- break;
77
- }
78
- }
79
- } else {
80
- push(field, "eq", rawVal);
124
+ for (let i = 0; i < remainingNonEmpty.length; i++) {
125
+ for (const c of remainingNonEmpty[i]) {
126
+ out.push({ field: c.field, op: c.op, value: c.value, orGroup: `or_${i}` });
81
127
  }
82
128
  }
83
129
  return out;
@@ -229,6 +275,8 @@ async function applyJoinFilters({
229
275
  return nextBuilder;
230
276
  }
231
277
  export {
278
+ AdvancedFilterComplexityError,
279
+ MAX_DNF_DISJUNCTS,
232
280
  applyJoinFilters,
233
281
  buildJoinChain,
234
282
  normalizeFilters,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/query/join-utils.ts"],
4
- "sourcesContent": ["import type { Kysely } from 'kysely'\nimport { sql } from 'kysely'\nimport type { QueryOptions, QueryJoinEdge } from './types'\nimport type { FilterOp } from './types'\n\ntype AnyBuilder = any\n\nexport type NormalizedFilter = { field: string; op: FilterOp; value?: unknown; orGroup?: string; qualified?: string | null }\n\nexport function normalizeFilters(filters?: QueryOptions['filters']): NormalizedFilter[] {\n if (!filters) return []\n const normalizeField = (key: string) => (key.startsWith('cf_') ? `cf:${key.slice(3)}` : key)\n if (Array.isArray(filters)) {\n return (filters as any[]).map((f) => ({\n ...f,\n field: normalizeField(String((f as any).field)),\n }))\n }\n const out: NormalizedFilter[] = []\n const obj = filters as Record<string, unknown>\n const push = (field: string, op: FilterOp, value?: unknown, orGroup?: string) => {\n out.push({ field, op, value, orGroup })\n }\n // Handle $or at top level \u2014 one group id per disjunct so fields inside a clause are ANDed by the engine.\n if (Array.isArray(obj.$or)) {\n const clauses = obj.$or as Record<string, unknown>[]\n for (let clauseIndex = 0; clauseIndex < clauses.length; clauseIndex++) {\n const clause = clauses[clauseIndex]\n const orGroupId = `or_${clauseIndex}`\n if (clause && typeof clause === 'object') {\n for (const [rawKey, rawVal] of Object.entries(clause)) {\n const field = normalizeField(rawKey)\n if (rawVal !== null && typeof rawVal === 'object' && !Array.isArray(rawVal)) {\n for (const [opKey, opVal] of Object.entries(rawVal as Record<string, unknown>)) {\n const op = opKey.replace('$', '') as FilterOp\n if (['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'like', 'ilike', 'exists'].includes(op)) {\n push(field, op, opVal, orGroupId)\n }\n }\n } else {\n push(field, 'eq', rawVal, orGroupId)\n }\n }\n }\n }\n }\n for (const [rawKey, rawVal] of Object.entries(obj)) {\n if (rawKey === '$or') continue\n const field = normalizeField(rawKey)\n if (rawVal !== null && typeof rawVal === 'object' && !Array.isArray(rawVal)) {\n for (const [opKey, opVal] of Object.entries(rawVal as Record<string, unknown>)) {\n switch (opKey) {\n case '$eq':\n push(field, 'eq', opVal)\n break\n case '$ne':\n push(field, 'ne', opVal)\n break\n case '$gt':\n push(field, 'gt', opVal)\n break\n case '$gte':\n push(field, 'gte', opVal)\n break\n case '$lt':\n push(field, 'lt', opVal)\n break\n case '$lte':\n push(field, 'lte', opVal)\n break\n case '$in':\n push(field, 'in', opVal)\n break\n case '$nin':\n push(field, 'nin', opVal)\n break\n case '$like':\n push(field, 'like', opVal)\n break\n case '$ilike':\n push(field, 'ilike', opVal)\n break\n case '$exists':\n push(field, 'exists', opVal)\n break\n }\n }\n } else {\n push(field, 'eq', rawVal)\n }\n }\n return out\n}\n\nexport type ResolvedJoin = {\n alias: string\n table: string\n entityId?: string | null\n fromAlias: string\n fromField: string\n toField: string\n type: 'left' | 'inner'\n}\n\nexport type BaseFilter = NormalizedFilter & { qualified?: string | null }\nexport type JoinFilter = { alias: string; column: string; op: FilterOp; value?: unknown }\n\nexport function resolveJoins(\n baseTable: string,\n joins: QueryJoinEdge[] | null | undefined,\n resolveTable: (entityId: string) => string | null,\n): ResolvedJoin[] {\n if (!joins || joins.length === 0) return []\n const resolved: ResolvedJoin[] = []\n const seen = new Set<string>()\n for (const entry of joins) {\n if (!entry || typeof entry !== 'object') continue\n const alias = typeof entry.alias === 'string' ? entry.alias.trim() : ''\n if (!alias) continue\n if (seen.has(alias)) continue\n const table =\n entry.table ??\n (entry.entityId ? resolveTable(String(entry.entityId)) : null)\n if (!table) continue\n const fromField = entry.from?.field?.trim()\n const toField = entry.to?.field?.trim()\n if (!fromField || !toField) continue\n const fromAliasRaw = entry.from?.alias?.trim()\n const fromAlias = fromAliasRaw && fromAliasRaw.length > 0 ? fromAliasRaw : 'base'\n const type: 'left' | 'inner' = entry.type === 'inner' ? 'inner' : 'left'\n resolved.push({\n alias,\n table,\n entityId: entry.entityId ? String(entry.entityId) : null,\n fromAlias,\n fromField,\n toField,\n type,\n })\n seen.add(alias)\n }\n return resolved\n}\n\nexport function buildJoinChain(\n alias: string,\n joinMap: Map<string, ResolvedJoin>,\n baseTable: string,\n visited: Set<string> = new Set(),\n): ResolvedJoin[] {\n if (visited.has(alias)) {\n throw new Error(`QueryEngine: circular join reference detected for alias ${alias}`)\n }\n const cfg = joinMap.get(alias)\n if (!cfg) return []\n visited.add(alias)\n if (!cfg.fromAlias || cfg.fromAlias === 'base' || cfg.fromAlias === baseTable) {\n return [cfg]\n }\n const parentChain = buildJoinChain(cfg.fromAlias, joinMap, baseTable, visited)\n if (parentChain.length === 0) return []\n return [...parentChain, cfg]\n}\n\nexport function partitionFilters(\n baseTable: string,\n filters: NormalizedFilter[],\n joinMap: Map<string, ResolvedJoin>,\n): { baseFilters: BaseFilter[]; joinFilters: Map<string, JoinFilter[]> } {\n const baseFilters: BaseFilter[] = []\n const joinFilters = new Map<string, JoinFilter[]>()\n for (const filter of filters) {\n const field = String(filter.field)\n if (field.startsWith('cf:')) continue\n const parts = field.split('.')\n if (parts.length === 2) {\n const [aliasNameRaw, column] = parts\n const aliasName = aliasNameRaw || ''\n if (joinMap.has(aliasName)) {\n const list = joinFilters.get(aliasName) ?? []\n list.push({ alias: aliasName, column, op: filter.op, value: filter.value })\n joinFilters.set(aliasName, list)\n continue\n }\n if (aliasName === baseTable || aliasName === 'base') {\n baseFilters.push({\n field: column,\n op: filter.op,\n value: filter.value,\n qualified: `${baseTable}.${column}`,\n })\n continue\n }\n }\n baseFilters.push({ ...filter })\n }\n return { baseFilters, joinFilters }\n}\n\ntype ApplyJoinFiltersOptions = {\n db: Kysely<any>\n baseTable: string\n builder: AnyBuilder\n joinMap: Map<string, ResolvedJoin>\n joinFilters: Map<string, JoinFilter[]>\n aliasTables: Map<string, string>\n qualifyBase: (column: string) => string\n applyAliasScope: (builder: AnyBuilder, alias: string, table: string) => Promise<AnyBuilder> | AnyBuilder\n applyFilterOp: (builder: AnyBuilder, column: string, op: FilterOp, value?: unknown) => AnyBuilder\n applyJoinFilterOp?: (builder: AnyBuilder, filter: JoinFilter, qualified: string, join: ResolvedJoin, table: string) => Promise<{ applied: boolean; builder: AnyBuilder }> | { applied: boolean; builder: AnyBuilder }\n columnExists?: (table: string, column: string) => Promise<boolean> | boolean\n}\n\nexport async function applyJoinFilters({\n db,\n baseTable,\n builder,\n joinMap,\n joinFilters,\n aliasTables,\n qualifyBase,\n applyAliasScope,\n applyFilterOp,\n applyJoinFilterOp,\n columnExists,\n}: ApplyJoinFiltersOptions): Promise<AnyBuilder> {\n const resolveAliasName = (aliasName?: string | null) => {\n if (!aliasName || aliasName === 'base') return baseTable\n return aliasName\n }\n\n let nextBuilder = builder\n for (const [alias, filtersForAlias] of joinFilters.entries()) {\n const chain = buildJoinChain(alias, joinMap, baseTable)\n if (!chain.length) continue\n const first = chain[0]\n let sub: AnyBuilder = db.selectFrom(`${first.table} as ${first.alias}` as any).select(sql`1`.as('one'))\n sub = await applyAliasScope(sub, first.alias, first.table)\n const parentAlias = resolveAliasName(first.fromAlias)\n const parentRef = parentAlias === baseTable ? qualifyBase(first.fromField) : `${parentAlias}.${first.fromField}`\n sub = sub.whereRef(`${first.alias}.${first.toField}`, '=', parentRef)\n for (const cfg of chain.slice(1)) {\n const parent = resolveAliasName(cfg.fromAlias)\n const rightRef = parent === baseTable ? qualifyBase(cfg.fromField) : `${parent}.${cfg.fromField}`\n const joinFn = cfg.type === 'inner' ? 'innerJoin' : 'leftJoin'\n sub = (sub as any)[joinFn](`${cfg.table} as ${cfg.alias}`, (jb: any) =>\n jb.onRef(`${cfg.alias}.${cfg.toField}`, '=', rightRef))\n sub = await applyAliasScope(sub, cfg.alias, cfg.table)\n }\n let existsDirective: boolean | null = null\n for (const filter of filtersForAlias) {\n if (filter.op === 'exists') {\n if (filter.value === false) existsDirective = false\n else if (existsDirective === null) existsDirective = true\n continue\n }\n const join = joinMap.get(filter.alias)\n if (!join) continue\n const targetTable = aliasTables.get(filter.alias)\n if (!targetTable) continue\n if (columnExists) {\n const exists = await columnExists(targetTable, filter.column)\n if (!exists) continue\n }\n const qualified = `${filter.alias}.${filter.column}`\n if (applyJoinFilterOp) {\n const result = await applyJoinFilterOp(sub, filter, qualified, join, targetTable)\n if (result && result.applied) {\n sub = result.builder\n continue\n }\n if (result && result.builder) {\n sub = result.builder\n }\n }\n sub = applyFilterOp(sub, qualified, filter.op, filter.value)\n }\n if (existsDirective === false) {\n const capturedSub = sub\n nextBuilder = nextBuilder.where((eb: any) => eb.not(eb.exists(capturedSub)))\n } else {\n const capturedSub = sub\n nextBuilder = nextBuilder.where((eb: any) => eb.exists(capturedSub))\n }\n }\n return nextBuilder\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { Kysely } from 'kysely'\nimport { sql } from 'kysely'\nimport type { QueryOptions, QueryJoinEdge } from './types'\nimport type { FilterOp } from './types'\n\ntype AnyBuilder = any\n\nexport type NormalizedFilter = { field: string; op: FilterOp; value?: unknown; orGroup?: string; qualified?: string | null }\n\nconst VALID_OPS = ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'like', 'ilike', 'exists'] as const\n\nconst normalizeField = (key: string) => (key.startsWith('cf_') ? `cf:${key.slice(3)}` : key)\n\ntype LeafClause = { field: string; op: FilterOp; value?: unknown }\n\n/**\n * Cap on DNF disjunct count to prevent adversarial / pathological filters from\n * exploding the in-memory normalization. `validateTreeLimits` already caps\n * editor input at 50 rules / 15 children per group / 3 levels of nesting, but\n * a hand-crafted URL or buggy preset could still bypass that. The cap mirrors\n * the editor's posture: fail loud, refuse to compile.\n */\nexport const MAX_DNF_DISJUNCTS = 1000\n\nexport class AdvancedFilterComplexityError extends Error {\n readonly code = 'ADVANCED_FILTER_TOO_COMPLEX'\n readonly disjunctCount: number\n constructor(disjunctCount: number) {\n super(\n `Advanced filter expanded to ${disjunctCount} disjuncts, exceeding the limit of ${MAX_DNF_DISJUNCTS}. ` +\n `Reduce the number of OR branches or simplify nested groups.`,\n )\n this.name = 'AdvancedFilterComplexityError'\n this.disjunctCount = disjunctCount\n }\n}\n\n/**\n * Expand a Mongo-style filter object (with arbitrary nesting of `$and`/`$or` and\n * implicit AND between sibling keys) into Disjunctive Normal Form: `Clause[][]`,\n * where the outer array is OR'd alternatives and each inner array is the AND'd\n * leaves of one alternative.\n *\n * Empty filter -> `[[]]` (one empty disjunct, treated as \"no constraint\").\n * Throws `AdvancedFilterComplexityError` if any intermediate cartesian product\n * exceeds `MAX_DNF_DISJUNCTS`.\n */\nfunction compileToDnf(filter: unknown): LeafClause[][] {\n if (filter === null || filter === undefined) return [[]]\n if (Array.isArray(filter) || typeof filter !== 'object') return [[]]\n const obj = filter as Record<string, unknown>\n const partialDnfs: LeafClause[][][] = []\n\n for (const [rawKey, rawVal] of Object.entries(obj)) {\n if (rawKey === '$and' && Array.isArray(rawVal)) {\n partialDnfs.push(cartesianAnd((rawVal as unknown[]).map(compileToDnf)))\n continue\n }\n if (rawKey === '$or' && Array.isArray(rawVal)) {\n const expanded = (rawVal as unknown[]).flatMap(compileToDnf)\n if (expanded.length > MAX_DNF_DISJUNCTS) {\n throw new AdvancedFilterComplexityError(expanded.length)\n }\n partialDnfs.push(expanded)\n continue\n }\n const field = normalizeField(rawKey)\n if (rawVal !== null && typeof rawVal === 'object' && !Array.isArray(rawVal)) {\n const clauses: LeafClause[] = []\n for (const [opKey, opVal] of Object.entries(rawVal as Record<string, unknown>)) {\n const op = (opKey.startsWith('$') ? opKey.slice(1) : opKey) as FilterOp\n if (VALID_OPS.includes(op as (typeof VALID_OPS)[number])) {\n clauses.push({ field, op, value: opVal })\n }\n }\n if (clauses.length > 0) partialDnfs.push([clauses])\n } else {\n partialDnfs.push([[{ field, op: 'eq' as FilterOp, value: rawVal }]])\n }\n }\n\n return cartesianAnd(partialDnfs)\n}\n\nfunction cartesianAnd(dnfs: LeafClause[][][]): LeafClause[][] {\n if (dnfs.length === 0) return [[]]\n let result: LeafClause[][] = [[]]\n for (const dnf of dnfs) {\n if (dnf.length === 0) {\n // An empty DNF means an unsatisfiable subexpression; the AND becomes unsatisfiable\n // too. Represent that as an empty outer array (no disjuncts).\n return []\n }\n // Predict the next size to fail loud before allocating millions of arrays.\n const projected = result.length * dnf.length\n if (projected > MAX_DNF_DISJUNCTS) {\n throw new AdvancedFilterComplexityError(projected)\n }\n const next: LeafClause[][] = []\n for (const left of result) {\n for (const right of dnf) {\n next.push([...left, ...right])\n }\n }\n result = next\n }\n return result\n}\n\nfunction clauseKey(c: LeafClause): string {\n return JSON.stringify([c.field, c.op, c.value])\n}\n\n/**\n * Lift clauses that appear in every disjunct into a \"common\" set so the engine\n * can treat them as regular ANDed base filters (preserving the search-tokens\n * optimization for like/ilike clauses) instead of duplicating them across OR\n * groups.\n */\nfunction liftCommonClauses(dnf: LeafClause[][]): { common: LeafClause[]; remaining: LeafClause[][] } {\n if (dnf.length <= 1) return { common: [], remaining: dnf }\n const counts = new Map<string, { clause: LeafClause; count: number }>()\n for (const disjunct of dnf) {\n const seen = new Set<string>()\n for (const c of disjunct) {\n const key = clauseKey(c)\n if (seen.has(key)) continue // count once per disjunct even if duplicated\n seen.add(key)\n const entry = counts.get(key)\n if (entry) entry.count += 1\n else counts.set(key, { clause: c, count: 1 })\n }\n }\n const total = dnf.length\n const commonKeys = new Set<string>()\n const common: LeafClause[] = []\n for (const [key, { clause, count }] of counts) {\n if (count === total) {\n commonKeys.add(key)\n common.push(clause)\n }\n }\n if (common.length === 0) return { common: [], remaining: dnf }\n const remaining = dnf.map((d) => d.filter((c) => !commonKeys.has(clauseKey(c))))\n return { common, remaining }\n}\n\nexport function normalizeFilters(filters?: QueryOptions['filters']): NormalizedFilter[] {\n if (!filters) return []\n if (Array.isArray(filters)) {\n return (filters as any[]).map((f) => ({\n ...f,\n field: normalizeField(String((f as any).field)),\n }))\n }\n const dnf = compileToDnf(filters)\n if (dnf.length === 0) return []\n const nonEmpty = dnf.filter((disjunct) => disjunct.length > 0)\n if (nonEmpty.length === 0) return []\n if (nonEmpty.length === 1) {\n return nonEmpty[0].map((c) => ({ field: c.field, op: c.op, value: c.value }))\n }\n const { common, remaining } = liftCommonClauses(nonEmpty)\n const out: NormalizedFilter[] = []\n for (const c of common) out.push({ field: c.field, op: c.op, value: c.value })\n // Drop disjuncts that became empty after lifting (they're already represented in `common`).\n const remainingNonEmpty = remaining.filter((d) => d.length > 0)\n if (remainingNonEmpty.length === 0) return out\n if (remainingNonEmpty.length === 1) {\n for (const c of remainingNonEmpty[0]) out.push({ field: c.field, op: c.op, value: c.value })\n return out\n }\n for (let i = 0; i < remainingNonEmpty.length; i++) {\n for (const c of remainingNonEmpty[i]) {\n out.push({ field: c.field, op: c.op, value: c.value, orGroup: `or_${i}` })\n }\n }\n return out\n}\n\nexport type ResolvedJoin = {\n alias: string\n table: string\n entityId?: string | null\n fromAlias: string\n fromField: string\n toField: string\n type: 'left' | 'inner'\n}\n\nexport type BaseFilter = NormalizedFilter & { qualified?: string | null }\nexport type JoinFilter = { alias: string; column: string; op: FilterOp; value?: unknown }\n\nexport function resolveJoins(\n baseTable: string,\n joins: QueryJoinEdge[] | null | undefined,\n resolveTable: (entityId: string) => string | null,\n): ResolvedJoin[] {\n if (!joins || joins.length === 0) return []\n const resolved: ResolvedJoin[] = []\n const seen = new Set<string>()\n for (const entry of joins) {\n if (!entry || typeof entry !== 'object') continue\n const alias = typeof entry.alias === 'string' ? entry.alias.trim() : ''\n if (!alias) continue\n if (seen.has(alias)) continue\n const table =\n entry.table ??\n (entry.entityId ? resolveTable(String(entry.entityId)) : null)\n if (!table) continue\n const fromField = entry.from?.field?.trim()\n const toField = entry.to?.field?.trim()\n if (!fromField || !toField) continue\n const fromAliasRaw = entry.from?.alias?.trim()\n const fromAlias = fromAliasRaw && fromAliasRaw.length > 0 ? fromAliasRaw : 'base'\n const type: 'left' | 'inner' = entry.type === 'inner' ? 'inner' : 'left'\n resolved.push({\n alias,\n table,\n entityId: entry.entityId ? String(entry.entityId) : null,\n fromAlias,\n fromField,\n toField,\n type,\n })\n seen.add(alias)\n }\n return resolved\n}\n\nexport function buildJoinChain(\n alias: string,\n joinMap: Map<string, ResolvedJoin>,\n baseTable: string,\n visited: Set<string> = new Set(),\n): ResolvedJoin[] {\n if (visited.has(alias)) {\n throw new Error(`QueryEngine: circular join reference detected for alias ${alias}`)\n }\n const cfg = joinMap.get(alias)\n if (!cfg) return []\n visited.add(alias)\n if (!cfg.fromAlias || cfg.fromAlias === 'base' || cfg.fromAlias === baseTable) {\n return [cfg]\n }\n const parentChain = buildJoinChain(cfg.fromAlias, joinMap, baseTable, visited)\n if (parentChain.length === 0) return []\n return [...parentChain, cfg]\n}\n\nexport function partitionFilters(\n baseTable: string,\n filters: NormalizedFilter[],\n joinMap: Map<string, ResolvedJoin>,\n): { baseFilters: BaseFilter[]; joinFilters: Map<string, JoinFilter[]> } {\n const baseFilters: BaseFilter[] = []\n const joinFilters = new Map<string, JoinFilter[]>()\n for (const filter of filters) {\n const field = String(filter.field)\n if (field.startsWith('cf:')) continue\n const parts = field.split('.')\n if (parts.length === 2) {\n const [aliasNameRaw, column] = parts\n const aliasName = aliasNameRaw || ''\n if (joinMap.has(aliasName)) {\n const list = joinFilters.get(aliasName) ?? []\n list.push({ alias: aliasName, column, op: filter.op, value: filter.value })\n joinFilters.set(aliasName, list)\n continue\n }\n if (aliasName === baseTable || aliasName === 'base') {\n baseFilters.push({\n field: column,\n op: filter.op,\n value: filter.value,\n qualified: `${baseTable}.${column}`,\n })\n continue\n }\n }\n baseFilters.push({ ...filter })\n }\n return { baseFilters, joinFilters }\n}\n\ntype ApplyJoinFiltersOptions = {\n db: Kysely<any>\n baseTable: string\n builder: AnyBuilder\n joinMap: Map<string, ResolvedJoin>\n joinFilters: Map<string, JoinFilter[]>\n aliasTables: Map<string, string>\n qualifyBase: (column: string) => string\n applyAliasScope: (builder: AnyBuilder, alias: string, table: string) => Promise<AnyBuilder> | AnyBuilder\n applyFilterOp: (builder: AnyBuilder, column: string, op: FilterOp, value?: unknown) => AnyBuilder\n applyJoinFilterOp?: (builder: AnyBuilder, filter: JoinFilter, qualified: string, join: ResolvedJoin, table: string) => Promise<{ applied: boolean; builder: AnyBuilder }> | { applied: boolean; builder: AnyBuilder }\n columnExists?: (table: string, column: string) => Promise<boolean> | boolean\n}\n\nexport async function applyJoinFilters({\n db,\n baseTable,\n builder,\n joinMap,\n joinFilters,\n aliasTables,\n qualifyBase,\n applyAliasScope,\n applyFilterOp,\n applyJoinFilterOp,\n columnExists,\n}: ApplyJoinFiltersOptions): Promise<AnyBuilder> {\n const resolveAliasName = (aliasName?: string | null) => {\n if (!aliasName || aliasName === 'base') return baseTable\n return aliasName\n }\n\n let nextBuilder = builder\n for (const [alias, filtersForAlias] of joinFilters.entries()) {\n const chain = buildJoinChain(alias, joinMap, baseTable)\n if (!chain.length) continue\n const first = chain[0]\n let sub: AnyBuilder = db.selectFrom(`${first.table} as ${first.alias}` as any).select(sql`1`.as('one'))\n sub = await applyAliasScope(sub, first.alias, first.table)\n const parentAlias = resolveAliasName(first.fromAlias)\n const parentRef = parentAlias === baseTable ? qualifyBase(first.fromField) : `${parentAlias}.${first.fromField}`\n sub = sub.whereRef(`${first.alias}.${first.toField}`, '=', parentRef)\n for (const cfg of chain.slice(1)) {\n const parent = resolveAliasName(cfg.fromAlias)\n const rightRef = parent === baseTable ? qualifyBase(cfg.fromField) : `${parent}.${cfg.fromField}`\n const joinFn = cfg.type === 'inner' ? 'innerJoin' : 'leftJoin'\n sub = (sub as any)[joinFn](`${cfg.table} as ${cfg.alias}`, (jb: any) =>\n jb.onRef(`${cfg.alias}.${cfg.toField}`, '=', rightRef))\n sub = await applyAliasScope(sub, cfg.alias, cfg.table)\n }\n let existsDirective: boolean | null = null\n for (const filter of filtersForAlias) {\n if (filter.op === 'exists') {\n if (filter.value === false) existsDirective = false\n else if (existsDirective === null) existsDirective = true\n continue\n }\n const join = joinMap.get(filter.alias)\n if (!join) continue\n const targetTable = aliasTables.get(filter.alias)\n if (!targetTable) continue\n if (columnExists) {\n const exists = await columnExists(targetTable, filter.column)\n if (!exists) continue\n }\n const qualified = `${filter.alias}.${filter.column}`\n if (applyJoinFilterOp) {\n const result = await applyJoinFilterOp(sub, filter, qualified, join, targetTable)\n if (result && result.applied) {\n sub = result.builder\n continue\n }\n if (result && result.builder) {\n sub = result.builder\n }\n }\n sub = applyFilterOp(sub, qualified, filter.op, filter.value)\n }\n if (existsDirective === false) {\n const capturedSub = sub\n nextBuilder = nextBuilder.where((eb: any) => eb.not(eb.exists(capturedSub)))\n } else {\n const capturedSub = sub\n nextBuilder = nextBuilder.where((eb: any) => eb.exists(capturedSub))\n }\n }\n return nextBuilder\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.6.1-develop.3276.1.1fcd145078";
1
+ const APP_VERSION = "0.6.1-develop.3278.1.a4d26475e4";
2
2
  const appVersion = APP_VERSION;
3
3
  export {
4
4
  APP_VERSION,
@@ -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.1-develop.3276.1.1fcd145078'\nexport const appVersion = APP_VERSION\n"],
4
+ "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.1-develop.3278.1.a4d26475e4'\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.1-develop.3276.1.1fcd145078",
3
+ "version": "0.6.1-develop.3278.1.a4d26475e4",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -92,7 +92,7 @@
92
92
  "@mikro-orm/core": "^7.0.15",
93
93
  "@mikro-orm/decorators": "^7.0.15",
94
94
  "@mikro-orm/postgresql": "^7.0.15",
95
- "@open-mercato/cache": "0.6.1-develop.3276.1.1fcd145078",
95
+ "@open-mercato/cache": "0.6.1-develop.3278.1.a4d26475e4",
96
96
  "dotenv": "^17.4.2",
97
97
  "rate-limiter-flexible": "^11.1.0",
98
98
  "reflect-metadata": "^0.2.2",
@@ -0,0 +1,113 @@
1
+ /** @jest-environment node */
2
+ import { mergeAdvancedFilterTree, mergeAdvancedFiltersFromQuery } from '../advanced-filter-integration'
3
+ import type { AdvancedFilterTree, FilterRule, FilterGroup } from '../../query/advanced-filter-tree'
4
+ import { createEmptyTree } from '../../query/advanced-filter-tree'
5
+
6
+ const rule = (field: string, value: string): FilterRule => ({
7
+ id: field, type: 'rule', field, operator: 'contains', value,
8
+ })
9
+ const group = (combinator: 'and' | 'or', children: Array<FilterRule | FilterGroup>): FilterGroup => ({
10
+ id: combinator, type: 'group', combinator, children,
11
+ })
12
+ const treeOf = (root: FilterGroup): AdvancedFilterTree => ({ root })
13
+
14
+ describe('mergeAdvancedFilterTree', () => {
15
+ it('returns route filters unchanged when tree is null', () => {
16
+ expect(mergeAdvancedFilterTree({ kind: 'person' }, null)).toEqual({ kind: 'person' })
17
+ })
18
+
19
+ it('returns route filters unchanged when tree is empty', () => {
20
+ expect(mergeAdvancedFilterTree({ kind: 'person' }, createEmptyTree())).toEqual({ kind: 'person' })
21
+ })
22
+
23
+ it('returns the tree where when route filters are empty', () => {
24
+ const tree = treeOf(group('and', [rule('display_name', 'Daniel')]))
25
+ expect(mergeAdvancedFilterTree({}, tree)).toEqual({ display_name: { $ilike: '%Daniel%' } })
26
+ })
27
+
28
+ it('AND-combines route filters with tree output via $and', () => {
29
+ const tree = treeOf(group('and', [
30
+ rule('display_name', 'Daniel'),
31
+ rule('display_name', 'Harris'),
32
+ ]))
33
+ expect(mergeAdvancedFilterTree({ kind: 'person' }, tree)).toEqual({
34
+ $and: [
35
+ { kind: 'person' },
36
+ { $and: [
37
+ { display_name: { $ilike: '%Daniel%' } },
38
+ { display_name: { $ilike: '%Harris%' } },
39
+ ]},
40
+ ],
41
+ })
42
+ })
43
+
44
+ it('preserves nested OR groups in the AND-combined output', () => {
45
+ const tree = treeOf(group('and', [
46
+ group('or', [rule('display_name', 'Daniel'), rule('display_name', 'Naomi')]),
47
+ rule('status', 'Lead'),
48
+ ]))
49
+ expect(mergeAdvancedFilterTree({ kind: 'person' }, tree)).toEqual({
50
+ $and: [
51
+ { kind: 'person' },
52
+ { $and: [
53
+ { $or: [
54
+ { display_name: { $ilike: '%Daniel%' } },
55
+ { display_name: { $ilike: '%Naomi%' } },
56
+ ]},
57
+ { status: { $ilike: '%Lead%' } },
58
+ ]},
59
+ ],
60
+ })
61
+ })
62
+ })
63
+
64
+ describe('mergeAdvancedFiltersFromQuery', () => {
65
+ it('parses v2 tree URLs', () => {
66
+ const out = mergeAdvancedFiltersFromQuery(
67
+ { kind: 'person' },
68
+ {
69
+ 'filter[v]': '2',
70
+ 'filter[root][combinator]': 'and',
71
+ 'filter[root][children][0][type]': 'rule',
72
+ 'filter[root][children][0][field]': 'display_name',
73
+ 'filter[root][children][0][op]': 'contains',
74
+ 'filter[root][children][0][value]': 'Daniel',
75
+ },
76
+ )
77
+ expect(out).toEqual({
78
+ $and: [
79
+ { kind: 'person' },
80
+ { display_name: { $ilike: '%Daniel%' } },
81
+ ],
82
+ })
83
+ })
84
+
85
+ it('upgrades legacy v1 URLs into a tree under SQL precedence', () => {
86
+ const out = mergeAdvancedFiltersFromQuery(
87
+ { kind: 'person' },
88
+ {
89
+ 'filter[logic]': 'and',
90
+ 'filter[conditions][0][field]': 'display_name',
91
+ 'filter[conditions][0][op]': 'contains',
92
+ 'filter[conditions][0][value]': 'Daniel',
93
+ 'filter[conditions][1][field]': 'display_name',
94
+ 'filter[conditions][1][op]': 'contains',
95
+ 'filter[conditions][1][value]': 'Naomi',
96
+ 'filter[conditions][1][join]': 'or',
97
+ },
98
+ )
99
+ expect(out).toEqual({
100
+ $and: [
101
+ { kind: 'person' },
102
+ { $or: [
103
+ { display_name: { $ilike: '%Daniel%' } },
104
+ { display_name: { $ilike: '%Naomi%' } },
105
+ ]},
106
+ ],
107
+ })
108
+ })
109
+
110
+ it('returns route filters unchanged when no advanced filter present', () => {
111
+ expect(mergeAdvancedFiltersFromQuery({ kind: 'person' }, { page: '1' })).toEqual({ kind: 'person' })
112
+ })
113
+ })
@@ -1,4 +1,10 @@
1
- import { deserializeAdvancedFilter, convertAdvancedFilterToWhere } from '../query/advanced-filter'
1
+ import {
2
+ deserializeAdvancedFilter,
3
+ deserializeTree,
4
+ flatToTree,
5
+ convertAdvancedFilterToWhere,
6
+ } from '../query/advanced-filter'
7
+ import { compileTreeToWhere, type AdvancedFilterTree } from '../query/advanced-filter-tree'
2
8
 
3
9
  function splitOrClauses(filters: Record<string, unknown>): {
4
10
  directFilters: Record<string, unknown>
@@ -72,3 +78,81 @@ export function mergeAdvancedFilters(
72
78
  // AND logic: merge directly
73
79
  return { ...existingFilters, ...advancedWhere }
74
80
  }
81
+
82
+ /**
83
+ * Combine route-side filters with an advanced filter tree by AND.
84
+ *
85
+ * The compiled tree may itself contain nested `$and`/`$or` — the query engine's
86
+ * normalize step (`compileToDnf` in join-utils.ts) expands those into Disjunctive
87
+ * Normal Form, so the engine sees a single OR group with disjuncts that AND'd
88
+ * leaves. Common-clause lifting in normalize keeps shared predicates ungrouped
89
+ * (preserving the search-tokens optimization).
90
+ */
91
+ export function mergeAdvancedFilterTree(
92
+ routeFilters: Record<string, unknown>,
93
+ tree: AdvancedFilterTree | null,
94
+ ): Record<string, unknown> {
95
+ if (!tree) return routeFilters
96
+ const treeWhere = compileTreeToWhere(tree)
97
+ if (!treeWhere) return routeFilters
98
+ if (Object.keys(routeFilters).length === 0) return treeWhere
99
+ // AND-combine via $and so any nested $and/$or in either side reaches normalize unchanged.
100
+ return { $and: [routeFilters, treeWhere] }
101
+ }
102
+
103
+ /**
104
+ * Read-old-write-new helper: parse the request `query` for either a v2 tree URL
105
+ * or a legacy v1 flat URL (auto-upgraded via flatToTree under SQL precedence),
106
+ * and AND-combine the result with the route's existing filters.
107
+ */
108
+ export function mergeAdvancedFiltersFromQuery(
109
+ routeFilters: Record<string, unknown>,
110
+ query: Record<string, unknown>,
111
+ ): Record<string, unknown> {
112
+ const v2 = deserializeTree(query)
113
+ if (v2) return mergeAdvancedFilterTree(routeFilters, v2)
114
+ const flat = deserializeAdvancedFilter(query)
115
+ if (!flat) return routeFilters
116
+ return mergeAdvancedFilterTree(routeFilters, flatToTree(flat))
117
+ }
118
+
119
+ /**
120
+ * Returns the request's advanced filter as a tree, after stripping the
121
+ * `filter[...]` keys from the query map. Mirrors `mergeAdvancedFiltersFromQuery`
122
+ * but exposes the tree so callers can apply additional shaping (e.g. mapping to
123
+ * `query.ids`, narrowing by feature flags, or surfacing the tree to enrichers)
124
+ * before merging into the route filter.
125
+ *
126
+ * - Detects v2 tree URL params first.
127
+ * - Falls back to legacy v1 flat params, upgraded via `flatToTree` under
128
+ * standard SQL precedence (AND binds tighter than OR).
129
+ * - Mutates `query` in place: every `filter[...]` key is removed after parsing.
130
+ *
131
+ * Returns `null` when no advanced-filter query params are present.
132
+ */
133
+ export function consumeAdvancedFilterState(query: Record<string, unknown>): AdvancedFilterTree | null {
134
+ let tree: AdvancedFilterTree | null = deserializeTree(query)
135
+ if (!tree) {
136
+ const flat = deserializeAdvancedFilter(query)
137
+ if (flat) {
138
+ tree = flatToTree(flat)
139
+ if (process.env.NODE_ENV !== 'production') {
140
+ const sentinel = '__OM_FILTER_V1_WARNED'
141
+ const g = globalThis as Record<string, unknown>
142
+ if (!g[sentinel]) {
143
+ g[sentinel] = true
144
+ // eslint-disable-next-line no-console
145
+ console.warn('[advanced-filter] legacy v1 URL detected; auto-upgraded to v2 tree')
146
+ }
147
+ }
148
+ }
149
+ }
150
+
151
+ for (const key of Object.keys(query)) {
152
+ if (key.startsWith('filter[')) {
153
+ delete query[key]
154
+ }
155
+ }
156
+
157
+ return tree
158
+ }