pipework 0.280.0 → 0.282.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.
@@ -0,0 +1,120 @@
1
+ import { sql } from './sql.js'
2
+ import type { SQL } from './types.js'
3
+ import { Subquery } from '../query/subquery.js'
4
+ import { SelectionProxyHandler } from '../query/selection-proxy.js'
5
+
6
+ const CAST = /^[a-z_][a-z0-9_]*( [a-z_][a-z0-9_]*)*(\([0-9, ]+\))?$/i
7
+ const COLUMN_NAME = /^[a-z_][a-z0-9_]*$/i
8
+
9
+ type TextLike = 'text' | 'uuid' | 'varchar' | 'char' | 'citext'
10
+ type NumberLike = 'int' | 'integer' | 'int4' | 'smallint' | 'int2' | 'real' | 'float4' | 'double precision' | 'float8'
11
+ type DateLike = 'timestamptz' | 'timestamp' | 'date'
12
+
13
+ /** The JS type a column of this SQL type reads as: text and uuid as string, ints and floats as number, bigint and numeric as string (as postgres.js returns them), dates as Date, boolean as boolean; any other type name as unknown. A cell may be null. */
14
+ export type RowsColumnValue<T extends string> =
15
+ T extends TextLike ? string
16
+ : T extends NumberLike ? number
17
+ : T extends 'bigint' | 'int8' | 'numeric' ? string
18
+ : T extends 'boolean' | 'bool' ? boolean
19
+ : T extends DateLike ? Date | string
20
+ : unknown
21
+
22
+ /** Column name to SQL type name: `{ g: 'text', id: 'int' }`. */
23
+ export type RowsColumns = Readonly<Record<string, string>>
24
+
25
+ /** One array per column, all the same length: `{ g: ['a', 'b'], id: [1, 2] }` is two rows. */
26
+ export type RowsData<TColumns extends RowsColumns> = { readonly [K in keyof TColumns]: ReadonlyArray<RowsColumnValue<TColumns[K]> | null> }
27
+
28
+ type RowsFields<TColumns extends RowsColumns> = {
29
+ [K in keyof TColumns]: SQL<RowsColumnValue<TColumns[K]> | null>
30
+ }
31
+
32
+ /** A rowset usable in `.from()` and joins; its columns are properties of it. */
33
+ export type RowsSource<TAlias extends string, TColumns extends RowsColumns> =
34
+ & Subquery<TAlias, RowsFields<TColumns>>
35
+ & RowsFields<TColumns>
36
+ & {
37
+ /** The same rowset under another alias, to use it twice in a query. */
38
+ as<TNew extends string>(alias: TNew): RowsSource<TNew, TColumns>
39
+ }
40
+
41
+ function fail(problem: string, fix: string): never {
42
+ throw new Error(`[pipework] rows: ${problem}\n\n Fix: ${fix}\n`)
43
+ }
44
+
45
+ function quote(name: string): string {
46
+ return `"${name.replace(/"/g, '""')}"`
47
+ }
48
+
49
+ // One column as a Postgres array literal, sent as a single text parameter and cast to `T[]`. postgres.js
50
+ // serialises a JS array only for the few element types it knows (text, int, uuid ...), so a `timestamptz[]`
51
+ // parameter would throw; a literal string is parsed by Postgres for any type.
52
+ function arrayLiteral(cells: readonly unknown[]): string {
53
+ const item = (v: unknown): string => {
54
+ if (v === null || v === undefined) return 'NULL'
55
+ const text = v instanceof Date ? v.toISOString()
56
+ : typeof v === 'string' ? v
57
+ : typeof v === 'number' || typeof v === 'bigint' || typeof v === 'boolean' ? String(v)
58
+ : JSON.stringify(v)
59
+ return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
60
+ }
61
+ return `{${cells.map(item).join(',')}}`
62
+ }
63
+
64
+ /**
65
+ * A typed rowset from parallel array parameters, as a source for `.from()` and joins:
66
+ * `rows({ g: 'text', id: 'int' }, { g: ['a', 'b'], id: [1, 2] })` renders
67
+ * `(select * from unnest($1::text[], $2::int[]) as "rows"("g", "id")) "rows"`, one parameter per column
68
+ * whatever the row count, so a 5,000-row key list is two parameters. Each column is read by name, like a
69
+ * table's: `rs.g`. Nulls in an array are NULL cells. Zero rows is valid and returns no rows. Each column
70
+ * is sent as one array-literal text parameter, so any element type Postgres can parse works: Dates as ISO text,
71
+ * objects as JSON (for jsonb). Column names are used as written (not snake-cased); the default alias is `rows`, change it with `.as()`.
72
+ * Throws when the columns are empty, a type is not a plain type name, the data and columns name different keys,
73
+ * or the arrays differ in length.
74
+ */
75
+ export function rows<const TColumns extends RowsColumns>(columns: TColumns, data: RowsData<TColumns>): RowsSource<'rows', TColumns> {
76
+ const names = Object.keys(columns)
77
+ if (names.length === 0) fail('the column spec is empty.', "declare at least one column, e.g. { id: 'uuid' }.")
78
+ for (const name of names) {
79
+ if (!COLUMN_NAME.test(name) || name === 'as' || name === '_') {
80
+ fail(`column name "${name}" is not usable.`, 'use letters, digits and underscores, not starting with a digit, and not "as" or "_".')
81
+ }
82
+ const type = columns[name]
83
+ if (typeof type !== 'string' || type === '') fail(`column "${name}" has no type.`, `give it a type name: { ${name}: 'text' }.`)
84
+ if (!CAST.test(type)) fail(`column "${name}" has type "${type}", which is not a plain type name.`, "pass a type name such as 'uuid' or 'int'.")
85
+ }
86
+ const given = Object.keys(data)
87
+ const unknownKeys = given.filter(k => !names.includes(k))
88
+ if (unknownKeys.length > 0) fail(`values for ${unknownKeys.map(k => `"${k}"`).join(', ')} have no column in the spec.`, 'add the column to the spec or drop its values.')
89
+ const missing = names.filter(k => !given.includes(k))
90
+ if (missing.length > 0) fail(`the spec names ${missing.map(k => `"${k}"`).join(', ')} but the data has no values for it.`, 'pass an array for every column, [] for none.')
91
+ const lengths = new Set<number>()
92
+ for (const name of names) {
93
+ const col = (data as Record<string, unknown>)[name]
94
+ if (!Array.isArray(col)) fail(`values for "${name}" are not an array.`, 'pass one array per column.')
95
+ lengths.add(col.length)
96
+ }
97
+ if (lengths.size > 1) {
98
+ const detail = names.map(n => `${n}: ${((data as Record<string, readonly unknown[]>)[n]!).length}`).join(', ')
99
+ fail(`the column arrays differ in length (${detail}).`, 'pass arrays of one length; row i is the i-th element of each.')
100
+ }
101
+
102
+ const build = (alias: string): RowsSource<string, TColumns> => {
103
+ const arrays = names.map(n => {
104
+ return sql`${sql.param(arrayLiteral((data as Record<string, readonly unknown[]>)[n]!))}::${sql.raw(columns[n]!)}[]`
105
+ })
106
+ const nameList = sql.raw(names.map(quote).join(', '))
107
+ const inner = sql`select * from unnest(${sql.join(arrays, sql`, `)}) as ${sql.raw(quote(alias))}(${nameList})`
108
+ const fields: Record<string, SQL> = {}
109
+ for (const n of names) fields[n] = sql.raw(`${quote(alias)}.${quote(n)}`)
110
+ const subquery = new Subquery(inner, fields, alias)
111
+ const selection = new SelectionProxyHandler<Subquery>({ alias, sqlAliasedBehavior: 'alias', sqlBehavior: 'sql' })
112
+ return new Proxy(subquery, {
113
+ get(target, prop) {
114
+ if (prop === 'as') return (next: string) => build(next)
115
+ return selection.get(target, prop)
116
+ },
117
+ }) as unknown as RowsSource<string, TColumns>
118
+ }
119
+ return build('rows') as RowsSource<'rows', TColumns>
120
+ }
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ export type { DB, SQL, Table, Column, TransactionConfig } from './data/db/namesp
9
9
  export type { WindowSpec, WindowFrame, FrameBound } from './data/db/expressions.js'
10
10
  export type { Aggregate, EmptyableAggregate, AggregateOptions, JsonOf } from './data/db/aggregates.js'
11
11
  export type { ValueColumnDef } from './data/db/values.js'
12
+ export type { RowsColumns, RowsData, RowsSource, RowsColumnValue } from './data/db/rows.js'
12
13
  export type { ArraySource, ElementOf, JsonbElements } from './data/db/array-elements.js'
13
14
  export type { FunctionSpec, FunctionSource, FunctionSourceFields } from './data/db/function-source.js'
14
15
  export type { SerializableOptions } from './data/db/serializable.js'
@@ -83,7 +84,7 @@ export type { AuditChainHead, AuditChainBreak, AuditChainBreakReason, AuditVerif
83
84
  export { jobs } from './async/jobs/namespace.js'
84
85
  export { JobAbortedError, JobLaterError, JobPermanentError } from './async/jobs/index.js'
85
86
  export { serializeError } from './async/jobs/serialize-error.js'
86
- export type { ExecuteJobOptions, Queue, QueueConfig, EnqueueOptions, ClaimedJob, JobClaim, JobClaimReason, ReapedJob, FailedAttempt, QueueTotals, QueueTypeTotals, JobResult, BackoffConfig,
87
+ export type { ExecuteJobOptions, Queue, QueueConfig, EnqueueOptions, ClaimedJob, JobClaim, JobClaimReason, ReapedJob, FailedAttempt, FailOptions, QueueTotals, QueueTypeTotals, JobResult, BackoffConfig,
87
88
  WaitOptions, WaitResult, ListenClient, ListenHandle, CronSchedule, ScheduleSpec,
88
89
  ScheduleInfo, SchedulerOptions } from './async/jobs/index.js'
89
90