arn-db 0.1.0 → 0.2.1
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 +108 -9
- package/README.md +58 -7
- package/dist/index.cjs +136 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +114 -13
- package/dist/index.d.mts +114 -13
- package/dist/index.mjs +135 -12
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
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
|
|
|
@@ -54,10 +55,12 @@ await db.from('users').update({ scores: db.ops.append(4) }).eq('id', id)
|
|
|
54
55
|
await db.from('posts').insert({ title, user_id, comments: db.rel.create([{ body, author_id }]) })
|
|
55
56
|
```
|
|
56
57
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
Importing arn-db loads `./.env` from the working directory into `process.env`
|
|
59
|
+
(Node 20.12+), like `import "dotenv/config"`: the app's other variables are set
|
|
60
|
+
too, so files need no dotenv import. Variables that are already set always win,
|
|
61
|
+
and a missing `.env` is fine. `db` reads its two variables on the first query (it
|
|
62
|
+
tries `./.env` once more then), so setting them later in startup still works. A
|
|
63
|
+
missing variable throws on that first query.
|
|
61
64
|
|
|
62
65
|
Inside `transaction()` the same helpers are on `tx`: `tx.from()`, `tx.ref()`,
|
|
63
66
|
`tx.ops.*`, `tx.rel.*`. (`import { ops, rel } from 'arn-db'` still works too.)
|
|
@@ -483,7 +486,8 @@ await db.from('posts').select('user_id').distinct() // SELECT DISTINCT
|
|
|
483
486
|
```
|
|
484
487
|
|
|
485
488
|
Aggregates, `groupBy()` and `distinct()` don't combine with related tables in the
|
|
486
|
-
same select.
|
|
489
|
+
same select. For `HAVING`, use `.having(db.raw('count(*) >= ?', [2]))` (see
|
|
490
|
+
[Raw SQL fragments](#raw-sql-fragments)).
|
|
487
491
|
|
|
488
492
|
---
|
|
489
493
|
|
|
@@ -700,6 +704,98 @@ await db.from('users').select().eq('email', email).primary()
|
|
|
700
704
|
|
|
701
705
|
---
|
|
702
706
|
|
|
707
|
+
## Raw SQL fragments
|
|
708
|
+
|
|
709
|
+
When the builder has no method for it, `db.raw(sql, bindings?)` adds one SQL
|
|
710
|
+
expression to the query (same as `import { raw }`, and `tx.raw` in
|
|
711
|
+
transactions). It is knex's `whereRaw` / `orderByRaw` / `havingRaw` /
|
|
712
|
+
`knex.raw`: a piece of a query, never a whole statement. `test/raw.test.ts`
|
|
713
|
+
checks these against the test database.
|
|
714
|
+
|
|
715
|
+
```ts
|
|
716
|
+
const n = db.raw('substring(name from 6)::int') // user_42 -> 42
|
|
717
|
+
|
|
718
|
+
// where(): AND (<fragment>)
|
|
719
|
+
await db.from('users').select('name').where(db.raw('substring(name from 6)::int <= ?', [3])).order('name')
|
|
720
|
+
// [{ name: 'user_1' }, { name: 'user_2' }, { name: 'user_3' }]
|
|
721
|
+
|
|
722
|
+
// inside or() / and() / not() it is one condition of the group
|
|
723
|
+
await db.from('users').select('name')
|
|
724
|
+
.or((q) => q.where(db.raw('substring(name from 6)::int = ?', [1])).eq('name', 'user_2'))
|
|
725
|
+
// user_1, user_2
|
|
726
|
+
|
|
727
|
+
// array binding: = any(?)
|
|
728
|
+
await db.from('users').select('name').where(db.raw('lower(trim(email)) = any(?)', [['user_1@example.com', 'user_7@example.com']]))
|
|
729
|
+
|
|
730
|
+
// jsonb ? operator: \? (written '\\?' in JS) is a literal ?
|
|
731
|
+
await db.from('users').where(db.raw('meta \\? ?', ['vip'])).count() // 100
|
|
732
|
+
|
|
733
|
+
// order()
|
|
734
|
+
await db.from('users').select('name').order(n, { ascending: false }).limit(3)
|
|
735
|
+
// user_100, user_99, user_98
|
|
736
|
+
|
|
737
|
+
// select(): extra arguments are raw columns, each needs .as(name); options go last
|
|
738
|
+
await db.from('users').select('name', db.raw('char_length(name)').as('name_len')).eq('name', 'user_42').single()
|
|
739
|
+
// { name: 'user_42', name_len: 7 }
|
|
740
|
+
|
|
741
|
+
// having(), with groupBy()
|
|
742
|
+
await db.from('posts').select('user_id, n:count()').groupBy('user_id').having(db.raw('count(*) >= ?', [2]))
|
|
743
|
+
// 33 groups, each n: 2
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
As values in `insert()` / `update()` / `upsert()` rows, and in upsert `merge`,
|
|
747
|
+
where `excluded.col` is the incoming value and `table.col` the stored one:
|
|
748
|
+
|
|
749
|
+
```ts
|
|
750
|
+
await db.from('users').update({ age: db.raw('age + ?', [5]) }).eq('email', email).returning('age') // 10 -> [{ age: 15 }]
|
|
751
|
+
|
|
752
|
+
await db.from('users').upsert({ email, name: 'second', age: 5 }, {
|
|
753
|
+
onConflict: 'email',
|
|
754
|
+
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]) },
|
|
755
|
+
})
|
|
756
|
+
// stored age 10 -> 15; name stays (only merge columns change); with age: null -> 100
|
|
757
|
+
```
|
|
758
|
+
|
|
759
|
+
Rules:
|
|
760
|
+
|
|
761
|
+
- Each `?` is one binding, passed as a query parameter, so values are never
|
|
762
|
+
pasted into the SQL. The count must match `bindings`, else a 400 error
|
|
763
|
+
(`raw fragment: 2 placeholders but 1 binding`). Strings, numbers, booleans and
|
|
764
|
+
`null` pass through; an array becomes a Postgres array (cast it if needed:
|
|
765
|
+
`?::text[]`); an object becomes jsonb (`?::jsonb`). A `?` inside `'quoted
|
|
766
|
+
text'` is not a binding.
|
|
767
|
+
- `\?` (in a JS string: `'\\?'`) is a literal `?`, for the jsonb `?` operator.
|
|
768
|
+
- Fragments see the main table only: its columns, plain or qualified with the
|
|
769
|
+
table's name (`users.age`, `"users".age`), and `excluded.col` in `merge`.
|
|
770
|
+
Related tables are not visible, and `order(raw)` with `referencedTable` is an error.
|
|
771
|
+
- The API key must be allowed to use raw fragments. Otherwise every request
|
|
772
|
+
(or transaction) that contains one gets 403, code `RAW_NOT_ALLOWED`.
|
|
773
|
+
- The server rejects with 400, code `RAW_FRAGMENT`: `;`, `--` and `/*`
|
|
774
|
+
comments, dollar quoting (`$$`), the keywords `select`, `insert`, `update`,
|
|
775
|
+
`delete`, `with`, `union`, `into`, `copy`, `grant`, `drop`, `alter`,
|
|
776
|
+
`create`, `truncate`, `execute`, `do`, `call` outside quoted text, an empty
|
|
777
|
+
fragment or one over 2,000 characters, and more than 100 bindings.
|
|
778
|
+
Functions, `CASE WHEN`, casts, `interval '1 hour'`, operators, `count(*)`
|
|
779
|
+
and `excluded.x` are fine.
|
|
780
|
+
- In the client, `raw()` throws when `sql` is not a non-empty string or
|
|
781
|
+
`bindings` not an array. `select()` with a raw column lacking `.as()`, and
|
|
782
|
+
`where()` / `having()` with something other than `raw()`, resolve to `{ error }`
|
|
783
|
+
without sending.
|
|
784
|
+
|
|
785
|
+
Moving from knex:
|
|
786
|
+
|
|
787
|
+
| knex | arn-db |
|
|
788
|
+
|---|---|
|
|
789
|
+
| `.whereRaw(sql, b)`, `.andWhereRaw(sql, b)` | `.where(db.raw(sql, b))` |
|
|
790
|
+
| `.orWhereRaw(sql, b)` | `.or((q) => q.eq(...).where(db.raw(sql, b)))` |
|
|
791
|
+
| `.orderByRaw(sql, b)` | `.order(db.raw(sql, b), { ascending, nullsFirst })` |
|
|
792
|
+
| `.select('id', knex.raw('char_length(title) as n'))` | `.select('id', db.raw('char_length(title)').as('n'))` |
|
|
793
|
+
| `{ total: knex.raw('total + ?', [1]) }` | `{ total: db.raw('total + ?', [1]) }` |
|
|
794
|
+
| `.onConflict('email').merge({ total: knex.raw('…') })` | `.upsert(rows, { onConflict: 'email', merge: { total: db.raw('…') } })` |
|
|
795
|
+
| `.havingRaw(sql, b)` | `.having(db.raw(sql, b))` |
|
|
796
|
+
|
|
797
|
+
---
|
|
798
|
+
|
|
703
799
|
## Errors
|
|
704
800
|
|
|
705
801
|
`error` has `message`, and usually `code` and `details`:
|
|
@@ -712,11 +808,13 @@ await db.from('users').select().eq('email', email).primary()
|
|
|
712
808
|
| `22P02` | value has the wrong format | `.eq('id', 'abc')` on a uuid |
|
|
713
809
|
| `42703` | column doesn't exist | `select('nope')` → `column _t0.nope does not exist` (`_t0` is the main table) |
|
|
714
810
|
| `42P10` | `onConflict` columns have no unique constraint | `upsert(…, { onConflict: 'title' })` |
|
|
811
|
+
| `RAW_FRAGMENT` | (400) a `db.raw()` fragment the server refuses | `db.raw('1; drop table x')` |
|
|
812
|
+
| `RAW_NOT_ALLOWED` | (403) the API key may not use raw fragments | |
|
|
715
813
|
| `401` | missing or wrong API key | |
|
|
716
814
|
| `403` | request came from a browser | |
|
|
717
815
|
|
|
718
|
-
Only setup mistakes throw: `createClient()` without a key,
|
|
719
|
-
query when `ARN_DB_URL` or `ARN_DB_KEY` is missing.
|
|
816
|
+
Only setup mistakes throw: `createClient()` without a key, `db.raw()` without a
|
|
817
|
+
SQL string, and `db`'s first query when `ARN_DB_URL` or `ARN_DB_KEY` is missing.
|
|
720
818
|
|
|
721
819
|
Errors without a code are checks done before the database runs anything, for
|
|
722
820
|
example: `more than one relationship between "posts" and "users", use one of: users!reviewer_id, users!user_id`.
|
|
@@ -727,6 +825,7 @@ example: `more than one relationship between "posts" and "users", use one of: us
|
|
|
727
825
|
|
|
728
826
|
- Sorting main rows by a related table's column (e.g. posts by author name). Sorting *inside* a relation works.
|
|
729
827
|
- OR across tables in one group (e.g. `name = 'x' OR posts.published`): each `or()` group is on one table.
|
|
730
|
-
- Full-text search
|
|
731
|
-
functions like `lower(trim(email))
|
|
828
|
+
- Full-text search and calling Postgres functions (`rpc`) as methods. Column
|
|
829
|
+
functions like `lower(trim(email))` and `having` work through
|
|
830
|
+
[raw fragments](#raw-sql-fragments).
|
|
732
831
|
- `db.col()` across tables, and as a value in `insert()` / `update()`.
|
package/README.md
CHANGED
|
@@ -34,15 +34,16 @@ import { db } from 'arn-db' // or: const { db } = require('arn-db')
|
|
|
34
34
|
const { data, error } = await db.from('users').select().eq('id', 1).single()
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
-
That's all
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
That's all. Importing arn-db loads `./.env` into `process.env` (Node 20.12+;
|
|
38
|
+
variables that are already set win), like arn-knexjs's `dotenv/config`, so your
|
|
39
|
+
files need no dotenv import. `db` reads the two variables on its first query.
|
|
40
|
+
ESM, CommonJS and TypeScript types are included.
|
|
40
41
|
|
|
41
42
|
## Quick tour
|
|
42
43
|
|
|
43
44
|
Import the ready `db` everywhere: `db.from()`, `db.transaction()`, `db.ops.*`,
|
|
44
|
-
`db.rel
|
|
45
|
-
`tx.rel.*`, `tx.ref()`). For another key or options, make your own client with
|
|
45
|
+
`db.rel.*`, `db.raw()`. Inside transactions the same helpers are on `tx` (`tx.ops.*`,
|
|
46
|
+
`tx.rel.*`, `tx.raw()`, `tx.ref()`). For another key or options, make your own client with
|
|
46
47
|
`createClient(url, key, options)`.
|
|
47
48
|
|
|
48
49
|
```ts
|
|
@@ -90,8 +91,8 @@ await db.from('users').update({ meta: db.ops.append('x').at('address', 'lines')
|
|
|
90
91
|
|
|
91
92
|
Errors never throw: every call (and every transaction) resolves to
|
|
92
93
|
`{ data, error, count, status }`, like Supabase. Only setup mistakes throw:
|
|
93
|
-
`createClient()` without an API key or fetch,
|
|
94
|
-
`ARN_DB_URL` / `ARN_DB_KEY` is missing.
|
|
94
|
+
`createClient()` without an API key or fetch, `db.raw()` without a SQL string,
|
|
95
|
+
and `db`'s first query when `ARN_DB_URL` / `ARN_DB_KEY` is missing.
|
|
95
96
|
|
|
96
97
|
## Searching arrays (jsonb arrays and Postgres arrays)
|
|
97
98
|
|
|
@@ -214,3 +215,53 @@ await db.transaction((tx) => [
|
|
|
214
215
|
returned an array, pick one: `tx.ref('posts.0.id')`.
|
|
215
216
|
- Step names: words without dots. Number-like keys (`'1'`) are rejected because
|
|
216
217
|
JavaScript would reorder them.
|
|
218
|
+
|
|
219
|
+
## Raw SQL fragments
|
|
220
|
+
|
|
221
|
+
For the bits the builder has no method for, `db.raw(sql, bindings?)` (also
|
|
222
|
+
`import { raw }` and `tx.raw`) adds one SQL expression to the query the builder
|
|
223
|
+
makes, like knex's `whereRaw` / `orderByRaw` / `knex.raw`. It is a fragment,
|
|
224
|
+
never a whole statement.
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
// filters: AND (<fragment>), or one member of an or() group
|
|
228
|
+
await db.from('users').select('id').where(db.raw('lower(trim(email)) = any(?)', [emails]))
|
|
229
|
+
await db.from('accounts').select().or((q) => q.where(db.raw('sent_per_hour < limit_per_hour')).is('limit_per_hour', null))
|
|
230
|
+
|
|
231
|
+
// order, computed select columns (.as() is required), having
|
|
232
|
+
await db.from('accounts').select().order(db.raw("coalesce(last_read_time, '1970-01-01')"), { ascending: true })
|
|
233
|
+
await db.from('posts').select('id, title', db.raw('char_length(title)').as('title_len'))
|
|
234
|
+
await db.from('posts').select('user_id, n:count()').groupBy('user_id').having(db.raw('count(*) >= ?', [2]))
|
|
235
|
+
|
|
236
|
+
// values in insert / update / upsert rows, and in upsert merge (excluded.col = incoming value)
|
|
237
|
+
await db.from('accounts').update({ sent_total: db.raw('sent_total + ?', [1]) }).eq('id', id)
|
|
238
|
+
await db.from('accounts').upsert(rows, {
|
|
239
|
+
onConflict: 'email',
|
|
240
|
+
merge: { total: db.raw('CASE WHEN excluded.total IS NULL THEN accounts.total ELSE excluded.total END') },
|
|
241
|
+
})
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
- Each `?` is a binding, sent as a query parameter (never pasted into the SQL).
|
|
245
|
+
Their number must match `bindings`. An array binding is a Postgres array
|
|
246
|
+
(`= any(?)`), an object is jsonb (`?::jsonb`). `?` inside `'quoted text'` is
|
|
247
|
+
not a binding.
|
|
248
|
+
- `\?` (in a JS string: `'\\?'`) is a literal `?`, for the jsonb `?` operator:
|
|
249
|
+
`db.raw('meta \\? ?', ['vip'])`.
|
|
250
|
+
- A fragment sees the main table's columns, plain or qualified with the table
|
|
251
|
+
name (`accounts.total`); not related tables.
|
|
252
|
+
- The API key must be allowed to use raw fragments; otherwise the request gets
|
|
253
|
+
403 `RAW_NOT_ALLOWED`.
|
|
254
|
+
- The server rejects (400 `RAW_FRAGMENT`): `;`, `--` and `/* */` comments,
|
|
255
|
+
dollar quoting, statement keywords (`select`, `insert`, `update`, `delete`,
|
|
256
|
+
`with`, `union`, `into`, `drop`, `alter`, `create`, `truncate`, …) outside
|
|
257
|
+
quoted text, fragments over 2,000 characters and more than 100 bindings.
|
|
258
|
+
|
|
259
|
+
| knex | arn-db |
|
|
260
|
+
|---|---|
|
|
261
|
+
| `whereRaw(sql, b)` / `andWhereRaw(sql, b)` | `.where(db.raw(sql, b))` |
|
|
262
|
+
| `orWhereRaw(sql, b)` | `.or((q) => q.where(db.raw(sql, b)).eq(...))` |
|
|
263
|
+
| `orderByRaw(sql, b)` | `.order(db.raw(sql, b), { ascending })` |
|
|
264
|
+
| `select('id', knex.raw('… as x'))` | `.select('id', db.raw('…').as('x'))` |
|
|
265
|
+
| `{ col: knex.raw('col + ?', [1]) }` value | `{ col: db.raw('col + ?', [1]) }` |
|
|
266
|
+
| `merge({ col: knex.raw(...) })` | `upsert(rows, { merge: { col: db.raw(...) } })` |
|
|
267
|
+
| `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 = "*",
|
|
528
|
+
select(columns = "*", ...extra) {
|
|
450
529
|
this.req.columns = columns;
|
|
451
|
-
|
|
452
|
-
|
|
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 });
|
|
@@ -944,14 +1060,20 @@ function createClient(url, apiKey, options = {}) {
|
|
|
944
1060
|
}
|
|
945
1061
|
// src/env.ts
|
|
946
1062
|
let client;
|
|
1063
|
+
const proc = globalThis.process;
|
|
1064
|
+
/** Reads ./.env into process.env (Node 20.12+; never overrides set vars). */
|
|
1065
|
+
function loadDotEnv() {
|
|
1066
|
+
if (!proc?.loadEnvFile) return;
|
|
1067
|
+
try {
|
|
1068
|
+
proc.loadEnvFile();
|
|
1069
|
+
} catch {}
|
|
1070
|
+
}
|
|
1071
|
+
loadDotEnv();
|
|
947
1072
|
/** The ARN_DB_URL / ARN_DB_KEY client, made on the first query (so a later dotenv still counts). */
|
|
948
1073
|
function envClient() {
|
|
949
1074
|
if (client) return client;
|
|
950
|
-
const proc = globalThis.process;
|
|
951
1075
|
if (!proc) throw new Error("arn-db: `db` reads ARN_DB_URL / ARN_DB_KEY from process.env; use createClient(url, key) here");
|
|
952
|
-
if (
|
|
953
|
-
proc.loadEnvFile();
|
|
954
|
-
} catch {}
|
|
1076
|
+
if (!proc.env.ARN_DB_URL || !proc.env.ARN_DB_KEY) loadDotEnv();
|
|
955
1077
|
const url = proc.env.ARN_DB_URL;
|
|
956
1078
|
const key = proc.env.ARN_DB_KEY;
|
|
957
1079
|
if (!url) throw new Error("arn-db: set ARN_DB_URL (e.g. https://your-api.example.com/v2/raw) or use createClient(url, key)");
|
|
@@ -975,19 +1097,22 @@ const db = {
|
|
|
975
1097
|
ops,
|
|
976
1098
|
rel,
|
|
977
1099
|
col,
|
|
978
|
-
now
|
|
1100
|
+
now,
|
|
1101
|
+
raw
|
|
979
1102
|
};
|
|
980
1103
|
exports.Col = Col;
|
|
981
1104
|
exports.FilterGroup = FilterGroup;
|
|
982
1105
|
exports.Now = Now;
|
|
983
1106
|
exports.Op = Op;
|
|
984
1107
|
exports.QueryBuilder = QueryBuilder;
|
|
1108
|
+
exports.Raw = Raw;
|
|
985
1109
|
exports.WriteBuilder = WriteBuilder;
|
|
986
1110
|
exports.col = col;
|
|
987
1111
|
exports.createClient = createClient;
|
|
988
1112
|
exports.db = db;
|
|
989
1113
|
exports.now = now;
|
|
990
1114
|
exports.ops = ops;
|
|
1115
|
+
exports.raw = raw;
|
|
991
1116
|
exports.rel = rel;
|
|
992
1117
|
|
|
993
1118
|
//# sourceMappingURL=index.cjs.map
|