@zmdb/sql 1.0.0-beta.1
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/LICENSE +674 -0
- package/README.md +35 -0
- package/dist/aggregations/index.d.ts +30 -0
- package/dist/aggregations/index.d.ts.map +1 -0
- package/dist/aggregations/index.js +52 -0
- package/dist/aggregations/index.js.map +1 -0
- package/dist/clauses.d.ts +112 -0
- package/dist/clauses.d.ts.map +1 -0
- package/dist/clauses.js +267 -0
- package/dist/clauses.js.map +1 -0
- package/dist/comments/index.d.ts +26 -0
- package/dist/comments/index.d.ts.map +1 -0
- package/dist/comments/index.js +28 -0
- package/dist/comments/index.js.map +1 -0
- package/dist/compiled-query.d.ts +12 -0
- package/dist/compiled-query.d.ts.map +1 -0
- package/dist/compiled-query.js +2 -0
- package/dist/compiled-query.js.map +1 -0
- package/dist/dialects/index.d.ts +19 -0
- package/dist/dialects/index.d.ts.map +1 -0
- package/dist/dialects/index.js +43 -0
- package/dist/dialects/index.js.map +1 -0
- package/dist/dialects/protocol.d.ts +230 -0
- package/dist/dialects/protocol.d.ts.map +1 -0
- package/dist/dialects/protocol.js +230 -0
- package/dist/dialects/protocol.js.map +1 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +17 -0
- package/dist/errors.js.map +1 -0
- package/dist/expressions/index.d.ts +50 -0
- package/dist/expressions/index.d.ts.map +1 -0
- package/dist/expressions/index.js +77 -0
- package/dist/expressions/index.js.map +1 -0
- package/dist/extensions/index.d.ts +85 -0
- package/dist/extensions/index.d.ts.map +1 -0
- package/dist/extensions/index.js +120 -0
- package/dist/extensions/index.js.map +1 -0
- package/dist/fts/index.d.ts +28 -0
- package/dist/fts/index.d.ts.map +1 -0
- package/dist/fts/index.js +110 -0
- package/dist/fts/index.js.map +1 -0
- package/dist/index.d.ts +128 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +364 -0
- package/dist/index.js.map +1 -0
- package/dist/introspect/types.d.ts +58 -0
- package/dist/introspect/types.d.ts.map +1 -0
- package/dist/introspect/types.js +2 -0
- package/dist/introspect/types.js.map +1 -0
- package/dist/joins/index.d.ts +20 -0
- package/dist/joins/index.d.ts.map +1 -0
- package/dist/joins/index.js +24 -0
- package/dist/joins/index.js.map +1 -0
- package/dist/migrations/types.d.ts +100 -0
- package/dist/migrations/types.d.ts.map +1 -0
- package/dist/migrations/types.js +2 -0
- package/dist/migrations/types.js.map +1 -0
- package/dist/quoting.d.ts +40 -0
- package/dist/quoting.d.ts.map +1 -0
- package/dist/quoting.js +98 -0
- package/dist/quoting.js.map +1 -0
- package/dist/schema-objects/extensions.d.ts +5 -0
- package/dist/schema-objects/extensions.d.ts.map +1 -0
- package/dist/schema-objects/extensions.js +9 -0
- package/dist/schema-objects/extensions.js.map +1 -0
- package/dist/schema-objects/index.d.ts +25 -0
- package/dist/schema-objects/index.d.ts.map +1 -0
- package/dist/schema-objects/index.js +86 -0
- package/dist/schema-objects/index.js.map +1 -0
- package/dist/schema-objects/types.d.ts +62 -0
- package/dist/schema-objects/types.d.ts.map +1 -0
- package/dist/schema-objects/types.js +2 -0
- package/dist/schema-objects/types.js.map +1 -0
- package/dist/set-ops/index.d.ts +16 -0
- package/dist/set-ops/index.d.ts.map +1 -0
- package/dist/set-ops/index.js +41 -0
- package/dist/set-ops/index.js.map +1 -0
- package/package.json +70 -0
- package/src/aggregations/index.ts +127 -0
- package/src/clauses.ts +387 -0
- package/src/comments/index.ts +49 -0
- package/src/compiled-query.ts +12 -0
- package/src/dialects/index.ts +103 -0
- package/src/dialects/protocol.ts +478 -0
- package/src/errors.ts +18 -0
- package/src/expressions/index.ts +127 -0
- package/src/extensions/index.ts +235 -0
- package/src/fts/index.ts +157 -0
- package/src/index.ts +685 -0
- package/src/introspect/types.ts +60 -0
- package/src/joins/index.ts +69 -0
- package/src/migrations/types.ts +93 -0
- package/src/quoting.ts +111 -0
- package/src/schema-objects/extensions.ts +12 -0
- package/src/schema-objects/index.ts +125 -0
- package/src/schema-objects/types.ts +73 -0
- package/src/set-ops/index.ts +48 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { dialectTraits, type DialectTarget } from '../dialects/index.js';
|
|
2
|
+
import { formatPlaceholder, quoteIdentifier } from '../quoting.js';
|
|
3
|
+
|
|
4
|
+
/** Runtime brand for compiler-owned column expressions. */
|
|
5
|
+
export const EXPR: unique symbol = Symbol('zmdb.column-expression');
|
|
6
|
+
const PHANTOM: unique symbol = Symbol('zmdb.column-expression.phantom');
|
|
7
|
+
|
|
8
|
+
type ExpressionBrand<T> = {
|
|
9
|
+
readonly [EXPR]: true;
|
|
10
|
+
readonly [PHANTOM]?: T;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** A deliberately closed expression over the column named by its SET key. */
|
|
14
|
+
export type ColumnExpr<T> = ExpressionBrand<T> &
|
|
15
|
+
(
|
|
16
|
+
| { readonly op: 'add' | 'sub' | 'mul'; readonly by: T }
|
|
17
|
+
| { readonly op: 'not' }
|
|
18
|
+
| { readonly op: 'concat'; readonly with: string }
|
|
19
|
+
| { readonly op: 'coalesce'; readonly fallback: T }
|
|
20
|
+
| { readonly op: 'proposed' }
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
/** A literal value or a compiler-owned expression for the same column type. */
|
|
24
|
+
export type SetValue<T> = T | ColumnExpr<T>;
|
|
25
|
+
|
|
26
|
+
function brand<T>(expression: ColumnExpr<T>): ColumnExpr<T> {
|
|
27
|
+
Object.defineProperty(expression, EXPR, {
|
|
28
|
+
configurable: false,
|
|
29
|
+
enumerable: false,
|
|
30
|
+
writable: false,
|
|
31
|
+
});
|
|
32
|
+
return expression;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function inc<T extends number | bigint>(by?: T): ColumnExpr<T>;
|
|
36
|
+
export function inc(by: number | bigint = 1): ColumnExpr<number | bigint> {
|
|
37
|
+
return brand<number | bigint>({ [EXPR]: true, op: 'add', by });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function dec<T extends number | bigint>(by?: T): ColumnExpr<T>;
|
|
41
|
+
export function dec(by: number | bigint = 1): ColumnExpr<number | bigint> {
|
|
42
|
+
return brand<number | bigint>({ [EXPR]: true, op: 'sub', by });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function mul<T extends number>(by: T): ColumnExpr<T> {
|
|
46
|
+
return brand<T>({ [EXPR]: true, op: 'mul', by });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function not(): ColumnExpr<boolean> {
|
|
50
|
+
return brand<boolean>({ [EXPR]: true, op: 'not' });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function concat(withText: string): ColumnExpr<string> {
|
|
54
|
+
return brand<string>({ [EXPR]: true, op: 'concat', with: withText });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function coalesce<T>(fallback: T): ColumnExpr<T> {
|
|
58
|
+
return brand<T>({ [EXPR]: true, op: 'coalesce', fallback });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function proposed<T>(): ColumnExpr<T> {
|
|
62
|
+
return brand<T>({ [EXPR]: true, op: 'proposed' });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function isColumnExpr(value: unknown): value is ColumnExpr<unknown> {
|
|
66
|
+
return typeof value === 'object' && value !== null && EXPR in value;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface EmittedExpr {
|
|
70
|
+
readonly sql: string;
|
|
71
|
+
readonly params: readonly unknown[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface EmitColumnExprOptions {
|
|
75
|
+
readonly dialect: DialectTarget;
|
|
76
|
+
readonly table: string;
|
|
77
|
+
readonly column: string;
|
|
78
|
+
readonly parameterIndex: number;
|
|
79
|
+
readonly scope: 'update' | 'upsert';
|
|
80
|
+
readonly currentReference?: string;
|
|
81
|
+
readonly proposedReference?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Emit one expression and only the parameters contributed by that expression. */
|
|
85
|
+
export function emitColumnExpr(expression: ColumnExpr<unknown>, options: EmitColumnExprOptions): EmittedExpr {
|
|
86
|
+
const { dialect, table, column, parameterIndex, scope } = options;
|
|
87
|
+
const traits = dialectTraits(dialect);
|
|
88
|
+
const quotedColumn = options.currentReference ?? quoteIdentifier(dialect, column);
|
|
89
|
+
const placeholder = formatPlaceholder(dialect, parameterIndex);
|
|
90
|
+
|
|
91
|
+
switch (expression.op) {
|
|
92
|
+
case 'add':
|
|
93
|
+
return { sql: `${quotedColumn} + ${placeholder}`, params: [expression.by] };
|
|
94
|
+
case 'sub':
|
|
95
|
+
return { sql: `${quotedColumn} - ${placeholder}`, params: [expression.by] };
|
|
96
|
+
case 'mul':
|
|
97
|
+
return { sql: `${quotedColumn} * ${placeholder}`, params: [expression.by] };
|
|
98
|
+
case 'not':
|
|
99
|
+
return {
|
|
100
|
+
sql: traits.booleanNot === 'not' ? `NOT ${quotedColumn}` : `~${quotedColumn}`,
|
|
101
|
+
params: [],
|
|
102
|
+
};
|
|
103
|
+
case 'concat':
|
|
104
|
+
return {
|
|
105
|
+
sql:
|
|
106
|
+
traits.concat === 'function'
|
|
107
|
+
? `CONCAT(${quotedColumn}, ${placeholder})`
|
|
108
|
+
: `${quotedColumn} || ${placeholder}`,
|
|
109
|
+
params: [expression.with],
|
|
110
|
+
};
|
|
111
|
+
case 'coalesce':
|
|
112
|
+
return { sql: `COALESCE(${quotedColumn}, ${placeholder})`, params: [expression.fallback] };
|
|
113
|
+
case 'proposed':
|
|
114
|
+
if (scope !== 'upsert') {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`proposed() references the row being inserted and is only valid inside onConflict().doUpdate() ` +
|
|
117
|
+
`(${JSON.stringify(column)} on ${JSON.stringify(table)})`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
sql:
|
|
122
|
+
options.proposedReference ??
|
|
123
|
+
(traits.upsert === 'onDuplicateKey' ? `VALUES(${quotedColumn})` : `EXCLUDED.${quotedColumn}`),
|
|
124
|
+
params: [],
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { dialectName, dialectTraits, type DialectTarget } from '../dialects/index.js';
|
|
2
|
+
import { UnsupportedFeatureError } from '../errors.js';
|
|
3
|
+
import { formatPlaceholder, quoteColumn, quoteIdentifier } from '../quoting.js';
|
|
4
|
+
|
|
5
|
+
export type DistanceOp = 'l2' | 'cosine' | 'ip';
|
|
6
|
+
|
|
7
|
+
export const DISTANCE_OPERATORS = Object.freeze({
|
|
8
|
+
l2: '<->',
|
|
9
|
+
cosine: '<=>',
|
|
10
|
+
ip: '<#>',
|
|
11
|
+
} satisfies Record<DistanceOp, string>);
|
|
12
|
+
|
|
13
|
+
export type SpatialFn = 'st_contains' | 'st_within' | 'st_intersects' | 'st_dwithin';
|
|
14
|
+
|
|
15
|
+
const SPATIAL_FUNCTIONS = Object.freeze({
|
|
16
|
+
st_contains: 'ST_Contains',
|
|
17
|
+
st_within: 'ST_Within',
|
|
18
|
+
st_intersects: 'ST_Intersects',
|
|
19
|
+
st_dwithin: 'ST_DWithin',
|
|
20
|
+
} satisfies Record<SpatialFn, string>);
|
|
21
|
+
|
|
22
|
+
type ExtensionMarker<Name extends string> = {
|
|
23
|
+
readonly __zmdbExt?: readonly [extension: string, name: Name, args: readonly (string | number)[]];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Keys whose declared column type carries `Ext<…, Name, …>`. */
|
|
27
|
+
export type ExtensionColumnOf<T, Name extends string> = {
|
|
28
|
+
[K in keyof T]-?: NonNullable<T[K]> extends ExtensionMarker<Name> ? (K extends string ? K : never) : never;
|
|
29
|
+
}[keyof T];
|
|
30
|
+
|
|
31
|
+
export type VectorColumnOf<T> = ExtensionColumnOf<T, 'vector'>;
|
|
32
|
+
export type GeometryColumnOf<T> = ExtensionColumnOf<T, 'geometry'>;
|
|
33
|
+
|
|
34
|
+
/** A GeoJSON geometry accepted by `ST_GeomFromGeoJSON`. */
|
|
35
|
+
export interface GeoJsonGeometry {
|
|
36
|
+
readonly type: string;
|
|
37
|
+
readonly coordinates: unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The declared application geometry for one extension-backed column. */
|
|
41
|
+
export type GeometryValueOf<T, C extends GeometryColumnOf<T>> =
|
|
42
|
+
NonNullable<T[C]> extends GeoJsonGeometry ? NonNullable<T[C]> : never;
|
|
43
|
+
|
|
44
|
+
type GeometryArguments<T> = {
|
|
45
|
+
[C in GeometryColumnOf<T>]: readonly [column: C, geometry: GeometryValueOf<T, C>];
|
|
46
|
+
}[GeometryColumnOf<T>];
|
|
47
|
+
|
|
48
|
+
type GeometryDistanceArguments<T> = {
|
|
49
|
+
[C in GeometryColumnOf<T>]: readonly [column: C, geometry: GeometryValueOf<T, C>, distance: number];
|
|
50
|
+
}[GeometryColumnOf<T>];
|
|
51
|
+
|
|
52
|
+
const EXTENSION_EXPRESSION: unique symbol = Symbol('zmdb.extension-expression');
|
|
53
|
+
|
|
54
|
+
export interface DistanceExpression<C extends string = string> {
|
|
55
|
+
readonly [EXTENSION_EXPRESSION]: 'distance';
|
|
56
|
+
readonly kind: 'distance';
|
|
57
|
+
readonly column: C;
|
|
58
|
+
readonly operator: DistanceOp;
|
|
59
|
+
readonly query: readonly number[];
|
|
60
|
+
as(alias: string): AliasedDistanceExpression<C>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface AliasedDistanceExpression<C extends string = string> {
|
|
64
|
+
readonly [EXTENSION_EXPRESSION]: 'aliased-distance';
|
|
65
|
+
readonly kind: 'aliased';
|
|
66
|
+
readonly expression: DistanceExpression<C>;
|
|
67
|
+
readonly alias: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface SpatialPredicate<C extends string = string> {
|
|
71
|
+
readonly [EXTENSION_EXPRESSION]: 'spatial';
|
|
72
|
+
readonly kind: 'spatial';
|
|
73
|
+
readonly fn: 'st_contains' | 'st_dwithin';
|
|
74
|
+
readonly col: C;
|
|
75
|
+
readonly value: GeoJsonGeometry;
|
|
76
|
+
readonly distance?: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Internal closed predicate shape shared with the dialect matrix. */
|
|
80
|
+
export interface SpatialPredicateNode {
|
|
81
|
+
readonly kind: 'spatial';
|
|
82
|
+
readonly fn: SpatialFn;
|
|
83
|
+
readonly col: string;
|
|
84
|
+
readonly value: unknown;
|
|
85
|
+
readonly distance?: number;
|
|
86
|
+
readonly connector?: 'AND' | 'OR';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function aliasedDistance<C extends string>(
|
|
90
|
+
expression: DistanceExpression<C>,
|
|
91
|
+
alias: string,
|
|
92
|
+
): AliasedDistanceExpression<C> {
|
|
93
|
+
const aliased: AliasedDistanceExpression<C> = {
|
|
94
|
+
[EXTENSION_EXPRESSION]: 'aliased-distance',
|
|
95
|
+
kind: 'aliased',
|
|
96
|
+
expression,
|
|
97
|
+
alias,
|
|
98
|
+
};
|
|
99
|
+
return Object.freeze(aliased);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A pgvector distance expression. Supplying the declaration type makes the column
|
|
104
|
+
* argument the closed set of its `Ext<…, 'vector', …>` properties.
|
|
105
|
+
*/
|
|
106
|
+
export function distance<T extends object>(
|
|
107
|
+
column: VectorColumnOf<T>,
|
|
108
|
+
operator: DistanceOp,
|
|
109
|
+
query: readonly number[],
|
|
110
|
+
): DistanceExpression<VectorColumnOf<T>> {
|
|
111
|
+
if (!Object.hasOwn(DISTANCE_OPERATORS, operator)) {
|
|
112
|
+
throw new TypeError(
|
|
113
|
+
`unknown distance operator ${JSON.stringify(operator)}; expected ${Object.keys(DISTANCE_OPERATORS).join(' | ')}`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const expression: DistanceExpression<VectorColumnOf<T>> = {
|
|
118
|
+
[EXTENSION_EXPRESSION]: 'distance',
|
|
119
|
+
kind: 'distance',
|
|
120
|
+
column,
|
|
121
|
+
operator,
|
|
122
|
+
query,
|
|
123
|
+
as: alias => aliasedDistance(expression, alias),
|
|
124
|
+
};
|
|
125
|
+
return Object.freeze(expression);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A typed `ST_Contains(column, geometry)` predicate. */
|
|
129
|
+
export function stContains<T extends object>(
|
|
130
|
+
...[column, geometry]: GeometryArguments<T>
|
|
131
|
+
): SpatialPredicate<GeometryColumnOf<T>> {
|
|
132
|
+
const predicate: SpatialPredicate<GeometryColumnOf<T>> = {
|
|
133
|
+
[EXTENSION_EXPRESSION]: 'spatial',
|
|
134
|
+
kind: 'spatial',
|
|
135
|
+
fn: 'st_contains',
|
|
136
|
+
col: column,
|
|
137
|
+
value: geometry,
|
|
138
|
+
};
|
|
139
|
+
return Object.freeze(predicate);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A typed `ST_DWithin(column, geometry, distance)` predicate. */
|
|
143
|
+
export function stDWithin<T extends object>(
|
|
144
|
+
...[column, geometry, maxDistance]: GeometryDistanceArguments<T>
|
|
145
|
+
): SpatialPredicate<GeometryColumnOf<T>> {
|
|
146
|
+
const predicate: SpatialPredicate<GeometryColumnOf<T>> = {
|
|
147
|
+
[EXTENSION_EXPRESSION]: 'spatial',
|
|
148
|
+
kind: 'spatial',
|
|
149
|
+
fn: 'st_dwithin',
|
|
150
|
+
col: column,
|
|
151
|
+
value: geometry,
|
|
152
|
+
distance: maxDistance,
|
|
153
|
+
};
|
|
154
|
+
return Object.freeze(predicate);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Encode a numeric vector into pgvector's text input syntax before binding it. */
|
|
158
|
+
export function encodePgVector(value: unknown): string {
|
|
159
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
160
|
+
throw new TypeError('a pgvector query must be a non-empty numeric array');
|
|
161
|
+
}
|
|
162
|
+
for (const component of value) {
|
|
163
|
+
if (typeof component !== 'number' || !Number.isFinite(component)) {
|
|
164
|
+
throw new TypeError('a pgvector query may contain only finite numbers');
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return `[${value.join(',')}]`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function isDistanceExpression(value: unknown): value is DistanceExpression {
|
|
171
|
+
return value !== null && typeof value === 'object' && Reflect.get(value, EXTENSION_EXPRESSION) === 'distance';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function isAliasedDistanceExpression(value: unknown): value is AliasedDistanceExpression {
|
|
175
|
+
return value !== null && typeof value === 'object' && Reflect.get(value, EXTENSION_EXPRESSION) === 'aliased-distance';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function isSpatialPredicate(value: unknown): value is SpatialPredicate {
|
|
179
|
+
return value !== null && typeof value === 'object' && Reflect.get(value, EXTENSION_EXPRESSION) === 'spatial';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function isDistanceOp(operator: string): operator is DistanceOp {
|
|
183
|
+
return Object.hasOwn(DISTANCE_OPERATORS, operator);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function requireExtensionFeature(feature: string, supported: boolean, dialect: DialectTarget): void {
|
|
187
|
+
if (!supported) throw new UnsupportedFeatureError(feature, dialectName(dialect));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Render a compiler-owned distance expression and append its bound vector. */
|
|
191
|
+
export function renderDistanceExpression(
|
|
192
|
+
dialect: DialectTarget,
|
|
193
|
+
expression: DistanceExpression,
|
|
194
|
+
parameters: unknown[],
|
|
195
|
+
): string {
|
|
196
|
+
requireExtensionFeature(expression.operator, dialectTraits(dialect).vectorDistance, dialect);
|
|
197
|
+
parameters.push(encodePgVector(expression.query));
|
|
198
|
+
return `${quoteColumn(dialect, expression.column)} ${DISTANCE_OPERATORS[expression.operator]} ${formatPlaceholder(
|
|
199
|
+
dialect,
|
|
200
|
+
parameters.length,
|
|
201
|
+
)}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Render a selected distance expression with its compiler-quoted alias. */
|
|
205
|
+
export function renderAliasedDistanceExpression(
|
|
206
|
+
dialect: DialectTarget,
|
|
207
|
+
expression: AliasedDistanceExpression,
|
|
208
|
+
parameters: unknown[],
|
|
209
|
+
): string {
|
|
210
|
+
return `${renderDistanceExpression(dialect, expression.expression, parameters)} AS ${quoteIdentifier(
|
|
211
|
+
dialect,
|
|
212
|
+
expression.alias,
|
|
213
|
+
)}`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Render one of the closed PostGIS predicate nodes, binding every value operand. */
|
|
217
|
+
export function renderSpatialPredicate(
|
|
218
|
+
dialect: DialectTarget,
|
|
219
|
+
predicate: SpatialPredicateNode,
|
|
220
|
+
parameters: unknown[],
|
|
221
|
+
): string {
|
|
222
|
+
if (!Object.hasOwn(SPATIAL_FUNCTIONS, predicate.fn)) {
|
|
223
|
+
throw new TypeError(`unknown spatial predicate ${JSON.stringify(predicate.fn)}`);
|
|
224
|
+
}
|
|
225
|
+
requireExtensionFeature(predicate.fn, dialectTraits(dialect).spatialPredicates, dialect);
|
|
226
|
+
|
|
227
|
+
parameters.push(predicate.value);
|
|
228
|
+
const geometry = `ST_GeomFromGeoJSON(${formatPlaceholder(dialect, parameters.length)})`;
|
|
229
|
+
const column = quoteColumn(dialect, predicate.col);
|
|
230
|
+
if (predicate.fn !== 'st_dwithin') return `${SPATIAL_FUNCTIONS[predicate.fn]}(${column}, ${geometry})`;
|
|
231
|
+
|
|
232
|
+
if (predicate.distance === undefined) throw new TypeError('st_dwithin requires a distance');
|
|
233
|
+
parameters.push(predicate.distance);
|
|
234
|
+
return `ST_DWithin(${column}, ${geometry}, ${formatPlaceholder(dialect, parameters.length)})`;
|
|
235
|
+
}
|
package/src/fts/index.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import {
|
|
2
|
+
frozenQuery,
|
|
3
|
+
queryTelemetry,
|
|
4
|
+
renderPredicate,
|
|
5
|
+
tailClause,
|
|
6
|
+
type ComparisonPredicate,
|
|
7
|
+
type PredicateGroup,
|
|
8
|
+
} from '../clauses.js';
|
|
9
|
+
import { dialectName, dialectTraits, type DialectTarget } from '../dialects/index.js';
|
|
10
|
+
import { UnsupportedFeatureError } from '../errors.js';
|
|
11
|
+
import { type CompiledQuery, type QueryCompilerOptions } from '../index.js';
|
|
12
|
+
import { formatPlaceholder, quoteColumn, quoteIdentifier, quoteTable } from '../quoting.js';
|
|
13
|
+
|
|
14
|
+
export { UnsupportedFeatureError };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* An FTS5 term, quoted so that its own quotes cannot end the string.
|
|
18
|
+
*
|
|
19
|
+
* `unknown` rather than `string`, because a `Predicate`'s value is `unknown` — every other
|
|
20
|
+
* operator takes one and binds it as a parameter — and the `String()` this always did is a
|
|
21
|
+
* better answer for a caller who passes a number than an assertion at the one call site.
|
|
22
|
+
*/
|
|
23
|
+
export function escapeFts5Term(term: unknown): string {
|
|
24
|
+
return `"${String(term).replace(/"/g, '""')}"`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseTableSpec(spec: string): { baseName: string; alias?: string } {
|
|
28
|
+
const m = /^(\S+)\s+(?:as\s+)?(\S+)$/i.exec(spec.trim());
|
|
29
|
+
if (m && m[1] && m[2]) {
|
|
30
|
+
return { baseName: m[1], alias: m[2] };
|
|
31
|
+
}
|
|
32
|
+
return { baseName: spec.trim() };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface FtsTableOptions {
|
|
36
|
+
ftsTable?: string | boolean | undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface FtsOptions extends FtsTableOptions, QueryCompilerOptions {}
|
|
40
|
+
|
|
41
|
+
interface Predicate {
|
|
42
|
+
kind: 'match' | 'cmp';
|
|
43
|
+
col: string;
|
|
44
|
+
op?: string | undefined;
|
|
45
|
+
value: unknown;
|
|
46
|
+
}
|
|
47
|
+
interface State {
|
|
48
|
+
table: string;
|
|
49
|
+
preds: Array<Predicate | PredicateGroup>;
|
|
50
|
+
ftsTable?: string | boolean | undefined;
|
|
51
|
+
limitN?: number | undefined;
|
|
52
|
+
offsetN?: number | undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface FtsSelect {
|
|
56
|
+
whereMatch(column: string, term: string, options?: FtsTableOptions | string | boolean): FtsSelect;
|
|
57
|
+
where(col: string, op: string, value: unknown): FtsSelect;
|
|
58
|
+
whereGroup(predicates: readonly ComparisonPredicate[]): FtsSelect;
|
|
59
|
+
limit(n: number): FtsSelect;
|
|
60
|
+
offset(n: number): FtsSelect;
|
|
61
|
+
compile(): CompiledQuery;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function make(d: DialectTarget, s: State, telemetry: boolean): FtsSelect {
|
|
65
|
+
const next = (p: Partial<State>): FtsSelect => make(d, { ...s, ...p }, telemetry);
|
|
66
|
+
return {
|
|
67
|
+
whereMatch: (column, term, options) => {
|
|
68
|
+
const ftsTable =
|
|
69
|
+
s.ftsTable ?? (typeof options === 'string' || typeof options === 'boolean' ? options : options?.ftsTable);
|
|
70
|
+
return next({ preds: [...s.preds, { kind: 'match', col: column, value: term }], ftsTable });
|
|
71
|
+
},
|
|
72
|
+
where: (col, op, value) => next({ preds: [...s.preds, { kind: 'cmp', col, op, value }] }),
|
|
73
|
+
whereGroup: predicates => next({ preds: [...s.preds, { kind: 'group', predicates, connector: 'AND' }] }),
|
|
74
|
+
limit: n => next({ limitN: n }),
|
|
75
|
+
offset: n => next({ offsetN: n }),
|
|
76
|
+
compile: () => {
|
|
77
|
+
const params: unknown[] = [];
|
|
78
|
+
let text = '';
|
|
79
|
+
const fts = dialectTraits(d).fts;
|
|
80
|
+
|
|
81
|
+
if (fts === 'companionTable') {
|
|
82
|
+
const { baseName, alias } = parseTableSpec(s.table);
|
|
83
|
+
const quotedBaseTable = quoteTable(d, s.table);
|
|
84
|
+
const baseRef = alias ? quoteIdentifier(d, alias) : quoteColumn(d, baseName);
|
|
85
|
+
|
|
86
|
+
const hasMatch = s.preds.some(p => p.kind === 'match');
|
|
87
|
+
if (hasMatch) {
|
|
88
|
+
if (!s.ftsTable) {
|
|
89
|
+
throw new UnsupportedFeatureError('full-text search', dialectName(d));
|
|
90
|
+
}
|
|
91
|
+
const ftsTableName = typeof s.ftsTable === 'string' ? s.ftsTable : `${baseName}_fts`;
|
|
92
|
+
const ftsAlias = alias ? `${alias}_fts` : undefined;
|
|
93
|
+
const quotedFtsTable = ftsAlias
|
|
94
|
+
? `${quoteColumn(d, ftsTableName)} AS ${quoteIdentifier(d, ftsAlias)}`
|
|
95
|
+
: quoteColumn(d, ftsTableName);
|
|
96
|
+
const ftsRef = ftsAlias ? quoteIdentifier(d, ftsAlias) : quoteColumn(d, ftsTableName);
|
|
97
|
+
|
|
98
|
+
text = `SELECT * FROM ${quotedBaseTable} INNER JOIN ${quotedFtsTable} ON ${baseRef}.${quoteIdentifier(d, 'rowid')} = ${ftsRef}.${quoteIdentifier(d, 'rowid')}`;
|
|
99
|
+
} else {
|
|
100
|
+
text = `SELECT * FROM ${quotedBaseTable}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (s.preds.length > 0) {
|
|
104
|
+
const ftsTableName = typeof s.ftsTable === 'string' ? s.ftsTable : `${baseName}_fts`;
|
|
105
|
+
const ftsAlias = alias ? `${alias}_fts` : undefined;
|
|
106
|
+
const ftsRef = ftsAlias ? quoteIdentifier(d, ftsAlias) : quoteColumn(d, ftsTableName);
|
|
107
|
+
|
|
108
|
+
const parts = s.preds.map(p => {
|
|
109
|
+
if (p.kind === 'group') return renderPredicate(d, p, params);
|
|
110
|
+
if (p.kind === 'match') {
|
|
111
|
+
const colName = p.col.slice(p.col.lastIndexOf('.') + 1);
|
|
112
|
+
params.push(escapeFts5Term(p.value));
|
|
113
|
+
return `${ftsRef}.${quoteIdentifier(d, colName)} MATCH ${formatPlaceholder(d, params.length)}`;
|
|
114
|
+
}
|
|
115
|
+
return renderPredicate(d, { col: p.col, op: p.op ?? '=', value: p.value }, params);
|
|
116
|
+
});
|
|
117
|
+
text += ` WHERE ${parts.join(' AND ')}`;
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
text = `SELECT * FROM ${quoteTable(d, s.table)}`;
|
|
121
|
+
if (s.preds.length > 0) {
|
|
122
|
+
const parts = s.preds.map(p => {
|
|
123
|
+
if (p.kind === 'group') return renderPredicate(d, p, params);
|
|
124
|
+
if (p.kind !== 'match') {
|
|
125
|
+
return renderPredicate(d, { col: p.col, op: p.op ?? '=', value: p.value }, params);
|
|
126
|
+
}
|
|
127
|
+
params.push(p.value);
|
|
128
|
+
if (fts === 'tsvector') {
|
|
129
|
+
return `to_tsvector('english', ${quoteColumn(d, p.col)}) @@ to_tsquery('english', ${formatPlaceholder(d, params.length)})`;
|
|
130
|
+
}
|
|
131
|
+
if (fts === 'match') {
|
|
132
|
+
return `MATCH(${quoteColumn(d, p.col)}) AGAINST(${formatPlaceholder(d, params.length)} IN NATURAL LANGUAGE MODE)`;
|
|
133
|
+
}
|
|
134
|
+
if (fts === 'matchPlain') {
|
|
135
|
+
return `MATCH(${quoteColumn(d, p.col)}) AGAINST(${formatPlaceholder(d, params.length)})`;
|
|
136
|
+
}
|
|
137
|
+
throw new UnsupportedFeatureError('full-text search', dialectName(d));
|
|
138
|
+
});
|
|
139
|
+
text += ` WHERE ${parts.join(' AND ')}`;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
text += tailClause(d, s);
|
|
144
|
+
return frozenQuery(text, params, queryTelemetry(d, 'SELECT', s.table, telemetry));
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function ftsSelectFrom(
|
|
150
|
+
table: string,
|
|
151
|
+
dialect: DialectTarget,
|
|
152
|
+
options?: FtsOptions | string | boolean,
|
|
153
|
+
): FtsSelect {
|
|
154
|
+
const ftsTable = typeof options === 'string' || typeof options === 'boolean' ? options : options?.ftsTable;
|
|
155
|
+
const telemetry = typeof options === 'object' && options?.telemetry === true;
|
|
156
|
+
return make(dialect, { table, preds: [], ftsTable }, telemetry);
|
|
157
|
+
}
|