@entifix/sql 0.2.0 → 0.4.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.
|
@@ -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,
|
|
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,4 +1,4 @@
|
|
|
1
|
-
import { allowedDescriptors,
|
|
1
|
+
import { allowedDescriptors, 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: '<>',
|
|
@@ -24,7 +24,7 @@ function isFilterGroup(node) {
|
|
|
24
24
|
return !('property' in node) && (node.operator === 'and' || node.operator === 'or');
|
|
25
25
|
}
|
|
26
26
|
/**
|
|
27
|
-
* Resolves a filter/sort member to
|
|
27
|
+
* Resolves a filter/sort member to the expression it compares, or rejects it.
|
|
28
28
|
*
|
|
29
29
|
* This is the security boundary of this file. A property name reaches SQL as an
|
|
30
30
|
* identifier, so it can never be taken on trust: it is looked up in the
|
|
@@ -33,18 +33,20 @@ function isFilterGroup(node) {
|
|
|
33
33
|
* ever emitted. A member the entity did not declare is an
|
|
34
34
|
* {@link EntifixBuildError}, which the service maps to a `400`.
|
|
35
35
|
*
|
|
36
|
+
* A `localized` member is stored as one `jsonb` column and queried a locale at
|
|
37
|
+
* a time (`title.en`), which becomes `"title"->>$1`: the locale is a bound
|
|
38
|
+
* parameter, never an identifier, and core has already checked it is one the
|
|
39
|
+
* host declared.
|
|
40
|
+
*
|
|
36
41
|
* A request arriving through `parseLoadRequestParams` has already been through
|
|
37
42
|
* this gate, so this is deliberately a second check — an `EntityLoadRequest`
|
|
38
43
|
* built in code reaches the adapter without passing the URL codec at all.
|
|
39
|
-
*/ function
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
return descriptor.key;
|
|
44
|
+
*/ function resolveExpression(sql, property, allowed, requirement) {
|
|
45
|
+
const { descriptor, key, locale } = resolveAllowedProperty(allowed, String(property), requirement);
|
|
46
|
+
return {
|
|
47
|
+
key,
|
|
48
|
+
expression: locale === undefined ? sql`${sql(descriptor.key)}` : sql`${sql(descriptor.key)}->>${locale}`
|
|
49
|
+
};
|
|
48
50
|
}
|
|
49
51
|
/**
|
|
50
52
|
* Translates one filter node into a parameterized SQL fragment.
|
|
@@ -61,7 +63,7 @@ function isFilterGroup(node) {
|
|
|
61
63
|
if (children.length === 0) return undefined;
|
|
62
64
|
return node.operator === 'or' ? sql.or(children) : sql.and(children);
|
|
63
65
|
}
|
|
64
|
-
const column = sql
|
|
66
|
+
const column = resolveExpression(sql, node.property, allowed, 'filterable').expression;
|
|
65
67
|
switch(node.operator){
|
|
66
68
|
case 'eq':
|
|
67
69
|
case 'ne':
|
|
@@ -136,19 +138,28 @@ function isFilterGroup(node) {
|
|
|
136
138
|
return undefined;
|
|
137
139
|
}
|
|
138
140
|
const allowed = allowedDescriptors(entityConstructor, (descriptor)=>descriptor.sortable);
|
|
141
|
+
// Keyed by what the property resolves to, so a member named twice — by its
|
|
142
|
+
// name and its alias, say — keeps its first position with the last
|
|
143
|
+
// direction, as Mongo's sort document does by assignment.
|
|
139
144
|
const directions = new Map();
|
|
140
145
|
for (const record of sorting){
|
|
141
146
|
for (const priority of Object.keys(record).map(Number).sort((left, right)=>left - right)){
|
|
142
147
|
const entry = record[priority];
|
|
143
148
|
if (entry) {
|
|
144
|
-
|
|
149
|
+
var _ref;
|
|
150
|
+
var _directions_get;
|
|
151
|
+
const { key, expression } = resolveExpression(sql, entry.property, allowed, 'sortable');
|
|
152
|
+
directions.set(key, {
|
|
153
|
+
expression: (_ref = (_directions_get = directions.get(key)) == null ? void 0 : _directions_get.expression) != null ? _ref : expression,
|
|
154
|
+
direction: entry.type === 'desc' ? 'DESC' : 'ASC'
|
|
155
|
+
});
|
|
145
156
|
}
|
|
146
157
|
}
|
|
147
158
|
}
|
|
148
159
|
if (directions.size === 0) return undefined;
|
|
149
160
|
return sql.csv([
|
|
150
|
-
...directions
|
|
151
|
-
].map((
|
|
161
|
+
...directions.values()
|
|
162
|
+
].map(({ expression, direction })=>sql`${expression} ${sql.literal(direction)}`));
|
|
152
163
|
}
|
|
153
164
|
|
|
154
165
|
//# sourceMappingURL=sql-filter-translator.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/adapters/sql-filter-translator.ts"],"sourcesContent":["import {\n allowedDescriptors,\n 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} 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 its **column name**, 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 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 resolveColumn(\n property: string | number | symbol,\n allowed: Map<string, EntityFieldDescriptor>,\n requirement: 'filterable' | 'sortable',\n): string {\n const name = String(property);\n const descriptor = allowed.get(name);\n if (!descriptor) {\n throw new EntifixBuildError(\n `\"${name}\" is not a ${requirement} member`,\n undefined,\n { property: name },\n );\n }\n return descriptor.key;\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 = sql(resolveColumn(node.property, allowed, 'filterable'));\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 const directions = new Map<string, 'ASC' | 'DESC'>();\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 directions.set(\n resolveColumn(entry.property, allowed, 'sortable'),\n entry.type === 'desc' ? 'DESC' : 'ASC',\n );\n }\n }\n }\n\n if (directions.size === 0) return undefined;\n\n return sql.csv(\n [...directions].map(\n ([column, direction]) => sql`${sql(column)} ${sql.literal(direction)}`,\n ),\n );\n}\n"],"names":["allowedDescriptors","EntifixBuildError","BINARY_OPERATORS","eq","ne","gt","gte","lt","lte","escapeLikePattern","value","replace","isFilterGroup","node","operator","resolveColumn","property","allowed","requirement","name","String","descriptor","get","undefined","key","translateNode","sql","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","type","size","csv","direction"],"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 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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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@entifix/sql",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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": {
|
|
@@ -18,7 +18,6 @@
|
|
|
18
18
|
"exports": {
|
|
19
19
|
"./package.json": "./package.json",
|
|
20
20
|
".": {
|
|
21
|
-
"@entifix/source": "./src/index.ts",
|
|
22
21
|
"types": "./dist/index.d.ts",
|
|
23
22
|
"import": "./dist/index.js",
|
|
24
23
|
"default": "./dist/index.js"
|
|
@@ -52,12 +51,12 @@
|
|
|
52
51
|
},
|
|
53
52
|
"dependencies": {
|
|
54
53
|
"@swc/helpers": "0.5.23",
|
|
55
|
-
"@entifix/
|
|
56
|
-
"@entifix/
|
|
54
|
+
"@entifix/core": "0.4.0",
|
|
55
|
+
"@entifix/business": "0.4.0"
|
|
57
56
|
},
|
|
58
57
|
"devDependencies": {
|
|
59
58
|
"effect": "4.0.5",
|
|
60
|
-
"@entifix/testing-unit": "0.
|
|
59
|
+
"@entifix/testing-unit": "0.4.0"
|
|
61
60
|
},
|
|
62
61
|
"peerDependencies": {
|
|
63
62
|
"effect": "^4.0.5"
|