arn-db 0.1.0 → 0.2.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/GUIDE.md CHANGED
@@ -30,6 +30,7 @@ Contents:
30
30
  [Writing related rows](#writing-related-rows) ·
31
31
  [Transactions](#transactions) ·
32
32
  [Read replicas](#read-replicas) ·
33
+ [Raw SQL fragments](#raw-sql-fragments) ·
33
34
  [Errors](#errors) ·
34
35
  [Not supported yet](#not-supported-yet)
35
36
 
@@ -483,7 +484,8 @@ await db.from('posts').select('user_id').distinct() // SELECT DISTINCT
483
484
  ```
484
485
 
485
486
  Aggregates, `groupBy()` and `distinct()` don't combine with related tables in the
486
- same select. Not yet: `having`.
487
+ same select. For `HAVING`, use `.having(db.raw('count(*) >= ?', [2]))` (see
488
+ [Raw SQL fragments](#raw-sql-fragments)).
487
489
 
488
490
  ---
489
491
 
@@ -700,6 +702,98 @@ await db.from('users').select().eq('email', email).primary()
700
702
 
701
703
  ---
702
704
 
705
+ ## Raw SQL fragments
706
+
707
+ When the builder has no method for it, `db.raw(sql, bindings?)` adds one SQL
708
+ expression to the query (same as `import { raw }`, and `tx.raw` in
709
+ transactions). It is knex's `whereRaw` / `orderByRaw` / `havingRaw` /
710
+ `knex.raw`: a piece of a query, never a whole statement. `test/raw.test.ts`
711
+ checks these against the test database.
712
+
713
+ ```ts
714
+ const n = db.raw('substring(name from 6)::int') // user_42 -> 42
715
+
716
+ // where(): AND (<fragment>)
717
+ await db.from('users').select('name').where(db.raw('substring(name from 6)::int <= ?', [3])).order('name')
718
+ // [{ name: 'user_1' }, { name: 'user_2' }, { name: 'user_3' }]
719
+
720
+ // inside or() / and() / not() it is one condition of the group
721
+ await db.from('users').select('name')
722
+ .or((q) => q.where(db.raw('substring(name from 6)::int = ?', [1])).eq('name', 'user_2'))
723
+ // user_1, user_2
724
+
725
+ // array binding: = any(?)
726
+ await db.from('users').select('name').where(db.raw('lower(trim(email)) = any(?)', [['user_1@example.com', 'user_7@example.com']]))
727
+
728
+ // jsonb ? operator: \? (written '\\?' in JS) is a literal ?
729
+ await db.from('users').where(db.raw('meta \\? ?', ['vip'])).count() // 100
730
+
731
+ // order()
732
+ await db.from('users').select('name').order(n, { ascending: false }).limit(3)
733
+ // user_100, user_99, user_98
734
+
735
+ // select(): extra arguments are raw columns, each needs .as(name); options go last
736
+ await db.from('users').select('name', db.raw('char_length(name)').as('name_len')).eq('name', 'user_42').single()
737
+ // { name: 'user_42', name_len: 7 }
738
+
739
+ // having(), with groupBy()
740
+ await db.from('posts').select('user_id, n:count()').groupBy('user_id').having(db.raw('count(*) >= ?', [2]))
741
+ // 33 groups, each n: 2
742
+ ```
743
+
744
+ As values in `insert()` / `update()` / `upsert()` rows, and in upsert `merge`,
745
+ where `excluded.col` is the incoming value and `table.col` the stored one:
746
+
747
+ ```ts
748
+ await db.from('users').update({ age: db.raw('age + ?', [5]) }).eq('email', email).returning('age') // 10 -> [{ age: 15 }]
749
+
750
+ await db.from('users').upsert({ email, name: 'second', age: 5 }, {
751
+ onConflict: 'email',
752
+ merge: { age: db.raw('CASE WHEN excluded.age IS NOT NULL AND users.age IS NOT NULL THEN users.age + excluded.age ELSE ? END', [100]) },
753
+ })
754
+ // stored age 10 -> 15; name stays (only merge columns change); with age: null -> 100
755
+ ```
756
+
757
+ Rules:
758
+
759
+ - Each `?` is one binding, passed as a query parameter, so values are never
760
+ pasted into the SQL. The count must match `bindings`, else a 400 error
761
+ (`raw fragment: 2 placeholders but 1 binding`). Strings, numbers, booleans and
762
+ `null` pass through; an array becomes a Postgres array (cast it if needed:
763
+ `?::text[]`); an object becomes jsonb (`?::jsonb`). A `?` inside `'quoted
764
+ text'` is not a binding.
765
+ - `\?` (in a JS string: `'\\?'`) is a literal `?`, for the jsonb `?` operator.
766
+ - Fragments see the main table only: its columns, plain or qualified with the
767
+ table's name (`users.age`, `"users".age`), and `excluded.col` in `merge`.
768
+ Related tables are not visible, and `order(raw)` with `referencedTable` is an error.
769
+ - The API key must be allowed to use raw fragments. Otherwise every request
770
+ (or transaction) that contains one gets 403, code `RAW_NOT_ALLOWED`.
771
+ - The server rejects with 400, code `RAW_FRAGMENT`: `;`, `--` and `/*`
772
+ comments, dollar quoting (`$$`), the keywords `select`, `insert`, `update`,
773
+ `delete`, `with`, `union`, `into`, `copy`, `grant`, `drop`, `alter`,
774
+ `create`, `truncate`, `execute`, `do`, `call` outside quoted text, an empty
775
+ fragment or one over 2,000 characters, and more than 100 bindings.
776
+ Functions, `CASE WHEN`, casts, `interval '1 hour'`, operators, `count(*)`
777
+ and `excluded.x` are fine.
778
+ - In the client, `raw()` throws when `sql` is not a non-empty string or
779
+ `bindings` not an array. `select()` with a raw column lacking `.as()`, and
780
+ `where()` / `having()` with something other than `raw()`, resolve to `{ error }`
781
+ without sending.
782
+
783
+ Moving from knex:
784
+
785
+ | knex | arn-db |
786
+ |---|---|
787
+ | `.whereRaw(sql, b)`, `.andWhereRaw(sql, b)` | `.where(db.raw(sql, b))` |
788
+ | `.orWhereRaw(sql, b)` | `.or((q) => q.eq(...).where(db.raw(sql, b)))` |
789
+ | `.orderByRaw(sql, b)` | `.order(db.raw(sql, b), { ascending, nullsFirst })` |
790
+ | `.select('id', knex.raw('char_length(title) as n'))` | `.select('id', db.raw('char_length(title)').as('n'))` |
791
+ | `{ total: knex.raw('total + ?', [1]) }` | `{ total: db.raw('total + ?', [1]) }` |
792
+ | `.onConflict('email').merge({ total: knex.raw('…') })` | `.upsert(rows, { onConflict: 'email', merge: { total: db.raw('…') } })` |
793
+ | `.havingRaw(sql, b)` | `.having(db.raw(sql, b))` |
794
+
795
+ ---
796
+
703
797
  ## Errors
704
798
 
705
799
  `error` has `message`, and usually `code` and `details`:
@@ -712,11 +806,13 @@ await db.from('users').select().eq('email', email).primary()
712
806
  | `22P02` | value has the wrong format | `.eq('id', 'abc')` on a uuid |
713
807
  | `42703` | column doesn't exist | `select('nope')` → `column _t0.nope does not exist` (`_t0` is the main table) |
714
808
  | `42P10` | `onConflict` columns have no unique constraint | `upsert(…, { onConflict: 'title' })` |
809
+ | `RAW_FRAGMENT` | (400) a `db.raw()` fragment the server refuses | `db.raw('1; drop table x')` |
810
+ | `RAW_NOT_ALLOWED` | (403) the API key may not use raw fragments | |
715
811
  | `401` | missing or wrong API key | |
716
812
  | `403` | request came from a browser | |
717
813
 
718
- Only setup mistakes throw: `createClient()` without a key, and `db`'s first
719
- query when `ARN_DB_URL` or `ARN_DB_KEY` is missing.
814
+ Only setup mistakes throw: `createClient()` without a key, `db.raw()` without a
815
+ SQL string, and `db`'s first query when `ARN_DB_URL` or `ARN_DB_KEY` is missing.
720
816
 
721
817
  Errors without a code are checks done before the database runs anything, for
722
818
  example: `more than one relationship between "posts" and "users", use one of: users!reviewer_id, users!user_id`.
@@ -727,6 +823,7 @@ example: `more than one relationship between "posts" and "users", use one of: us
727
823
 
728
824
  - Sorting main rows by a related table's column (e.g. posts by author name). Sorting *inside* a relation works.
729
825
  - OR across tables in one group (e.g. `name = 'x' OR posts.published`): each `or()` group is on one table.
730
- - Full-text search, calling Postgres functions (`rpc`), `having` for groups, column
731
- functions like `lower(trim(email))`.
826
+ - Full-text search and calling Postgres functions (`rpc`) as methods. Column
827
+ functions like `lower(trim(email))` and `having` work through
828
+ [raw fragments](#raw-sql-fragments).
732
829
  - `db.col()` across tables, and as a value in `insert()` / `update()`.
package/README.md CHANGED
@@ -41,8 +41,8 @@ TypeScript types are included.
41
41
  ## Quick tour
42
42
 
43
43
  Import the ready `db` everywhere: `db.from()`, `db.transaction()`, `db.ops.*`,
44
- `db.rel.*`. Inside transactions the same helpers are on `tx` (`tx.ops.*`,
45
- `tx.rel.*`, `tx.ref()`). For another key or options, make your own client with
44
+ `db.rel.*`, `db.raw()`. Inside transactions the same helpers are on `tx` (`tx.ops.*`,
45
+ `tx.rel.*`, `tx.raw()`, `tx.ref()`). For another key or options, make your own client with
46
46
  `createClient(url, key, options)`.
47
47
 
48
48
  ```ts
@@ -90,8 +90,8 @@ await db.from('users').update({ meta: db.ops.append('x').at('address', 'lines')
90
90
 
91
91
  Errors never throw: every call (and every transaction) resolves to
92
92
  `{ data, error, count, status }`, like Supabase. Only setup mistakes throw:
93
- `createClient()` without an API key or fetch, and `db`'s first query when
94
- `ARN_DB_URL` / `ARN_DB_KEY` is missing.
93
+ `createClient()` without an API key or fetch, `db.raw()` without a SQL string,
94
+ and `db`'s first query when `ARN_DB_URL` / `ARN_DB_KEY` is missing.
95
95
 
96
96
  ## Searching arrays (jsonb arrays and Postgres arrays)
97
97
 
@@ -214,3 +214,53 @@ await db.transaction((tx) => [
214
214
  returned an array, pick one: `tx.ref('posts.0.id')`.
215
215
  - Step names: words without dots. Number-like keys (`'1'`) are rejected because
216
216
  JavaScript would reorder them.
217
+
218
+ ## Raw SQL fragments
219
+
220
+ For the bits the builder has no method for, `db.raw(sql, bindings?)` (also
221
+ `import { raw }` and `tx.raw`) adds one SQL expression to the query the builder
222
+ makes, like knex's `whereRaw` / `orderByRaw` / `knex.raw`. It is a fragment,
223
+ never a whole statement.
224
+
225
+ ```ts
226
+ // filters: AND (<fragment>), or one member of an or() group
227
+ await db.from('users').select('id').where(db.raw('lower(trim(email)) = any(?)', [emails]))
228
+ await db.from('accounts').select().or((q) => q.where(db.raw('sent_per_hour < limit_per_hour')).is('limit_per_hour', null))
229
+
230
+ // order, computed select columns (.as() is required), having
231
+ await db.from('accounts').select().order(db.raw("coalesce(last_read_time, '1970-01-01')"), { ascending: true })
232
+ await db.from('posts').select('id, title', db.raw('char_length(title)').as('title_len'))
233
+ await db.from('posts').select('user_id, n:count()').groupBy('user_id').having(db.raw('count(*) >= ?', [2]))
234
+
235
+ // values in insert / update / upsert rows, and in upsert merge (excluded.col = incoming value)
236
+ await db.from('accounts').update({ sent_total: db.raw('sent_total + ?', [1]) }).eq('id', id)
237
+ await db.from('accounts').upsert(rows, {
238
+ onConflict: 'email',
239
+ merge: { total: db.raw('CASE WHEN excluded.total IS NULL THEN accounts.total ELSE excluded.total END') },
240
+ })
241
+ ```
242
+
243
+ - Each `?` is a binding, sent as a query parameter (never pasted into the SQL).
244
+ Their number must match `bindings`. An array binding is a Postgres array
245
+ (`= any(?)`), an object is jsonb (`?::jsonb`). `?` inside `'quoted text'` is
246
+ not a binding.
247
+ - `\?` (in a JS string: `'\\?'`) is a literal `?`, for the jsonb `?` operator:
248
+ `db.raw('meta \\? ?', ['vip'])`.
249
+ - A fragment sees the main table's columns, plain or qualified with the table
250
+ name (`accounts.total`); not related tables.
251
+ - The API key must be allowed to use raw fragments; otherwise the request gets
252
+ 403 `RAW_NOT_ALLOWED`.
253
+ - The server rejects (400 `RAW_FRAGMENT`): `;`, `--` and `/* */` comments,
254
+ dollar quoting, statement keywords (`select`, `insert`, `update`, `delete`,
255
+ `with`, `union`, `into`, `drop`, `alter`, `create`, `truncate`, …) outside
256
+ quoted text, fragments over 2,000 characters and more than 100 bindings.
257
+
258
+ | knex | arn-db |
259
+ |---|---|
260
+ | `whereRaw(sql, b)` / `andWhereRaw(sql, b)` | `.where(db.raw(sql, b))` |
261
+ | `orWhereRaw(sql, b)` | `.or((q) => q.where(db.raw(sql, b)).eq(...))` |
262
+ | `orderByRaw(sql, b)` | `.order(db.raw(sql, b), { ascending })` |
263
+ | `select('id', knex.raw('… as x'))` | `.select('id', db.raw('…').as('x'))` |
264
+ | `{ col: knex.raw('col + ?', [1]) }` value | `{ col: db.raw('col + ?', [1]) }` |
265
+ | `merge({ col: knex.raw(...) })` | `upsert(rows, { merge: { col: db.raw(...) } })` |
266
+ | `havingRaw(sql, b)` | `.having(db.raw(sql, b))` |
package/dist/index.cjs CHANGED
@@ -152,10 +152,66 @@ var Now = class Now {
152
152
  };
153
153
  const col = (name) => new Col(name);
154
154
  const now = () => new Now();
155
+ /**
156
+ * A raw SQL fragment: one expression inside the query the builder makes, with
157
+ * `?` for each binding (`\\?` is a literal `?`, e.g. the jsonb `?` operator).
158
+ * Bindings are sent as query parameters, never pasted into the SQL. Use it in
159
+ * `where()`, `order()`, `having()`, as a `select()` column with `.as()`, and as
160
+ * an insert/update/upsert value or upsert `merge` value. The API key must be
161
+ * allowed to use raw fragments.
162
+ *
163
+ * .where(db.raw('lower(email) = any(?)', [emails]))
164
+ * .update({ sent_total: db.raw('sent_total + ?', [1]) })
165
+ */
166
+ var Raw = class Raw {
167
+ sql;
168
+ bindings;
169
+ #as;
170
+ constructor(sql, bindings = [], as) {
171
+ if (typeof sql !== "string" || sql.trim() === "") throw new Error("arn-db: raw(sql) needs a non-empty SQL string");
172
+ if (!Array.isArray(bindings)) throw new Error("arn-db: raw(sql, bindings) takes the bindings as an array");
173
+ this.sql = sql;
174
+ this.bindings = bindings;
175
+ this.#as = as;
176
+ }
177
+ /** Name of this fragment as a `select()` column: `raw('char_length(title)').as('title_len')`. */
178
+ as(name) {
179
+ return new Raw(this.sql, this.bindings, name);
180
+ }
181
+ /** @internal The `.as()` name, if any. */
182
+ alias() {
183
+ return this.#as;
184
+ }
185
+ /** @internal The fragment without its name, for filters, order and having. */
186
+ fragment() {
187
+ return {
188
+ $raw: this.sql,
189
+ bindings: this.bindings
190
+ };
191
+ }
192
+ /** @internal Wire format. */
193
+ toJSON() {
194
+ return this.#as === void 0 ? this.fragment() : {
195
+ ...this.fragment(),
196
+ as: this.#as
197
+ };
198
+ }
199
+ };
200
+ /**
201
+ * A raw SQL fragment with `?` bindings: knex's `whereRaw` / `orderByRaw` /
202
+ * `havingRaw` / `knex.raw` value. Throws if `sql` is not a non-empty string
203
+ * or `bindings` not an array; the server checks the rest.
204
+ *
205
+ * @example
206
+ * db.from('users').select().where(raw('lower(trim(email)) = any(?)', [emails]))
207
+ */
208
+ const raw = (sql, bindings) => new Raw(sql, bindings);
155
209
  // src/builder.ts
156
210
  const embedOf = (o) => o.referencedTable ?? o.foreignTable;
157
211
  /** Filter methods, shared by builders and groups. */
158
212
  var Filters = class {
213
+ /** @internal A problem found while building, reported instead of sending. */
214
+ problem;
159
215
  #filter(column, op, value) {
160
216
  if (column instanceof Col) {
161
217
  const ifNull = column.nullValue();
@@ -288,6 +344,24 @@ var Filters = class {
288
344
  return this;
289
345
  }
290
346
  /**
347
+ * A raw SQL condition, knex's `whereRaw`: `AND (<fragment>)`, or one member
348
+ * of an `or()` group. Only takes `raw()`; for a column use `eq()` and friends.
349
+ * The fragment sees the main table's columns, not related tables.
350
+ *
351
+ * @example
352
+ * .where(db.raw('lower(trim(email)) = any(?)', [emails]))
353
+ * .where(db.raw('sent_per_hour < limit_per_hour'))
354
+ * .or((q) => q.where(db.raw('meta \\? ?', ['vip'])).eq('age', 30)) // jsonb ? operator
355
+ */
356
+ where(fragment) {
357
+ if (fragment instanceof Raw) this.push({
358
+ op: "raw",
359
+ value: fragment.fragment()
360
+ });
361
+ else this.problem ??= "where() takes a raw() fragment; for a column use eq(), gt(), in() and friends";
362
+ return this;
363
+ }
364
+ /**
291
365
  * Any of the conditions.
292
366
  *
293
367
  * @example
@@ -314,6 +388,7 @@ var Filters = class {
314
388
  #group(group, conditions) {
315
389
  const q = new FilterGroup();
316
390
  conditions(q);
391
+ if (q.problem) this.problem ??= q.problem;
317
392
  this.push({
318
393
  group,
319
394
  filters: q.filters
@@ -340,8 +415,6 @@ var FilterGroup = class extends Filters {
340
415
  var Builder = class extends Filters {
341
416
  /** @internal */ transport;
342
417
  /** @internal */ req;
343
- /** @internal A problem found while building, reported instead of sending. */
344
- problem;
345
418
  #name;
346
419
  /** @internal */
347
420
  constructor(transport, req, name, problem) {
@@ -445,11 +518,30 @@ var QueryBuilder = class extends Builder {
445
518
  * db.from('users').select('name, posts!user_id!inner(title)') // only users that have posts
446
519
  * db.from('users').select('name, posts!user_id(count)') // posts: [{ count: 2 }]
447
520
  * db.from('posts').select('*', { count: 'exact' }).range(0, 9) // rows + total
521
+ *
522
+ * Extra `raw(...).as(name)` arguments add SQL expressions of the main table
523
+ * as columns (options, if any, go last).
524
+ *
525
+ * @example
526
+ * db.from('posts').select('id, title', db.raw('char_length(title)').as('title_len'))
448
527
  */
449
- select(columns = "*", options = {}) {
528
+ select(columns = "*", ...extra) {
450
529
  this.req.columns = columns;
451
- if (options.count) this.req.count = true;
452
- if (options.head) this.req.head = true;
530
+ const rawColumns = [];
531
+ for (const e of extra) if (e instanceof Raw) {
532
+ const as = e.alias();
533
+ if (as === void 0) this.problem ??= `raw("${e.sql}") in select() needs a column name: add .as('name')`;
534
+ else if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(as)) this.problem ??= `raw(...).as("${as}"): the name must be letters, digits and _`;
535
+ else rawColumns.push({
536
+ ...e.fragment(),
537
+ as
538
+ });
539
+ } else if (e) {
540
+ if (e.count) this.req.count = true;
541
+ if (e.head) this.req.head = true;
542
+ }
543
+ if (rawColumns.length) this.req.rawColumns = rawColumns;
544
+ else delete this.req.rawColumns;
453
545
  return this;
454
546
  }
455
547
  /**
@@ -511,6 +603,18 @@ var QueryBuilder = class extends Builder {
511
603
  return this;
512
604
  }
513
605
  /**
606
+ * Keep only the groups where the raw condition holds, knex's `havingRaw`.
607
+ * Use with `groupBy()`; several calls are joined with AND.
608
+ *
609
+ * @example
610
+ * db.from('posts').select('user_id, n:count()').groupBy('user_id').having(db.raw('count(*) >= ?', [2]))
611
+ */
612
+ having(fragment) {
613
+ if (fragment instanceof Raw) (this.req.having ??= []).push(fragment.fragment());
614
+ else this.problem ??= "having() takes a raw() fragment: having(db.raw(\"count(*) >= ?\", [2]))";
615
+ return this;
616
+ }
617
+ /**
514
618
  * Leave out repeated rows: `SELECT DISTINCT`.
515
619
  *
516
620
  * @example
@@ -637,9 +741,19 @@ var QueryBuilder = class extends Builder {
637
741
  * .order('active', { ascending: false }).order('age', { ascending: false }).order('name')
638
742
  * .order('meta->>score::int', { ascending: false, nullsFirst: false })
639
743
  * .order('title', { referencedTable: 'posts' }) // posts inside each user
744
+ * .order(db.raw("coalesce(last_read_time, '1970-01-01')")) // raw, main table only
640
745
  */
641
746
  order(column, options = {}) {
642
747
  const { ascending = true, nullsFirst } = options;
748
+ if (column instanceof Raw) {
749
+ if (embedOf(options)) this.problem ??= "order(raw(...)) sorts the main table only: drop referencedTable";
750
+ else this.req.order.push({
751
+ raw: column.fragment(),
752
+ ascending,
753
+ nullsFirst
754
+ });
755
+ return this;
756
+ }
643
757
  this.req.order.push({
644
758
  column,
645
759
  ascending,
@@ -921,6 +1035,7 @@ function createClient(url, apiKey, options = {}) {
921
1035
  rel,
922
1036
  col,
923
1037
  now,
1038
+ raw,
924
1039
  async transaction(build) {
925
1040
  const steps = toSteps(build({
926
1041
  from: (table) => new QueryBuilder(null, table),
@@ -928,7 +1043,8 @@ function createClient(url, apiKey, options = {}) {
928
1043
  ops,
929
1044
  rel,
930
1045
  col,
931
- now
1046
+ now,
1047
+ raw
932
1048
  }));
933
1049
  if (typeof steps === "string") return fail(steps);
934
1050
  const res = await transport.post("/transaction", { steps: steps.wire });
@@ -975,19 +1091,22 @@ const db = {
975
1091
  ops,
976
1092
  rel,
977
1093
  col,
978
- now
1094
+ now,
1095
+ raw
979
1096
  };
980
1097
  exports.Col = Col;
981
1098
  exports.FilterGroup = FilterGroup;
982
1099
  exports.Now = Now;
983
1100
  exports.Op = Op;
984
1101
  exports.QueryBuilder = QueryBuilder;
1102
+ exports.Raw = Raw;
985
1103
  exports.WriteBuilder = WriteBuilder;
986
1104
  exports.col = col;
987
1105
  exports.createClient = createClient;
988
1106
  exports.db = db;
989
1107
  exports.now = now;
990
1108
  exports.ops = ops;
1109
+ exports.raw = raw;
991
1110
  exports.rel = rel;
992
1111
 
993
1112
  //# sourceMappingURL=index.cjs.map