@weft-js/db 0.1.1 → 0.1.2

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/src/sql.ts CHANGED
@@ -1,7 +1,18 @@
1
1
  // A list source from bound SQL: `columns` is required, identifiers come only from it, and every value
2
2
  // is a parameter. `target` is anything with `query(text, params)`: `ctx.db` from `@weft-js/db/plugin-native`, or a driver.
3
3
 
4
- import { likeOf, pageOf, plan, rowOf, storedId, written, type DbAdapter, type ListParams, type ListSource, type SourceSpec } from '@weft-js/start/list'
4
+ import {
5
+ likeOf,
6
+ pageOf,
7
+ plan,
8
+ rowOf,
9
+ storedId,
10
+ written,
11
+ type DbAdapter,
12
+ type ListParams,
13
+ type ListSource,
14
+ type SourceSpec,
15
+ } from '@weft-js/start/list'
5
16
  import { StartError } from '@weft-js/start/data'
6
17
 
7
18
  /** Whatever runs a bound statement, and says which dialect it speaks. */
@@ -12,20 +23,101 @@ export interface SqlTarget {
12
23
  }
13
24
 
14
25
  /** A source's allowlist, and the table it covers. */
15
- export interface SqlSpec extends SourceSpec { table: string }
26
+ export interface SqlSpec extends SourceSpec {
27
+ table: string
28
+ }
16
29
 
17
30
  const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/
18
31
 
19
32
  function ident(dialect: string) {
20
33
  const q = dialect === 'mysql' ? '`' : '"'
21
34
  return (name: string) => {
22
- if (!IDENT.test(name)) throw new StartError('Q002', `\`${name}\` is not a plain identifier, so it can't go in SQL`, 'use a column name')
35
+ if (!IDENT.test(name))
36
+ throw new StartError(
37
+ 'Q002',
38
+ `\`${name}\` is not a plain identifier, so it can't go in SQL`,
39
+ 'use a column name',
40
+ )
23
41
  return `${q}${name}${q}`
24
42
  }
25
43
  }
26
44
 
45
+ function listSql<Row>({
46
+ target,
47
+ spec,
48
+ q,
49
+ bind,
50
+ read,
51
+ table,
52
+ select,
53
+ idColumn,
54
+ }: {
55
+ target: SqlTarget
56
+ spec: SqlSpec
57
+ q: (name: string) => string
58
+ bind: (params: unknown[]) => (value: unknown) => string
59
+ read: (text: string, params: unknown[]) => Promise<Row[]>
60
+ table: string
61
+ select: string
62
+ idColumn: string
63
+ }): ListSource<Row>['list'] {
64
+ return async function list(p: ListParams) {
65
+ const qp = plan(spec, p)
66
+ const params: unknown[] = []
67
+ const put = bind(params)
68
+ const parts = qp.where.map((w) => {
69
+ const some = w.values.filter((v) => v !== null)
70
+ const hit =
71
+ some.length === 1
72
+ ? `${q(w.column)} = ${put(some[0])}`
73
+ : some.length
74
+ ? `${q(w.column)} in (${some.map(put).join(', ')})`
75
+ : ''
76
+ return w.values.includes(null) ? `(${hit ? `${hit} or ` : ''}${q(w.column)} is null)` : hit
77
+ })
78
+ if (qp.search) {
79
+ const text = likeOf(qp.search.text)
80
+ parts.push(
81
+ `(${qp.search.columns.map((c) => `lower(${q(c)}) like ${put(text)} escape '!'`).join(' or ')})`,
82
+ )
83
+ }
84
+ const filtered = [...parts],
85
+ mark = params.length // what the count binds: the keyset isn't part of it
86
+ const cmp = qp.sort?.dir === 'desc' ? '<' : '>'
87
+ if (p.after) {
88
+ const [value, id] = p.after
89
+ const col = qp.sort && q(qp.sort.column)
90
+ parts.push(
91
+ col
92
+ ? `(${col} ${cmp} ${put(value)} or (${col} = ${put(value)} and ${idColumn} ${cmp} ${put(id)}))`
93
+ : `${idColumn} > ${put(id)}`,
94
+ )
95
+ }
96
+ const where = (conditions: string[]) =>
97
+ conditions.length ? ` where ${conditions.join(' and ')}` : ''
98
+ const dir = qp.sort?.dir === 'desc' ? 'desc' : 'asc'
99
+ const order = qp.sort ? `${q(qp.sort.column)} ${dir}, ${idColumn} ${dir}` : `${idColumn} asc`
100
+ const cursor = p.mode === 'cursor'
101
+ const counted = params.slice(0, mark)
102
+ const rows = await read(
103
+ `select ${select} from ${table}${where(parts)} order by ${order} limit ${Math.trunc(cursor ? p.limit + 1 : p.limit)} offset ${Math.trunc(p.offset)}`,
104
+ params,
105
+ )
106
+ const total = cursor
107
+ ? undefined
108
+ : Number(
109
+ (await target.query(`select count(*) as n from ${table}${where(filtered)}`, counted))[0]!
110
+ .n,
111
+ )
112
+ return pageOf(rows, p, { sort: qp.sort?.field, id: qp.id.field }, total)
113
+ }
114
+ }
115
+
27
116
  /** A `ListSource` over one table, as bound SQL. */
28
- export function sqlSource<Row = Record<string, unknown>>(target: SqlTarget, spec: SqlSpec): ListSource<Row> {
117
+ export function sqlSource<Row = Record<string, unknown>>(
118
+ target: SqlTarget,
119
+ spec: SqlSpec,
120
+ ): ListSource<Row> {
29
121
  const dialect = target.dialect ?? target.$weft?.dialect ?? 'postgresql'
30
122
  const q = ident(dialect)
31
123
  for (const c of [spec.table, ...Object.values(spec.columns)]) q(c)
@@ -37,52 +129,47 @@ export function sqlSource<Row = Record<string, unknown>>(target: SqlTarget, spec
37
129
  params.push(v)
38
130
  return dialect === 'postgresql' ? `$${params.length}` : '?'
39
131
  }
40
- const read = async (text: string, params: unknown[]) => (await target.query(text, params)).map(reading)
41
- const byId = (id: unknown) => { const params: unknown[] = []; return read(`select ${select} from ${table} where ${idColumn} = ${bind(params)(id)}`, params).then(r => r[0] ?? null) }
132
+ const read = async (text: string, params: unknown[]) =>
133
+ (await target.query(text, params)).map(reading)
134
+ const byId = (id: unknown) => {
135
+ const params: unknown[] = []
136
+ return read(
137
+ `select ${select} from ${table} where ${idColumn} = ${bind(params)(id)}`,
138
+ params,
139
+ ).then((r) => r[0] ?? null)
140
+ }
42
141
  return {
43
- async list(p: ListParams) {
44
- const qp = plan(spec, p)
45
- const params: unknown[] = []
46
- const put = bind(params)
47
- const parts = qp.where.map(w => {
48
- const some = w.values.filter(v => v !== null)
49
- const hit = some.length === 1 ? `${q(w.column)} = ${put(some[0])}` : some.length ? `${q(w.column)} in (${some.map(put).join(', ')})` : ''
50
- return w.values.includes(null) ? `(${hit ? `${hit} or ` : ''}${q(w.column)} is null)` : hit
51
- })
52
- if (qp.search) {
53
- const text = likeOf(qp.search.text)
54
- parts.push(`(${qp.search.columns.map(c => `lower(${q(c)}) like ${put(text)} escape '!'`).join(' or ')})`)
55
- }
56
- const filtered = [...parts], mark = params.length // what the count binds: the keyset isn't part of it
57
- const cmp = qp.sort?.dir === 'desc' ? '<' : '>'
58
- if (p.after) {
59
- const [value, id] = p.after
60
- const col = qp.sort && q(qp.sort.column)
61
- parts.push(col ? `(${col} ${cmp} ${put(value)} or (${col} = ${put(value)} and ${idColumn} ${cmp} ${put(id)}))` : `${idColumn} > ${put(id)}`)
62
- }
63
- const where = (list: string[]) => (list.length ? ` where ${list.join(' and ')}` : '')
64
- const dir = qp.sort?.dir === 'desc' ? 'desc' : 'asc'
65
- const order = qp.sort ? `${q(qp.sort.column)} ${dir}, ${idColumn} ${dir}` : `${idColumn} asc`
66
- const cursor = p.mode === 'cursor'
67
- const counted = params.slice(0, mark)
68
- const rows = await read(`select ${select} from ${table}${where(parts)} order by ${order} limit ${Math.trunc(cursor ? p.limit + 1 : p.limit)} offset ${Math.trunc(p.offset)}`, params)
69
- const total = cursor ? undefined : Number((await target.query(`select count(*) as n from ${table}${where(filtered)}`, counted))[0]!.n)
70
- return pageOf(rows, p, { sort: qp.sort?.field, id: qp.id.field }, total)
71
- },
142
+ list: listSql({ target, spec, q, bind, read, table, select, idColumn }),
72
143
  get: byId,
73
144
  async create(data) {
74
145
  const row = written(spec, data, true)
75
146
  const id = data[spec.id ?? 'id']
76
- if (id === undefined) throw new StartError('Q002', '`create` needs the id: a generated one can\'t be read back on every dialect', 'generate it before the insert')
77
- const keys = Object.keys(row), params: unknown[] = [], put = bind(params)
78
- await target.query(`insert into ${table} (${keys.map(q).join(', ')}) values (${keys.map(k => put(row[k])).join(', ')})`, params)
147
+ if (id === undefined)
148
+ throw new StartError(
149
+ 'Q002',
150
+ "`create` needs the id: a generated one can't be read back on every dialect",
151
+ 'generate it before the insert',
152
+ )
153
+ const keys = Object.keys(row),
154
+ params: unknown[] = [],
155
+ put = bind(params)
156
+ await target.query(
157
+ `insert into ${table} (${keys.map(q).join(', ')}) values (${keys.map((k) => put(row[k])).join(', ')})`,
158
+ params,
159
+ )
79
160
  return (await byId(id))!
80
161
  },
81
162
  async update(id, patch) {
82
- const row = written(spec, patch), params: unknown[] = [], put = bind(params)
83
- const sets = Object.keys(row).map(k => `${q(k)} = ${put(row[k])}`)
163
+ const row = written(spec, patch),
164
+ params: unknown[] = [],
165
+ put = bind(params)
166
+ const sets = Object.keys(row).map((k) => `${q(k)} = ${put(row[k])}`)
84
167
  if (!(await byId(id))) return null
85
- if (sets.length) await target.query(`update ${table} set ${sets.join(', ')} where ${idColumn} = ${put(id)}`, params)
168
+ if (sets.length)
169
+ await target.query(
170
+ `update ${table} set ${sets.join(', ')} where ${idColumn} = ${put(id)}`,
171
+ params,
172
+ )
86
173
  return byId(id)
87
174
  },
88
175
  async remove(id) {
@@ -95,13 +182,24 @@ export function sqlSource<Row = Record<string, unknown>>(target: SqlTarget, spec
95
182
  }
96
183
 
97
184
  /** `db.adapter@1` over bound SQL. */
98
- export const adapter: DbAdapter<SqlTarget> = { name: 'sql', source: (t, spec) => sqlSource(t, spec as SqlSpec) }
185
+ export const adapter: DbAdapter<SqlTarget> = {
186
+ name: 'sql',
187
+ source: (t, spec) => sqlSource(t, spec as SqlSpec),
188
+ }
99
189
 
100
190
  /**
101
191
  * A trigger so writes from anywhere (cron, psql, another service) reach `.live` queries, on the postgres bus: tags, never
102
192
  * rows. Run the text once in a migration.
103
193
  */
104
- export function postgresTrigger({ table, id = 'id', tag = table }: { table: string; id?: string; tag?: string }): string {
194
+ export function postgresTrigger({
195
+ table,
196
+ id = 'id',
197
+ tag = table,
198
+ }: {
199
+ table: string
200
+ id?: string
201
+ tag?: string
202
+ }): string {
105
203
  const q = ident('postgresql')
106
204
  if (!/^[\w:-]+$/.test(tag)) throw new StartError('Q002', `\`${tag}\` can't be a tag in a trigger`)
107
205
  const fn = `weft_poke_${table}`