@mandujs/core 0.54.20 → 0.54.21
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/package.json +1 -1
- package/src/db/__tests__/db.test.ts +133 -0
- package/src/db/index.ts +183 -1
package/package.json
CHANGED
|
@@ -20,6 +20,9 @@ import {
|
|
|
20
20
|
_createDbWith,
|
|
21
21
|
createDb,
|
|
22
22
|
detectProvider,
|
|
23
|
+
isSqlFragment,
|
|
24
|
+
join,
|
|
25
|
+
sql,
|
|
23
26
|
type BunSqlCtor,
|
|
24
27
|
type SqlProvider,
|
|
25
28
|
} from "../index";
|
|
@@ -480,3 +483,133 @@ describe("@mandujs/core/db — public createDb lazy probe", () => {
|
|
|
480
483
|
await expect(db.close()).resolves.toBeUndefined();
|
|
481
484
|
});
|
|
482
485
|
});
|
|
486
|
+
|
|
487
|
+
// ─── Composable SQL fragments (#315) ─────────────────────────────────────────
|
|
488
|
+
|
|
489
|
+
describe("@mandujs/core/db — sql fragments", () => {
|
|
490
|
+
it("sql`` produces an inert fragment; isSqlFragment recognizes it", () => {
|
|
491
|
+
const frag = sql`x = ${1}`;
|
|
492
|
+
expect(isSqlFragment(frag)).toBe(true);
|
|
493
|
+
expect(isSqlFragment({})).toBe(false);
|
|
494
|
+
expect(isSqlFragment(null)).toBe(false);
|
|
495
|
+
expect(isSqlFragment("x = 1")).toBe(false);
|
|
496
|
+
// A fragment must NOT be a Promise — it should not be executed on its own.
|
|
497
|
+
expect(typeof (frag as unknown as { then?: unknown }).then).toBe("undefined");
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it("flattens a fragment into merged static text + bound values", async () => {
|
|
501
|
+
const { Ctor, state } = createFakeCtor();
|
|
502
|
+
state.nextRowsQueue.push([]);
|
|
503
|
+
const db = _createDbWith(Ctor, { url: "sqlite::memory:" });
|
|
504
|
+
|
|
505
|
+
const where = db.sql`WHERE party = ${"green"}`;
|
|
506
|
+
await db`SELECT * FROM pledges ${where} ORDER BY id`;
|
|
507
|
+
|
|
508
|
+
const call = state.calls[0]!;
|
|
509
|
+
// Static text merged; the value stays a bound parameter (placeholder gap).
|
|
510
|
+
expect(Array.from(call.strings).join("?")).toBe(
|
|
511
|
+
"SELECT * FROM pledges WHERE party = ? ORDER BY id",
|
|
512
|
+
);
|
|
513
|
+
expect(call.values).toEqual(["green"]);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it("composes optional filters with join and keeps every value bound", async () => {
|
|
517
|
+
const { Ctor, state } = createFakeCtor();
|
|
518
|
+
state.nextRowsQueue.push([]);
|
|
519
|
+
const db = _createDbWith(Ctor, { url: "sqlite::memory:" });
|
|
520
|
+
|
|
521
|
+
const party = "green";
|
|
522
|
+
const region: string | null = "seoul";
|
|
523
|
+
const search = "climate";
|
|
524
|
+
const conds = [];
|
|
525
|
+
if (party) conds.push(db.sql`party = ${party}`);
|
|
526
|
+
if (region) conds.push(db.sql`region = ${region}`);
|
|
527
|
+
if (search) conds.push(db.sql`title ILIKE ${"%" + search + "%"}`);
|
|
528
|
+
|
|
529
|
+
const where = conds.length
|
|
530
|
+
? db.sql`WHERE ${db.join(conds, " AND ")}`
|
|
531
|
+
: db.sql``;
|
|
532
|
+
const limit = 20;
|
|
533
|
+
await db`SELECT * FROM pledges ${where} ORDER BY created_at DESC LIMIT ${limit}`;
|
|
534
|
+
|
|
535
|
+
const call = state.calls[0]!;
|
|
536
|
+
// Values bind left-to-right across the whole assembled query.
|
|
537
|
+
expect(call.values).toEqual(["green", "seoul", "%climate%", 20]);
|
|
538
|
+
// No interpolated value leaked into the SQL text.
|
|
539
|
+
const joined = Array.from(call.strings).join("");
|
|
540
|
+
expect(joined).toContain("WHERE party = ");
|
|
541
|
+
expect(joined).toContain(" AND region = ");
|
|
542
|
+
expect(joined).toContain(" AND title ILIKE ");
|
|
543
|
+
expect(joined).toContain("LIMIT ");
|
|
544
|
+
expect(joined).not.toContain("green");
|
|
545
|
+
expect(joined).not.toContain("%climate%");
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it("empty join / empty fragment collapse to no-op text and bind nothing", async () => {
|
|
549
|
+
const { Ctor, state } = createFakeCtor();
|
|
550
|
+
state.nextRowsQueue.push([]);
|
|
551
|
+
const db = _createDbWith(Ctor, { url: "sqlite::memory:" });
|
|
552
|
+
|
|
553
|
+
const conds: ReturnType<typeof sql>[] = [];
|
|
554
|
+
const where = conds.length ? db.sql`WHERE ${db.join(conds, " AND ")}` : db.sql``;
|
|
555
|
+
await db`SELECT * FROM pledges ${where} ORDER BY id`;
|
|
556
|
+
|
|
557
|
+
const call = state.calls[0]!;
|
|
558
|
+
expect(Array.from(call.strings).join("")).toBe(
|
|
559
|
+
"SELECT * FROM pledges ORDER BY id",
|
|
560
|
+
);
|
|
561
|
+
expect(call.values).toEqual([]);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it("flattens nested fragments recursively with correct bind order", async () => {
|
|
565
|
+
const { Ctor, state } = createFakeCtor();
|
|
566
|
+
state.nextRowsQueue.push([]);
|
|
567
|
+
const db = _createDbWith(Ctor, { url: "sqlite::memory:" });
|
|
568
|
+
|
|
569
|
+
const inner = sql`b = ${2} AND c = ${3}`;
|
|
570
|
+
const outer = sql`a = ${1} AND (${inner})`;
|
|
571
|
+
await db`SELECT * FROM t WHERE ${outer}`;
|
|
572
|
+
|
|
573
|
+
const call = state.calls[0]!;
|
|
574
|
+
expect(call.values).toEqual([1, 2, 3]);
|
|
575
|
+
expect(Array.from(call.strings).join("?")).toBe(
|
|
576
|
+
"SELECT * FROM t WHERE a = ? AND (b = ? AND c = ?)",
|
|
577
|
+
);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it("treats injection attempts inside a fragment value as a bound parameter", async () => {
|
|
581
|
+
const { Ctor, state } = createFakeCtor();
|
|
582
|
+
state.nextRowsQueue.push([]);
|
|
583
|
+
const db = _createDbWith(Ctor, { url: "sqlite::memory:" });
|
|
584
|
+
|
|
585
|
+
const evil = "'; DROP TABLE users; --";
|
|
586
|
+
const where = db.sql`name = ${evil}`;
|
|
587
|
+
await db`SELECT * FROM users WHERE ${where}`;
|
|
588
|
+
|
|
589
|
+
const call = state.calls[0]!;
|
|
590
|
+
for (const s of call.strings) {
|
|
591
|
+
expect(s).not.toContain("DROP TABLE");
|
|
592
|
+
expect(s).not.toContain(evil);
|
|
593
|
+
}
|
|
594
|
+
expect(call.values).toEqual([evil]);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it("fragments compose through .one() as well", async () => {
|
|
598
|
+
const { Ctor, state } = createFakeCtor();
|
|
599
|
+
state.nextRowsQueue.push([{ id: 7 }]);
|
|
600
|
+
const db = _createDbWith(Ctor, { url: "sqlite::memory:" });
|
|
601
|
+
|
|
602
|
+
const where = db.sql`id = ${7}`;
|
|
603
|
+
const row = await db.one`SELECT * FROM t WHERE ${where}`;
|
|
604
|
+
|
|
605
|
+
expect(row).toEqual({ id: 7 });
|
|
606
|
+
expect(state.calls[0]!.values).toEqual([7]);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it("exposes sql/join on the lazy createDb handle too", () => {
|
|
610
|
+
const db = createDb({ url: "sqlite::memory:" });
|
|
611
|
+
expect(typeof db.sql).toBe("function");
|
|
612
|
+
expect(typeof db.join).toBe("function");
|
|
613
|
+
expect(isSqlFragment(db.sql`x = ${1}`)).toBe(true);
|
|
614
|
+
});
|
|
615
|
+
});
|
package/src/db/index.ts
CHANGED
|
@@ -45,6 +45,22 @@
|
|
|
45
45
|
* escape hatch on the public surface — use Bun.SQL directly if you need
|
|
46
46
|
* `sql.unsafe()` semantics.
|
|
47
47
|
*
|
|
48
|
+
* ## Dynamic queries
|
|
49
|
+
*
|
|
50
|
+
* For runtime-variable filters (optional `WHERE` conditions, sort direction,
|
|
51
|
+
* pagination) compose {@link SqlFragment}s with `db.sql` and `db.join`
|
|
52
|
+
* instead of branching whole queries or hand-synthesising a
|
|
53
|
+
* `TemplateStringsArray`. Fragments are inert until embedded in an executing
|
|
54
|
+
* call, at which point their static text inlines and their values stay bound:
|
|
55
|
+
*
|
|
56
|
+
* ```ts
|
|
57
|
+
* const conds = [];
|
|
58
|
+
* if (party) conds.push(db.sql`party = ${party}`);
|
|
59
|
+
* if (search) conds.push(db.sql`title ILIKE ${"%" + search + "%"}`);
|
|
60
|
+
* const where = conds.length ? db.sql`WHERE ${db.join(conds, " AND ")}` : db.sql``;
|
|
61
|
+
* const rows = await db`SELECT * FROM pledges ${where} LIMIT ${limit}`;
|
|
62
|
+
* ```
|
|
63
|
+
*
|
|
48
64
|
* @example
|
|
49
65
|
* ```ts
|
|
50
66
|
* import { createDb } from "@mandujs/core/db";
|
|
@@ -159,6 +175,36 @@ export interface Db {
|
|
|
159
175
|
* "pool closed" error. Calling `close()` twice is a no-op (idempotent).
|
|
160
176
|
*/
|
|
161
177
|
close(options?: DbCloseOptions): Promise<void>;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Builds a composable, not-yet-executed SQL {@link SqlFragment}. Embed it
|
|
181
|
+
* inside another `db`/`db.one` tagged-template call to assemble dynamic
|
|
182
|
+
* queries (optional filters, conditional `WHERE`, sort direction) while
|
|
183
|
+
* keeping every interpolated value a bound parameter.
|
|
184
|
+
*
|
|
185
|
+
* Static text in the fragment template is merged into the surrounding SQL;
|
|
186
|
+
* `${value}` placeholders stay bound — never string-interpolated.
|
|
187
|
+
*
|
|
188
|
+
* @example
|
|
189
|
+
* ```ts
|
|
190
|
+
* const conds = [];
|
|
191
|
+
* if (party) conds.push(db.sql`party = ${party}`);
|
|
192
|
+
* if (region) conds.push(db.sql`region = ${region}`);
|
|
193
|
+
* const where = conds.length ? db.sql`WHERE ${db.join(conds, " AND ")}` : db.sql``;
|
|
194
|
+
* const rows = await db`SELECT * FROM pledges ${where} ORDER BY created_at DESC`;
|
|
195
|
+
* ```
|
|
196
|
+
*/
|
|
197
|
+
sql(strings: TemplateStringsArray, ...values: unknown[]): SqlFragment;
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Joins fragments with a separator into a single {@link SqlFragment}.
|
|
201
|
+
* Empty list → an empty fragment. The separator is static text (default
|
|
202
|
+
* `", "`); pass a fragment if you need a bound value inside it.
|
|
203
|
+
*/
|
|
204
|
+
join(
|
|
205
|
+
fragments: readonly SqlFragment[],
|
|
206
|
+
separator?: SqlFragment | string,
|
|
207
|
+
): SqlFragment;
|
|
162
208
|
}
|
|
163
209
|
|
|
164
210
|
/** Options forwarded to Bun.SQL pool shutdown. */
|
|
@@ -170,6 +216,128 @@ export interface DbCloseOptions {
|
|
|
170
216
|
timeout?: number;
|
|
171
217
|
}
|
|
172
218
|
|
|
219
|
+
// ─── Composable SQL fragments (dynamic WHERE / filters) ─────────────────────
|
|
220
|
+
|
|
221
|
+
const SQL_FRAGMENT_MARKER = "__manduSqlFragment";
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* A composable, not-yet-executed SQL fragment produced by {@link sql} (or
|
|
225
|
+
* `db.sql`). Holds the static template `strings` and the interpolated
|
|
226
|
+
* `values` (which may themselves be nested fragments). Flattened into the
|
|
227
|
+
* surrounding query at execution time so values stay bound parameters.
|
|
228
|
+
*/
|
|
229
|
+
export interface SqlFragment {
|
|
230
|
+
readonly [SQL_FRAGMENT_MARKER]: true;
|
|
231
|
+
readonly strings: readonly string[];
|
|
232
|
+
readonly values: readonly unknown[];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Structural check for a {@link SqlFragment}. */
|
|
236
|
+
export function isSqlFragment(value: unknown): value is SqlFragment {
|
|
237
|
+
return (
|
|
238
|
+
typeof value === "object" &&
|
|
239
|
+
value !== null &&
|
|
240
|
+
(value as Record<string, unknown>)[SQL_FRAGMENT_MARKER] === true
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Tagged-template builder for a {@link SqlFragment}. Also exposed as
|
|
246
|
+
* `db.sql`. The fragment is inert until embedded in an executing
|
|
247
|
+
* `db`/`db.one` call.
|
|
248
|
+
*/
|
|
249
|
+
export function sql(
|
|
250
|
+
strings: TemplateStringsArray,
|
|
251
|
+
...values: unknown[]
|
|
252
|
+
): SqlFragment {
|
|
253
|
+
return { [SQL_FRAGMENT_MARKER]: true, strings: Array.from(strings), values };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** A fragment carrying only static text and no bound values. */
|
|
257
|
+
function staticFragment(text: string): SqlFragment {
|
|
258
|
+
return { [SQL_FRAGMENT_MARKER]: true, strings: [text], values: [] };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Joins fragments with a separator. Also exposed as `db.join`. An empty list
|
|
263
|
+
* yields an empty fragment; a single-element list returns that element.
|
|
264
|
+
*/
|
|
265
|
+
export function join(
|
|
266
|
+
fragments: readonly SqlFragment[],
|
|
267
|
+
separator: SqlFragment | string = ", ",
|
|
268
|
+
): SqlFragment {
|
|
269
|
+
if (fragments.length === 0) return staticFragment("");
|
|
270
|
+
if (fragments.length === 1) return fragments[0]!;
|
|
271
|
+
const sep = typeof separator === "string" ? staticFragment(separator) : separator;
|
|
272
|
+
const values: unknown[] = [];
|
|
273
|
+
for (let i = 0; i < fragments.length; i++) {
|
|
274
|
+
if (i > 0) values.push(sep);
|
|
275
|
+
values.push(fragments[i]);
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
[SQL_FRAGMENT_MARKER]: true,
|
|
279
|
+
strings: new Array<string>(values.length + 1).fill(""),
|
|
280
|
+
values,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
interface FlatSql {
|
|
285
|
+
parts: string[];
|
|
286
|
+
binds: unknown[];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Recursively flattens a (possibly fragment-bearing) template into a single
|
|
291
|
+
* `{ parts, binds }` pair. Static text from nested fragments is merged into
|
|
292
|
+
* `parts`; every non-fragment value becomes one bound parameter in `binds`.
|
|
293
|
+
* Invariant: `parts.length === binds.length + 1`.
|
|
294
|
+
*/
|
|
295
|
+
function flattenSqlTemplate(
|
|
296
|
+
strings: readonly string[],
|
|
297
|
+
values: readonly unknown[],
|
|
298
|
+
): FlatSql {
|
|
299
|
+
const parts: string[] = [strings[0] ?? ""];
|
|
300
|
+
const binds: unknown[] = [];
|
|
301
|
+
|
|
302
|
+
for (let i = 0; i < values.length; i++) {
|
|
303
|
+
const value = values[i];
|
|
304
|
+
const tail = strings[i + 1] ?? "";
|
|
305
|
+
|
|
306
|
+
if (isSqlFragment(value)) {
|
|
307
|
+
const inner = flattenSqlTemplate(value.strings, value.values);
|
|
308
|
+
parts[parts.length - 1] += inner.parts[0] ?? "";
|
|
309
|
+
for (let j = 0; j < inner.binds.length; j++) {
|
|
310
|
+
binds.push(inner.binds[j]);
|
|
311
|
+
parts.push(inner.parts[j + 1] ?? "");
|
|
312
|
+
}
|
|
313
|
+
parts[parts.length - 1] += tail;
|
|
314
|
+
} else {
|
|
315
|
+
binds.push(value);
|
|
316
|
+
parts.push(tail);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return { parts, binds };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** True when any value needs fragment flattening before reaching Bun.SQL. */
|
|
324
|
+
function hasSqlFragment(values: readonly unknown[]): boolean {
|
|
325
|
+
for (const value of values) {
|
|
326
|
+
if (isSqlFragment(value)) return true;
|
|
327
|
+
}
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Rebuilds a synthetic `TemplateStringsArray` from flattened parts so the
|
|
333
|
+
* merged query can be handed back to Bun.SQL's tagged-template entry point.
|
|
334
|
+
* `raw` mirrors the cooked strings — we never expose a raw-interpolation path.
|
|
335
|
+
*/
|
|
336
|
+
function toTemplateStringsArray(parts: readonly string[]): TemplateStringsArray {
|
|
337
|
+
const arr = parts.slice();
|
|
338
|
+
return Object.assign(arr, { raw: parts.slice() }) as unknown as TemplateStringsArray;
|
|
339
|
+
}
|
|
340
|
+
|
|
173
341
|
// ─── Bun runtime surface (structural; no `any`) ─────────────────────────────
|
|
174
342
|
|
|
175
343
|
/** Shape of the options object Bun.SQL accepts. */
|
|
@@ -405,9 +573,18 @@ function buildDbHandle(bunSql: BunSqlInstance, provider: SqlProvider): Db {
|
|
|
405
573
|
if (closed) {
|
|
406
574
|
throw new Error(POOL_CLOSED_MESSAGE);
|
|
407
575
|
}
|
|
576
|
+
// Flatten any composable fragments into a single merged template so
|
|
577
|
+
// their static text inlines and their values stay bound parameters.
|
|
578
|
+
let tsa: TemplateStringsArray = strings;
|
|
579
|
+
let binds: unknown[] = values;
|
|
580
|
+
if (hasSqlFragment(values)) {
|
|
581
|
+
const flat = flattenSqlTemplate(strings, values);
|
|
582
|
+
tsa = toTemplateStringsArray(flat.parts);
|
|
583
|
+
binds = flat.binds;
|
|
584
|
+
}
|
|
408
585
|
try {
|
|
409
586
|
// `bunSql` is itself a tagged-template callable; pass through verbatim.
|
|
410
|
-
const result = await bunSql<T>(
|
|
587
|
+
const result = await bunSql<T>(tsa, ...binds);
|
|
411
588
|
// Bun.SQL returns an array-like with metadata props (count/command/…).
|
|
412
589
|
// Coerce to a plain array so consumers don't accidentally couple to
|
|
413
590
|
// those fields through this wrapper's public surface.
|
|
@@ -474,6 +651,9 @@ function buildDbHandle(bunSql: BunSqlInstance, provider: SqlProvider): Db {
|
|
|
474
651
|
}
|
|
475
652
|
};
|
|
476
653
|
|
|
654
|
+
(db as { sql: Db["sql"] }).sql = sql;
|
|
655
|
+
(db as { join: Db["join"] }).join = join;
|
|
656
|
+
|
|
477
657
|
return db;
|
|
478
658
|
}
|
|
479
659
|
|
|
@@ -581,6 +761,8 @@ export function createDb(config: DbConfig): Db {
|
|
|
581
761
|
real = null;
|
|
582
762
|
await db.close(options);
|
|
583
763
|
};
|
|
764
|
+
(forward as { sql: Db["sql"] }).sql = sql;
|
|
765
|
+
(forward as { join: Db["join"] }).join = join;
|
|
584
766
|
Object.defineProperty(forward, PIN_DB_HANDLE, {
|
|
585
767
|
value: async function withPinnedHandle<R>(fn: () => Promise<R>): Promise<R> {
|
|
586
768
|
pinDepth += 1;
|