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/dist/index.d.mts CHANGED
@@ -52,6 +52,47 @@ export declare class Now {
52
52
  type Column = string | Col;
53
53
  export declare const col: (name: string) => Col;
54
54
  export declare const now: () => Now;
55
+ /**
56
+ * A raw SQL fragment: one expression inside the query the builder makes, with
57
+ * `?` for each binding (`\\?` is a literal `?`, e.g. the jsonb `?` operator).
58
+ * Bindings are sent as query parameters, never pasted into the SQL. Use it in
59
+ * `where()`, `order()`, `having()`, as a `select()` column with `.as()`, and as
60
+ * an insert/update/upsert value or upsert `merge` value. The API key must be
61
+ * allowed to use raw fragments.
62
+ *
63
+ * .where(db.raw('lower(email) = any(?)', [emails]))
64
+ * .update({ sent_total: db.raw('sent_total + ?', [1]) })
65
+ */
66
+ export declare class Raw {
67
+ #private;
68
+ readonly sql: string;
69
+ readonly bindings: unknown[];
70
+ constructor(sql: string, bindings?: unknown[], as?: string);
71
+ /** Name of this fragment as a `select()` column: `raw('char_length(title)').as('title_len')`. */
72
+ as(name: string): Raw;
73
+ /** @internal The `.as()` name, if any. */
74
+ alias(): string | undefined;
75
+ /** @internal The fragment without its name, for filters, order and having. */
76
+ fragment(): {
77
+ $raw: string;
78
+ bindings: unknown[];
79
+ };
80
+ /** @internal Wire format. */
81
+ toJSON(): {
82
+ $raw: string;
83
+ bindings: unknown[];
84
+ as?: string;
85
+ };
86
+ }
87
+ /**
88
+ * A raw SQL fragment with `?` bindings: knex's `whereRaw` / `orderByRaw` /
89
+ * `havingRaw` / `knex.raw` value. Throws if `sql` is not a non-empty string
90
+ * or `bindings` not an array; the server checks the rest.
91
+ *
92
+ * @example
93
+ * db.from('users').select().where(raw('lower(trim(email)) = any(?)', [emails]))
94
+ */
95
+ export declare const raw: (sql: string, bindings?: unknown[]) => Raw;
55
96
  /**
56
97
  * Atomic update operator, applied by Postgres to the current column value.
57
98
  *
@@ -150,9 +191,21 @@ interface GroupFilter {
150
191
  group: 'and' | 'or' | 'not';
151
192
  filters: Filter[];
152
193
  }
153
- type Filter = ConditionFilter | GroupFilter;
194
+ /** A raw SQL fragment on the wire: `?` placeholders and their values. */
195
+ interface RawFragment {
196
+ $raw: string;
197
+ bindings: unknown[];
198
+ }
199
+ /** A `where(raw(...))` condition: `AND (<fragment>)`, or one member of an OR group. */
200
+ interface RawFilter {
201
+ op: 'raw';
202
+ value: RawFragment;
203
+ }
204
+ type Filter = ConditionFilter | GroupFilter | RawFilter;
154
205
  interface Order {
155
206
  column?: string;
207
+ /** `order(raw(...))`: sort by the fragment instead of a column. */
208
+ raw?: RawFragment;
156
209
  ascending?: boolean;
157
210
  nullsFirst?: boolean;
158
211
  random?: boolean;
@@ -180,8 +233,14 @@ interface QueryRequest {
180
233
  upsert?: boolean;
181
234
  onConflict?: string[];
182
235
  ignoreDuplicates?: boolean;
183
- merge?: Record<string, MergeMode | Op>;
236
+ merge?: Record<string, MergeMode | Op | Raw>;
184
237
  groupBy?: string[];
238
+ /** `having(raw(...))` fragments, joined with AND. Only with `groupBy`. */
239
+ having?: RawFragment[];
240
+ /** `select(..., raw(...).as(name))` columns of the main table. */
241
+ rawColumns?: (RawFragment & {
242
+ as: string;
243
+ })[];
185
244
  distinct?: boolean;
186
245
  aggregate?: {
187
246
  fn: AggregateFn;
@@ -210,14 +269,21 @@ interface UpsertOptions {
210
269
  /**
211
270
  * Change only these columns of an existing row; the rest keep their stored
212
271
  * value. Each is a {@link MergeMode} or a `db.ops` value applied to the stored
213
- * value. A list of names means `'set'` for each. Without `merge`, every column
214
- * you send (except the conflict key) is replaced.
272
+ * value, or a `db.raw` fragment (`excluded.col` is the incoming value). A
273
+ * list of names means `'set'` for each. Without `merge`, every column you
274
+ * send (except the conflict key) is replaced.
215
275
  *
216
276
  * @example
217
277
  * merge: { total: 'add', email: 'set', hits: db.ops.increment(1) }
278
+ * merge: { total: db.raw('greatest(accounts.total, excluded.total)') }
218
279
  * merge: ['email', 'name']
219
280
  */
220
- merge?: Record<string, MergeMode | Op> | string[];
281
+ merge?: Record<string, MergeMode | Op | Raw> | string[];
282
+ }
283
+ /** `select()` options: `count: 'exact'` also fills `count`; `head: true` skips the rows. */
284
+ interface SelectOptions {
285
+ count?: 'exact';
286
+ head?: boolean;
221
287
  }
222
288
  /** Options for `order()`, `random()` and `limit()` that target an embedded table. */
223
289
  interface ReferencedTableOption {
@@ -235,6 +301,8 @@ interface TxStepRequest extends QueryRequest {
235
301
  /** Filter methods, shared by builders and groups. */
236
302
  declare abstract class Filters {
237
303
  #private;
304
+ /** @internal A problem found while building, reported instead of sending. */
305
+ protected problem: string | undefined;
238
306
  /** @internal Adds one filter or group. */
239
307
  protected abstract push(filter: Filter): void;
240
308
  /**
@@ -317,6 +385,17 @@ declare abstract class Filters {
317
385
  * .match({ active: true, age: 30 })
318
386
  */
319
387
  match(values: Row): this;
388
+ /**
389
+ * A raw SQL condition, knex's `whereRaw`: `AND (<fragment>)`, or one member
390
+ * of an `or()` group. Only takes `raw()`; for a column use `eq()` and friends.
391
+ * The fragment sees the main table's columns, not related tables.
392
+ *
393
+ * @example
394
+ * .where(db.raw('lower(trim(email)) = any(?)', [emails]))
395
+ * .where(db.raw('sent_per_hour < limit_per_hour'))
396
+ * .or((q) => q.where(db.raw('meta \\? ?', ['vip'])).eq('age', 30)) // jsonb ? operator
397
+ */
398
+ where(fragment: Raw): this;
320
399
  /**
321
400
  * Any of the conditions.
322
401
  *
@@ -364,8 +443,6 @@ declare abstract class Builder<T extends Row, R> extends Filters implements Prom
364
443
  #private;
365
444
  /** @internal */ protected readonly transport: Transport | null;
366
445
  /** @internal */ protected readonly req: QueryRequest;
367
- /** @internal A problem found while building, reported instead of sending. */
368
- protected problem: string | undefined;
369
446
  /** @internal */
370
447
  constructor(transport: Transport | null, req: QueryRequest, name?: string, problem?: string);
371
448
  /** @internal */
@@ -416,11 +493,14 @@ export declare class QueryBuilder<T extends Row = Row, R = T[]> extends Builder<
416
493
  * db.from('users').select('name, posts!user_id!inner(title)') // only users that have posts
417
494
  * db.from('users').select('name, posts!user_id(count)') // posts: [{ count: 2 }]
418
495
  * db.from('posts').select('*', { count: 'exact' }).range(0, 9) // rows + total
496
+ *
497
+ * Extra `raw(...).as(name)` arguments add SQL expressions of the main table
498
+ * as columns (options, if any, go last).
499
+ *
500
+ * @example
501
+ * db.from('posts').select('id, title', db.raw('char_length(title)').as('title_len'))
419
502
  */
420
- select(columns?: string, options?: {
421
- count?: 'exact';
422
- head?: boolean;
423
- }): QueryBuilder<T, T[]>;
503
+ select(columns?: string, ...extra: (Raw | SelectOptions)[]): QueryBuilder<T, T[]>;
424
504
  /**
425
505
  * Number of matching rows: `data` is the number. All filters apply, including
426
506
  * jsonb paths and `relation.column` filters on `!inner` relations.
@@ -455,6 +535,14 @@ export declare class QueryBuilder<T extends Row = Row, R = T[]> extends Builder<
455
535
  * db.from('posts').select('user_id, n:count(), last:max(created_at)').groupBy('user_id').order('n', { ascending: false })
456
536
  */
457
537
  groupBy(columns: string): this;
538
+ /**
539
+ * Keep only the groups where the raw condition holds, knex's `havingRaw`.
540
+ * Use with `groupBy()`; several calls are joined with AND.
541
+ *
542
+ * @example
543
+ * db.from('posts').select('user_id, n:count()').groupBy('user_id').having(db.raw('count(*) >= ?', [2]))
544
+ */
545
+ having(fragment: Raw): this;
458
546
  /**
459
547
  * Leave out repeated rows: `SELECT DISTINCT`.
460
548
  *
@@ -540,8 +628,9 @@ export declare class QueryBuilder<T extends Row = Row, R = T[]> extends Builder<
540
628
  * .order('active', { ascending: false }).order('age', { ascending: false }).order('name')
541
629
  * .order('meta->>score::int', { ascending: false, nullsFirst: false })
542
630
  * .order('title', { referencedTable: 'posts' }) // posts inside each user
631
+ * .order(db.raw("coalesce(last_read_time, '1970-01-01')")) // raw, main table only
543
632
  */
544
- order(column: string, options?: {
633
+ order(column: string | Raw, options?: {
545
634
  ascending?: boolean;
546
635
  nullsFirst?: boolean;
547
636
  } & ReferencedTableOption): this;
@@ -722,6 +811,8 @@ interface Tx {
722
811
  readonly col: typeof col;
723
812
  /** The database server's time, same as `db.now`. */
724
813
  readonly now: typeof now;
814
+ /** A raw SQL fragment, same as `db.raw`. */
815
+ readonly raw: typeof raw;
725
816
  }
726
817
  interface Client {
727
818
  from<T extends Row = Row>(table: string): QueryBuilder<T>;
@@ -756,6 +847,16 @@ interface Client {
756
847
  * await db.from('accounts').update({ last_read_time: db.now() }).eq('id', id)
757
848
  */
758
849
  readonly now: typeof now;
850
+ /**
851
+ * A raw SQL fragment with `?` bindings (same as `import { raw }`), for
852
+ * `where()`, `order()`, `having()`, `select()` columns and write values.
853
+ * The API key must be allowed to use raw fragments.
854
+ *
855
+ * @example
856
+ * await db.from('accounts').select().where(db.raw('sent_per_hour < limit_per_hour'))
857
+ * await db.from('accounts').update({ sent_total: db.raw('sent_total + ?', [1]) }).eq('id', id)
858
+ */
859
+ readonly raw: typeof raw;
759
860
  /**
760
861
  * Run several steps in one database transaction: all succeed or nothing is saved.
761
862
  * On failure `error.step` / `error.stepName` say which step failed.
@@ -794,5 +895,5 @@ export declare function createClient(url: string, apiKey: string, options?: Clie
794
895
  * const { data, error } = await db.from('users').select().eq('id', id).single()
795
896
  */
796
897
  export declare const db: Client;
797
- export type { AggregateFn, Client, ClientOptions, Column, ConditionFilter, Filter, FilterOp, GroupFilter, Key, MergeMode, Order, PgError, QueryRequest, QueryResult, ReadFrom, Ref, ReferencedTableOption, RelOp, RelOpName, Row, Step, StepData, Tx, TxStepRequest, UpsertOptions };
898
+ export type { AggregateFn, Client, ClientOptions, Column, ConditionFilter, Filter, FilterOp, GroupFilter, Key, MergeMode, Order, PgError, QueryRequest, QueryResult, RawFilter, RawFragment, ReadFrom, Ref, ReferencedTableOption, RelOp, RelOpName, Row, SelectOptions, Step, StepData, Tx, TxStepRequest, UpsertOptions };
798
899
  //# sourceMappingURL=index.d.mts.map
package/dist/index.mjs CHANGED
@@ -151,10 +151,66 @@ var Now = class Now {
151
151
  };
152
152
  const col = (name) => new Col(name);
153
153
  const now = () => new Now();
154
+ /**
155
+ * A raw SQL fragment: one expression inside the query the builder makes, with
156
+ * `?` for each binding (`\\?` is a literal `?`, e.g. the jsonb `?` operator).
157
+ * Bindings are sent as query parameters, never pasted into the SQL. Use it in
158
+ * `where()`, `order()`, `having()`, as a `select()` column with `.as()`, and as
159
+ * an insert/update/upsert value or upsert `merge` value. The API key must be
160
+ * allowed to use raw fragments.
161
+ *
162
+ * .where(db.raw('lower(email) = any(?)', [emails]))
163
+ * .update({ sent_total: db.raw('sent_total + ?', [1]) })
164
+ */
165
+ var Raw = class Raw {
166
+ sql;
167
+ bindings;
168
+ #as;
169
+ constructor(sql, bindings = [], as) {
170
+ if (typeof sql !== "string" || sql.trim() === "") throw new Error("arn-db: raw(sql) needs a non-empty SQL string");
171
+ if (!Array.isArray(bindings)) throw new Error("arn-db: raw(sql, bindings) takes the bindings as an array");
172
+ this.sql = sql;
173
+ this.bindings = bindings;
174
+ this.#as = as;
175
+ }
176
+ /** Name of this fragment as a `select()` column: `raw('char_length(title)').as('title_len')`. */
177
+ as(name) {
178
+ return new Raw(this.sql, this.bindings, name);
179
+ }
180
+ /** @internal The `.as()` name, if any. */
181
+ alias() {
182
+ return this.#as;
183
+ }
184
+ /** @internal The fragment without its name, for filters, order and having. */
185
+ fragment() {
186
+ return {
187
+ $raw: this.sql,
188
+ bindings: this.bindings
189
+ };
190
+ }
191
+ /** @internal Wire format. */
192
+ toJSON() {
193
+ return this.#as === void 0 ? this.fragment() : {
194
+ ...this.fragment(),
195
+ as: this.#as
196
+ };
197
+ }
198
+ };
199
+ /**
200
+ * A raw SQL fragment with `?` bindings: knex's `whereRaw` / `orderByRaw` /
201
+ * `havingRaw` / `knex.raw` value. Throws if `sql` is not a non-empty string
202
+ * or `bindings` not an array; the server checks the rest.
203
+ *
204
+ * @example
205
+ * db.from('users').select().where(raw('lower(trim(email)) = any(?)', [emails]))
206
+ */
207
+ const raw = (sql, bindings) => new Raw(sql, bindings);
154
208
  // src/builder.ts
155
209
  const embedOf = (o) => o.referencedTable ?? o.foreignTable;
156
210
  /** Filter methods, shared by builders and groups. */
157
211
  var Filters = class {
212
+ /** @internal A problem found while building, reported instead of sending. */
213
+ problem;
158
214
  #filter(column, op, value) {
159
215
  if (column instanceof Col) {
160
216
  const ifNull = column.nullValue();
@@ -287,6 +343,24 @@ var Filters = class {
287
343
  return this;
288
344
  }
289
345
  /**
346
+ * A raw SQL condition, knex's `whereRaw`: `AND (<fragment>)`, or one member
347
+ * of an `or()` group. Only takes `raw()`; for a column use `eq()` and friends.
348
+ * The fragment sees the main table's columns, not related tables.
349
+ *
350
+ * @example
351
+ * .where(db.raw('lower(trim(email)) = any(?)', [emails]))
352
+ * .where(db.raw('sent_per_hour < limit_per_hour'))
353
+ * .or((q) => q.where(db.raw('meta \\? ?', ['vip'])).eq('age', 30)) // jsonb ? operator
354
+ */
355
+ where(fragment) {
356
+ if (fragment instanceof Raw) this.push({
357
+ op: "raw",
358
+ value: fragment.fragment()
359
+ });
360
+ else this.problem ??= "where() takes a raw() fragment; for a column use eq(), gt(), in() and friends";
361
+ return this;
362
+ }
363
+ /**
290
364
  * Any of the conditions.
291
365
  *
292
366
  * @example
@@ -313,6 +387,7 @@ var Filters = class {
313
387
  #group(group, conditions) {
314
388
  const q = new FilterGroup();
315
389
  conditions(q);
390
+ if (q.problem) this.problem ??= q.problem;
316
391
  this.push({
317
392
  group,
318
393
  filters: q.filters
@@ -339,8 +414,6 @@ var FilterGroup = class extends Filters {
339
414
  var Builder = class extends Filters {
340
415
  /** @internal */ transport;
341
416
  /** @internal */ req;
342
- /** @internal A problem found while building, reported instead of sending. */
343
- problem;
344
417
  #name;
345
418
  /** @internal */
346
419
  constructor(transport, req, name, problem) {
@@ -444,11 +517,30 @@ var QueryBuilder = class extends Builder {
444
517
  * db.from('users').select('name, posts!user_id!inner(title)') // only users that have posts
445
518
  * db.from('users').select('name, posts!user_id(count)') // posts: [{ count: 2 }]
446
519
  * db.from('posts').select('*', { count: 'exact' }).range(0, 9) // rows + total
520
+ *
521
+ * Extra `raw(...).as(name)` arguments add SQL expressions of the main table
522
+ * as columns (options, if any, go last).
523
+ *
524
+ * @example
525
+ * db.from('posts').select('id, title', db.raw('char_length(title)').as('title_len'))
447
526
  */
448
- select(columns = "*", options = {}) {
527
+ select(columns = "*", ...extra) {
449
528
  this.req.columns = columns;
450
- if (options.count) this.req.count = true;
451
- if (options.head) this.req.head = true;
529
+ const rawColumns = [];
530
+ for (const e of extra) if (e instanceof Raw) {
531
+ const as = e.alias();
532
+ if (as === void 0) this.problem ??= `raw("${e.sql}") in select() needs a column name: add .as('name')`;
533
+ else if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(as)) this.problem ??= `raw(...).as("${as}"): the name must be letters, digits and _`;
534
+ else rawColumns.push({
535
+ ...e.fragment(),
536
+ as
537
+ });
538
+ } else if (e) {
539
+ if (e.count) this.req.count = true;
540
+ if (e.head) this.req.head = true;
541
+ }
542
+ if (rawColumns.length) this.req.rawColumns = rawColumns;
543
+ else delete this.req.rawColumns;
452
544
  return this;
453
545
  }
454
546
  /**
@@ -510,6 +602,18 @@ var QueryBuilder = class extends Builder {
510
602
  return this;
511
603
  }
512
604
  /**
605
+ * Keep only the groups where the raw condition holds, knex's `havingRaw`.
606
+ * Use with `groupBy()`; several calls are joined with AND.
607
+ *
608
+ * @example
609
+ * db.from('posts').select('user_id, n:count()').groupBy('user_id').having(db.raw('count(*) >= ?', [2]))
610
+ */
611
+ having(fragment) {
612
+ if (fragment instanceof Raw) (this.req.having ??= []).push(fragment.fragment());
613
+ else this.problem ??= "having() takes a raw() fragment: having(db.raw(\"count(*) >= ?\", [2]))";
614
+ return this;
615
+ }
616
+ /**
513
617
  * Leave out repeated rows: `SELECT DISTINCT`.
514
618
  *
515
619
  * @example
@@ -636,9 +740,19 @@ var QueryBuilder = class extends Builder {
636
740
  * .order('active', { ascending: false }).order('age', { ascending: false }).order('name')
637
741
  * .order('meta->>score::int', { ascending: false, nullsFirst: false })
638
742
  * .order('title', { referencedTable: 'posts' }) // posts inside each user
743
+ * .order(db.raw("coalesce(last_read_time, '1970-01-01')")) // raw, main table only
639
744
  */
640
745
  order(column, options = {}) {
641
746
  const { ascending = true, nullsFirst } = options;
747
+ if (column instanceof Raw) {
748
+ if (embedOf(options)) this.problem ??= "order(raw(...)) sorts the main table only: drop referencedTable";
749
+ else this.req.order.push({
750
+ raw: column.fragment(),
751
+ ascending,
752
+ nullsFirst
753
+ });
754
+ return this;
755
+ }
642
756
  this.req.order.push({
643
757
  column,
644
758
  ascending,
@@ -920,6 +1034,7 @@ function createClient(url, apiKey, options = {}) {
920
1034
  rel,
921
1035
  col,
922
1036
  now,
1037
+ raw,
923
1038
  async transaction(build) {
924
1039
  const steps = toSteps(build({
925
1040
  from: (table) => new QueryBuilder(null, table),
@@ -927,7 +1042,8 @@ function createClient(url, apiKey, options = {}) {
927
1042
  ops,
928
1043
  rel,
929
1044
  col,
930
- now
1045
+ now,
1046
+ raw
931
1047
  }));
932
1048
  if (typeof steps === "string") return fail(steps);
933
1049
  const res = await transport.post("/transaction", { steps: steps.wire });
@@ -943,14 +1059,20 @@ function createClient(url, apiKey, options = {}) {
943
1059
  }
944
1060
  // src/env.ts
945
1061
  let client;
1062
+ const proc = globalThis.process;
1063
+ /** Reads ./.env into process.env (Node 20.12+; never overrides set vars). */
1064
+ function loadDotEnv() {
1065
+ if (!proc?.loadEnvFile) return;
1066
+ try {
1067
+ proc.loadEnvFile();
1068
+ } catch {}
1069
+ }
1070
+ loadDotEnv();
946
1071
  /** The ARN_DB_URL / ARN_DB_KEY client, made on the first query (so a later dotenv still counts). */
947
1072
  function envClient() {
948
1073
  if (client) return client;
949
- const proc = globalThis.process;
950
1074
  if (!proc) throw new Error("arn-db: `db` reads ARN_DB_URL / ARN_DB_KEY from process.env; use createClient(url, key) here");
951
- if ((!proc.env.ARN_DB_URL || !proc.env.ARN_DB_KEY) && proc.loadEnvFile) try {
952
- proc.loadEnvFile();
953
- } catch {}
1075
+ if (!proc.env.ARN_DB_URL || !proc.env.ARN_DB_KEY) loadDotEnv();
954
1076
  const url = proc.env.ARN_DB_URL;
955
1077
  const key = proc.env.ARN_DB_KEY;
956
1078
  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)");
@@ -974,8 +1096,9 @@ const db = {
974
1096
  ops,
975
1097
  rel,
976
1098
  col,
977
- now
1099
+ now,
1100
+ raw
978
1101
  };
979
- export { Col, FilterGroup, Now, Op, QueryBuilder, WriteBuilder, col, createClient, db, now, ops, rel };
1102
+ export { Col, FilterGroup, Now, Op, QueryBuilder, Raw, WriteBuilder, col, createClient, db, now, ops, raw, rel };
980
1103
 
981
1104
  //# sourceMappingURL=index.mjs.map