bun-query-builder 0.2.29 → 0.2.31

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/dist/bin/cli.js CHANGED
@@ -11862,6 +11862,68 @@ import { Database } from "bun:sqlite";
11862
11862
  import { mkdirSync as mkdirSync5 } from "fs";
11863
11863
  import { dirname as dirname5 } from "path";
11864
11864
  import process21 from "process";
11865
+ function bindNumberedPlaceholders(sql, params) {
11866
+ if (params.length === 0 || !/\$\d/.test(sql))
11867
+ return { sql, params };
11868
+ const out = [];
11869
+ let rewritten = "";
11870
+ let highest = 0;
11871
+ for (let i = 0;i < sql.length; i++) {
11872
+ const char = sql[i];
11873
+ if (char === "'" || char === '"' || char === "`") {
11874
+ const end = skipQuoted(sql, i, char);
11875
+ rewritten += sql.slice(i, end);
11876
+ i = end - 1;
11877
+ continue;
11878
+ }
11879
+ if (char === "-" && sql[i + 1] === "-") {
11880
+ const end = sql.indexOf(`
11881
+ `, i);
11882
+ const stop = end === -1 ? sql.length : end;
11883
+ rewritten += sql.slice(i, stop);
11884
+ i = stop - 1;
11885
+ continue;
11886
+ }
11887
+ if (char === "/" && sql[i + 1] === "*") {
11888
+ const end = sql.indexOf("*/", i + 2);
11889
+ const stop = end === -1 ? sql.length : end + 2;
11890
+ rewritten += sql.slice(i, stop);
11891
+ i = stop - 1;
11892
+ continue;
11893
+ }
11894
+ if (char === "$") {
11895
+ const digits = /^\d+/.exec(sql.slice(i + 1))?.[0];
11896
+ if (digits) {
11897
+ const index = Number(digits);
11898
+ highest = Math.max(highest, index);
11899
+ if (index >= 1 && index <= params.length) {
11900
+ out.push(params[index - 1]);
11901
+ rewritten += "?";
11902
+ i += digits.length;
11903
+ continue;
11904
+ }
11905
+ }
11906
+ }
11907
+ rewritten += char;
11908
+ }
11909
+ if (highest > params.length)
11910
+ throw new Error(`SQL references $${highest} but only ${params.length} value${params.length === 1 ? " was" : "s were"} provided`);
11911
+ if (out.length === 0)
11912
+ return { sql, params };
11913
+ return { sql: rewritten, params: out };
11914
+ }
11915
+ function skipQuoted(sql, start, quote) {
11916
+ for (let i = start + 1;i < sql.length; i++) {
11917
+ if (sql[i] !== quote)
11918
+ continue;
11919
+ if (sql[i + 1] === quote) {
11920
+ i++;
11921
+ continue;
11922
+ }
11923
+ return i + 1;
11924
+ }
11925
+ return sql.length;
11926
+ }
11865
11927
 
11866
11928
  class SQLiteWrapper {
11867
11929
  db;
@@ -11876,12 +11938,14 @@ class SQLiteWrapper {
11876
11938
  applySqliteBootstrapPragmas(this.db);
11877
11939
  }
11878
11940
  query(sql, params = []) {
11879
- const stmt = this.db.prepare(sql);
11880
- return stmt.all(...params);
11941
+ const bound = bindNumberedPlaceholders(sql, params);
11942
+ const stmt = this.db.prepare(bound.sql);
11943
+ return stmt.all(...bound.params);
11881
11944
  }
11882
11945
  run(sql, params = []) {
11883
- const stmt = this.db.prepare(sql);
11884
- return stmt.run(...params);
11946
+ const bound = bindNumberedPlaceholders(sql, params);
11947
+ const stmt = this.db.prepare(bound.sql);
11948
+ return stmt.run(...bound.params);
11885
11949
  }
11886
11950
  close() {
11887
11951
  this.db.close();
@@ -12650,6 +12714,21 @@ function renderSelectColumn(col) {
12650
12714
  }
12651
12715
  throw new TypeError(`[query-builder] select(): unsupported column ${String(col)} \u2014 pass a column name, a string[], or a SQL fragment (e.g. sql\`count(*) as c\`)`);
12652
12716
  }
12717
+ function hasTopLevelOrderBy(s) {
12718
+ const re = /\(|\)|\bORDER\s+BY\b/gi;
12719
+ let depth = 0;
12720
+ let m;
12721
+ while (m = re.exec(s)) {
12722
+ if (m[0] === "(") {
12723
+ depth++;
12724
+ } else if (m[0] === ")") {
12725
+ depth = Math.max(0, depth - 1);
12726
+ } else if (depth === 0) {
12727
+ return true;
12728
+ }
12729
+ }
12730
+ return false;
12731
+ }
12653
12732
  function validateIdentifier(name, context) {
12654
12733
  if (!SQL_PATTERNS.IDENTIFIER.test(name)) {
12655
12734
  const contextMsg = context ? ` in ${context}` : "";
@@ -13160,8 +13239,8 @@ function createQueryBuilder(state) {
13160
13239
  }
13161
13240
  return -1;
13162
13241
  };
13163
- const currentSql = () => {
13164
- const body = renderWhereTerms(whereTerms);
13242
+ const currentSql = (extra) => {
13243
+ const body = renderWhereTerms(extra ? [...whereTerms, extra] : whereTerms);
13165
13244
  if (!body)
13166
13245
  return text;
13167
13246
  if (whereTail) {
@@ -14888,25 +14967,33 @@ function createQueryBuilder(state) {
14888
14967
  return { data: hasMore ? data.slice(0, perPage) : data, meta: { perPage, page: p, hasMore } };
14889
14968
  },
14890
14969
  async cursorPaginate(perPage, cursor, column = "id", direction = "asc") {
14891
- let q = ensureBuilt();
14970
+ if (!Number.isInteger(perPage) || perPage <= 0)
14971
+ throw new TypeError(`[query-builder] cursorPaginate(perPage): expected positive integer, got ${perPage}`);
14972
+ const cols = (Array.isArray(column) ? column : [column]).map(String);
14973
+ for (const c of cols)
14974
+ validateIdentifier(c, "cursorPaginate(column)");
14975
+ const cmp = direction === "asc" ? ">" : "<";
14976
+ const dir = direction === "asc" ? "ASC" : "DESC";
14977
+ const params = [...whereParams];
14978
+ let predicate = "";
14892
14979
  if (cursor !== undefined && cursor !== null) {
14893
14980
  if (Array.isArray(column)) {
14894
- const cols = column.map((c) => sql(String(c)));
14895
- const comp = direction === "asc" ? sql`>` : sql`<`;
14896
- const tupleCols = sql`(${sql(cols)})`;
14897
- const tupleVals = sql`(${sql(cursor)})`;
14898
- q = sql`${q} WHERE ${tupleCols} ${comp} ${tupleVals}`;
14981
+ const phs = cursor.map((v) => {
14982
+ params.push(v);
14983
+ return getPlaceholder(params.length);
14984
+ });
14985
+ predicate = `(${cols.join(", ")}) ${cmp} (${phs.join(", ")})`;
14899
14986
  } else {
14900
- q = direction === "asc" ? sql`${q} WHERE ${sql(String(column))} > ${cursor}` : sql`${q} WHERE ${sql(String(column))} < ${cursor}`;
14987
+ params.push(cursor);
14988
+ predicate = `${cols[0]} ${cmp} ${getPlaceholder(params.length)}`;
14901
14989
  }
14902
14990
  }
14903
- if (Array.isArray(column)) {
14904
- const orderParts = column.map((c) => sql`${sql(String(c))} ${direction === "asc" ? sql`ASC` : sql`DESC`}`);
14905
- const orderExpr = orderParts.reduce((acc, p, i) => i === 0 ? p : sql`${acc}, ${p}`);
14906
- q = sql`${q} ORDER BY ${orderExpr} LIMIT ${perPage + 1}`;
14907
- } else {
14908
- q = sql`${q} ORDER BY ${sql(String(column))} ${direction === "asc" ? sql`ASC` : sql`DESC`} LIMIT ${perPage + 1}`;
14991
+ const base2 = reorderSelectClauses(predicate ? currentSql({ conn: "AND", sql: predicate }) : currentSql());
14992
+ const order = cols.map((c) => `${c} ${dir}`).join(", ");
14993
+ if (hasTopLevelOrderBy(base2)) {
14994
+ throw new TypeError(`[query-builder] cursorPaginate() cannot be combined with orderBy() \u2014 it orders by ` + `${order} itself, and a cursor is only meaningful in that order. ` + `Remove the orderBy(), or pass the column and direction to cursorPaginate().`);
14909
14995
  }
14996
+ const q = params.length > 0 ? _sql.unsafe(`${base2} ORDER BY ${order} LIMIT ${perPage + 1}`, params) : _sql.unsafe(`${base2} ORDER BY ${order} LIMIT ${perPage + 1}`);
14910
14997
  const rows = await runWithHooks(q, "select", { signal: abortSignal, timeoutMs });
14911
14998
  const hasMore = rows.length > perPage;
14912
14999
  const data = rows.slice(0, perPage);
@@ -18431,6 +18518,28 @@ ${sql};` : `${sql};`;
18431
18518
  recordMigrationQuery() {
18432
18519
  return "INSERT INTO migrations (migration) VALUES (?)";
18433
18520
  }
18521
+ jsonExtract(column, key) {
18522
+ return `JSON_UNQUOTE(JSON_EXTRACT(${column}, CONCAT('$.', ${key})))`;
18523
+ }
18524
+ dateBucket(column, grain, offsetHours = 0) {
18525
+ const shifted = offsetHours === 0 ? column : `DATE_ADD(${column}, INTERVAL ${offsetHours} HOUR)`;
18526
+ switch (grain) {
18527
+ case "hour":
18528
+ return `DATE_FORMAT(${shifted}, '%Y-%m-%dT%H:00:00.000Z')`;
18529
+ case "week":
18530
+ return `DATE_FORMAT(DATE_SUB(${shifted}, INTERVAL WEEKDAY(${shifted}) DAY), '%Y-%m-%dT00:00:00.000Z')`;
18531
+ case "month":
18532
+ return `DATE_FORMAT(${shifted}, '%Y-%m-01T00:00:00.000Z')`;
18533
+ case "year":
18534
+ return `DATE_FORMAT(${shifted}, '%Y-01-01T00:00:00.000Z')`;
18535
+ case "day":
18536
+ default:
18537
+ return `DATE_FORMAT(${shifted}, '%Y-%m-%dT00:00:00.000Z')`;
18538
+ }
18539
+ }
18540
+ booleanLiteral(value) {
18541
+ return value ? "TRUE" : "FALSE";
18542
+ }
18434
18543
  renderColumn(column) {
18435
18544
  const typeSql = this.getColumnType(column);
18436
18545
  const parts = [this.quoteIdentifier(column.name), typeSql];
@@ -18628,6 +18737,28 @@ ${sql};` : `${sql};`;
18628
18737
  recordMigrationQuery() {
18629
18738
  return "INSERT INTO migrations (migration) VALUES ($1)";
18630
18739
  }
18740
+ jsonExtract(column, key) {
18741
+ return `(${column}::jsonb ->> ${key})`;
18742
+ }
18743
+ dateBucket(column, grain, offsetHours = 0) {
18744
+ const shifted = offsetHours === 0 ? `${column}::timestamp` : `(${column}::timestamp ${offsetHours >= 0 ? "+" : "-"} interval '${Math.abs(offsetHours)} hours')`;
18745
+ const truncated = grain === "week" ? `date_trunc('week', ${shifted})` : shifted;
18746
+ switch (grain) {
18747
+ case "hour":
18748
+ return `to_char(${truncated}, 'YYYY-MM-DD"T"HH24:00:00.000"Z"')`;
18749
+ case "month":
18750
+ return `to_char(${truncated}, 'YYYY-MM-01"T"00:00:00.000"Z"')`;
18751
+ case "year":
18752
+ return `to_char(${truncated}, 'YYYY-01-01"T"00:00:00.000"Z"')`;
18753
+ case "week":
18754
+ case "day":
18755
+ default:
18756
+ return `to_char(${truncated}, 'YYYY-MM-DD"T"00:00:00.000"Z"')`;
18757
+ }
18758
+ }
18759
+ booleanLiteral(value) {
18760
+ return value ? "TRUE" : "FALSE";
18761
+ }
18631
18762
  renderColumn(column) {
18632
18763
  const typeSql = column.isPrimaryKey ? this.getPrimaryKeyType(column) : this.getColumnType(column);
18633
18764
  const parts = [this.quoteIdentifier(column.name), typeSql];
@@ -18911,6 +19042,28 @@ class SQLiteDriver {
18911
19042
  recordMigrationQuery() {
18912
19043
  return "INSERT INTO migrations (migration) VALUES (?)";
18913
19044
  }
19045
+ jsonExtract(column, key) {
19046
+ return `json_extract(${column}, '$.' || ${key})`;
19047
+ }
19048
+ dateBucket(column, grain, offsetHours = 0) {
19049
+ const shifted = offsetHours === 0 ? column : `datetime(${column}, '${offsetHours >= 0 ? "+" : "-"}${Math.abs(offsetHours)} hours')`;
19050
+ switch (grain) {
19051
+ case "hour":
19052
+ return `strftime('%Y-%m-%dT%H:00:00.000Z', ${shifted})`;
19053
+ case "week":
19054
+ return `strftime('%Y-%m-%dT00:00:00.000Z', ${shifted}, '-6 days', 'weekday 1')`;
19055
+ case "month":
19056
+ return `strftime('%Y-%m-01T00:00:00.000Z', ${shifted})`;
19057
+ case "year":
19058
+ return `strftime('%Y-01-01T00:00:00.000Z', ${shifted})`;
19059
+ case "day":
19060
+ default:
19061
+ return `strftime('%Y-%m-%dT00:00:00.000Z', ${shifted})`;
19062
+ }
19063
+ }
19064
+ booleanLiteral(value) {
19065
+ return value ? "TRUE" : "FALSE";
19066
+ }
18914
19067
  renderColumn(column) {
18915
19068
  const typeSql = this.getColumnType(column);
18916
19069
  const parts = [this.quoteIdentifier(column.name), typeSql];
@@ -31980,7 +32133,7 @@ function getPrefix() {
31980
32133
  }
31981
32134
  var prefix = getPrefix();
31982
32135
  // package.json
31983
- var version2 = "0.2.29";
32136
+ var version2 = "0.2.31";
31984
32137
 
31985
32138
  // bin/cli.ts
31986
32139
  init_actions();
package/dist/db.d.ts CHANGED
@@ -1,6 +1,35 @@
1
1
  import { Database } from 'bun:sqlite';
2
2
  import { SQL } from 'bun';
3
3
  import type { DatabaseConfig, PoolConfig, SupportedDialect } from './types';
4
+ /**
5
+ * SQLite wrapper that provides a SQL-like tagged template literal interface
6
+ * using bun:sqlite's Database class for better compiled binary support.
7
+ */
8
+ /**
9
+ * Give `$1`-style placeholders their Postgres meaning on SQLite: `$n` is the
10
+ * n-th value, wherever it appears and however often.
11
+ *
12
+ * bun:sqlite treats `$1` as a NAME, not an index. Handed an array it binds the
13
+ * values to those names in ORDER OF FIRST APPEARANCE and ignores the numbers
14
+ * entirely, so a query whose placeholders are not already in ascending order
15
+ * binds the wrong values and says nothing:
16
+ *
17
+ * sql.unsafe('SELECT $2 AS a, $1 AS b', ['ONE', 'TWO'])
18
+ * -> { a: 'ONE', b: 'TWO' } // both backwards, no error
19
+ *
20
+ * Every caller writing `$n` means the Postgres semantics, because the same
21
+ * query text is expected to run on both drivers. The failure is silent, and it
22
+ * lands hardest in exactly the queries that repeat a parameter - authorisation
23
+ * checks of the shape "owner_id = $2 OR EXISTS (... user_id = $2)" - where the
24
+ * result is a permission answer computed from the wrong values.
25
+ *
26
+ * Rewritten to ordinal `?` here, once, at the only place both `query` and `run`
27
+ * pass through. Placeholders inside string literals, quoted identifiers and
28
+ * comments are left alone: `'$1'` is data, not a parameter. SQL with no
29
+ * numbered placeholders is returned untouched, so `?` queries and Bun's own
30
+ * tagged-template output are unaffected.
31
+ */
32
+ export declare function bindNumberedPlaceholders(sql: string, params: any[]): { sql: string, params: any[] };
4
33
  export declare function splitSqlStatements(sql: string): string[];
5
34
  /**
6
35
  * Creates a database connection string based on the configured dialect and database settings.
@@ -98,10 +127,6 @@ export declare interface DriverConnection {
98
127
  }
99
128
  /** An `unsafe(...)` result: a query object that is ALSO directly awaitable. */
100
129
  export type AwaitableDriverQuery = DriverQuery & PromiseLike<any>;
101
- /**
102
- * SQLite wrapper that provides a SQL-like tagged template literal interface
103
- * using bun:sqlite's Database class for better compiled binary support.
104
- */
105
130
  declare class SQLiteWrapper {
106
131
  constructor(filename: string);
107
132
  query(sql: string, params?: any[]): any[];
@@ -1,4 +1,5 @@
1
1
  import type { ColumnPlan, IndexPlan, RebuildTableSpec, TablePlan } from '../migrations';
2
+ import type { DateBucketGrain } from './postgres';
2
3
  export declare interface DialectDriver {
3
4
  createEnumType: (enumTypeName: string, values: string[]) => string
4
5
  createTable: (table: TablePlan) => string
@@ -39,5 +40,8 @@ export declare class MySQLDriver implements DialectDriver {
39
40
  createMigrationsTable(): string;
40
41
  getExecutedMigrationsQuery(): string;
41
42
  recordMigrationQuery(): string;
43
+ jsonExtract(column: string, key: string): string;
44
+ dateBucket(column: string, grain: DateBucketGrain, offsetHours?: number): string;
45
+ booleanLiteral(value: boolean): string;
42
46
  protected renderColumn(column: ColumnPlan): string;
43
47
  }
@@ -1,4 +1,11 @@
1
1
  import type { ColumnPlan, IndexPlan, RebuildTableSpec, TablePlan } from '../migrations';
2
+ /**
3
+ * The ISO shape every driver's {@link DialectDriver.dateBucket} must produce.
4
+ *
5
+ * Exported so a driver's tests can assert against one definition rather than
6
+ * each restating the format it happens to emit.
7
+ */
8
+ export declare const DATE_BUCKET_FORMAT: 'YYYY-MM-DDTHH:MM:SS.000Z';
2
9
  export declare interface DialectDriver {
3
10
  createEnumType: (enumTypeName: string, values: string[]) => string
4
11
  createTable: (table: TablePlan) => string
@@ -16,7 +23,12 @@ export declare interface DialectDriver {
16
23
  createMigrationsTable: () => string
17
24
  getExecutedMigrationsQuery: () => string
18
25
  recordMigrationQuery: () => string
26
+ jsonExtract: (column: string, key: string) => string
27
+ dateBucket: (column: string, grain: DateBucketGrain, offsetHours?: number) => string
28
+ booleanLiteral: (value: boolean) => string
19
29
  }
30
+ /** The buckets {@link DialectDriver.dateBucket} can truncate to. */
31
+ export type DateBucketGrain = 'hour' | 'day' | 'week' | 'month' | 'year';
20
32
  export declare class PostgresDriver implements DialectDriver {
21
33
  createEnumType(enumTypeName: string, values: string[]): string;
22
34
  createTable(table: TablePlan): string;
@@ -34,4 +46,7 @@ export declare class PostgresDriver implements DialectDriver {
34
46
  createMigrationsTable(): string;
35
47
  getExecutedMigrationsQuery(): string;
36
48
  recordMigrationQuery(): string;
49
+ jsonExtract(column: string, key: string): string;
50
+ dateBucket(column: string, grain: DateBucketGrain, offsetHours?: number): string;
51
+ booleanLiteral(value: boolean): string;
37
52
  }
@@ -1,4 +1,5 @@
1
1
  import type { ColumnPlan, IndexPlan, RebuildTableSpec, TablePlan } from '../migrations';
2
+ import type { DateBucketGrain } from './postgres';
2
3
  /**
3
4
  * Whether a plan type belongs to the numeric family. Used by the `_id`
4
5
  * safety net (numeric ids must store as INTEGER on SQLite) — non-numeric
@@ -40,4 +41,7 @@ export declare class SQLiteDriver implements DialectDriver {
40
41
  createMigrationsTable(): string;
41
42
  getExecutedMigrationsQuery(): string;
42
43
  recordMigrationQuery(): string;
44
+ jsonExtract(column: string, key: string): string;
45
+ dateBucket(column: string, grain: DateBucketGrain, offsetHours?: number): string;
46
+ booleanLiteral(value: boolean): string;
43
47
  }
package/dist/src/index.js CHANGED
@@ -11861,6 +11861,68 @@ import { Database } from "bun:sqlite";
11861
11861
  import { mkdirSync as mkdirSync5 } from "fs";
11862
11862
  import { dirname as dirname5 } from "path";
11863
11863
  import process21 from "process";
11864
+ function bindNumberedPlaceholders(sql, params) {
11865
+ if (params.length === 0 || !/\$\d/.test(sql))
11866
+ return { sql, params };
11867
+ const out = [];
11868
+ let rewritten = "";
11869
+ let highest = 0;
11870
+ for (let i = 0;i < sql.length; i++) {
11871
+ const char = sql[i];
11872
+ if (char === "'" || char === '"' || char === "`") {
11873
+ const end = skipQuoted(sql, i, char);
11874
+ rewritten += sql.slice(i, end);
11875
+ i = end - 1;
11876
+ continue;
11877
+ }
11878
+ if (char === "-" && sql[i + 1] === "-") {
11879
+ const end = sql.indexOf(`
11880
+ `, i);
11881
+ const stop = end === -1 ? sql.length : end;
11882
+ rewritten += sql.slice(i, stop);
11883
+ i = stop - 1;
11884
+ continue;
11885
+ }
11886
+ if (char === "/" && sql[i + 1] === "*") {
11887
+ const end = sql.indexOf("*/", i + 2);
11888
+ const stop = end === -1 ? sql.length : end + 2;
11889
+ rewritten += sql.slice(i, stop);
11890
+ i = stop - 1;
11891
+ continue;
11892
+ }
11893
+ if (char === "$") {
11894
+ const digits = /^\d+/.exec(sql.slice(i + 1))?.[0];
11895
+ if (digits) {
11896
+ const index = Number(digits);
11897
+ highest = Math.max(highest, index);
11898
+ if (index >= 1 && index <= params.length) {
11899
+ out.push(params[index - 1]);
11900
+ rewritten += "?";
11901
+ i += digits.length;
11902
+ continue;
11903
+ }
11904
+ }
11905
+ }
11906
+ rewritten += char;
11907
+ }
11908
+ if (highest > params.length)
11909
+ throw new Error(`SQL references $${highest} but only ${params.length} value${params.length === 1 ? " was" : "s were"} provided`);
11910
+ if (out.length === 0)
11911
+ return { sql, params };
11912
+ return { sql: rewritten, params: out };
11913
+ }
11914
+ function skipQuoted(sql, start, quote) {
11915
+ for (let i = start + 1;i < sql.length; i++) {
11916
+ if (sql[i] !== quote)
11917
+ continue;
11918
+ if (sql[i + 1] === quote) {
11919
+ i++;
11920
+ continue;
11921
+ }
11922
+ return i + 1;
11923
+ }
11924
+ return sql.length;
11925
+ }
11864
11926
 
11865
11927
  class SQLiteWrapper {
11866
11928
  db;
@@ -11875,12 +11937,14 @@ class SQLiteWrapper {
11875
11937
  applySqliteBootstrapPragmas(this.db);
11876
11938
  }
11877
11939
  query(sql, params = []) {
11878
- const stmt = this.db.prepare(sql);
11879
- return stmt.all(...params);
11940
+ const bound = bindNumberedPlaceholders(sql, params);
11941
+ const stmt = this.db.prepare(bound.sql);
11942
+ return stmt.all(...bound.params);
11880
11943
  }
11881
11944
  run(sql, params = []) {
11882
- const stmt = this.db.prepare(sql);
11883
- return stmt.run(...params);
11945
+ const bound = bindNumberedPlaceholders(sql, params);
11946
+ const stmt = this.db.prepare(bound.sql);
11947
+ return stmt.run(...bound.params);
11884
11948
  }
11885
11949
  close() {
11886
11950
  this.db.close();
@@ -12649,6 +12713,21 @@ function renderSelectColumn(col) {
12649
12713
  }
12650
12714
  throw new TypeError(`[query-builder] select(): unsupported column ${String(col)} \u2014 pass a column name, a string[], or a SQL fragment (e.g. sql\`count(*) as c\`)`);
12651
12715
  }
12716
+ function hasTopLevelOrderBy(s) {
12717
+ const re = /\(|\)|\bORDER\s+BY\b/gi;
12718
+ let depth = 0;
12719
+ let m;
12720
+ while (m = re.exec(s)) {
12721
+ if (m[0] === "(") {
12722
+ depth++;
12723
+ } else if (m[0] === ")") {
12724
+ depth = Math.max(0, depth - 1);
12725
+ } else if (depth === 0) {
12726
+ return true;
12727
+ }
12728
+ }
12729
+ return false;
12730
+ }
12652
12731
  function validateIdentifier(name, context) {
12653
12732
  if (!SQL_PATTERNS.IDENTIFIER.test(name)) {
12654
12733
  const contextMsg = context ? ` in ${context}` : "";
@@ -13159,8 +13238,8 @@ function createQueryBuilder(state) {
13159
13238
  }
13160
13239
  return -1;
13161
13240
  };
13162
- const currentSql = () => {
13163
- const body = renderWhereTerms(whereTerms);
13241
+ const currentSql = (extra) => {
13242
+ const body = renderWhereTerms(extra ? [...whereTerms, extra] : whereTerms);
13164
13243
  if (!body)
13165
13244
  return text;
13166
13245
  if (whereTail) {
@@ -14887,25 +14966,33 @@ function createQueryBuilder(state) {
14887
14966
  return { data: hasMore ? data.slice(0, perPage) : data, meta: { perPage, page: p, hasMore } };
14888
14967
  },
14889
14968
  async cursorPaginate(perPage, cursor, column = "id", direction = "asc") {
14890
- let q = ensureBuilt();
14969
+ if (!Number.isInteger(perPage) || perPage <= 0)
14970
+ throw new TypeError(`[query-builder] cursorPaginate(perPage): expected positive integer, got ${perPage}`);
14971
+ const cols = (Array.isArray(column) ? column : [column]).map(String);
14972
+ for (const c of cols)
14973
+ validateIdentifier(c, "cursorPaginate(column)");
14974
+ const cmp = direction === "asc" ? ">" : "<";
14975
+ const dir = direction === "asc" ? "ASC" : "DESC";
14976
+ const params = [...whereParams];
14977
+ let predicate = "";
14891
14978
  if (cursor !== undefined && cursor !== null) {
14892
14979
  if (Array.isArray(column)) {
14893
- const cols = column.map((c) => sql(String(c)));
14894
- const comp = direction === "asc" ? sql`>` : sql`<`;
14895
- const tupleCols = sql`(${sql(cols)})`;
14896
- const tupleVals = sql`(${sql(cursor)})`;
14897
- q = sql`${q} WHERE ${tupleCols} ${comp} ${tupleVals}`;
14980
+ const phs = cursor.map((v) => {
14981
+ params.push(v);
14982
+ return getPlaceholder(params.length);
14983
+ });
14984
+ predicate = `(${cols.join(", ")}) ${cmp} (${phs.join(", ")})`;
14898
14985
  } else {
14899
- q = direction === "asc" ? sql`${q} WHERE ${sql(String(column))} > ${cursor}` : sql`${q} WHERE ${sql(String(column))} < ${cursor}`;
14986
+ params.push(cursor);
14987
+ predicate = `${cols[0]} ${cmp} ${getPlaceholder(params.length)}`;
14900
14988
  }
14901
14989
  }
14902
- if (Array.isArray(column)) {
14903
- const orderParts = column.map((c) => sql`${sql(String(c))} ${direction === "asc" ? sql`ASC` : sql`DESC`}`);
14904
- const orderExpr = orderParts.reduce((acc, p, i) => i === 0 ? p : sql`${acc}, ${p}`);
14905
- q = sql`${q} ORDER BY ${orderExpr} LIMIT ${perPage + 1}`;
14906
- } else {
14907
- q = sql`${q} ORDER BY ${sql(String(column))} ${direction === "asc" ? sql`ASC` : sql`DESC`} LIMIT ${perPage + 1}`;
14990
+ const base2 = reorderSelectClauses(predicate ? currentSql({ conn: "AND", sql: predicate }) : currentSql());
14991
+ const order = cols.map((c) => `${c} ${dir}`).join(", ");
14992
+ if (hasTopLevelOrderBy(base2)) {
14993
+ throw new TypeError(`[query-builder] cursorPaginate() cannot be combined with orderBy() \u2014 it orders by ` + `${order} itself, and a cursor is only meaningful in that order. ` + `Remove the orderBy(), or pass the column and direction to cursorPaginate().`);
14908
14994
  }
14995
+ const q = params.length > 0 ? _sql.unsafe(`${base2} ORDER BY ${order} LIMIT ${perPage + 1}`, params) : _sql.unsafe(`${base2} ORDER BY ${order} LIMIT ${perPage + 1}`);
14909
14996
  const rows = await runWithHooks(q, "select", { signal: abortSignal, timeoutMs });
14910
14997
  const hasMore = rows.length > perPage;
14911
14998
  const data = rows.slice(0, perPage);
@@ -18430,6 +18517,28 @@ ${sql};` : `${sql};`;
18430
18517
  recordMigrationQuery() {
18431
18518
  return "INSERT INTO migrations (migration) VALUES (?)";
18432
18519
  }
18520
+ jsonExtract(column, key) {
18521
+ return `JSON_UNQUOTE(JSON_EXTRACT(${column}, CONCAT('$.', ${key})))`;
18522
+ }
18523
+ dateBucket(column, grain, offsetHours = 0) {
18524
+ const shifted = offsetHours === 0 ? column : `DATE_ADD(${column}, INTERVAL ${offsetHours} HOUR)`;
18525
+ switch (grain) {
18526
+ case "hour":
18527
+ return `DATE_FORMAT(${shifted}, '%Y-%m-%dT%H:00:00.000Z')`;
18528
+ case "week":
18529
+ return `DATE_FORMAT(DATE_SUB(${shifted}, INTERVAL WEEKDAY(${shifted}) DAY), '%Y-%m-%dT00:00:00.000Z')`;
18530
+ case "month":
18531
+ return `DATE_FORMAT(${shifted}, '%Y-%m-01T00:00:00.000Z')`;
18532
+ case "year":
18533
+ return `DATE_FORMAT(${shifted}, '%Y-01-01T00:00:00.000Z')`;
18534
+ case "day":
18535
+ default:
18536
+ return `DATE_FORMAT(${shifted}, '%Y-%m-%dT00:00:00.000Z')`;
18537
+ }
18538
+ }
18539
+ booleanLiteral(value) {
18540
+ return value ? "TRUE" : "FALSE";
18541
+ }
18433
18542
  renderColumn(column) {
18434
18543
  const typeSql = this.getColumnType(column);
18435
18544
  const parts = [this.quoteIdentifier(column.name), typeSql];
@@ -18627,6 +18736,28 @@ ${sql};` : `${sql};`;
18627
18736
  recordMigrationQuery() {
18628
18737
  return "INSERT INTO migrations (migration) VALUES ($1)";
18629
18738
  }
18739
+ jsonExtract(column, key) {
18740
+ return `(${column}::jsonb ->> ${key})`;
18741
+ }
18742
+ dateBucket(column, grain, offsetHours = 0) {
18743
+ const shifted = offsetHours === 0 ? `${column}::timestamp` : `(${column}::timestamp ${offsetHours >= 0 ? "+" : "-"} interval '${Math.abs(offsetHours)} hours')`;
18744
+ const truncated = grain === "week" ? `date_trunc('week', ${shifted})` : shifted;
18745
+ switch (grain) {
18746
+ case "hour":
18747
+ return `to_char(${truncated}, 'YYYY-MM-DD"T"HH24:00:00.000"Z"')`;
18748
+ case "month":
18749
+ return `to_char(${truncated}, 'YYYY-MM-01"T"00:00:00.000"Z"')`;
18750
+ case "year":
18751
+ return `to_char(${truncated}, 'YYYY-01-01"T"00:00:00.000"Z"')`;
18752
+ case "week":
18753
+ case "day":
18754
+ default:
18755
+ return `to_char(${truncated}, 'YYYY-MM-DD"T"00:00:00.000"Z"')`;
18756
+ }
18757
+ }
18758
+ booleanLiteral(value) {
18759
+ return value ? "TRUE" : "FALSE";
18760
+ }
18630
18761
  renderColumn(column) {
18631
18762
  const typeSql = column.isPrimaryKey ? this.getPrimaryKeyType(column) : this.getColumnType(column);
18632
18763
  const parts = [this.quoteIdentifier(column.name), typeSql];
@@ -18910,6 +19041,28 @@ class SQLiteDriver {
18910
19041
  recordMigrationQuery() {
18911
19042
  return "INSERT INTO migrations (migration) VALUES (?)";
18912
19043
  }
19044
+ jsonExtract(column, key) {
19045
+ return `json_extract(${column}, '$.' || ${key})`;
19046
+ }
19047
+ dateBucket(column, grain, offsetHours = 0) {
19048
+ const shifted = offsetHours === 0 ? column : `datetime(${column}, '${offsetHours >= 0 ? "+" : "-"}${Math.abs(offsetHours)} hours')`;
19049
+ switch (grain) {
19050
+ case "hour":
19051
+ return `strftime('%Y-%m-%dT%H:00:00.000Z', ${shifted})`;
19052
+ case "week":
19053
+ return `strftime('%Y-%m-%dT00:00:00.000Z', ${shifted}, '-6 days', 'weekday 1')`;
19054
+ case "month":
19055
+ return `strftime('%Y-%m-01T00:00:00.000Z', ${shifted})`;
19056
+ case "year":
19057
+ return `strftime('%Y-01-01T00:00:00.000Z', ${shifted})`;
19058
+ case "day":
19059
+ default:
19060
+ return `strftime('%Y-%m-%dT00:00:00.000Z', ${shifted})`;
19061
+ }
19062
+ }
19063
+ booleanLiteral(value) {
19064
+ return value ? "TRUE" : "FALSE";
19065
+ }
18913
19066
  renderColumn(column) {
18914
19067
  const typeSql = this.getColumnType(column);
18915
19068
  const parts = [this.quoteIdentifier(column.name), typeSql];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "bun-query-builder",
3
3
  "type": "module",
4
- "version": "0.2.29",
4
+ "version": "0.2.31",
5
5
  "description": "A simple yet performant query builder for TypeScript. Built with Bun.",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",
7
7
  "license": "MIT",