turbine-orm 0.19.2 → 0.22.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.
- package/README.md +99 -18
- package/dist/adapters/cockroachdb.js +4 -9
- package/dist/cjs/adapters/cockroachdb.js +4 -9
- package/dist/cjs/cli/index.js +6 -3
- package/dist/cjs/cli/observe-ui.js +12 -2
- package/dist/cjs/cli/observe.js +6 -7
- package/dist/cjs/cli/studio.js +6 -7
- package/dist/cjs/client.js +44 -22
- package/dist/cjs/dialect.js +116 -0
- package/dist/cjs/errors.js +19 -1
- package/dist/cjs/generate.js +4 -0
- package/dist/cjs/index.js +3 -2
- package/dist/cjs/introspect.js +20 -0
- package/dist/cjs/mssql.js +1338 -0
- package/dist/cjs/mysql.js +1052 -0
- package/dist/cjs/powdb.js +851 -0
- package/dist/cjs/powql.js +903 -0
- package/dist/cjs/query/builder.js +293 -73
- package/dist/cjs/sqlite.js +849 -0
- package/dist/cjs/typed-sql.js +9 -7
- package/dist/cli/index.js +6 -3
- package/dist/cli/observe-ui.d.ts +1 -1
- package/dist/cli/observe-ui.js +12 -2
- package/dist/cli/observe.js +7 -8
- package/dist/cli/studio.js +7 -8
- package/dist/cli/ui.d.ts +1 -1
- package/dist/client.d.ts +23 -1
- package/dist/client.js +45 -23
- package/dist/dialect.d.ts +258 -1
- package/dist/dialect.js +83 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +17 -0
- package/dist/generate.js +4 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/introspect.d.ts +18 -0
- package/dist/introspect.js +19 -0
- package/dist/mssql.d.ts +233 -0
- package/dist/mssql.js +1298 -0
- package/dist/mysql.d.ts +174 -0
- package/dist/mysql.js +1012 -0
- package/dist/powdb.d.ts +338 -0
- package/dist/powdb.js +802 -0
- package/dist/powql.d.ts +153 -0
- package/dist/powql.js +900 -0
- package/dist/query/builder.d.ts +105 -0
- package/dist/query/builder.js +294 -74
- package/dist/query/index.d.ts +1 -1
- package/dist/sqlite.d.ts +144 -0
- package/dist/sqlite.js +842 -0
- package/dist/typed-sql.d.ts +7 -5
- package/dist/typed-sql.js +9 -7
- package/package.json +45 -1
package/dist/mssql.js
ADDED
|
@@ -0,0 +1,1298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* turbine-orm/mssql — Microsoft SQL Server engine (driver-injected, optional peer)
|
|
3
|
+
*
|
|
4
|
+
* Binds Turbine to SQL Server 2016+ via the `mssql` driver (which wraps
|
|
5
|
+
* `tedious`). `mssql` is **not** a root dependency — it is an **optional peer**:
|
|
6
|
+
* `npm i turbine-orm` pulls nothing extra, and only consumers who
|
|
7
|
+
* `import 'turbine-orm/mssql'` install `mssql` themselves. The factory loads it
|
|
8
|
+
* through a dynamic `import('mssql')` so importing this module never crashes when
|
|
9
|
+
* `mssql` is absent for a consumer who does not use it. Turbine's root runtime
|
|
10
|
+
* dependency stays exactly `pg`.
|
|
11
|
+
*
|
|
12
|
+
* ## The three hard SQL Server realities this engine solves
|
|
13
|
+
*
|
|
14
|
+
* 1. **No `RETURNING`.** `INSERT`/`UPDATE`/`DELETE` cannot trail a `RETURNING`
|
|
15
|
+
* clause; SQL Server returns affected rows via `OUTPUT INSERTED.*` /
|
|
16
|
+
* `OUTPUT DELETED.*` injected MID-statement (between the column list and
|
|
17
|
+
* `VALUES`, or between `SET …` and `WHERE …`). `mssqlDialect.resultStrategy =
|
|
18
|
+
* 'output'`: the statement returns its own rows in ONE round-trip (executed
|
|
19
|
+
* exactly like the PostgreSQL `'returning'` path). Upsert becomes a
|
|
20
|
+
* `MERGE … WHEN MATCHED … WHEN NOT MATCHED … OUTPUT INSERTED.* ;` (a MERGE
|
|
21
|
+
* must end with `;`). This is the first shipped engine to exercise the
|
|
22
|
+
* Phase-0 `'output'` result strategy.
|
|
23
|
+
* 2. **No `json_agg`.** SQL Server has no JSON aggregate function; the idiomatic
|
|
24
|
+
* single-query nested-relation path is `(SELECT child cols … FOR JSON PATH)`,
|
|
25
|
+
* whose object shape is expressed by the child SELECT's column ALIASES rather
|
|
26
|
+
* than an explicit `JSON_OBJECT(...)`. That does NOT map onto
|
|
27
|
+
* `buildJsonObject`/`buildJsonArrayAgg`, so `mssqlDialect` defines the
|
|
28
|
+
* additive `Dialect.buildRelationSubquery` override (the sanctioned Phase-3
|
|
29
|
+
* seam extension) and owns the whole correlated subquery. To-many wraps
|
|
30
|
+
* `ISNULL((… FOR JSON PATH), '[]')` (FOR JSON over zero rows is NULL, not
|
|
31
|
+
* `[]`); to-one adds `, WITHOUT_ARRAY_WRAPPER` and lets NULL be the no-row
|
|
32
|
+
* value. Nested relations are embedded with `JSON_QUERY(...)` so they stay
|
|
33
|
+
* real JSON instead of being escaped as a string. `INCLUDE_NULL_VALUES`
|
|
34
|
+
* keeps NULL columns present (matching PostgreSQL `json_build_object`).
|
|
35
|
+
* 3. **No `LIMIT`.** Paging is `ORDER BY … OFFSET n ROWS FETCH NEXT m ROWS ONLY`,
|
|
36
|
+
* which requires an ORDER BY — a stable `ORDER BY (SELECT NULL)` is injected
|
|
37
|
+
* when the query has none (`Dialect.buildLimitOffset`).
|
|
38
|
+
*
|
|
39
|
+
* ## Named `@pN` placeholders (no positional `?`)
|
|
40
|
+
*
|
|
41
|
+
* `mssqlDialect.paramPlaceholder = (i) => '@p' + i`. The driver shim binds via
|
|
42
|
+
* `request.input('p' + i, value)`, so binding is by NAME and independent of where
|
|
43
|
+
* each placeholder lands in the SQL text — exactly the guarantee PostgreSQL's
|
|
44
|
+
* numbered `$N` gives. (SQL Server is naturally named-param friendly, sidestepping
|
|
45
|
+
* the positional-`?` mis-bind bug the SQLite/MySQL phases hit.)
|
|
46
|
+
*
|
|
47
|
+
* ## Capabilities & limits (vs PostgreSQL)
|
|
48
|
+
*
|
|
49
|
+
* - **Single query nested relations preserved** via `FOR JSON PATH` (SQL Server
|
|
50
|
+
* 2016+). Ordered/limited to-many uses `ORDER BY … OFFSET/FETCH` inside the FOR
|
|
51
|
+
* JSON subquery (no inner-subquery rewrite needed — FOR JSON aggregates AFTER
|
|
52
|
+
* the row selection).
|
|
53
|
+
* - **Result strategy `'output'`:** create/update/delete/upsert return their rows
|
|
54
|
+
* from the same statement. `createMany` returns the inserted rows via
|
|
55
|
+
* `OUTPUT INSERTED.*` on the multi-row VALUES insert (≤ 1000 rows / 2100 params
|
|
56
|
+
* per statement — exceeding either throws a clear `ValidationError`; chunk
|
|
57
|
+
* yourself or use single `create`s).
|
|
58
|
+
* - **MERGE concurrency caveat:** `MERGE` is the upsert primitive; under high
|
|
59
|
+
* concurrency a `MERGE` can still race (it is NOT a substitute for a unique
|
|
60
|
+
* constraint). Keep the conflict target backed by a real `UNIQUE`/`PK` index,
|
|
61
|
+
* and rely on the typed `UniqueConstraintError` (2627/2601 → E008) for the
|
|
62
|
+
* loser of a race.
|
|
63
|
+
* - **Unsupported (throw `UnsupportedFeatureError`):** pgvector distance ops,
|
|
64
|
+
* LISTEN/NOTIFY (`$listen`/`$notify`), RLS `sessionContext` (sp_set_session_context
|
|
65
|
+
* exists but is connection-scoped, not transaction-local, so it is not wired —
|
|
66
|
+
* throws rather than silently leaking context across pooled connections).
|
|
67
|
+
* - **Advisory-lock migration locking** is available in principle via
|
|
68
|
+
* `sp_getapplock`/`sp_releaseapplock` (`supportsAdvisoryLock = true`); the
|
|
69
|
+
* migrate CLI is still PostgreSQL-only, so this flag documents intent for a
|
|
70
|
+
* future adapter.
|
|
71
|
+
* - **Case-insensitive matching** uses `LOWER(col) LIKE LOWER(ref)` — deterministic
|
|
72
|
+
* regardless of the column's collation (note this can defeat an index unless a
|
|
73
|
+
* computed/persisted `LOWER()` index exists).
|
|
74
|
+
* - **bignum:** the shim applies the same safe-int policy Turbine uses for Postgres
|
|
75
|
+
* `int8` (number when it fits in 2^53, decimal string otherwise) WITHOUT mutating
|
|
76
|
+
* any global driver state. `DECIMAL`/`NUMERIC`/`MONEY` come back as strings;
|
|
77
|
+
* `BIT` binds/returns booleans.
|
|
78
|
+
* - **`DISTINCT ON`** is PostgreSQL-only and is not translated — avoid `distinct`
|
|
79
|
+
* on SQL Server.
|
|
80
|
+
*
|
|
81
|
+
* ## Example
|
|
82
|
+
*
|
|
83
|
+
* ```ts
|
|
84
|
+
* import { turbineMssql } from 'turbine-orm/mssql';
|
|
85
|
+
* import { SCHEMA } from './generated/turbine/metadata.js';
|
|
86
|
+
*
|
|
87
|
+
* const db = await turbineMssql('mssql://sa:Passw0rd!@localhost:1433/app', SCHEMA);
|
|
88
|
+
* const users = await db.users.findMany({ with: { posts: true }, limit: 10 });
|
|
89
|
+
* await db.disconnect();
|
|
90
|
+
* ```
|
|
91
|
+
*/
|
|
92
|
+
import { TurbineClient } from './client.js';
|
|
93
|
+
import { postgresDialect, } from './dialect.js';
|
|
94
|
+
import { ConnectionError, RelationError, UnsupportedFeatureError, ValidationError } from './errors.js';
|
|
95
|
+
import { camelToSnake, isDateType, normalizeKeyColumns, singularize, snakeToCamel, } from './schema.js';
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// SQL Server / connection limits
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
/** Max bound parameters per SQL Server statement. */
|
|
100
|
+
const MSSQL_MAX_PARAMS = 2100;
|
|
101
|
+
/** Max rows per multi-row INSERT … VALUES statement. */
|
|
102
|
+
const MSSQL_MAX_INSERT_ROWS = 1000;
|
|
103
|
+
/** Canonical key sort matching the query builder's `sortedEntries`/collect order. */
|
|
104
|
+
function sortedRelEntries(obj) {
|
|
105
|
+
return Object.entries(obj).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
106
|
+
}
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Value coercion (params in)
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
/**
|
|
111
|
+
* Coerce an arbitrary JS value into something `mssql` can bind. `BIT` accepts JS
|
|
112
|
+
* booleans directly; `undefined`/`null` → NULL; `bigint` follows the safe-int
|
|
113
|
+
* policy (number when it fits in 2^53, else a decimal string — no precision
|
|
114
|
+
* loss); `Date`/`Uint8Array` pass through; any remaining object/array → JSON text
|
|
115
|
+
* (matches how Turbine already pre-serializes JSON filter / `IN`-list params).
|
|
116
|
+
*/
|
|
117
|
+
function toMssqlParam(value) {
|
|
118
|
+
if (value === undefined || value === null)
|
|
119
|
+
return null;
|
|
120
|
+
switch (typeof value) {
|
|
121
|
+
case 'boolean':
|
|
122
|
+
case 'number':
|
|
123
|
+
case 'string':
|
|
124
|
+
return value;
|
|
125
|
+
case 'bigint':
|
|
126
|
+
return value <= BigInt(Number.MAX_SAFE_INTEGER) && value >= BigInt(Number.MIN_SAFE_INTEGER)
|
|
127
|
+
? Number(value)
|
|
128
|
+
: value.toString();
|
|
129
|
+
}
|
|
130
|
+
if (value instanceof Date)
|
|
131
|
+
return value;
|
|
132
|
+
if (value instanceof Uint8Array)
|
|
133
|
+
return value; // Buffer is a Uint8Array subclass
|
|
134
|
+
return JSON.stringify(value);
|
|
135
|
+
}
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Result shaping + error translation
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
/** Shape an `mssql` result into a `pg.QueryResult`-like object. */
|
|
140
|
+
function shapeResult(result) {
|
|
141
|
+
const rows = result.recordset ?? [];
|
|
142
|
+
coerceBigIntColumns(rows, result.recordset?.columns);
|
|
143
|
+
const affected = result.rowsAffected?.[0];
|
|
144
|
+
return { rows, rowCount: typeof affected === 'number' ? affected : rows.length };
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Coerce top-level `BIGINT` columns from strings to numbers, mirroring the
|
|
148
|
+
* Postgres `int8` policy (the TurbineClient registers a pg type parser that
|
|
149
|
+
* returns bigint as number within the JS safe-integer range). The `mssql`/tedious
|
|
150
|
+
* driver returns BIGINT as a string to avoid precision loss, so a BIGINT IDENTITY
|
|
151
|
+
* `id` would otherwise surface as `'1'` instead of `1`. Values outside the safe
|
|
152
|
+
* range are left as strings (same as the Postgres path). Nested relations are
|
|
153
|
+
* unaffected — `FOR JSON PATH` already renders BIGINT as a JSON number.
|
|
154
|
+
*/
|
|
155
|
+
function coerceBigIntColumns(rows, columns) {
|
|
156
|
+
if (!columns || rows.length === 0)
|
|
157
|
+
return;
|
|
158
|
+
const bigIntCols = [];
|
|
159
|
+
for (const [name, col] of Object.entries(columns)) {
|
|
160
|
+
const t = col?.type;
|
|
161
|
+
const decl = String(t?.declaration ?? t?.name ?? '').toLowerCase();
|
|
162
|
+
if (decl === 'bigint')
|
|
163
|
+
bigIntCols.push(name);
|
|
164
|
+
}
|
|
165
|
+
if (bigIntCols.length === 0)
|
|
166
|
+
return;
|
|
167
|
+
for (const row of rows) {
|
|
168
|
+
for (const c of bigIntCols) {
|
|
169
|
+
const v = row[c];
|
|
170
|
+
if (typeof v === 'string' && /^-?\d+$/.test(v)) {
|
|
171
|
+
const n = Number(v);
|
|
172
|
+
if (Number.isSafeInteger(n))
|
|
173
|
+
row[c] = n;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const EMPTY_RESULT = { rows: [], rowCount: 0 };
|
|
179
|
+
/**
|
|
180
|
+
* Augment a `mssql` driver error with the Postgres-shaped `.code` (SQLSTATE) and
|
|
181
|
+
* detail fields that `wrapPgError` understands, so SQL Server constraint failures
|
|
182
|
+
* surface as the same typed Turbine errors as Postgres (E008/E009/E010/E011) and
|
|
183
|
+
* deadlock / lock-timeout become retryable (E012/E013). The original error (with
|
|
184
|
+
* its real message) is preserved as the wrapped error's `.cause` downstream.
|
|
185
|
+
* Returns the value unchanged when it is not a recognizable mssql error.
|
|
186
|
+
*/
|
|
187
|
+
function augmentMssqlError(err) {
|
|
188
|
+
if (!err || typeof err !== 'object')
|
|
189
|
+
return err;
|
|
190
|
+
const e = err;
|
|
191
|
+
if (typeof e.number !== 'number')
|
|
192
|
+
return err;
|
|
193
|
+
const target = e;
|
|
194
|
+
const msg = e.message ?? '';
|
|
195
|
+
switch (e.number) {
|
|
196
|
+
// 2627 = unique constraint / PK violation; 2601 = unique index violation.
|
|
197
|
+
case 2627:
|
|
198
|
+
case 2601: {
|
|
199
|
+
target.code = '23505';
|
|
200
|
+
// "...constraint 'UQ_users_email'..." or "...index 'IX_users_email'..."
|
|
201
|
+
const m = /(?:constraint|index)\s+'([^']+)'/i.exec(msg) ?? /'([^']+)'/.exec(msg);
|
|
202
|
+
if (m?.[1])
|
|
203
|
+
target.constraint = m[1];
|
|
204
|
+
return err;
|
|
205
|
+
}
|
|
206
|
+
// 547 = FOREIGN KEY / CHECK constraint conflict (message distinguishes them).
|
|
207
|
+
case 547:
|
|
208
|
+
target.code = /CHECK constraint/i.test(msg) ? '23514' : '23503';
|
|
209
|
+
return err;
|
|
210
|
+
// 515 = cannot insert NULL into a non-nullable column.
|
|
211
|
+
case 515: {
|
|
212
|
+
target.code = '23502';
|
|
213
|
+
const c = /column '([^']+)'/i.exec(msg);
|
|
214
|
+
if (c?.[1])
|
|
215
|
+
target.column = c[1];
|
|
216
|
+
return err;
|
|
217
|
+
}
|
|
218
|
+
// 1205 = transaction chosen as deadlock victim (retryable → pg 40P01).
|
|
219
|
+
case 1205:
|
|
220
|
+
target.code = '40P01';
|
|
221
|
+
return err;
|
|
222
|
+
// 1222 = lock request time out (retryable → pg 40001 serialization failure).
|
|
223
|
+
case 1222:
|
|
224
|
+
target.code = '40001';
|
|
225
|
+
return err;
|
|
226
|
+
default:
|
|
227
|
+
return err;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function normalizeQueryArgs(arg, values) {
|
|
231
|
+
if (typeof arg === 'string')
|
|
232
|
+
return { text: arg, params: values ?? [] };
|
|
233
|
+
return { text: arg.text, params: arg.values ?? values ?? [] };
|
|
234
|
+
}
|
|
235
|
+
/** Bind positional `params[]` to `@p1`, `@p2`, … inputs and run the statement. */
|
|
236
|
+
async function runRequest(request, text, params) {
|
|
237
|
+
for (let i = 0; i < params.length; i++) {
|
|
238
|
+
request.input(`p${i + 1}`, toMssqlParam(params[i]));
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
const result = await request.query(text);
|
|
242
|
+
return shapeResult(result);
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
throw augmentMssqlError(err);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/** Map a SQL-standard isolation-level name to the mssql `ISOLATION_LEVEL` constant. */
|
|
249
|
+
function mapIsolationLevel(sqlNS, level) {
|
|
250
|
+
if (!level)
|
|
251
|
+
return undefined;
|
|
252
|
+
return sqlNS.ISOLATION_LEVEL[level.trim().toUpperCase().replace(/\s+/g, '_')];
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* A transaction-scoped {@link PgCompatPoolClient}. SQL Server transactions are
|
|
256
|
+
* driven through the `mssql` `Transaction` API, NOT raw `BEGIN`/`COMMIT` SQL (the
|
|
257
|
+
* driver owns the connection a transaction is pinned to). This client intercepts
|
|
258
|
+
* the dialect's transaction-control statements and routes them to that API, while
|
|
259
|
+
* regular queries run on a `Request` bound to the active transaction (or the pool
|
|
260
|
+
* before BEGIN). Mirrors the MySQL shim's `runOnConnection` intent.
|
|
261
|
+
*/
|
|
262
|
+
class MssqlTxClient {
|
|
263
|
+
pool;
|
|
264
|
+
sqlNS;
|
|
265
|
+
tx = null;
|
|
266
|
+
pendingIsolation = null;
|
|
267
|
+
constructor(pool, sqlNS) {
|
|
268
|
+
this.pool = pool;
|
|
269
|
+
this.sqlNS = sqlNS;
|
|
270
|
+
}
|
|
271
|
+
// biome-ignore lint/suspicious/noExplicitAny: pg-compat query is generic over the row shape.
|
|
272
|
+
async query(text, values) {
|
|
273
|
+
const { text: rawSql, params } = normalizeQueryArgs(text, values);
|
|
274
|
+
const sql = rawSql.trim();
|
|
275
|
+
// RELEASE SAVEPOINT has no SQL Server equivalent (savepoints auto-persist until
|
|
276
|
+
// the outer commit/rollback) — releaseSavepointStatement() returns '' → no-op.
|
|
277
|
+
if (sql === '')
|
|
278
|
+
return EMPTY_RESULT;
|
|
279
|
+
// The dialect composes `SET TRANSACTION ISOLATION LEVEL …; BEGIN TRANSACTION`
|
|
280
|
+
// (SQL Server cannot take an inline isolation level on BEGIN). Split and run
|
|
281
|
+
// the parts in order — same pattern as the MySQL shim. Gated on the exact
|
|
282
|
+
// transaction-control prefix so no builder/user SQL is ever split.
|
|
283
|
+
if (/^SET TRANSACTION ISOLATION LEVEL /i.test(sql) && sql.includes('; ')) {
|
|
284
|
+
let last = EMPTY_RESULT;
|
|
285
|
+
for (const part of sql.split('; ')) {
|
|
286
|
+
const p = part.trim();
|
|
287
|
+
if (p)
|
|
288
|
+
last = await this.query(p, []);
|
|
289
|
+
}
|
|
290
|
+
return last;
|
|
291
|
+
}
|
|
292
|
+
if (/^SET TRANSACTION ISOLATION LEVEL /i.test(sql)) {
|
|
293
|
+
this.pendingIsolation = sql.replace(/^SET TRANSACTION ISOLATION LEVEL /i, '').trim();
|
|
294
|
+
return EMPTY_RESULT;
|
|
295
|
+
}
|
|
296
|
+
if (/^BEGIN TRAN(SACTION)?\b/i.test(sql)) {
|
|
297
|
+
this.tx = new this.sqlNS.Transaction(this.pool);
|
|
298
|
+
await this.tx.begin(mapIsolationLevel(this.sqlNS, this.pendingIsolation));
|
|
299
|
+
this.pendingIsolation = null;
|
|
300
|
+
return EMPTY_RESULT;
|
|
301
|
+
}
|
|
302
|
+
if (/^COMMIT\b/i.test(sql)) {
|
|
303
|
+
if (this.tx)
|
|
304
|
+
await this.tx.commit();
|
|
305
|
+
this.tx = null;
|
|
306
|
+
return EMPTY_RESULT;
|
|
307
|
+
}
|
|
308
|
+
// ROLLBACK TRANSACTION <name> = rollback to a savepoint; bare ROLLBACK = abort.
|
|
309
|
+
const spRollback = /^ROLLBACK TRAN(?:SACTION)?\s+(\S+)/i.exec(sql);
|
|
310
|
+
if (spRollback) {
|
|
311
|
+
const req = new this.sqlNS.Request(this.tx ?? this.pool);
|
|
312
|
+
await req.batch(`ROLLBACK TRANSACTION ${spRollback[1]}`);
|
|
313
|
+
return EMPTY_RESULT;
|
|
314
|
+
}
|
|
315
|
+
if (/^ROLLBACK\b/i.test(sql)) {
|
|
316
|
+
if (this.tx)
|
|
317
|
+
await this.tx.rollback();
|
|
318
|
+
this.tx = null;
|
|
319
|
+
return EMPTY_RESULT;
|
|
320
|
+
}
|
|
321
|
+
if (/^SAVE TRAN(SACTION)?\b/i.test(sql)) {
|
|
322
|
+
const req = new this.sqlNS.Request(this.tx ?? this.pool);
|
|
323
|
+
await req.batch(sql);
|
|
324
|
+
return EMPTY_RESULT;
|
|
325
|
+
}
|
|
326
|
+
const request = new this.sqlNS.Request(this.tx ?? this.pool);
|
|
327
|
+
return runRequest(request, rawSql, params);
|
|
328
|
+
}
|
|
329
|
+
release() {
|
|
330
|
+
// The transaction's connection is owned by the mssql Transaction object and is
|
|
331
|
+
// returned to the pool on commit/rollback; nothing to release here.
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* A {@link PgCompatPool} backed by an `mssql` ConnectionPool. Non-transaction
|
|
336
|
+
* queries run on a fresh pooled `Request`; `connect()` returns a
|
|
337
|
+
* {@link MssqlTxClient} that drives a single transaction through the mssql
|
|
338
|
+
* `Transaction` API so `BEGIN`/`COMMIT`/`ROLLBACK`/savepoints all run on the same
|
|
339
|
+
* physical connection.
|
|
340
|
+
*/
|
|
341
|
+
export class MssqlPool {
|
|
342
|
+
/** The underlying `mssql` ConnectionPool — exposed as an escape hatch (seed / DDL / advanced ops). */
|
|
343
|
+
pool;
|
|
344
|
+
sqlNS;
|
|
345
|
+
closed = false;
|
|
346
|
+
constructor(pool, sqlNS) {
|
|
347
|
+
this.pool = pool;
|
|
348
|
+
this.sqlNS = sqlNS;
|
|
349
|
+
}
|
|
350
|
+
// biome-ignore lint/suspicious/noExplicitAny: pg-compat query is generic over the row shape.
|
|
351
|
+
async query(text, values) {
|
|
352
|
+
const { text: sql, params } = normalizeQueryArgs(text, values);
|
|
353
|
+
return runRequest(this.pool.request(), sql, params);
|
|
354
|
+
}
|
|
355
|
+
async connect() {
|
|
356
|
+
return new MssqlTxClient(this.pool, this.sqlNS);
|
|
357
|
+
}
|
|
358
|
+
async end() {
|
|
359
|
+
if (this.closed)
|
|
360
|
+
return;
|
|
361
|
+
this.closed = true;
|
|
362
|
+
await this.pool.close();
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
// Type mapping (SQL Server data type → TypeScript)
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
/**
|
|
369
|
+
* Map a SQL Server column type to a TypeScript type. `dialectType` is the
|
|
370
|
+
* `INFORMATION_SCHEMA.COLUMNS.DATA_TYPE` (lowercase, e.g. `bigint`, `nvarchar`,
|
|
371
|
+
* `datetime2`, `bit`, `uniqueidentifier`).
|
|
372
|
+
*/
|
|
373
|
+
export function mssqlTypeToTs(dialectType, nullable) {
|
|
374
|
+
const t = dialectType.toLowerCase();
|
|
375
|
+
let base;
|
|
376
|
+
if (t === 'bit')
|
|
377
|
+
base = 'boolean';
|
|
378
|
+
else if (/^(tinyint|smallint|int|bigint)$/.test(t))
|
|
379
|
+
base = 'number';
|
|
380
|
+
else if (/^(decimal|numeric|money|smallmoney)$/.test(t))
|
|
381
|
+
base = 'string';
|
|
382
|
+
else if (/^(float|real)$/.test(t))
|
|
383
|
+
base = 'number';
|
|
384
|
+
else if (/^(datetime|datetime2|smalldatetime|date|datetimeoffset)$/.test(t))
|
|
385
|
+
base = 'Date';
|
|
386
|
+
else if (t === 'uniqueidentifier')
|
|
387
|
+
base = 'string';
|
|
388
|
+
else if (/(varbinary|binary|image|rowversion|timestamp)$/.test(t))
|
|
389
|
+
base = 'Uint8Array';
|
|
390
|
+
else if (/(char|text|xml|time)/.test(t))
|
|
391
|
+
base = 'string';
|
|
392
|
+
else
|
|
393
|
+
base = 'unknown';
|
|
394
|
+
return nullable ? `${base} | null` : base;
|
|
395
|
+
}
|
|
396
|
+
/** Is a SQL Server declared type a date/time type (so values coerce back to `Date`)? */
|
|
397
|
+
function isMssqlDateType(dialectType) {
|
|
398
|
+
const t = dialectType.toLowerCase();
|
|
399
|
+
return isDateType(t) || /^(datetime|datetime2|smalldatetime|date|datetimeoffset)$/.test(t);
|
|
400
|
+
}
|
|
401
|
+
/** Map a schema-builder (Postgres-flavored) column type to SQL Server DDL. */
|
|
402
|
+
function mssqlColumnType(type, maxLength) {
|
|
403
|
+
const t = type.toUpperCase();
|
|
404
|
+
if (/BIGSERIAL/.test(t))
|
|
405
|
+
return 'BIGINT IDENTITY(1,1)';
|
|
406
|
+
if (/SERIAL/.test(t))
|
|
407
|
+
return 'INT IDENTITY(1,1)';
|
|
408
|
+
if (/BIGINT|INT8/.test(t))
|
|
409
|
+
return 'BIGINT';
|
|
410
|
+
if (/SMALLINT|INT2/.test(t))
|
|
411
|
+
return 'SMALLINT';
|
|
412
|
+
if (/INTEGER|INT4|\bINT\b/.test(t))
|
|
413
|
+
return 'INT';
|
|
414
|
+
if (/BOOL/.test(t))
|
|
415
|
+
return 'BIT';
|
|
416
|
+
if (/DOUBLE|FLOAT8/.test(t))
|
|
417
|
+
return 'FLOAT';
|
|
418
|
+
if (/REAL|FLOAT4|FLOAT/.test(t))
|
|
419
|
+
return 'REAL';
|
|
420
|
+
if (/NUMERIC|DECIMAL|MONEY/.test(t))
|
|
421
|
+
return 'DECIMAL(38,18)';
|
|
422
|
+
if (/JSONB|JSON/.test(t))
|
|
423
|
+
return 'NVARCHAR(MAX)';
|
|
424
|
+
if (/UUID/.test(t))
|
|
425
|
+
return 'UNIQUEIDENTIFIER';
|
|
426
|
+
if (/TIMESTAMPTZ/.test(t))
|
|
427
|
+
return 'DATETIMEOFFSET';
|
|
428
|
+
if (/TIMESTAMP|DATETIME/.test(t))
|
|
429
|
+
return 'DATETIME2';
|
|
430
|
+
if (/\bDATE\b/.test(t))
|
|
431
|
+
return 'DATE';
|
|
432
|
+
if (/\bTIME\b/.test(t))
|
|
433
|
+
return 'TIME';
|
|
434
|
+
if (/BYTEA|BLOB/.test(t))
|
|
435
|
+
return 'VARBINARY(MAX)';
|
|
436
|
+
if (/VARCHAR/.test(t))
|
|
437
|
+
return maxLength != null ? `NVARCHAR(${maxLength})` : 'NVARCHAR(255)';
|
|
438
|
+
if (/CHAR/.test(t))
|
|
439
|
+
return maxLength != null ? `NCHAR(${maxLength})` : 'NCHAR(255)';
|
|
440
|
+
if (/TEXT|CLOB/.test(t))
|
|
441
|
+
return 'NVARCHAR(MAX)';
|
|
442
|
+
if (/ENUM/.test(t))
|
|
443
|
+
return 'NVARCHAR(255)';
|
|
444
|
+
return type;
|
|
445
|
+
}
|
|
446
|
+
// ---------------------------------------------------------------------------
|
|
447
|
+
// mssqlDialect — the full Dialect contract for SQL Server 2016+
|
|
448
|
+
// ---------------------------------------------------------------------------
|
|
449
|
+
/**
|
|
450
|
+
* SQL Server 2016+ implementation of the {@link Dialect} contract. Bracket
|
|
451
|
+
* identifier quoting (`[…]`), named `@pN` placeholders, the `FOR JSON PATH`
|
|
452
|
+
* nested-relation override (no `json_agg`), no `RETURNING`
|
|
453
|
+
* (`resultStrategy = 'output'` via `OUTPUT INSERTED.*` / `MERGE`), `OFFSET/FETCH`
|
|
454
|
+
* paging, and the Postgres-only capabilities disabled (vector / LISTEN-NOTIFY /
|
|
455
|
+
* RLS).
|
|
456
|
+
*/
|
|
457
|
+
export const mssqlDialect = {
|
|
458
|
+
...postgresDialect,
|
|
459
|
+
name: 'mssql',
|
|
460
|
+
// No RETURNING → the statement emits its rows via OUTPUT INSERTED.* in ONE
|
|
461
|
+
// round-trip (Phase-0 'output' strategy, executed like 'returning').
|
|
462
|
+
resultStrategy: 'output',
|
|
463
|
+
supportsReturning: false,
|
|
464
|
+
supportsILike: false,
|
|
465
|
+
supportsVector: false,
|
|
466
|
+
supportsListenNotify: false,
|
|
467
|
+
supportsRLS: false,
|
|
468
|
+
// sp_getapplock / sp_releaseapplock exist (used by a future migrate adapter).
|
|
469
|
+
supportsAdvisoryLock: true,
|
|
470
|
+
// FOR JSON over zero rows is NULL → coalesced in the relation override.
|
|
471
|
+
aggSupportsInlineOrderBy: false,
|
|
472
|
+
jsonPathSupport: 'limited',
|
|
473
|
+
emptyJsonArrayLiteral: "'[]'",
|
|
474
|
+
nullJsonLiteral: 'NULL',
|
|
475
|
+
// Named `@pN` placeholders bound by name via request.input('p'+i, value).
|
|
476
|
+
paramPlaceholder(index) {
|
|
477
|
+
return `@p${index}`;
|
|
478
|
+
},
|
|
479
|
+
// Bracket-quote identifiers; escape a literal ']' as ']]' per T-SQL rules.
|
|
480
|
+
quoteIdentifier(name) {
|
|
481
|
+
return `[${name.replace(/]/g, ']]')}]`;
|
|
482
|
+
},
|
|
483
|
+
// SQL Server aggregate casts: COUNT → INT, AVG/float → FLOAT.
|
|
484
|
+
castAggregate(expr, target) {
|
|
485
|
+
return `CAST(${expr} AS ${target === 'int' ? 'INT' : 'FLOAT'})`;
|
|
486
|
+
},
|
|
487
|
+
// No array params. OPENJSON expands a single JSON-array param into a row set,
|
|
488
|
+
// keeping ONE placeholder (so the SQL cache stays valid regardless of list
|
|
489
|
+
// length) and handling the empty list (OPENJSON('[]') → zero rows). SQL Server
|
|
490
|
+
// implicitly converts the nvarchar `value` to the column's type, so this works
|
|
491
|
+
// for numbers AND strings. OPENJSON requires SQL Server 2016+.
|
|
492
|
+
buildInClause(expr, paramRef, negated) {
|
|
493
|
+
return `${expr} ${negated ? 'NOT IN' : 'IN'} (SELECT [value] FROM OPENJSON(${paramRef}))`;
|
|
494
|
+
},
|
|
495
|
+
inClauseParam(values) {
|
|
496
|
+
return JSON.stringify(values ?? []);
|
|
497
|
+
},
|
|
498
|
+
// OUTPUT replaces RETURNING — injected mid-statement by the statement builders,
|
|
499
|
+
// never as a trailing clause.
|
|
500
|
+
buildReturningClause() {
|
|
501
|
+
return '';
|
|
502
|
+
},
|
|
503
|
+
buildInsertStatement(input) {
|
|
504
|
+
const out = input.returning ? ` OUTPUT INSERTED.${input.returning}` : '';
|
|
505
|
+
return `INSERT INTO ${input.table} (${input.columns.join(', ')})${out} VALUES (${input.valuePlaceholders.join(', ')})`;
|
|
506
|
+
},
|
|
507
|
+
buildBulkInsertStatement(input) {
|
|
508
|
+
// No UNNEST in SQL Server — emit multi-row VALUES with flattened, named `@pN`
|
|
509
|
+
// placeholders. Enforce the engine's 1000-row / 2100-param statement limits.
|
|
510
|
+
const rowCount = input.rowValues.length;
|
|
511
|
+
const paramCount = input.rowValues.reduce((n, row) => n + row.length, 0);
|
|
512
|
+
if (rowCount > MSSQL_MAX_INSERT_ROWS) {
|
|
513
|
+
throw new ValidationError(`[turbine] SQL Server INSERT … VALUES is limited to ${MSSQL_MAX_INSERT_ROWS} rows per statement (got ${rowCount}). ` +
|
|
514
|
+
'Chunk the data or use individual create() calls.');
|
|
515
|
+
}
|
|
516
|
+
if (paramCount > MSSQL_MAX_PARAMS) {
|
|
517
|
+
throw new ValidationError(`[turbine] SQL Server is limited to ${MSSQL_MAX_PARAMS} bound parameters per statement (got ${paramCount}). ` +
|
|
518
|
+
'Reduce the batch size.');
|
|
519
|
+
}
|
|
520
|
+
let n = 0;
|
|
521
|
+
const placeholders = input.rowValues
|
|
522
|
+
.map((row) => `(${row.map(() => this.paramPlaceholder(++n)).join(', ')})`)
|
|
523
|
+
.join(', ');
|
|
524
|
+
const out = input.returning ? ` OUTPUT INSERTED.${input.returning}` : '';
|
|
525
|
+
// skipDuplicates has no single-statement equivalent here; ignored (documented).
|
|
526
|
+
return {
|
|
527
|
+
sql: `INSERT INTO ${input.table} (${input.columns.join(', ')})${out} VALUES ${placeholders}`,
|
|
528
|
+
params: input.rowValues.flat(),
|
|
529
|
+
};
|
|
530
|
+
},
|
|
531
|
+
buildUpsertStatement(input) {
|
|
532
|
+
// MERGE is the SQL Server upsert. The MERGE statement MUST end with `;`.
|
|
533
|
+
// CONCURRENCY CAVEAT: MERGE is not a substitute for a UNIQUE/PK constraint —
|
|
534
|
+
// keep the conflict columns backed by one and rely on UniqueConstraintError
|
|
535
|
+
// (2627/2601 → E008) for a concurrent loser.
|
|
536
|
+
const on = input.conflictColumns.map((c) => `T.${c} = S.${c}`).join(' AND ');
|
|
537
|
+
const insertCols = input.insertColumns.join(', ');
|
|
538
|
+
const sourceVals = input.insertColumns.map((c) => `S.${c}`).join(', ');
|
|
539
|
+
const out = input.returning ? ` OUTPUT INSERTED.${input.returning}` : '';
|
|
540
|
+
return (`MERGE INTO ${input.table} AS T ` +
|
|
541
|
+
`USING (VALUES (${input.valuePlaceholders.join(', ')})) AS S (${insertCols}) ` +
|
|
542
|
+
`ON (${on}) ` +
|
|
543
|
+
`WHEN MATCHED THEN UPDATE SET ${input.updateSetClauses.join(', ')} ` +
|
|
544
|
+
`WHEN NOT MATCHED THEN INSERT (${insertCols}) VALUES (${sourceVals})` +
|
|
545
|
+
`${out};`);
|
|
546
|
+
},
|
|
547
|
+
// UPDATE/DELETE inject OUTPUT mid-statement (between SET and WHERE / FROM and
|
|
548
|
+
// WHERE) — a trailing clause would be invalid T-SQL.
|
|
549
|
+
buildUpdateStatement(input) {
|
|
550
|
+
const out = input.returning ? ` OUTPUT INSERTED.${input.returning}` : '';
|
|
551
|
+
return `UPDATE ${input.table} SET ${input.setClauses.join(', ')}${out}${input.whereSql}`;
|
|
552
|
+
},
|
|
553
|
+
buildDeleteStatement(input) {
|
|
554
|
+
const out = input.returning ? ` OUTPUT DELETED.${input.returning}` : '';
|
|
555
|
+
return `DELETE FROM ${input.table}${out}${input.whereSql}`;
|
|
556
|
+
},
|
|
557
|
+
// SQL Server has no LIMIT — emit OFFSET/FETCH, injecting a stable ORDER BY when
|
|
558
|
+
// the outer query has none (OFFSET/FETCH requires an ORDER BY).
|
|
559
|
+
buildLimitOffset(input) {
|
|
560
|
+
const { limitPlaceholder, offsetPlaceholder, hasOrderBy } = input;
|
|
561
|
+
if (limitPlaceholder === undefined && offsetPlaceholder === undefined)
|
|
562
|
+
return '';
|
|
563
|
+
const order = hasOrderBy ? '' : ' ORDER BY (SELECT NULL)';
|
|
564
|
+
const offset = offsetPlaceholder ?? '0';
|
|
565
|
+
let clause = `${order} OFFSET ${offset} ROWS`;
|
|
566
|
+
if (limitPlaceholder !== undefined)
|
|
567
|
+
clause += ` FETCH NEXT ${limitPlaceholder} ROWS ONLY`;
|
|
568
|
+
return clause;
|
|
569
|
+
},
|
|
570
|
+
// The crown jewel: nested relations via FOR JSON PATH (no json_agg). See the
|
|
571
|
+
// module docstring + RelationSubqueryContext for the param-push ordering
|
|
572
|
+
// contract (mirrors collectRelationSubqueryParams so the SQL cache stays valid).
|
|
573
|
+
buildRelationSubquery(ctx) {
|
|
574
|
+
return buildForJsonSubquery(this, ctx);
|
|
575
|
+
},
|
|
576
|
+
buildInsensitiveLike(column, paramRef) {
|
|
577
|
+
// Deterministic regardless of collation. NOTE: LOWER(col) can defeat an index
|
|
578
|
+
// unless a computed/persisted LOWER() index exists.
|
|
579
|
+
return `LOWER(${column}) LIKE LOWER(${paramRef})`;
|
|
580
|
+
},
|
|
581
|
+
// SQL Server has no JSON_CONTAINS. Emulate "the JSON array column contains the
|
|
582
|
+
// scalar value" via OPENJSON (documented `limited`: object-containment and deep
|
|
583
|
+
// paths are not supported — use a generated column + index for those).
|
|
584
|
+
buildJsonContains(column, paramRef) {
|
|
585
|
+
return `EXISTS (SELECT 1 FROM OPENJSON(${column}) WHERE [value] = ${paramRef})`;
|
|
586
|
+
},
|
|
587
|
+
buildJsonPathExtract(column, pathParamRef) {
|
|
588
|
+
return `JSON_VALUE(${column}, ${pathParamRef})`;
|
|
589
|
+
},
|
|
590
|
+
// ---- Type mapping -------------------------------------------------------
|
|
591
|
+
typeToTypeScript(dialectType, nullable) {
|
|
592
|
+
return mssqlTypeToTs(dialectType, nullable);
|
|
593
|
+
},
|
|
594
|
+
// No native array columns; bulk insert uses multi-row VALUES.
|
|
595
|
+
arrayType: undefined,
|
|
596
|
+
// ---- DDL ----------------------------------------------------------------
|
|
597
|
+
buildColumnType(input) {
|
|
598
|
+
return mssqlColumnType(input.type, input.maxLength);
|
|
599
|
+
},
|
|
600
|
+
buildColumnDefinition(input) {
|
|
601
|
+
const isSerial = /serial/i.test(input.type);
|
|
602
|
+
const parts = [input.name, this.buildColumnType(input)];
|
|
603
|
+
if (input.primaryKey)
|
|
604
|
+
parts.push('PRIMARY KEY');
|
|
605
|
+
else if (input.unique)
|
|
606
|
+
parts.push('UNIQUE');
|
|
607
|
+
// IDENTITY columns are implicitly NOT NULL; PK is implicitly NOT NULL too.
|
|
608
|
+
if (input.notNull && !isSerial && !input.primaryKey)
|
|
609
|
+
parts.push('NOT NULL');
|
|
610
|
+
if (input.defaultValue != null)
|
|
611
|
+
parts.push(`DEFAULT ${input.defaultValue}`);
|
|
612
|
+
if (input.references)
|
|
613
|
+
parts.push(`REFERENCES ${input.references.table}(${input.references.column})`);
|
|
614
|
+
return parts.join(' ');
|
|
615
|
+
},
|
|
616
|
+
buildCreateIndexStatement(input) {
|
|
617
|
+
return `CREATE INDEX ${input.name} ON ${input.table}(${input.columns.join(', ')});`;
|
|
618
|
+
},
|
|
619
|
+
buildCreateTableStatement(input) {
|
|
620
|
+
const body = input.definitions.map((d) => ` ${d}`).join(',\n');
|
|
621
|
+
return `CREATE TABLE ${input.table} (\n${body}\n);`;
|
|
622
|
+
},
|
|
623
|
+
// ---- Migration tracking -------------------------------------------------
|
|
624
|
+
// SelectApplied / UpdateChecksum / DeleteApplied inherit from postgresDialect:
|
|
625
|
+
// they call `this.paramPlaceholder(n)` (→ `@pN`) and emit standard SQL valid on
|
|
626
|
+
// SQL Server. The tracking-table DDL and the conflict-free INSERT need T-SQL forms.
|
|
627
|
+
buildMigrationTrackingTable(table) {
|
|
628
|
+
return `
|
|
629
|
+
IF OBJECT_ID(N'${table}', N'U') IS NULL
|
|
630
|
+
CREATE TABLE ${table} (
|
|
631
|
+
id BIGINT IDENTITY(1,1) PRIMARY KEY,
|
|
632
|
+
name NVARCHAR(255) NOT NULL UNIQUE,
|
|
633
|
+
checksum NVARCHAR(255) NOT NULL,
|
|
634
|
+
applied_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
|
|
635
|
+
);
|
|
636
|
+
`;
|
|
637
|
+
},
|
|
638
|
+
buildMigrationInsertApplied(table) {
|
|
639
|
+
// INSERT-if-absent (SQL Server has no ON CONFLICT DO NOTHING).
|
|
640
|
+
return (`IF NOT EXISTS (SELECT 1 FROM ${table} WHERE name = ${this.paramPlaceholder(1)}) ` +
|
|
641
|
+
`INSERT INTO ${table} (name, checksum) VALUES (${this.paramPlaceholder(1)}, ${this.paramPlaceholder(2)})`);
|
|
642
|
+
},
|
|
643
|
+
// ---- Transaction control ------------------------------------------------
|
|
644
|
+
beginStatement(isolationLevel) {
|
|
645
|
+
// SQL Server cannot take an inline isolation level on BEGIN TRANSACTION. The
|
|
646
|
+
// driver shim (MssqlTxClient) splits this exact compound and runs the parts in
|
|
647
|
+
// order on the same transaction.
|
|
648
|
+
return isolationLevel
|
|
649
|
+
? `SET TRANSACTION ISOLATION LEVEL ${isolationLevel}; BEGIN TRANSACTION`
|
|
650
|
+
: 'BEGIN TRANSACTION';
|
|
651
|
+
},
|
|
652
|
+
commitStatement() {
|
|
653
|
+
return 'COMMIT TRANSACTION';
|
|
654
|
+
},
|
|
655
|
+
rollbackStatement() {
|
|
656
|
+
return 'ROLLBACK TRANSACTION';
|
|
657
|
+
},
|
|
658
|
+
savepointStatement(name) {
|
|
659
|
+
return `SAVE TRANSACTION ${name}`;
|
|
660
|
+
},
|
|
661
|
+
releaseSavepointStatement() {
|
|
662
|
+
// SQL Server has no RELEASE SAVEPOINT — savepoints persist until the outer
|
|
663
|
+
// commit/rollback. The shim treats the empty statement as a no-op.
|
|
664
|
+
return '';
|
|
665
|
+
},
|
|
666
|
+
rollbackToSavepointStatement(name) {
|
|
667
|
+
return `ROLLBACK TRANSACTION ${name}`;
|
|
668
|
+
},
|
|
669
|
+
buildSetSessionConfig() {
|
|
670
|
+
// Never reached: supportsRLS=false makes $transaction throw before calling
|
|
671
|
+
// this. Guarded here too so a misuse fails loudly. SQL Server's
|
|
672
|
+
// sp_set_session_context is CONNECTION-scoped (not transaction-local like
|
|
673
|
+
// Postgres set_config), so wiring it safely behind pooling is future work.
|
|
674
|
+
throw new UnsupportedFeatureError('sessionContext (RLS session GUCs)', 'mssql', 'SQL Server sp_set_session_context is connection-scoped, not transaction-local; not wired.');
|
|
675
|
+
},
|
|
676
|
+
// ---- Streaming ----------------------------------------------------------
|
|
677
|
+
/**
|
|
678
|
+
* The {@link StreamableConnection} seam only exposes `query()`, not the `mssql`
|
|
679
|
+
* `request.stream` event API, so this fetches once and yields the rows in
|
|
680
|
+
* `batchSize` chunks. True server-side streaming would require direct `mssql`
|
|
681
|
+
* access (documented limitation).
|
|
682
|
+
*/
|
|
683
|
+
async *openStream(connection, sql, params, batchSize) {
|
|
684
|
+
const result = await connection.query(sql, params);
|
|
685
|
+
for (let i = 0; i < result.rows.length; i += batchSize) {
|
|
686
|
+
yield result.rows.slice(i, i + batchSize);
|
|
687
|
+
}
|
|
688
|
+
},
|
|
689
|
+
// ---- Introspection ------------------------------------------------------
|
|
690
|
+
introspector: {
|
|
691
|
+
async introspect(options) {
|
|
692
|
+
return introspectMssql(options);
|
|
693
|
+
},
|
|
694
|
+
},
|
|
695
|
+
};
|
|
696
|
+
// ---------------------------------------------------------------------------
|
|
697
|
+
// FOR JSON PATH relation-subquery generator (the buildRelationSubquery override)
|
|
698
|
+
// ---------------------------------------------------------------------------
|
|
699
|
+
/**
|
|
700
|
+
* Generate a correlated `FOR JSON PATH` subquery for one relation. SQL Server has
|
|
701
|
+
* no `json_agg`, so the object shape is expressed by the child SELECT's column
|
|
702
|
+
* ALIASES; to-many wraps `ISNULL((… FOR JSON PATH), '[]')` and to-one adds
|
|
703
|
+
* `, WITHOUT_ARRAY_WRAPPER`. `INCLUDE_NULL_VALUES` keeps NULL columns present so
|
|
704
|
+
* the parsed tree matches PostgreSQL's `json_build_object`. Nested relations are
|
|
705
|
+
* embedded with `JSON_QUERY(...)` so they stay real JSON instead of being escaped
|
|
706
|
+
* as a string.
|
|
707
|
+
*
|
|
708
|
+
* **Param-push order** strictly mirrors `collectRelationSubqueryParams`:
|
|
709
|
+
* - manyToMany / to-many with limit|orderBy: `where` → `limit` → nested;
|
|
710
|
+
* - to-one / unordered-unlimited to-many: nested → `where` (no limit).
|
|
711
|
+
*/
|
|
712
|
+
function buildForJsonSubquery(dialect, ctx) {
|
|
713
|
+
const { relDef, spec, params, parentRef, alias, targetTable, targetMeta, targetColumns, depth, path } = ctx;
|
|
714
|
+
const q = (name) => dialect.quoteIdentifier(name);
|
|
715
|
+
const qTarget = q(targetTable);
|
|
716
|
+
const qParent = q(parentRef);
|
|
717
|
+
const orderEntries = spec !== true && spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
|
|
718
|
+
const hasOrder = orderEntries.length > 0;
|
|
719
|
+
const hasLimit = spec !== true && spec.limit !== undefined;
|
|
720
|
+
/** `<alias>.<col> AS [<field>]` selection for the FOR JSON object keys. */
|
|
721
|
+
const colSelect = (a) => targetColumns.map((col) => {
|
|
722
|
+
const field = targetMeta.reverseColumnMap[col] ?? snakeToCamel(col);
|
|
723
|
+
return `${a}.${q(col)} AS ${q(field)}`;
|
|
724
|
+
});
|
|
725
|
+
/** Build nested relations as `JSON_QUERY((<subquery>)) AS [<name>]` columns (pushes their params). */
|
|
726
|
+
const buildNested = (parentAlias) => {
|
|
727
|
+
if (spec === true || !spec.with)
|
|
728
|
+
return [];
|
|
729
|
+
const cols = [];
|
|
730
|
+
for (const [nestedRelName, nestedSpec] of sortedRelEntries(spec.with)) {
|
|
731
|
+
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
732
|
+
if (!nestedRelDef) {
|
|
733
|
+
throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
734
|
+
`Available: ${Object.keys(targetMeta.relations).join(', ')}`);
|
|
735
|
+
}
|
|
736
|
+
const sub = ctx.recurse(nestedRelDef, nestedSpec, parentAlias, depth + 1, [...path, relDef.name]);
|
|
737
|
+
cols.push(`JSON_QUERY((${sub})) AS ${q(nestedRelName)}`);
|
|
738
|
+
}
|
|
739
|
+
return cols;
|
|
740
|
+
};
|
|
741
|
+
/** ORDER BY + OFFSET/FETCH paging clause for a to-many FOR JSON subquery. */
|
|
742
|
+
const buildPaging = (a, limitPlaceholder) => {
|
|
743
|
+
if (hasOrder) {
|
|
744
|
+
const orderBy = orderEntries
|
|
745
|
+
.map(([k, dir]) => {
|
|
746
|
+
const col = camelToSnake(k);
|
|
747
|
+
if (!targetMeta.allColumns.includes(col)) {
|
|
748
|
+
throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
749
|
+
}
|
|
750
|
+
const safeDir = String(dir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
751
|
+
return `${a}.${q(col)} ${safeDir}`;
|
|
752
|
+
})
|
|
753
|
+
.join(', ');
|
|
754
|
+
// FOR JSON permits ORDER BY; OFFSET/FETCH is only needed to apply a LIMIT.
|
|
755
|
+
return limitPlaceholder !== undefined
|
|
756
|
+
? ` ORDER BY ${orderBy} OFFSET 0 ROWS FETCH NEXT ${limitPlaceholder} ROWS ONLY`
|
|
757
|
+
: ` ORDER BY ${orderBy}`;
|
|
758
|
+
}
|
|
759
|
+
if (limitPlaceholder !== undefined) {
|
|
760
|
+
return ` ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT ${limitPlaceholder} ROWS ONLY`;
|
|
761
|
+
}
|
|
762
|
+
return '';
|
|
763
|
+
};
|
|
764
|
+
// ----- manyToMany: JOIN target through the junction table -----------------
|
|
765
|
+
if (relDef.type === 'manyToMany') {
|
|
766
|
+
return buildForJsonManyToMany(dialect, ctx, { colSelect, buildNested, buildPaging, hasLimit });
|
|
767
|
+
}
|
|
768
|
+
const isToOne = relDef.type === 'belongsTo' || relDef.type === 'hasOne';
|
|
769
|
+
const correlation = isToOne
|
|
770
|
+
? dialect.buildCorrelation(alias, relDef.referenceKey, qParent, relDef.foreignKey)
|
|
771
|
+
: dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
|
|
772
|
+
// ----- to-one (belongsTo / hasOne): single object, no paging --------------
|
|
773
|
+
if (isToOne) {
|
|
774
|
+
// Non-wrap order: nested → where (matches collectRelationSubqueryParams).
|
|
775
|
+
const nestedCols = buildNested(alias);
|
|
776
|
+
const extra = ctx.buildWhere(alias);
|
|
777
|
+
const where = extra ? `${correlation} AND ${extra}` : correlation;
|
|
778
|
+
const cols = [...colSelect(alias), ...nestedCols].join(', ');
|
|
779
|
+
// TOP 1 guarantees a single object for WITHOUT_ARRAY_WRAPPER; NULL when no row.
|
|
780
|
+
return `SELECT TOP 1 ${cols} FROM ${qTarget} ${alias} WHERE ${where} FOR JSON PATH, WITHOUT_ARRAY_WRAPPER, INCLUDE_NULL_VALUES`;
|
|
781
|
+
}
|
|
782
|
+
// ----- hasMany ------------------------------------------------------------
|
|
783
|
+
const willWrap = hasLimit || hasOrder;
|
|
784
|
+
let nestedCols;
|
|
785
|
+
let extra;
|
|
786
|
+
let limitPlaceholder;
|
|
787
|
+
if (willWrap) {
|
|
788
|
+
// Wrap order: where → limit → nested.
|
|
789
|
+
extra = ctx.buildWhere(alias);
|
|
790
|
+
if (hasLimit) {
|
|
791
|
+
params.push(Number(spec.limit));
|
|
792
|
+
limitPlaceholder = dialect.paramPlaceholder(params.length);
|
|
793
|
+
}
|
|
794
|
+
nestedCols = buildNested(alias);
|
|
795
|
+
}
|
|
796
|
+
else {
|
|
797
|
+
// Non-wrap order: nested → where (no limit param).
|
|
798
|
+
nestedCols = buildNested(alias);
|
|
799
|
+
extra = ctx.buildWhere(alias);
|
|
800
|
+
}
|
|
801
|
+
const where = extra ? `${correlation} AND ${extra}` : correlation;
|
|
802
|
+
const cols = [...colSelect(alias), ...nestedCols].join(', ');
|
|
803
|
+
const paging = buildPaging(alias, limitPlaceholder);
|
|
804
|
+
return `SELECT ISNULL((SELECT ${cols} FROM ${qTarget} ${alias} WHERE ${where}${paging} FOR JSON PATH, INCLUDE_NULL_VALUES), '[]')`;
|
|
805
|
+
}
|
|
806
|
+
/** `FOR JSON PATH` subquery for a manyToMany relation (JOIN target through junction). */
|
|
807
|
+
function buildForJsonManyToMany(dialect, ctx, h) {
|
|
808
|
+
const { relDef, spec, params, parentRef, alias, targetTable, targetMeta } = ctx;
|
|
809
|
+
const q = (name) => dialect.quoteIdentifier(name);
|
|
810
|
+
if (!relDef.through) {
|
|
811
|
+
throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}" is missing a \`through\` junction descriptor.`);
|
|
812
|
+
}
|
|
813
|
+
const qTarget = q(targetTable);
|
|
814
|
+
const qJunction = q(relDef.through.table);
|
|
815
|
+
const qParent = q(parentRef);
|
|
816
|
+
const jalias = `${alias}j`;
|
|
817
|
+
// JOIN: junction.targetKey = target.<PK>.
|
|
818
|
+
const targetKeys = normalizeKeyColumns(relDef.through.targetKey);
|
|
819
|
+
if (targetMeta.primaryKey.length === 0) {
|
|
820
|
+
throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}" targets table "${targetTable}" which has no primary key; ` +
|
|
821
|
+
'cannot determine the join column.');
|
|
822
|
+
}
|
|
823
|
+
const targetPk = targetMeta.primaryKey;
|
|
824
|
+
if (targetKeys.length !== targetPk.length) {
|
|
825
|
+
throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}": through.targetKey has ${targetKeys.length} column(s) ` +
|
|
826
|
+
`but target "${targetTable}" primary key has ${targetPk.length}.`);
|
|
827
|
+
}
|
|
828
|
+
const joinOn = targetKeys.map((jcol, i) => `${jalias}.${q(jcol)} = ${alias}.${q(targetPk[i])}`).join(' AND ');
|
|
829
|
+
// Correlation: junction.sourceKey = parent.<referenceKey>.
|
|
830
|
+
const sourceKeys = normalizeKeyColumns(relDef.through.sourceKey);
|
|
831
|
+
const refKeys = normalizeKeyColumns(relDef.referenceKey);
|
|
832
|
+
if (sourceKeys.length !== refKeys.length) {
|
|
833
|
+
throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}": through.sourceKey has ${sourceKeys.length} column(s) ` +
|
|
834
|
+
`but referenceKey has ${refKeys.length}.`);
|
|
835
|
+
}
|
|
836
|
+
const correlation = sourceKeys.map((jcol, i) => `${jalias}.${q(jcol)} = ${qParent}.${q(refKeys[i])}`).join(' AND ');
|
|
837
|
+
// Param order mirrors collectRelationSubqueryParams: where → limit → nested.
|
|
838
|
+
const extra = ctx.buildWhere(alias);
|
|
839
|
+
let limitPlaceholder;
|
|
840
|
+
if (h.hasLimit) {
|
|
841
|
+
params.push(Number(spec.limit));
|
|
842
|
+
limitPlaceholder = dialect.paramPlaceholder(params.length);
|
|
843
|
+
}
|
|
844
|
+
const nestedCols = h.buildNested(alias);
|
|
845
|
+
const where = extra ? `${correlation} AND ${extra}` : correlation;
|
|
846
|
+
const cols = [...h.colSelect(alias), ...nestedCols].join(', ');
|
|
847
|
+
const paging = h.buildPaging(alias, limitPlaceholder);
|
|
848
|
+
return (`SELECT ISNULL((SELECT ${cols} FROM ${qTarget} ${alias} ` +
|
|
849
|
+
`JOIN ${qJunction} ${jalias} ON ${joinOn} WHERE ${where}${paging} ` +
|
|
850
|
+
`FOR JSON PATH, INCLUDE_NULL_VALUES), '[]')`);
|
|
851
|
+
}
|
|
852
|
+
const num = (v) => (typeof v === 'string' ? Number(v) : (v ?? 0));
|
|
853
|
+
/**
|
|
854
|
+
* Derive belongsTo + hasMany relations (and conservatively-detected manyToMany
|
|
855
|
+
* junctions) from a flat foreign-key list. Mirrors the PostgreSQL / MySQL / SQLite
|
|
856
|
+
* introspectors so the produced {@link SchemaMetadata} has an identical relation
|
|
857
|
+
* shape across engines.
|
|
858
|
+
*/
|
|
859
|
+
function buildRelationsFromForeignKeys(tableNames, foreignKeys, pkByTable, columnsByTable) {
|
|
860
|
+
const tableSet = new Set(tableNames);
|
|
861
|
+
const fkCounts = new Map();
|
|
862
|
+
for (const fk of foreignKeys) {
|
|
863
|
+
const key = `${fk.sourceTable}->${fk.targetTable}`;
|
|
864
|
+
fkCounts.set(key, (fkCounts.get(key) ?? 0) + 1);
|
|
865
|
+
}
|
|
866
|
+
const relationsByTable = new Map();
|
|
867
|
+
for (const fk of foreignKeys) {
|
|
868
|
+
if (!tableSet.has(fk.targetTable))
|
|
869
|
+
continue;
|
|
870
|
+
const needsDisambiguation = (fkCounts.get(`${fk.sourceTable}->${fk.targetTable}`) ?? 0) > 1;
|
|
871
|
+
const foreignKey = fk.sourceColumns.length === 1 ? fk.sourceColumns[0] : fk.sourceColumns;
|
|
872
|
+
const referenceKey = fk.targetColumns.length === 1 ? fk.targetColumns[0] : fk.targetColumns;
|
|
873
|
+
const belongsToName = needsDisambiguation && fk.sourceColumns.length === 1
|
|
874
|
+
? snakeToCamel(fk.sourceColumns[0].replace(/_id$/, ''))
|
|
875
|
+
: singularize(snakeToCamel(fk.targetTable));
|
|
876
|
+
if (!relationsByTable.has(fk.sourceTable))
|
|
877
|
+
relationsByTable.set(fk.sourceTable, {});
|
|
878
|
+
relationsByTable.get(fk.sourceTable)[belongsToName] = {
|
|
879
|
+
type: 'belongsTo',
|
|
880
|
+
name: belongsToName,
|
|
881
|
+
from: fk.sourceTable,
|
|
882
|
+
to: fk.targetTable,
|
|
883
|
+
foreignKey,
|
|
884
|
+
referenceKey,
|
|
885
|
+
};
|
|
886
|
+
const hasManyName = needsDisambiguation && fk.sourceColumns.length === 1
|
|
887
|
+
? snakeToCamel(`${fk.sourceTable}_by_${fk.sourceColumns[0].replace(/_id$/, '')}`)
|
|
888
|
+
: snakeToCamel(fk.sourceTable);
|
|
889
|
+
if (!relationsByTable.has(fk.targetTable))
|
|
890
|
+
relationsByTable.set(fk.targetTable, {});
|
|
891
|
+
relationsByTable.get(fk.targetTable)[hasManyName] = {
|
|
892
|
+
type: 'hasMany',
|
|
893
|
+
name: hasManyName,
|
|
894
|
+
from: fk.targetTable,
|
|
895
|
+
to: fk.sourceTable,
|
|
896
|
+
foreignKey,
|
|
897
|
+
referenceKey,
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
// Conservative many-to-many auto-detection (additive): a table J is a pure
|
|
901
|
+
// junction iff PK is exactly two columns, exactly two single-column FKs whose
|
|
902
|
+
// source columns ARE the PK, two distinct target tables, and no payload columns.
|
|
903
|
+
for (const tableName of tableNames) {
|
|
904
|
+
const pk = pkByTable.get(tableName) ?? [];
|
|
905
|
+
if (pk.length !== 2)
|
|
906
|
+
continue;
|
|
907
|
+
const tableFks = foreignKeys.filter((fk) => fk.sourceTable === tableName);
|
|
908
|
+
if (tableFks.length !== 2)
|
|
909
|
+
continue;
|
|
910
|
+
if (tableFks.some((fk) => fk.sourceColumns.length !== 1))
|
|
911
|
+
continue;
|
|
912
|
+
const fkCols = tableFks.map((fk) => fk.sourceColumns[0]);
|
|
913
|
+
const pkSet = new Set(pk);
|
|
914
|
+
if (!fkCols.every((c) => pkSet.has(c)))
|
|
915
|
+
continue;
|
|
916
|
+
if (new Set(fkCols).size !== 2)
|
|
917
|
+
continue;
|
|
918
|
+
const [fkA, fkB] = tableFks;
|
|
919
|
+
if (fkA.targetTable === fkB.targetTable)
|
|
920
|
+
continue;
|
|
921
|
+
const jCols = (columnsByTable.get(tableName) ?? []).map((c) => c.name);
|
|
922
|
+
if (jCols.length !== 2)
|
|
923
|
+
continue;
|
|
924
|
+
const addM2M = (self, other) => {
|
|
925
|
+
const sourceTbl = self.targetTable;
|
|
926
|
+
const targetTbl = other.targetTable;
|
|
927
|
+
const relName = snakeToCamel(targetTbl);
|
|
928
|
+
if (!relationsByTable.has(sourceTbl))
|
|
929
|
+
relationsByTable.set(sourceTbl, {});
|
|
930
|
+
const existing = relationsByTable.get(sourceTbl);
|
|
931
|
+
if (existing[relName])
|
|
932
|
+
return;
|
|
933
|
+
existing[relName] = {
|
|
934
|
+
type: 'manyToMany',
|
|
935
|
+
name: relName,
|
|
936
|
+
from: sourceTbl,
|
|
937
|
+
to: targetTbl,
|
|
938
|
+
referenceKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
|
|
939
|
+
foreignKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
|
|
940
|
+
through: {
|
|
941
|
+
table: tableName,
|
|
942
|
+
sourceKey: self.sourceColumns[0],
|
|
943
|
+
targetKey: other.sourceColumns[0],
|
|
944
|
+
},
|
|
945
|
+
};
|
|
946
|
+
};
|
|
947
|
+
addM2M(fkA, fkB);
|
|
948
|
+
addM2M(fkB, fkA);
|
|
949
|
+
}
|
|
950
|
+
return relationsByTable;
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
953
|
+
* Introspect a SQL Server database into the same {@link SchemaMetadata} shape the
|
|
954
|
+
* Postgres catalog introspector produces, using a caller-supplied query executor
|
|
955
|
+
* (so tests can dogfood an already-open `mssql` pool/connection). Reads
|
|
956
|
+
* `INFORMATION_SCHEMA.*` plus `sys.identity_columns` / `sys.foreign_keys` /
|
|
957
|
+
* `sys.indexes`.
|
|
958
|
+
*
|
|
959
|
+
* @param exec Runs a parameterized (`@p1`, `@p2`, …) query and returns rows.
|
|
960
|
+
* @param schema The SQL Server schema to introspect (default `dbo`).
|
|
961
|
+
*/
|
|
962
|
+
export async function introspectMssqlWith(exec, schema = 'dbo', options = {}) {
|
|
963
|
+
// ----- Tables -----
|
|
964
|
+
let tableNames = (await exec("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = @p1 AND TABLE_TYPE = 'BASE TABLE' ORDER BY TABLE_NAME", [schema])).map((r) => String(r.TABLE_NAME));
|
|
965
|
+
if (options.include?.length) {
|
|
966
|
+
const inc = new Set(options.include);
|
|
967
|
+
tableNames = tableNames.filter((t) => inc.has(t));
|
|
968
|
+
}
|
|
969
|
+
if (options.exclude?.length) {
|
|
970
|
+
const exc = new Set(options.exclude);
|
|
971
|
+
tableNames = tableNames.filter((t) => !exc.has(t));
|
|
972
|
+
}
|
|
973
|
+
const tableSet = new Set(tableNames);
|
|
974
|
+
// ----- Identity columns (mark hasDefault) -----
|
|
975
|
+
const identityRows = await exec(`SELECT t.name AS TABLE_NAME, c.name AS COLUMN_NAME
|
|
976
|
+
FROM sys.identity_columns c
|
|
977
|
+
JOIN sys.tables t ON t.object_id = c.object_id
|
|
978
|
+
JOIN sys.schemas s ON s.schema_id = t.schema_id
|
|
979
|
+
WHERE s.name = @p1`, [schema]);
|
|
980
|
+
const identityCols = new Set(identityRows.map((r) => `${r.TABLE_NAME}.${r.COLUMN_NAME}`));
|
|
981
|
+
// ----- Columns -----
|
|
982
|
+
const columnRows = await exec(`SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, CHARACTER_MAXIMUM_LENGTH
|
|
983
|
+
FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = @p1 ORDER BY TABLE_NAME, ORDINAL_POSITION`, [schema]);
|
|
984
|
+
// ----- Primary keys (ordered) -----
|
|
985
|
+
const pkRows = await exec(`SELECT kcu.TABLE_NAME, kcu.COLUMN_NAME, kcu.ORDINAL_POSITION
|
|
986
|
+
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
|
|
987
|
+
JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc
|
|
988
|
+
ON tc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME AND tc.TABLE_SCHEMA = kcu.TABLE_SCHEMA
|
|
989
|
+
WHERE tc.CONSTRAINT_TYPE = 'PRIMARY KEY' AND tc.TABLE_SCHEMA = @p1
|
|
990
|
+
ORDER BY kcu.TABLE_NAME, kcu.ORDINAL_POSITION`, [schema]);
|
|
991
|
+
const pkByTable = new Map();
|
|
992
|
+
for (const r of pkRows) {
|
|
993
|
+
const t = String(r.TABLE_NAME);
|
|
994
|
+
if (!tableSet.has(t))
|
|
995
|
+
continue;
|
|
996
|
+
if (!pkByTable.has(t))
|
|
997
|
+
pkByTable.set(t, []);
|
|
998
|
+
pkByTable.get(t).push(String(r.COLUMN_NAME));
|
|
999
|
+
}
|
|
1000
|
+
const pkColSet = new Set();
|
|
1001
|
+
for (const [t, cols] of pkByTable)
|
|
1002
|
+
for (const c of cols)
|
|
1003
|
+
pkColSet.add(`${t}.${c}`);
|
|
1004
|
+
// ----- Assemble columns -----
|
|
1005
|
+
const columnsByTable = new Map();
|
|
1006
|
+
for (const c of columnRows) {
|
|
1007
|
+
const tableName = String(c.TABLE_NAME);
|
|
1008
|
+
if (!tableSet.has(tableName))
|
|
1009
|
+
continue;
|
|
1010
|
+
const colName = String(c.COLUMN_NAME);
|
|
1011
|
+
const dataType = String(c.DATA_TYPE).toLowerCase();
|
|
1012
|
+
const isPk = pkColSet.has(`${tableName}.${colName}`);
|
|
1013
|
+
const nullable = String(c.IS_NULLABLE).toUpperCase() === 'YES' && !isPk;
|
|
1014
|
+
const maxLen = c.CHARACTER_MAXIMUM_LENGTH != null ? num(c.CHARACTER_MAXIMUM_LENGTH) : undefined;
|
|
1015
|
+
const col = {
|
|
1016
|
+
name: colName,
|
|
1017
|
+
field: snakeToCamel(colName),
|
|
1018
|
+
dialectType: dataType,
|
|
1019
|
+
pgType: dataType,
|
|
1020
|
+
tsType: mssqlTypeToTs(dataType, nullable),
|
|
1021
|
+
nullable,
|
|
1022
|
+
hasDefault: c.COLUMN_DEFAULT !== null || identityCols.has(`${tableName}.${colName}`),
|
|
1023
|
+
isArray: false,
|
|
1024
|
+
arrayType: undefined,
|
|
1025
|
+
pgArrayType: 'text[]',
|
|
1026
|
+
};
|
|
1027
|
+
if (maxLen !== undefined && maxLen >= 0)
|
|
1028
|
+
col.maxLength = maxLen;
|
|
1029
|
+
if (!columnsByTable.has(tableName))
|
|
1030
|
+
columnsByTable.set(tableName, []);
|
|
1031
|
+
columnsByTable.get(tableName).push(col);
|
|
1032
|
+
}
|
|
1033
|
+
// ----- Foreign keys (grouped by constraint for composite FKs) -----
|
|
1034
|
+
const fkRows = await exec(`SELECT fk.name AS CONSTRAINT_NAME,
|
|
1035
|
+
OBJECT_NAME(fkc.parent_object_id) AS TABLE_NAME,
|
|
1036
|
+
cps.name AS COLUMN_NAME,
|
|
1037
|
+
OBJECT_NAME(fkc.referenced_object_id) AS REFERENCED_TABLE_NAME,
|
|
1038
|
+
cpt.name AS REFERENCED_COLUMN_NAME,
|
|
1039
|
+
fkc.constraint_column_id AS ORDINAL_POSITION
|
|
1040
|
+
FROM sys.foreign_keys fk
|
|
1041
|
+
JOIN sys.foreign_key_columns fkc ON fkc.constraint_object_id = fk.object_id
|
|
1042
|
+
JOIN sys.columns cps ON cps.object_id = fkc.parent_object_id AND cps.column_id = fkc.parent_column_id
|
|
1043
|
+
JOIN sys.columns cpt ON cpt.object_id = fkc.referenced_object_id AND cpt.column_id = fkc.referenced_column_id
|
|
1044
|
+
JOIN sys.schemas s ON s.schema_id = fk.schema_id
|
|
1045
|
+
WHERE s.name = @p1
|
|
1046
|
+
ORDER BY fk.name, fkc.constraint_column_id`, [schema]);
|
|
1047
|
+
const fkByConstraint = new Map();
|
|
1048
|
+
for (const r of fkRows) {
|
|
1049
|
+
const sourceTable = String(r.TABLE_NAME);
|
|
1050
|
+
const targetTable = String(r.REFERENCED_TABLE_NAME);
|
|
1051
|
+
if (!tableSet.has(sourceTable))
|
|
1052
|
+
continue;
|
|
1053
|
+
const key = String(r.CONSTRAINT_NAME);
|
|
1054
|
+
let entry = fkByConstraint.get(key);
|
|
1055
|
+
if (!entry) {
|
|
1056
|
+
entry = { sourceTable, sourceColumns: [], targetTable, targetColumns: [], constraintName: key };
|
|
1057
|
+
fkByConstraint.set(key, entry);
|
|
1058
|
+
}
|
|
1059
|
+
entry.sourceColumns.push(String(r.COLUMN_NAME));
|
|
1060
|
+
entry.targetColumns.push(String(r.REFERENCED_COLUMN_NAME));
|
|
1061
|
+
}
|
|
1062
|
+
const foreignKeys = [...fkByConstraint.values()].filter((fk) => tableSet.has(fk.targetTable));
|
|
1063
|
+
// ----- Indexes + unique constraints -----
|
|
1064
|
+
const idxRows = await exec(`SELECT t.name AS TABLE_NAME, i.name AS INDEX_NAME, i.is_unique AS IS_UNIQUE,
|
|
1065
|
+
c.name AS COLUMN_NAME, ic.key_ordinal AS SEQ
|
|
1066
|
+
FROM sys.indexes i
|
|
1067
|
+
JOIN sys.tables t ON t.object_id = i.object_id
|
|
1068
|
+
JOIN sys.schemas s ON s.schema_id = t.schema_id
|
|
1069
|
+
JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id
|
|
1070
|
+
JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id
|
|
1071
|
+
WHERE s.name = @p1 AND i.is_primary_key = 0 AND ic.is_included_column = 0 AND i.name IS NOT NULL
|
|
1072
|
+
ORDER BY t.name, i.name, ic.key_ordinal`, [schema]);
|
|
1073
|
+
const indexGroups = new Map();
|
|
1074
|
+
for (const r of idxRows) {
|
|
1075
|
+
const t = String(r.TABLE_NAME);
|
|
1076
|
+
if (!tableSet.has(t))
|
|
1077
|
+
continue;
|
|
1078
|
+
const name = String(r.INDEX_NAME);
|
|
1079
|
+
const key = `${t}.${name}`;
|
|
1080
|
+
let g = indexGroups.get(key);
|
|
1081
|
+
if (!g) {
|
|
1082
|
+
g = { table: t, name, unique: num(r.IS_UNIQUE) === 1, columns: [] };
|
|
1083
|
+
indexGroups.set(key, g);
|
|
1084
|
+
}
|
|
1085
|
+
g.columns.push(String(r.COLUMN_NAME));
|
|
1086
|
+
}
|
|
1087
|
+
const indexesByTable = new Map();
|
|
1088
|
+
const uniqueByTable = new Map();
|
|
1089
|
+
for (const g of indexGroups.values()) {
|
|
1090
|
+
if (!indexesByTable.has(g.table))
|
|
1091
|
+
indexesByTable.set(g.table, []);
|
|
1092
|
+
indexesByTable.get(g.table).push({
|
|
1093
|
+
name: g.name,
|
|
1094
|
+
columns: g.columns,
|
|
1095
|
+
unique: g.unique,
|
|
1096
|
+
definition: `${g.unique ? 'UNIQUE ' : ''}INDEX ${g.name} ON ${g.table}(${g.columns.join(', ')})`,
|
|
1097
|
+
});
|
|
1098
|
+
if (g.unique && g.columns.length > 0) {
|
|
1099
|
+
if (!uniqueByTable.has(g.table))
|
|
1100
|
+
uniqueByTable.set(g.table, []);
|
|
1101
|
+
uniqueByTable.get(g.table).push(g.columns);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
// ----- Relations -----
|
|
1105
|
+
const relationsByTable = buildRelationsFromForeignKeys(tableNames, foreignKeys, pkByTable, columnsByTable);
|
|
1106
|
+
// ----- Assemble TableMetadata -----
|
|
1107
|
+
const tables = {};
|
|
1108
|
+
for (const tableName of tableNames) {
|
|
1109
|
+
const columns = columnsByTable.get(tableName) ?? [];
|
|
1110
|
+
const columnMap = {};
|
|
1111
|
+
const reverseColumnMap = {};
|
|
1112
|
+
const dateColumns = new Set();
|
|
1113
|
+
const dialectTypes = {};
|
|
1114
|
+
const pgTypes = {};
|
|
1115
|
+
const allColumns = [];
|
|
1116
|
+
for (const col of columns) {
|
|
1117
|
+
columnMap[col.field] = col.name;
|
|
1118
|
+
reverseColumnMap[col.name] = col.field;
|
|
1119
|
+
allColumns.push(col.name);
|
|
1120
|
+
dialectTypes[col.name] = col.dialectType ?? col.pgType;
|
|
1121
|
+
pgTypes[col.name] = col.pgType;
|
|
1122
|
+
if (isMssqlDateType(col.dialectType ?? col.pgType))
|
|
1123
|
+
dateColumns.add(col.name);
|
|
1124
|
+
}
|
|
1125
|
+
tables[tableName] = {
|
|
1126
|
+
name: tableName,
|
|
1127
|
+
columns,
|
|
1128
|
+
columnMap,
|
|
1129
|
+
reverseColumnMap,
|
|
1130
|
+
dateColumns,
|
|
1131
|
+
dialectTypes,
|
|
1132
|
+
pgTypes,
|
|
1133
|
+
allColumns,
|
|
1134
|
+
primaryKey: pkByTable.get(tableName) ?? [],
|
|
1135
|
+
uniqueColumns: uniqueByTable.get(tableName) ?? [],
|
|
1136
|
+
relations: relationsByTable.get(tableName) ?? {},
|
|
1137
|
+
indexes: indexesByTable.get(tableName) ?? [],
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
// SQL Server has no first-class enum type — enums are CHECK constraints; left empty.
|
|
1141
|
+
return { tables, enums: {} };
|
|
1142
|
+
}
|
|
1143
|
+
/**
|
|
1144
|
+
* Open a short-lived `mssql` connection from `options.connectionString`, introspect
|
|
1145
|
+
* the database (schema = `options.schema` or `dbo`), and close it. Wraps
|
|
1146
|
+
* {@link introspectMssqlWith} for the {@link DialectIntrospector} seam used by
|
|
1147
|
+
* `introspect()` / `npx turbine generate`.
|
|
1148
|
+
*/
|
|
1149
|
+
export async function introspectMssql(options) {
|
|
1150
|
+
const sqlNS = await loadMssql();
|
|
1151
|
+
const config = parseMssqlConfig(options.connectionString);
|
|
1152
|
+
const pool = await new sqlNS.ConnectionPool(config).connect();
|
|
1153
|
+
try {
|
|
1154
|
+
const mp = new MssqlPool(pool, sqlNS);
|
|
1155
|
+
const exec = async (sql, params) => (await mp.query(sql, params)).rows;
|
|
1156
|
+
return introspectMssqlWith(exec, options.schema ?? 'dbo', {
|
|
1157
|
+
include: options.include,
|
|
1158
|
+
exclude: options.exclude,
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
finally {
|
|
1162
|
+
await pool.close();
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
/** Parse a `mssql://user:pass@host:port/db` connection string into mssql config. */
|
|
1166
|
+
function parseMssqlConfig(connectionString) {
|
|
1167
|
+
try {
|
|
1168
|
+
const u = new URL(connectionString);
|
|
1169
|
+
const config = {
|
|
1170
|
+
server: u.hostname || 'localhost',
|
|
1171
|
+
port: u.port ? Number(u.port) : 1433,
|
|
1172
|
+
// Sensible local/dev defaults; pass an explicit config object to override.
|
|
1173
|
+
options: { encrypt: false, trustServerCertificate: true },
|
|
1174
|
+
};
|
|
1175
|
+
if (u.username)
|
|
1176
|
+
config.user = decodeURIComponent(u.username);
|
|
1177
|
+
if (u.password)
|
|
1178
|
+
config.password = decodeURIComponent(u.password);
|
|
1179
|
+
const db = u.pathname.replace(/^\//, '');
|
|
1180
|
+
if (db)
|
|
1181
|
+
config.database = decodeURIComponent(db);
|
|
1182
|
+
return config;
|
|
1183
|
+
}
|
|
1184
|
+
catch {
|
|
1185
|
+
throw new ConnectionError(`[turbine] Invalid MSSQL connection string: "${connectionString}"`);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
/**
|
|
1189
|
+
* Dynamically load the `mssql` module. Kept out of the static import graph so
|
|
1190
|
+
* `import 'turbine-orm/mssql'` never throws when `mssql` is absent for a consumer
|
|
1191
|
+
* that does not use the factory.
|
|
1192
|
+
*/
|
|
1193
|
+
async function loadMssql() {
|
|
1194
|
+
let mod;
|
|
1195
|
+
try {
|
|
1196
|
+
// `mssql` ships no bundled type declarations (it needs @types/mssql, which
|
|
1197
|
+
// Turbine deliberately does not depend on) — the structural MssqlModule above
|
|
1198
|
+
// is our typed surface. Import through a widened specifier so tsc treats the
|
|
1199
|
+
// typeless module as `any` instead of erroring (TS7016).
|
|
1200
|
+
const specifier = 'mssql';
|
|
1201
|
+
mod = (await import(specifier));
|
|
1202
|
+
}
|
|
1203
|
+
catch (err) {
|
|
1204
|
+
throw new ConnectionError("[turbine] turbine-orm/mssql requires the optional peer dependency 'mssql'. Install it: npm i mssql. " +
|
|
1205
|
+
`(${err.message})`);
|
|
1206
|
+
}
|
|
1207
|
+
// `mssql` is a CommonJS module; the namespace may be on `default` under ESM interop.
|
|
1208
|
+
const ns = (mod.default ?? mod);
|
|
1209
|
+
if (typeof ns.ConnectionPool !== 'function' || typeof ns.Request !== 'function') {
|
|
1210
|
+
throw new ConnectionError("[turbine] Loaded 'mssql' but it is missing ConnectionPool/Request exports.");
|
|
1211
|
+
}
|
|
1212
|
+
return ns;
|
|
1213
|
+
}
|
|
1214
|
+
/**
|
|
1215
|
+
* Fail fast on unsupported servers: SQL Server 2016+ is required (`FOR JSON PATH`,
|
|
1216
|
+
* `OPENJSON`, and the relation engine need it). ProductMajorVersion 13 = 2016.
|
|
1217
|
+
*/
|
|
1218
|
+
function assertSupportedVersion(majorVersion) {
|
|
1219
|
+
if (majorVersion > 0 && majorVersion < 13) {
|
|
1220
|
+
throw new ConnectionError(`[turbine] turbine-orm/mssql requires SQL Server 2016+ (FOR JSON / OPENJSON); got major version ${majorVersion}.`);
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
function isMssqlPool(x) {
|
|
1224
|
+
return (!!x &&
|
|
1225
|
+
typeof x.request === 'function' &&
|
|
1226
|
+
typeof x.close === 'function');
|
|
1227
|
+
}
|
|
1228
|
+
/**
|
|
1229
|
+
* Create a {@link TurbineClient} bound to SQL Server 2016+ via `mssql`.
|
|
1230
|
+
*
|
|
1231
|
+
* Pass one of:
|
|
1232
|
+
* - a connection string (`'mssql://sa:pass@host:1433/db'`),
|
|
1233
|
+
* - an `mssql` config object (`{ server, user, password, database, options }`),
|
|
1234
|
+
* - an existing `MssqlPool` (injection — you own its lifecycle, `disconnect()` is
|
|
1235
|
+
* a no-op).
|
|
1236
|
+
*
|
|
1237
|
+
* When Turbine builds the pool (string/config), it probes
|
|
1238
|
+
* `SERVERPROPERTY('ProductMajorVersion')` to reject SQL Server < 2016, and
|
|
1239
|
+
* `disconnect()` closes the pool it created.
|
|
1240
|
+
*
|
|
1241
|
+
* @example
|
|
1242
|
+
* ```ts
|
|
1243
|
+
* import { turbineMssql } from 'turbine-orm/mssql';
|
|
1244
|
+
* const db = await turbineMssql('mssql://sa:Passw0rd!@localhost:1433/app', SCHEMA);
|
|
1245
|
+
* ```
|
|
1246
|
+
*/
|
|
1247
|
+
export async function turbineMssql(target, schema, options = {}) {
|
|
1248
|
+
let pool;
|
|
1249
|
+
let owns = false;
|
|
1250
|
+
if (target instanceof MssqlPool) {
|
|
1251
|
+
pool = target;
|
|
1252
|
+
}
|
|
1253
|
+
else {
|
|
1254
|
+
const sqlNS = await loadMssql();
|
|
1255
|
+
if (isMssqlPool(target)) {
|
|
1256
|
+
pool = new MssqlPool(target, sqlNS);
|
|
1257
|
+
}
|
|
1258
|
+
else {
|
|
1259
|
+
const config = typeof target === 'string' ? parseMssqlConfig(target) : target;
|
|
1260
|
+
const rawPool = await new sqlNS.ConnectionPool(config).connect();
|
|
1261
|
+
pool = new MssqlPool(rawPool, sqlNS);
|
|
1262
|
+
owns = true;
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
// Probe the server version (fail fast on SQL Server < 2016).
|
|
1266
|
+
try {
|
|
1267
|
+
const rows = (await pool.query("SELECT CAST(SERVERPROPERTY('ProductMajorVersion') AS INT) AS v")).rows;
|
|
1268
|
+
assertSupportedVersion(Number(rows[0]?.v ?? 0));
|
|
1269
|
+
}
|
|
1270
|
+
catch (err) {
|
|
1271
|
+
if (err instanceof ConnectionError)
|
|
1272
|
+
throw err;
|
|
1273
|
+
// A non-version probe failure (permissions, etc.) should not block startup.
|
|
1274
|
+
}
|
|
1275
|
+
const client = new TurbineClient({
|
|
1276
|
+
pool,
|
|
1277
|
+
dialect: mssqlDialect,
|
|
1278
|
+
preparedStatements: false,
|
|
1279
|
+
logging: options.logging,
|
|
1280
|
+
defaultLimit: options.defaultLimit,
|
|
1281
|
+
warnOnUnlimited: options.warnOnUnlimited,
|
|
1282
|
+
}, schema);
|
|
1283
|
+
if (owns) {
|
|
1284
|
+
// Turbine built this pool, so disconnect()/end() must close it. External pools
|
|
1285
|
+
// (injection) stay the caller's responsibility (disconnect() no-op), consistent
|
|
1286
|
+
// with turbineHttp / turbineMysql / turbineSqlite.
|
|
1287
|
+
const baseDisconnect = client.disconnect.bind(client);
|
|
1288
|
+
const close = async () => {
|
|
1289
|
+
await baseDisconnect();
|
|
1290
|
+
await pool.end();
|
|
1291
|
+
};
|
|
1292
|
+
const patch = client;
|
|
1293
|
+
patch.disconnect = close;
|
|
1294
|
+
patch.end = close;
|
|
1295
|
+
}
|
|
1296
|
+
return client;
|
|
1297
|
+
}
|
|
1298
|
+
//# sourceMappingURL=mssql.js.map
|