@mandujs/core 0.54.19 → 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.
@@ -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>(strings, ...values);
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;
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import React from "react";
3
+ import type { RoutesManifest } from "../../spec/schema";
4
+ import {
5
+ createServerRegistry,
6
+ startServer,
7
+ type ManduServer,
8
+ } from "../server";
9
+
10
+ // Issue #314 — server page components must receive `searchParams` (query
11
+ // string) as a prop, matching the value `generateMetadata` already gets.
12
+ // SearchPage renders the query so we can assert it survives SSR.
13
+ function SearchPage({
14
+ params,
15
+ searchParams,
16
+ }: {
17
+ params: Record<string, string>;
18
+ searchParams: Record<string, string>;
19
+ }): React.ReactElement {
20
+ return React.createElement(
21
+ "main",
22
+ null,
23
+ React.createElement("p", { "data-testid": "q" }, `q = ${searchParams.q ?? "(undefined)"}`),
24
+ React.createElement("p", { "data-testid": "id" }, `id = ${params.id ?? "(none)"}`),
25
+ );
26
+ }
27
+
28
+ function searchManifest(): RoutesManifest {
29
+ return {
30
+ version: 1,
31
+ routes: [
32
+ {
33
+ id: "search",
34
+ kind: "page",
35
+ pattern: "/search",
36
+ module: "app/search/page.tsx",
37
+ componentModule: "app/search/page.tsx",
38
+ hydration: { strategy: "none", priority: "visible", preload: false },
39
+ },
40
+ ],
41
+ };
42
+ }
43
+
44
+ async function fetchSearch(query: string): Promise<{ status: number; html: string }> {
45
+ const registry = createServerRegistry();
46
+ registry.registerRouteComponent("search", SearchPage);
47
+
48
+ let server: ManduServer | undefined;
49
+ try {
50
+ server = startServer(searchManifest(), {
51
+ port: 0,
52
+ registry,
53
+ transitions: false,
54
+ prefetch: false,
55
+ spa: false,
56
+ devtools: false,
57
+ silent: true,
58
+ });
59
+ const response = await fetch(`http://127.0.0.1:${server.server.port}/search${query}`);
60
+ return { status: response.status, html: await response.text() };
61
+ } finally {
62
+ server?.stop();
63
+ }
64
+ }
65
+
66
+ describe("server page searchParams prop (#314)", () => {
67
+ it("passes the query string to the page component", async () => {
68
+ const { status, html } = await fetchSearch("?q=foo");
69
+
70
+ expect(status).toBe(200);
71
+ expect(html).toContain("q = foo");
72
+ expect(html).not.toContain("q = (undefined)");
73
+ });
74
+
75
+ it("renders gracefully when the query string is absent", async () => {
76
+ const { status, html } = await fetchSearch("");
77
+
78
+ expect(status).toBe(200);
79
+ expect(html).toContain("q = (undefined)");
80
+ });
81
+ });