@entifix/sql 0.8.0 → 0.9.0

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.
@@ -6,11 +6,24 @@ import type { Statement } from 'effect/sql';
6
6
  * are the wildcards, and `\` is Postgres' default escape character, so it has to
7
7
  * escape itself first — which a single character class does for free.
8
8
  *
9
- * This is the SQL counterpart to the Mongo translator's `escapeRegex`: `like` is
10
- * an unanchored "contains" match in both, so a user typing `100%` searches for
9
+ * This is the SQL counterpart to the Mongo translator's escaping: `like` is an
10
+ * unanchored "contains" match in both, so a user typing `100%` searches for
11
11
  * that string rather than for everything.
12
12
  */
13
13
  export declare function escapeLikePattern(value: string): string;
14
+ /**
15
+ * The two strings `translate` maps a column through so `like` ignores accents:
16
+ * every accented form, in both cases, onto its lowercase base letter — built
17
+ * from core's `FOLD_TABLE`, so the column folds by the same rule the in-memory
18
+ * matcher and the Mongo pattern use. `ILIKE` then handles the ASCII case.
19
+ *
20
+ * The built-in `translate` rather than `unaccent`, which is an extension a
21
+ * database must have created before any query could use it.
22
+ */
23
+ export declare const FOLD_TRANSLATION: {
24
+ readonly from: string;
25
+ readonly to: string;
26
+ };
14
27
  /**
15
28
  * Translates the request's `filtering` into a single `WHERE`-body fragment, or
16
29
  * `undefined` when there is nothing to filter on (the caller then omits the
@@ -1 +1 @@
1
- {"version":3,"file":"sql-filter-translator.d.ts","sourceRoot":"","sources":["../../src/adapters/sql-filter-translator.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,MAAM,EACX,KAAK,iBAAiB,EAItB,KAAK,eAAe,EACpB,KAAK,aAAa,EAGnB,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAC5C,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAY5C;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEvD;AAiHD;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,SAAS,MAAM,EACvD,GAAG,EAAE,SAAS,CAAC,SAAS,EACxB,iBAAiB,EAAE,iBAAiB,CAAC,OAAO,CAAC,EAC7C,SAAS,CAAC,EAAE,eAAe,CAAC,OAAO,CAAC,EAAE,GACrC,SAAS,CAAC,QAAQ,GAAG,SAAS,CAsBhC;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,SAAS,MAAM,EACrD,GAAG,EAAE,SAAS,CAAC,SAAS,EACxB,iBAAiB,EAAE,iBAAiB,CAAC,OAAO,CAAC,EAC7C,OAAO,CAAC,EAAE,aAAa,CAAC,OAAO,CAAC,EAAE,GACjC,SAAS,CAAC,QAAQ,GAAG,SAAS,CA6ChC"}
1
+ {"version":3,"file":"sql-filter-translator.d.ts","sourceRoot":"","sources":["../../src/adapters/sql-filter-translator.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,MAAM,EACX,KAAK,iBAAiB,EAItB,KAAK,eAAe,EACpB,KAAK,aAAa,EAKnB,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAC5C,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAY5C;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEvD;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,gBAAgB,EAAE;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;CAWrE,CAAC;AAiHP;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,SAAS,MAAM,EACvD,GAAG,EAAE,SAAS,CAAC,SAAS,EACxB,iBAAiB,EAAE,iBAAiB,CAAC,OAAO,CAAC,EAC7C,SAAS,CAAC,EAAE,eAAe,CAAC,OAAO,CAAC,EAAE,GACrC,SAAS,CAAC,QAAQ,GAAG,SAAS,CAsBhC;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,SAAS,MAAM,EACrD,GAAG,EAAE,SAAS,CAAC,SAAS,EACxB,iBAAiB,EAAE,iBAAiB,CAAC,OAAO,CAAC,EAC7C,OAAO,CAAC,EAAE,aAAa,CAAC,OAAO,CAAC,EAAE,GACjC,SAAS,CAAC,QAAQ,GAAG,SAAS,CA6ChC"}
@@ -1,4 +1,4 @@
1
- import { allowedDescriptors, resolveAllowedProperty } from '@entifix/core';
1
+ import { allowedDescriptors, FOLD_TABLE, foldText, resolveAllowedProperty } from '@entifix/core';
2
2
  /** SQL comparison for each binary operator. Fixed strings, never user input. */ const BINARY_OPERATORS = {
3
3
  eq: '=',
4
4
  ne: '<>',
@@ -12,12 +12,34 @@ import { allowedDescriptors, resolveAllowedProperty } from '@entifix/core';
12
12
  * are the wildcards, and `\` is Postgres' default escape character, so it has to
13
13
  * escape itself first — which a single character class does for free.
14
14
  *
15
- * This is the SQL counterpart to the Mongo translator's `escapeRegex`: `like` is
16
- * an unanchored "contains" match in both, so a user typing `100%` searches for
15
+ * This is the SQL counterpart to the Mongo translator's escaping: `like` is an
16
+ * unanchored "contains" match in both, so a user typing `100%` searches for
17
17
  * that string rather than for everything.
18
18
  */ export function escapeLikePattern(value) {
19
19
  return value.replace(/[\\%_]/g, '\\$&');
20
20
  }
21
+ /**
22
+ * The two strings `translate` maps a column through so `like` ignores accents:
23
+ * every accented form, in both cases, onto its lowercase base letter — built
24
+ * from core's `FOLD_TABLE`, so the column folds by the same rule the in-memory
25
+ * matcher and the Mongo pattern use. `ILIKE` then handles the ASCII case.
26
+ *
27
+ * The built-in `translate` rather than `unaccent`, which is an extension a
28
+ * database must have created before any query could use it.
29
+ */ export const FOLD_TRANSLATION = (()=>{
30
+ let from = '';
31
+ let to = '';
32
+ for (const [base, forms] of Object.entries(FOLD_TABLE)){
33
+ // The table lists the base and its capital first; `ILIKE` folds those.
34
+ const accented = forms.slice(2);
35
+ from += accented;
36
+ to += base.repeat(accented.length);
37
+ }
38
+ return {
39
+ from,
40
+ to
41
+ };
42
+ })();
21
43
  function isFilterGroup(node) {
22
44
  // A group carries `values` + a logic operator and, unlike the array filter,
23
45
  // has no `property`.
@@ -81,9 +103,9 @@ function isFilterGroup(node) {
81
103
  case 'nbetween':
82
104
  return sql`${column} NOT BETWEEN ${node.start} AND ${node.end}`;
83
105
  case 'like':
84
- return sql`${column} ILIKE ${`%${escapeLikePattern(node.value)}%`}`;
106
+ return sql`translate(${column}, ${FOLD_TRANSLATION.from}, ${FOLD_TRANSLATION.to}) ILIKE ${`%${escapeLikePattern(foldText(node.value))}%`}`;
85
107
  case 'nlike':
86
- return sql`${column} NOT ILIKE ${`%${escapeLikePattern(node.value)}%`}`;
108
+ return sql`translate(${column}, ${FOLD_TRANSLATION.from}, ${FOLD_TRANSLATION.to}) NOT ILIKE ${`%${escapeLikePattern(foldText(node.value))}%`}`;
87
109
  case 'isNull':
88
110
  return sql`${column} IS NULL`;
89
111
  case 'isNotNull':
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/adapters/sql-filter-translator.ts"],"sourcesContent":["import {\n allowedDescriptors,\n type EntifixBuildError,\n type Entity,\n type EntityConstructor,\n type EntityFieldDescriptor,\n type EntityFilter,\n type EntityFilterBinaryOperator,\n type EntityFiltering,\n type EntitySorting,\n type FilterGroup,\n resolveAllowedProperty,\n} from '@entifix/core';\nimport type { SqlClient } from 'effect/sql';\nimport type { Statement } from 'effect/sql';\n\n/** SQL comparison for each binary operator. Fixed strings, never user input. */\nconst BINARY_OPERATORS: Record<EntityFilterBinaryOperator, string> = {\n eq: '=',\n ne: '<>',\n gt: '>',\n gte: '>=',\n lt: '<',\n lte: '<=',\n};\n\n/**\n * Escapes a value so it matches literally inside an `ILIKE` pattern: `%` and `_`\n * are the wildcards, and `\\` is Postgres' default escape character, so it has to\n * escape itself first — which a single character class does for free.\n *\n * This is the SQL counterpart to the Mongo translator's `escapeRegex`: `like` is\n * an unanchored \"contains\" match in both, so a user typing `100%` searches for\n * that string rather than for everything.\n */\nexport function escapeLikePattern(value: string): string {\n return value.replace(/[\\\\%_]/g, '\\\\$&');\n}\n\nfunction isFilterGroup<TEntity extends Entity>(\n node: EntityFilter<TEntity> | FilterGroup<TEntity>,\n): node is FilterGroup<TEntity> {\n // A group carries `values` + a logic operator and, unlike the array filter,\n // has no `property`.\n return (\n !('property' in node) && (node.operator === 'and' || node.operator === 'or')\n );\n}\n\n/**\n * Resolves a filter/sort member to the expression it compares, or rejects it.\n *\n * This is the security boundary of this file. A property name reaches SQL as an\n * identifier, so it can never be taken on trust: it is looked up in the\n * entity's own allowlist (`filterable`/`sortable` accessor metadata, the same\n * map `coerceRsql` validates against) and only the descriptor's stored `key` is\n * ever emitted. A member the entity did not declare is an\n * {@link EntifixBuildError}, which the service maps to a `400`.\n *\n * A `localized` member is stored as one `jsonb` column and queried a locale at\n * a time (`title.en`), which becomes `\"title\"->>$1`: the locale is a bound\n * parameter, never an identifier, and core has already checked it is one the\n * host declared.\n *\n * A request arriving through `parseLoadRequestParams` has already been through\n * this gate, so this is deliberately a second check — an `EntityLoadRequest`\n * built in code reaches the adapter without passing the URL codec at all.\n */\nfunction resolveExpression(\n sql: SqlClient.SqlClient,\n property: string | number | symbol,\n allowed: Map<string, EntityFieldDescriptor>,\n requirement: 'filterable' | 'sortable',\n): { key: string; expression: Statement.Fragment } {\n const { descriptor, key, locale } = resolveAllowedProperty(\n allowed,\n String(property),\n requirement,\n );\n return {\n key,\n expression:\n locale === undefined\n ? sql`${sql(descriptor.key)}`\n : sql`${sql(descriptor.key)}->>${locale}`,\n };\n}\n\n/**\n * Translates one filter node into a parameterized SQL fragment.\n *\n * Values are always interpolated as template parameters and column names always\n * through `sql(identifier)`, which the compiler quotes — this file never\n * concatenates a string into SQL.\n *\n * Returns `undefined` for a group with no members, which has no meaningful\n * translation (`AND ()` is a syntax error) and is dropped by the caller.\n */\nfunction translateNode<TEntity extends Entity>(\n sql: SqlClient.SqlClient,\n node: EntityFilter<TEntity> | FilterGroup<TEntity>,\n allowed: Map<string, EntityFieldDescriptor>,\n): Statement.Fragment | undefined {\n if (isFilterGroup(node)) {\n const children = node.values\n .map(child => translateNode(sql, child, allowed))\n .filter((child): child is Statement.Fragment => child !== undefined);\n if (children.length === 0) return undefined;\n return node.operator === 'or' ? sql.or(children) : sql.and(children);\n }\n\n const column = resolveExpression(\n sql,\n node.property,\n allowed,\n 'filterable',\n ).expression;\n\n switch (node.operator) {\n case 'eq':\n case 'ne':\n case 'gt':\n case 'gte':\n case 'lt':\n case 'lte':\n return sql`${column} ${sql.literal(BINARY_OPERATORS[node.operator])} ${node.value}`;\n case 'in':\n return sql`${column} IN ${sql.in(node.values)}`;\n case 'nin':\n return sql`${column} NOT IN ${sql.in(node.values)}`;\n case 'between':\n return sql`${column} BETWEEN ${node.start} AND ${node.end}`;\n case 'nbetween':\n return sql`${column} NOT BETWEEN ${node.start} AND ${node.end}`;\n case 'like':\n return sql`${column} ILIKE ${`%${escapeLikePattern(node.value)}%`}`;\n case 'nlike':\n return sql`${column} NOT ILIKE ${`%${escapeLikePattern(node.value)}%`}`;\n case 'isNull':\n return sql`${column} IS NULL`;\n case 'isNotNull':\n return sql`${column} IS NOT NULL`;\n default: {\n // Exhaustiveness guard — a new operator must be handled explicitly.\n const _never: never = node;\n return _never;\n }\n }\n}\n\n/**\n * Translates the request's `filtering` into a single `WHERE`-body fragment, or\n * `undefined` when there is nothing to filter on (the caller then omits the\n * clause entirely rather than emitting `WHERE TRUE`).\n *\n * Every top-level entry is combined with `AND`, matching the Mongo translator.\n *\n * Throws {@link EntifixBuildError} for a member the entity did not declare\n * `filterable` — synchronously, because this is a framework-free codec like the\n * rest of the entifix query stack; the repository wraps it back into an Effect.\n */\nexport function translateFiltering<TEntity extends Entity>(\n sql: SqlClient.SqlClient,\n entityConstructor: EntityConstructor<TEntity>,\n filtering?: EntityFiltering<TEntity>[],\n): Statement.Fragment | undefined {\n if (!filtering || filtering.length === 0) {\n return undefined;\n }\n\n const allowed = allowedDescriptors(\n entityConstructor,\n descriptor => descriptor.filterable,\n );\n\n const clauses: Statement.Fragment[] = [];\n for (const entry of filtering) {\n const nodes = Array.isArray(entry) ? entry : [entry];\n for (const node of nodes) {\n const clause = translateNode(sql, node, allowed);\n if (clause !== undefined) clauses.push(clause);\n }\n }\n\n if (clauses.length === 0) return undefined;\n if (clauses.length === 1) return clauses[0];\n return sql.and(clauses);\n}\n\n/**\n * Translates the request's `sorting` into an `ORDER BY`-body fragment, or\n * `undefined` when there is nothing to sort by.\n *\n * Priorities are applied in ascending numeric order within each record, and a\n * column named twice keeps its first position with the last direction — the same\n * outcome Mongo's sort document produces by assignment, so the two adapters\n * order identically.\n */\nexport function translateSorting<TEntity extends Entity>(\n sql: SqlClient.SqlClient,\n entityConstructor: EntityConstructor<TEntity>,\n sorting?: EntitySorting<TEntity>[],\n): Statement.Fragment | undefined {\n if (!sorting || sorting.length === 0) {\n return undefined;\n }\n\n const allowed = allowedDescriptors(\n entityConstructor,\n descriptor => descriptor.sortable,\n );\n // Keyed by what the property resolves to, so a member named twice — by its\n // name and its alias, say — keeps its first position with the last\n // direction, as Mongo's sort document does by assignment.\n const directions = new Map<\n string,\n { expression: Statement.Fragment; direction: 'ASC' | 'DESC' }\n >();\n\n for (const record of sorting) {\n for (const priority of Object.keys(record)\n .map(Number)\n .sort((left, right) => left - right)) {\n const entry = record[priority];\n if (entry) {\n const { key, expression } = resolveExpression(\n sql,\n entry.property,\n allowed,\n 'sortable',\n );\n directions.set(key, {\n expression: directions.get(key)?.expression ?? expression,\n direction: entry.type === 'desc' ? 'DESC' : 'ASC',\n });\n }\n }\n }\n\n if (directions.size === 0) return undefined;\n\n return sql.csv(\n [...directions.values()].map(\n ({ expression, direction }) =>\n sql`${expression} ${sql.literal(direction)}`,\n ),\n );\n}\n"],"names":["allowedDescriptors","resolveAllowedProperty","BINARY_OPERATORS","eq","ne","gt","gte","lt","lte","escapeLikePattern","value","replace","isFilterGroup","node","operator","resolveExpression","sql","property","allowed","requirement","descriptor","key","locale","String","expression","undefined","translateNode","children","values","map","child","filter","length","or","and","column","literal","in","start","end","_never","translateFiltering","entityConstructor","filtering","filterable","clauses","entry","nodes","Array","isArray","clause","push","translateSorting","sorting","sortable","directions","Map","record","priority","Object","keys","Number","sort","left","right","set","get","direction","type","size","csv"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/adapters/sql-filter-translator.ts"],"sourcesContent":["import {\n allowedDescriptors,\n type EntifixBuildError,\n type Entity,\n type EntityConstructor,\n type EntityFieldDescriptor,\n type EntityFilter,\n type EntityFilterBinaryOperator,\n type EntityFiltering,\n type EntitySorting,\n type FilterGroup,\n FOLD_TABLE,\n foldText,\n resolveAllowedProperty,\n} from '@entifix/core';\nimport type { SqlClient } from 'effect/sql';\nimport type { Statement } from 'effect/sql';\n\n/** SQL comparison for each binary operator. Fixed strings, never user input. */\nconst BINARY_OPERATORS: Record<EntityFilterBinaryOperator, string> = {\n eq: '=',\n ne: '<>',\n gt: '>',\n gte: '>=',\n lt: '<',\n lte: '<=',\n};\n\n/**\n * Escapes a value so it matches literally inside an `ILIKE` pattern: `%` and `_`\n * are the wildcards, and `\\` is Postgres' default escape character, so it has to\n * escape itself first — which a single character class does for free.\n *\n * This is the SQL counterpart to the Mongo translator's escaping: `like` is an\n * unanchored \"contains\" match in both, so a user typing `100%` searches for\n * that string rather than for everything.\n */\nexport function escapeLikePattern(value: string): string {\n return value.replace(/[\\\\%_]/g, '\\\\$&');\n}\n\n/**\n * The two strings `translate` maps a column through so `like` ignores accents:\n * every accented form, in both cases, onto its lowercase base letter — built\n * from core's `FOLD_TABLE`, so the column folds by the same rule the in-memory\n * matcher and the Mongo pattern use. `ILIKE` then handles the ASCII case.\n *\n * The built-in `translate` rather than `unaccent`, which is an extension a\n * database must have created before any query could use it.\n */\nexport const FOLD_TRANSLATION: { readonly from: string; readonly to: string } =\n (() => {\n let from = '';\n let to = '';\n for (const [base, forms] of Object.entries(FOLD_TABLE)) {\n // The table lists the base and its capital first; `ILIKE` folds those.\n const accented = forms.slice(2);\n from += accented;\n to += base.repeat(accented.length);\n }\n return { from, to };\n })();\n\nfunction isFilterGroup<TEntity extends Entity>(\n node: EntityFilter<TEntity> | FilterGroup<TEntity>,\n): node is FilterGroup<TEntity> {\n // A group carries `values` + a logic operator and, unlike the array filter,\n // has no `property`.\n return (\n !('property' in node) && (node.operator === 'and' || node.operator === 'or')\n );\n}\n\n/**\n * Resolves a filter/sort member to the expression it compares, or rejects it.\n *\n * This is the security boundary of this file. A property name reaches SQL as an\n * identifier, so it can never be taken on trust: it is looked up in the\n * entity's own allowlist (`filterable`/`sortable` accessor metadata, the same\n * map `coerceRsql` validates against) and only the descriptor's stored `key` is\n * ever emitted. A member the entity did not declare is an\n * {@link EntifixBuildError}, which the service maps to a `400`.\n *\n * A `localized` member is stored as one `jsonb` column and queried a locale at\n * a time (`title.en`), which becomes `\"title\"->>$1`: the locale is a bound\n * parameter, never an identifier, and core has already checked it is one the\n * host declared.\n *\n * A request arriving through `parseLoadRequestParams` has already been through\n * this gate, so this is deliberately a second check — an `EntityLoadRequest`\n * built in code reaches the adapter without passing the URL codec at all.\n */\nfunction resolveExpression(\n sql: SqlClient.SqlClient,\n property: string | number | symbol,\n allowed: Map<string, EntityFieldDescriptor>,\n requirement: 'filterable' | 'sortable',\n): { key: string; expression: Statement.Fragment } {\n const { descriptor, key, locale } = resolveAllowedProperty(\n allowed,\n String(property),\n requirement,\n );\n return {\n key,\n expression:\n locale === undefined\n ? sql`${sql(descriptor.key)}`\n : sql`${sql(descriptor.key)}->>${locale}`,\n };\n}\n\n/**\n * Translates one filter node into a parameterized SQL fragment.\n *\n * Values are always interpolated as template parameters and column names always\n * through `sql(identifier)`, which the compiler quotes — this file never\n * concatenates a string into SQL.\n *\n * Returns `undefined` for a group with no members, which has no meaningful\n * translation (`AND ()` is a syntax error) and is dropped by the caller.\n */\nfunction translateNode<TEntity extends Entity>(\n sql: SqlClient.SqlClient,\n node: EntityFilter<TEntity> | FilterGroup<TEntity>,\n allowed: Map<string, EntityFieldDescriptor>,\n): Statement.Fragment | undefined {\n if (isFilterGroup(node)) {\n const children = node.values\n .map(child => translateNode(sql, child, allowed))\n .filter((child): child is Statement.Fragment => child !== undefined);\n if (children.length === 0) return undefined;\n return node.operator === 'or' ? sql.or(children) : sql.and(children);\n }\n\n const column = resolveExpression(\n sql,\n node.property,\n allowed,\n 'filterable',\n ).expression;\n\n switch (node.operator) {\n case 'eq':\n case 'ne':\n case 'gt':\n case 'gte':\n case 'lt':\n case 'lte':\n return sql`${column} ${sql.literal(BINARY_OPERATORS[node.operator])} ${node.value}`;\n case 'in':\n return sql`${column} IN ${sql.in(node.values)}`;\n case 'nin':\n return sql`${column} NOT IN ${sql.in(node.values)}`;\n case 'between':\n return sql`${column} BETWEEN ${node.start} AND ${node.end}`;\n case 'nbetween':\n return sql`${column} NOT BETWEEN ${node.start} AND ${node.end}`;\n case 'like':\n return sql`translate(${column}, ${FOLD_TRANSLATION.from}, ${FOLD_TRANSLATION.to}) ILIKE ${`%${escapeLikePattern(foldText(node.value))}%`}`;\n case 'nlike':\n return sql`translate(${column}, ${FOLD_TRANSLATION.from}, ${FOLD_TRANSLATION.to}) NOT ILIKE ${`%${escapeLikePattern(foldText(node.value))}%`}`;\n case 'isNull':\n return sql`${column} IS NULL`;\n case 'isNotNull':\n return sql`${column} IS NOT NULL`;\n default: {\n // Exhaustiveness guard — a new operator must be handled explicitly.\n const _never: never = node;\n return _never;\n }\n }\n}\n\n/**\n * Translates the request's `filtering` into a single `WHERE`-body fragment, or\n * `undefined` when there is nothing to filter on (the caller then omits the\n * clause entirely rather than emitting `WHERE TRUE`).\n *\n * Every top-level entry is combined with `AND`, matching the Mongo translator.\n *\n * Throws {@link EntifixBuildError} for a member the entity did not declare\n * `filterable` — synchronously, because this is a framework-free codec like the\n * rest of the entifix query stack; the repository wraps it back into an Effect.\n */\nexport function translateFiltering<TEntity extends Entity>(\n sql: SqlClient.SqlClient,\n entityConstructor: EntityConstructor<TEntity>,\n filtering?: EntityFiltering<TEntity>[],\n): Statement.Fragment | undefined {\n if (!filtering || filtering.length === 0) {\n return undefined;\n }\n\n const allowed = allowedDescriptors(\n entityConstructor,\n descriptor => descriptor.filterable,\n );\n\n const clauses: Statement.Fragment[] = [];\n for (const entry of filtering) {\n const nodes = Array.isArray(entry) ? entry : [entry];\n for (const node of nodes) {\n const clause = translateNode(sql, node, allowed);\n if (clause !== undefined) clauses.push(clause);\n }\n }\n\n if (clauses.length === 0) return undefined;\n if (clauses.length === 1) return clauses[0];\n return sql.and(clauses);\n}\n\n/**\n * Translates the request's `sorting` into an `ORDER BY`-body fragment, or\n * `undefined` when there is nothing to sort by.\n *\n * Priorities are applied in ascending numeric order within each record, and a\n * column named twice keeps its first position with the last direction — the same\n * outcome Mongo's sort document produces by assignment, so the two adapters\n * order identically.\n */\nexport function translateSorting<TEntity extends Entity>(\n sql: SqlClient.SqlClient,\n entityConstructor: EntityConstructor<TEntity>,\n sorting?: EntitySorting<TEntity>[],\n): Statement.Fragment | undefined {\n if (!sorting || sorting.length === 0) {\n return undefined;\n }\n\n const allowed = allowedDescriptors(\n entityConstructor,\n descriptor => descriptor.sortable,\n );\n // Keyed by what the property resolves to, so a member named twice — by its\n // name and its alias, say — keeps its first position with the last\n // direction, as Mongo's sort document does by assignment.\n const directions = new Map<\n string,\n { expression: Statement.Fragment; direction: 'ASC' | 'DESC' }\n >();\n\n for (const record of sorting) {\n for (const priority of Object.keys(record)\n .map(Number)\n .sort((left, right) => left - right)) {\n const entry = record[priority];\n if (entry) {\n const { key, expression } = resolveExpression(\n sql,\n entry.property,\n allowed,\n 'sortable',\n );\n directions.set(key, {\n expression: directions.get(key)?.expression ?? expression,\n direction: entry.type === 'desc' ? 'DESC' : 'ASC',\n });\n }\n }\n }\n\n if (directions.size === 0) return undefined;\n\n return sql.csv(\n [...directions.values()].map(\n ({ expression, direction }) =>\n sql`${expression} ${sql.literal(direction)}`,\n ),\n );\n}\n"],"names":["allowedDescriptors","FOLD_TABLE","foldText","resolveAllowedProperty","BINARY_OPERATORS","eq","ne","gt","gte","lt","lte","escapeLikePattern","value","replace","FOLD_TRANSLATION","from","to","base","forms","Object","entries","accented","slice","repeat","length","isFilterGroup","node","operator","resolveExpression","sql","property","allowed","requirement","descriptor","key","locale","String","expression","undefined","translateNode","children","values","map","child","filter","or","and","column","literal","in","start","end","_never","translateFiltering","entityConstructor","filtering","filterable","clauses","entry","nodes","Array","isArray","clause","push","translateSorting","sorting","sortable","directions","Map","record","priority","keys","Number","sort","left","right","set","get","direction","type","size","csv"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entifix/sql",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "SQL adapter over @effect/sql: an entity repository with RSQL filter and sort translation, and a health probe.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -51,12 +51,12 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "@swc/helpers": "0.5.23",
54
- "@entifix/core": "0.8.0",
55
- "@entifix/business": "0.8.0"
54
+ "@entifix/business": "0.9.0",
55
+ "@entifix/core": "0.9.0"
56
56
  },
57
57
  "devDependencies": {
58
58
  "effect": "4.0.5",
59
- "@entifix/testing-unit": "0.8.0"
59
+ "@entifix/testing-unit": "0.9.0"
60
60
  },
61
61
  "peerDependencies": {
62
62
  "effect": "^4.0.5"