@primate/postgresql 0.3.0 → 0.5.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/lib/private/ColumnTypes.d.ts +1 -1
- package/lib/private/PostgreSQL.d.ts +48 -0
- package/lib/private/PostgreSQL.js +493 -0
- package/lib/private/typemap.d.ts +1 -1
- package/lib/private/typemap.js +0 -12
- package/lib/public/index.d.ts +2 -2
- package/lib/public/index.js +2 -2
- package/package.json +19 -13
- package/lib/private/Database.d.ts +0 -46
- package/lib/private/Database.js +0 -166
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { As, DataDict, DB, Sort, With } from "@primate/core/db";
|
|
2
|
+
import type { Dict } from "@rcompat/type";
|
|
3
|
+
import type { StoreSchema } from "pema";
|
|
4
|
+
declare const schema: import("pema").ObjectType<{
|
|
5
|
+
database: import("pema").StringType;
|
|
6
|
+
host: import("pema").DefaultType<import("pema").StringType, "localhost">;
|
|
7
|
+
password: import("pema").OptionalType<import("pema").StringType>;
|
|
8
|
+
port: import("pema").DefaultType<import("pema").UintType<"u32">, 5432>;
|
|
9
|
+
username: import("pema").OptionalType<import("pema").StringType>;
|
|
10
|
+
}>;
|
|
11
|
+
export default class PostgreSQL implements DB {
|
|
12
|
+
#private;
|
|
13
|
+
static config: typeof schema.input;
|
|
14
|
+
constructor(config?: typeof schema.input, options?: {
|
|
15
|
+
debug?: boolean;
|
|
16
|
+
});
|
|
17
|
+
close(): Promise<void>;
|
|
18
|
+
get explain(): Dict<{
|
|
19
|
+
query: string;
|
|
20
|
+
plans: string[];
|
|
21
|
+
}>;
|
|
22
|
+
get schema(): {
|
|
23
|
+
create: (as: As, store: StoreSchema) => Promise<void>;
|
|
24
|
+
delete: (name: string) => Promise<void>;
|
|
25
|
+
};
|
|
26
|
+
create<O extends Dict>(as: As, record: Dict): Promise<O>;
|
|
27
|
+
read(as: As, args: {
|
|
28
|
+
count: true;
|
|
29
|
+
where: DataDict;
|
|
30
|
+
with?: never;
|
|
31
|
+
}): Promise<number>;
|
|
32
|
+
read(as: As, args: {
|
|
33
|
+
where: DataDict;
|
|
34
|
+
fields?: string[];
|
|
35
|
+
limit?: number;
|
|
36
|
+
sort?: Sort;
|
|
37
|
+
with?: With;
|
|
38
|
+
}): Promise<Dict[]>;
|
|
39
|
+
update(as: As, args: {
|
|
40
|
+
set: DataDict;
|
|
41
|
+
where: DataDict;
|
|
42
|
+
}): Promise<number>;
|
|
43
|
+
delete(as: As, args: {
|
|
44
|
+
where: DataDict;
|
|
45
|
+
}): Promise<number>;
|
|
46
|
+
}
|
|
47
|
+
export {};
|
|
48
|
+
//# sourceMappingURL=PostgreSQL.d.ts.map
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
import typemap from "#typemap";
|
|
2
|
+
import common from "@primate/core/db";
|
|
3
|
+
import E from "@primate/core/db/error";
|
|
4
|
+
import sql from "@primate/core/db/sql";
|
|
5
|
+
import assert from "@rcompat/assert";
|
|
6
|
+
import is from "@rcompat/is";
|
|
7
|
+
import p from "pema";
|
|
8
|
+
import postgres from "postgres";
|
|
9
|
+
const VALID_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
10
|
+
function Q(strings, ...values) {
|
|
11
|
+
return strings.reduce((result, string, i) => {
|
|
12
|
+
if (i === values.length)
|
|
13
|
+
return result + string;
|
|
14
|
+
const value = values[i];
|
|
15
|
+
let processed;
|
|
16
|
+
if (Array.isArray(value)) {
|
|
17
|
+
processed = value.join(", ");
|
|
18
|
+
}
|
|
19
|
+
else if (typeof value === "string") {
|
|
20
|
+
processed = quote(value);
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
throw "Q: use only strings or arrays";
|
|
24
|
+
}
|
|
25
|
+
return result + string + processed;
|
|
26
|
+
}, "");
|
|
27
|
+
}
|
|
28
|
+
function is_bigint_key(k) {
|
|
29
|
+
return k === "u64" || k === "u128" || k === "i64" || k === "i128";
|
|
30
|
+
}
|
|
31
|
+
function quote(name) {
|
|
32
|
+
if (!VALID_IDENTIFIER.test(name))
|
|
33
|
+
throw E.identifier_invalid(name);
|
|
34
|
+
return `"${name}"`;
|
|
35
|
+
}
|
|
36
|
+
function order_by(types, sort, alias) {
|
|
37
|
+
if (sort === undefined)
|
|
38
|
+
return "";
|
|
39
|
+
const entries = Object.entries(sort);
|
|
40
|
+
if (entries.length === 0)
|
|
41
|
+
return "";
|
|
42
|
+
const parts = entries.map(([k, dir]) => {
|
|
43
|
+
const d = String(dir).toLowerCase();
|
|
44
|
+
const direction = d === "desc" ? "DESC" : "ASC";
|
|
45
|
+
const base = alias !== undefined ? `${alias}.${quote(k)}` : quote(k);
|
|
46
|
+
const datatype = types[k];
|
|
47
|
+
// if bigint-ish might be stored as TEXT/NUMERIC, force numeric ordering
|
|
48
|
+
const expression = is_bigint_key(datatype) ? `(${base})::numeric` : base;
|
|
49
|
+
return `${expression} ${direction}`;
|
|
50
|
+
});
|
|
51
|
+
return ` ORDER BY ${parts.join(", ")}`;
|
|
52
|
+
}
|
|
53
|
+
function get_column(key) {
|
|
54
|
+
return typemap[key].column;
|
|
55
|
+
}
|
|
56
|
+
async function bind_value(key, value) {
|
|
57
|
+
if (value === null)
|
|
58
|
+
return null;
|
|
59
|
+
// typemap.bind may be sync or async; allow both
|
|
60
|
+
return await typemap[key].bind(value);
|
|
61
|
+
}
|
|
62
|
+
function unbind_value(key, value) {
|
|
63
|
+
return typemap[key].unbind(value);
|
|
64
|
+
}
|
|
65
|
+
function unbind(types, row) {
|
|
66
|
+
return sql.unbind(types, row, unbind_value);
|
|
67
|
+
}
|
|
68
|
+
function relation_order(types, sort) {
|
|
69
|
+
if (sort === undefined)
|
|
70
|
+
return "";
|
|
71
|
+
const entries = Object.entries(sort);
|
|
72
|
+
if (entries.length === 0)
|
|
73
|
+
return "";
|
|
74
|
+
return entries.map(([k, dir]) => {
|
|
75
|
+
const direction = dir.toLowerCase() === "desc" ? "DESC" : "ASC";
|
|
76
|
+
const expression = k in types && is_bigint_key(types[k])
|
|
77
|
+
? Q `(${k})::numeric`
|
|
78
|
+
: Q `${k}`;
|
|
79
|
+
return `${expression} ${direction}`;
|
|
80
|
+
}).join(", ");
|
|
81
|
+
}
|
|
82
|
+
const BIND_BY = "$";
|
|
83
|
+
const schema = p({
|
|
84
|
+
database: p.string,
|
|
85
|
+
host: p.string.default("localhost"),
|
|
86
|
+
password: p.string.optional(),
|
|
87
|
+
port: p.uint.port().default(5432),
|
|
88
|
+
username: p.string.optional(),
|
|
89
|
+
});
|
|
90
|
+
export default class PostgreSQL {
|
|
91
|
+
static config;
|
|
92
|
+
#factory;
|
|
93
|
+
#client;
|
|
94
|
+
#debug = false;
|
|
95
|
+
#explain = {};
|
|
96
|
+
constructor(config, options) {
|
|
97
|
+
const parsed = schema.parse(config);
|
|
98
|
+
this.#factory = () => postgres({
|
|
99
|
+
db: parsed.database,
|
|
100
|
+
host: parsed.host,
|
|
101
|
+
port: parsed.port,
|
|
102
|
+
user: parsed.username,
|
|
103
|
+
pass: parsed.password,
|
|
104
|
+
});
|
|
105
|
+
this.#debug = options?.debug ?? false;
|
|
106
|
+
}
|
|
107
|
+
get #db() {
|
|
108
|
+
return (this.#client ??= this.#factory());
|
|
109
|
+
}
|
|
110
|
+
async #sql(q, params) {
|
|
111
|
+
return await this.#db.unsafe(q, params);
|
|
112
|
+
}
|
|
113
|
+
async #capture(name, query, params) {
|
|
114
|
+
if (!this.#debug)
|
|
115
|
+
return;
|
|
116
|
+
// Keep stored query exactly as executed (like SQLite)
|
|
117
|
+
// but EXPLAIN must not end with a semicolon.
|
|
118
|
+
const explain_target = query.trim().replace(/;+\s*$/, "");
|
|
119
|
+
const rows = await this.#sql(`EXPLAIN ${explain_target}`, params);
|
|
120
|
+
const plans = rows.map(r => r["QUERY PLAN"]);
|
|
121
|
+
this.#explain[name] = { query, plans };
|
|
122
|
+
}
|
|
123
|
+
async close() {
|
|
124
|
+
await this.#db.end();
|
|
125
|
+
}
|
|
126
|
+
get explain() {
|
|
127
|
+
return this.#explain;
|
|
128
|
+
}
|
|
129
|
+
async #where(as, where, index = 1, alias) {
|
|
130
|
+
const keys = Object.keys(where);
|
|
131
|
+
if (keys.length === 0)
|
|
132
|
+
return { WHERE: "", params: [], next_index: index };
|
|
133
|
+
const parts = [];
|
|
134
|
+
const params = [];
|
|
135
|
+
let i = index;
|
|
136
|
+
for (const field of keys) {
|
|
137
|
+
const raw = where[field];
|
|
138
|
+
const datatype = as.types[field];
|
|
139
|
+
const base = alias ? `${alias}.${quote(field)}` : quote(field);
|
|
140
|
+
if (raw === null) {
|
|
141
|
+
parts.push(`${base} IS NULL`);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
// operator object
|
|
145
|
+
if (is.dict(raw)) {
|
|
146
|
+
const ops = Object.entries(raw);
|
|
147
|
+
if (ops.length === 0)
|
|
148
|
+
throw E.operator_empty(field);
|
|
149
|
+
for (const [op, op_value] of ops) {
|
|
150
|
+
const ph = `$${i++}`;
|
|
151
|
+
// bigint comparisons must be numeric, regardless of storage.
|
|
152
|
+
const numeric = is_bigint_key(datatype);
|
|
153
|
+
const lhs = numeric ? `(${base})::numeric` : base;
|
|
154
|
+
const rhs = numeric ? `${ph}::numeric` : ph;
|
|
155
|
+
switch (op) {
|
|
156
|
+
case "$like":
|
|
157
|
+
parts.push(`${base} LIKE ${ph}`);
|
|
158
|
+
params.push(await bind_value(datatype, op_value));
|
|
159
|
+
break;
|
|
160
|
+
case "$ilike":
|
|
161
|
+
parts.push(`${base} ILIKE ${ph}`);
|
|
162
|
+
params.push(await bind_value(datatype, op_value));
|
|
163
|
+
break;
|
|
164
|
+
case "$ne":
|
|
165
|
+
parts.push(`${lhs} != ${rhs}`);
|
|
166
|
+
params.push(await bind_value(datatype, op_value));
|
|
167
|
+
break;
|
|
168
|
+
case "$gt":
|
|
169
|
+
case "$gte":
|
|
170
|
+
case "$lt":
|
|
171
|
+
case "$lte":
|
|
172
|
+
case "$after":
|
|
173
|
+
case "$before": {
|
|
174
|
+
const sql_op = sql.OPS[op];
|
|
175
|
+
parts.push(`${lhs} ${sql_op} ${rhs}`);
|
|
176
|
+
params.push(await bind_value(datatype, op_value));
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
default:
|
|
180
|
+
throw E.operator_unknown(field, op);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
// scalar equality
|
|
186
|
+
const ph = `$${i++}`;
|
|
187
|
+
parts.push(`${base} = ${ph}`);
|
|
188
|
+
params.push(await bind_value(datatype, raw));
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
WHERE: `WHERE ${parts.join(" AND ")}`,
|
|
192
|
+
params,
|
|
193
|
+
next_index: i,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
get schema() {
|
|
197
|
+
return {
|
|
198
|
+
create: async (as, store) => {
|
|
199
|
+
const columns = [];
|
|
200
|
+
for (const [key, value] of Object.entries(store)) {
|
|
201
|
+
const column_type = get_column(value.datatype);
|
|
202
|
+
const column = quote(key);
|
|
203
|
+
if (key === as.pk) {
|
|
204
|
+
const is_int = ["INTEGER", "BIGINT"].includes(column_type);
|
|
205
|
+
if (as.generate_pk && is_int) {
|
|
206
|
+
const serial = column_type === "BIGINT" ? "BIGSERIAL" : "SERIAL";
|
|
207
|
+
columns.push(`${column} ${serial} PRIMARY KEY`);
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
columns.push(`${column} ${column_type} PRIMARY KEY`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
columns.push(`${column} ${column_type}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
await this.#sql(Q `CREATE TABLE IF NOT EXISTS ${as.table} (${columns})`);
|
|
218
|
+
},
|
|
219
|
+
delete: async (name) => {
|
|
220
|
+
await this.#sql(Q `DROP TABLE IF EXISTS ${name}`);
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
async #generate_pk(as) {
|
|
225
|
+
const pk = as.pk;
|
|
226
|
+
const type = as.types[pk];
|
|
227
|
+
if (type === "string")
|
|
228
|
+
return crypto.randomUUID();
|
|
229
|
+
if (common.BIGINT_STRING_TYPES.includes(type)) {
|
|
230
|
+
const q = Q `SELECT MAX((${pk})::numeric)::text AS v FROM ${as.table}`;
|
|
231
|
+
const rows = await this.#sql(q);
|
|
232
|
+
return rows[0]?.v ? BigInt(rows[0].v) + 1n : 1n;
|
|
233
|
+
}
|
|
234
|
+
throw "unreachable";
|
|
235
|
+
}
|
|
236
|
+
#create(record) {
|
|
237
|
+
const fields = Object.keys(record);
|
|
238
|
+
return [
|
|
239
|
+
fields.map(quote),
|
|
240
|
+
fields.map((_, i) => `${BIND_BY}${i + 1}`),
|
|
241
|
+
];
|
|
242
|
+
}
|
|
243
|
+
async #create_params(as, record) {
|
|
244
|
+
const fields = Object.keys(record);
|
|
245
|
+
const params = [];
|
|
246
|
+
for (const field of fields) {
|
|
247
|
+
params.push(await bind_value(as.types[field], record[field]));
|
|
248
|
+
}
|
|
249
|
+
return params;
|
|
250
|
+
}
|
|
251
|
+
async create(as, record) {
|
|
252
|
+
assert.dict(record);
|
|
253
|
+
const pk = as.pk;
|
|
254
|
+
const has_values = Object.keys(record).length > 0;
|
|
255
|
+
const table = as.table;
|
|
256
|
+
// PK provided or none defined, simple insert
|
|
257
|
+
if (pk === null || pk in record) {
|
|
258
|
+
if (!has_values) {
|
|
259
|
+
await this.#sql(Q `INSERT INTO ${table} DEFAULT VALUES`);
|
|
260
|
+
return record;
|
|
261
|
+
}
|
|
262
|
+
const [keys, values] = this.#create(record);
|
|
263
|
+
const params = await this.#create_params(as, record);
|
|
264
|
+
const q = Q `INSERT INTO ${table} (${keys}) VALUES (${values})`;
|
|
265
|
+
await this.#sql(q, params);
|
|
266
|
+
return record;
|
|
267
|
+
}
|
|
268
|
+
// PK missing
|
|
269
|
+
if (as.generate_pk === false)
|
|
270
|
+
throw E.pk_required(pk);
|
|
271
|
+
const type = as.types[pk];
|
|
272
|
+
// integer types, use RETURNING
|
|
273
|
+
if (!is_bigint_key(type) && type !== "string") {
|
|
274
|
+
const [keys, values] = this.#create(record);
|
|
275
|
+
const params = await this.#create_params(as, record);
|
|
276
|
+
const q = has_values
|
|
277
|
+
? Q `INSERT INTO ${table} (${keys}) VALUES (${values}) RETURNING ${pk}`
|
|
278
|
+
: Q `INSERT INTO ${table} DEFAULT VALUES RETURNING ${pk}`;
|
|
279
|
+
const rows = await this.#sql(q, params);
|
|
280
|
+
const pk_value = unbind_value(type, rows[0][pk]);
|
|
281
|
+
return { ...record, [pk]: pk_value };
|
|
282
|
+
}
|
|
283
|
+
// string or bigint, generate manually
|
|
284
|
+
const pk_value = await this.#generate_pk(as);
|
|
285
|
+
const to_insert = { ...record, [pk]: pk_value };
|
|
286
|
+
const [keys, values] = this.#create(to_insert);
|
|
287
|
+
const params = await this.#create_params(as, to_insert);
|
|
288
|
+
const q = Q `INSERT INTO ${table} (${keys}) VALUES (${values})`;
|
|
289
|
+
await this.#sql(q, params);
|
|
290
|
+
return to_insert;
|
|
291
|
+
}
|
|
292
|
+
async read(as, args) {
|
|
293
|
+
assert.dict(args.where);
|
|
294
|
+
this.#explain = {};
|
|
295
|
+
if (args.count === true)
|
|
296
|
+
return this.#count(as, args.where);
|
|
297
|
+
if (common.withed(args)) {
|
|
298
|
+
return sql.joinable(as, args.with)
|
|
299
|
+
? this.#read_joined(as, args)
|
|
300
|
+
: this.#read_phased(as, args);
|
|
301
|
+
}
|
|
302
|
+
return this.#read(as, args);
|
|
303
|
+
}
|
|
304
|
+
async #count(as, where) {
|
|
305
|
+
const { WHERE, params } = await this.#where(as, where);
|
|
306
|
+
const q = `SELECT COUNT(*)::text AS n FROM ${quote(as.table)} ${WHERE}`;
|
|
307
|
+
const rows = await this.#sql(q, params);
|
|
308
|
+
await this.#capture(as.table, q, params);
|
|
309
|
+
return Number(rows[0]?.n ?? 0);
|
|
310
|
+
}
|
|
311
|
+
async #base_query(as, args) {
|
|
312
|
+
const SELECT = args.fields === undefined
|
|
313
|
+
? "*"
|
|
314
|
+
: args.fields.map(quote).join(", ");
|
|
315
|
+
const table = quote(as.table);
|
|
316
|
+
const { WHERE, params } = await this.#where(as, args.where);
|
|
317
|
+
const ORDER_BY = order_by(as.types, args.sort);
|
|
318
|
+
const LIMIT = sql.limit(args.limit);
|
|
319
|
+
return {
|
|
320
|
+
query: `SELECT ${SELECT} FROM ${table} ${WHERE}${ORDER_BY}${LIMIT}`,
|
|
321
|
+
params,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
async #read_joined(as, args) {
|
|
325
|
+
if (as.pk === null)
|
|
326
|
+
throw E.relation_requires_pk("parent");
|
|
327
|
+
const [[, relation]] = Object.entries(args.with);
|
|
328
|
+
if (relation.as.pk === null)
|
|
329
|
+
throw E.relation_requires_pk("target");
|
|
330
|
+
const aliases = sql.aliases([as.table, relation.as.table]);
|
|
331
|
+
const alias = aliases[as.table];
|
|
332
|
+
const r_alias = aliases[relation.as.table];
|
|
333
|
+
const fields = common.fields(args.fields, as.pk) ?? Object.keys(as.types);
|
|
334
|
+
const r_fields = common.fields(relation.fields, relation.fk, relation.as.pk)
|
|
335
|
+
?? Object.keys(relation.as.types);
|
|
336
|
+
const SELECT = [
|
|
337
|
+
...fields.map(f => `${alias}.${quote(f)} AS ${alias}_${f}`),
|
|
338
|
+
...r_fields.map(f => `${r_alias}.${quote(f)} AS ${r_alias}_${f}`),
|
|
339
|
+
].join(", ");
|
|
340
|
+
const { query, params } = await this.#base_query(as, { ...args, fields });
|
|
341
|
+
const JOIN = `LEFT JOIN ${quote(relation.as.table)} ${r_alias}
|
|
342
|
+
ON ${r_alias}.${quote(relation.fk)} = ${alias}.${quote(as.pk)}`;
|
|
343
|
+
const q = `SELECT ${SELECT} FROM (${query}) ${alias}
|
|
344
|
+
${JOIN}${order_by(as.types, args.sort, alias)}`;
|
|
345
|
+
const rows = await this.#sql(q, params);
|
|
346
|
+
await this.#capture(as.table, q, params);
|
|
347
|
+
return sql.nest(as, { rows, aliases }, args, unbind_value);
|
|
348
|
+
}
|
|
349
|
+
async #read(as, args) {
|
|
350
|
+
const { query, params } = await this.#base_query(as, args);
|
|
351
|
+
const rows = await this.#sql(query, params);
|
|
352
|
+
await this.#capture(as.table, query, params);
|
|
353
|
+
return rows.map(r => unbind(as.types, r));
|
|
354
|
+
}
|
|
355
|
+
async #read_phased(as, args) {
|
|
356
|
+
const fields = common.expand(as, args.fields, args.with);
|
|
357
|
+
const rows = await this.#read(as, { ...args, fields });
|
|
358
|
+
const out = rows.map(row => common.project(row, args.fields));
|
|
359
|
+
for (const [table, relation] of Object.entries(args.with)) {
|
|
360
|
+
await this.#attach_relation(as, { rows, out, table, relation });
|
|
361
|
+
}
|
|
362
|
+
return out;
|
|
363
|
+
}
|
|
364
|
+
async #attach_relation(as, args) {
|
|
365
|
+
const relation = args.relation;
|
|
366
|
+
const by = relation.reverse ? relation.as.pk : relation.fk;
|
|
367
|
+
if (by === null)
|
|
368
|
+
throw E.relation_requires_pk("target");
|
|
369
|
+
const parent_by = relation.reverse ? relation.fk : as.pk;
|
|
370
|
+
if (parent_by === null)
|
|
371
|
+
throw E.relation_requires_pk("parent");
|
|
372
|
+
const join_values = [...new Set(args.rows.map(r => r[parent_by]).filter(v => v != null))];
|
|
373
|
+
const is_many = relation.kind === "many";
|
|
374
|
+
const many = is_many ? [] : null;
|
|
375
|
+
if (join_values.length === 0) {
|
|
376
|
+
for (const row of args.out)
|
|
377
|
+
row[args.table] = many;
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const related = await this.#load_related({ by, join_values, ...relation });
|
|
381
|
+
const grouped = new Map();
|
|
382
|
+
for (const row of related) {
|
|
383
|
+
const key = row[by];
|
|
384
|
+
if (key === null)
|
|
385
|
+
continue;
|
|
386
|
+
if (!grouped.has(key))
|
|
387
|
+
grouped.set(key, []);
|
|
388
|
+
grouped.get(key).push(row);
|
|
389
|
+
}
|
|
390
|
+
for (let i = 0; i < args.out.length; i++) {
|
|
391
|
+
const join_value = args.rows[i][parent_by];
|
|
392
|
+
if (join_value == null) {
|
|
393
|
+
args.out[i][args.table] = is_many ? [] : null;
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
const rows = grouped.get(join_value) ?? [];
|
|
397
|
+
args.out[i][args.table] = is_many
|
|
398
|
+
? rows.map(r => common.project(r, relation.fields))
|
|
399
|
+
: rows[0] ? common.project(rows[0], relation.fields) : null;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
async #load_related(args) {
|
|
403
|
+
const per_parent = args.kind === "one" ? 1 : args.limit;
|
|
404
|
+
// build WHERE, excluding the IN clause
|
|
405
|
+
const { WHERE: _where, params: where_params, next_index } = await this.#where(args.as, args.where);
|
|
406
|
+
const where_part = _where.length > 0 ? _where.slice("WHERE ".length) : "";
|
|
407
|
+
// build IN (...) placeholders and binds
|
|
408
|
+
const datatype = args.as.types[args.by];
|
|
409
|
+
const numeric = is_bigint_key(datatype);
|
|
410
|
+
let next_i = next_index;
|
|
411
|
+
const in_placeholders = [];
|
|
412
|
+
const in_params = [];
|
|
413
|
+
for (const v of args.join_values) {
|
|
414
|
+
const ph = `$${next_i++}`;
|
|
415
|
+
in_placeholders.push(numeric ? `${ph}::numeric` : ph);
|
|
416
|
+
in_params.push(await bind_value(datatype, v));
|
|
417
|
+
}
|
|
418
|
+
const lhs = numeric ? `(${quote(args.by)})::numeric` : quote(args.by);
|
|
419
|
+
const in_part = `${lhs} IN (${in_placeholders.join(", ")})`;
|
|
420
|
+
const WHERE = where_part
|
|
421
|
+
? `WHERE (${where_part}) AND (${in_part})`
|
|
422
|
+
: `WHERE ${in_part}`;
|
|
423
|
+
// SELECT fields, must include `by` for grouping
|
|
424
|
+
const all_columns = Object.keys(args.as.types);
|
|
425
|
+
const fields = args.fields !== undefined && args.fields.length > 0
|
|
426
|
+
? args.fields
|
|
427
|
+
: all_columns;
|
|
428
|
+
const select_fields = [...new Set([...fields, args.by])];
|
|
429
|
+
const SELECT = select_fields.map(quote).join(", ");
|
|
430
|
+
// relation sort, used both for row_number ranking and final ordering
|
|
431
|
+
const user_order = relation_order(args.as.types, args.sort);
|
|
432
|
+
const base_order = numeric
|
|
433
|
+
? `(${quote(args.by)})::numeric ASC`
|
|
434
|
+
: `${quote(args.by)} ASC`;
|
|
435
|
+
const ORDER_BY = user_order
|
|
436
|
+
? ` ORDER BY ${base_order}, ${user_order}`
|
|
437
|
+
: ` ORDER BY ${base_order}`;
|
|
438
|
+
let q;
|
|
439
|
+
const table = quote(args.as.table);
|
|
440
|
+
if (per_parent !== undefined) {
|
|
441
|
+
const rn_order = user_order ? ` ORDER BY ${user_order}` : "";
|
|
442
|
+
q = `
|
|
443
|
+
WITH ranked AS (
|
|
444
|
+
SELECT
|
|
445
|
+
${SELECT},
|
|
446
|
+
ROW_NUMBER() OVER (
|
|
447
|
+
PARTITION BY ${numeric ? Q `(${args.by})::numeric` : Q `${args.by}`}
|
|
448
|
+
${rn_order}
|
|
449
|
+
) AS __rn
|
|
450
|
+
FROM ${table}
|
|
451
|
+
${WHERE}
|
|
452
|
+
)
|
|
453
|
+
SELECT ${SELECT}
|
|
454
|
+
FROM ranked
|
|
455
|
+
WHERE __rn <= ${per_parent}
|
|
456
|
+
${ORDER_BY}
|
|
457
|
+
`;
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
q = `SELECT ${SELECT} FROM ${table} ${WHERE}${ORDER_BY}`;
|
|
461
|
+
}
|
|
462
|
+
const params = [...where_params, ...in_params];
|
|
463
|
+
const rows = await this.#sql(q, params);
|
|
464
|
+
await this.#capture(args.as.table, q, params);
|
|
465
|
+
return rows.map(r => unbind(args.as.types, r));
|
|
466
|
+
}
|
|
467
|
+
async update(as, args) {
|
|
468
|
+
assert.nonempty(args.set);
|
|
469
|
+
assert.dict(args.where);
|
|
470
|
+
const set_keys = Object.keys(args.set);
|
|
471
|
+
const set_parts = [];
|
|
472
|
+
const set_params = [];
|
|
473
|
+
let i = 1;
|
|
474
|
+
for (const k of set_keys) {
|
|
475
|
+
const ph = `$${i++}`;
|
|
476
|
+
set_parts.push(`${quote(k)} = ${ph}`);
|
|
477
|
+
set_params.push(await bind_value(as.types[k], args.set[k]));
|
|
478
|
+
}
|
|
479
|
+
const SET = `SET ${set_parts.join(", ")}`;
|
|
480
|
+
const { WHERE, params } = await this.#where(as, args.where, i);
|
|
481
|
+
const q = `UPDATE ${quote(as.table)} ${SET} ${WHERE} RETURNING 1`;
|
|
482
|
+
const rows = await this.#sql(q, [...set_params, ...params]);
|
|
483
|
+
return rows.length;
|
|
484
|
+
}
|
|
485
|
+
async delete(as, args) {
|
|
486
|
+
assert.nonempty(args.where);
|
|
487
|
+
const { WHERE, params } = await this.#where(as, args.where);
|
|
488
|
+
const q = `DELETE FROM ${quote(as.table)} ${WHERE} RETURNING 1`;
|
|
489
|
+
const rows = await this.#sql(q, params);
|
|
490
|
+
return rows.length;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
//# sourceMappingURL=PostgreSQL.js.map
|
package/lib/private/typemap.d.ts
CHANGED
package/lib/private/typemap.js
CHANGED
|
@@ -48,18 +48,6 @@ const typemap = {
|
|
|
48
48
|
},
|
|
49
49
|
},
|
|
50
50
|
i8: number("SMALLINT"),
|
|
51
|
-
primary: {
|
|
52
|
-
bind(value) {
|
|
53
|
-
if (typeof value === "string" && Number.isInteger(+value)) {
|
|
54
|
-
return Number(value);
|
|
55
|
-
}
|
|
56
|
-
throw new Error(`\`${value}\` is not a valid primary key value`);
|
|
57
|
-
},
|
|
58
|
-
column: "SERIAL PRIMARY KEY",
|
|
59
|
-
unbind(value) {
|
|
60
|
-
return String(value);
|
|
61
|
-
},
|
|
62
|
-
},
|
|
63
51
|
string: identity("TEXT"),
|
|
64
52
|
time: identity("TIME"),
|
|
65
53
|
u128: {
|
package/lib/public/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
2
|
-
declare const _default: (config: typeof
|
|
1
|
+
import PostgreSQL from "#PostgreSQL";
|
|
2
|
+
declare const _default: (config: typeof PostgreSQL.config) => PostgreSQL;
|
|
3
3
|
export default _default;
|
|
4
4
|
//# sourceMappingURL=index.d.ts.map
|
package/lib/public/index.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import
|
|
2
|
-
export default (config) => new
|
|
1
|
+
import PostgreSQL from "#PostgreSQL";
|
|
2
|
+
export default (config) => new PostgreSQL(config);
|
|
3
3
|
//# sourceMappingURL=index.js.map
|
package/package.json
CHANGED
|
@@ -1,28 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@primate/postgresql",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "PostgreSQL databases for Primate",
|
|
5
5
|
"homepage": "https://primate.run/docs/database/postgresql",
|
|
6
6
|
"bugs": "https://github.com/primate-run/primate/issues",
|
|
7
|
+
"type": "module",
|
|
7
8
|
"license": "MIT",
|
|
8
|
-
"files": [
|
|
9
|
-
"/lib/**/*.js",
|
|
10
|
-
"/lib/**/*.d.ts",
|
|
11
|
-
"!/**/*.spec.*"
|
|
12
|
-
],
|
|
13
9
|
"repository": {
|
|
14
10
|
"type": "git",
|
|
15
11
|
"url": "https://github.com/primate-run/primate",
|
|
16
12
|
"directory": "packages/postgresql"
|
|
17
13
|
},
|
|
14
|
+
"files": [
|
|
15
|
+
"/lib/**/*.js",
|
|
16
|
+
"/lib/**/*.d.ts",
|
|
17
|
+
"!/**/*.spec.*"
|
|
18
|
+
],
|
|
18
19
|
"dependencies": {
|
|
19
|
-
"@rcompat/assert": "^0.
|
|
20
|
-
"@rcompat/
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
20
|
+
"@rcompat/assert": "^0.6.0",
|
|
21
|
+
"@rcompat/is": "^0.4.2",
|
|
22
|
+
"postgres": "^3.4.8",
|
|
23
|
+
"@primate/core": "^0.5.0",
|
|
24
|
+
"pema": "0.5.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@rcompat/type": "^0.9.0"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"primate": "^0.36.0"
|
|
24
31
|
},
|
|
25
|
-
"type": "module",
|
|
26
32
|
"imports": {
|
|
27
33
|
"#*": {
|
|
28
34
|
"apekit": "./src/private/*.ts",
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import Database from "@primate/core/Database";
|
|
2
|
-
import type As from "@primate/core/database/As";
|
|
3
|
-
import type DataDict from "@primate/core/database/DataDict";
|
|
4
|
-
import type TypeMap from "@primate/core/database/TypeMap";
|
|
5
|
-
import type Dict from "@rcompat/type/Dict";
|
|
6
|
-
import type StoreSchema from "pema/StoreSchema";
|
|
7
|
-
declare const schema: import("pema").ObjectType<{
|
|
8
|
-
database: import("pema/string").StringType;
|
|
9
|
-
host: import("pema").DefaultType<import("pema/string").StringType, "localhost">;
|
|
10
|
-
password: import("pema").OptionalType<import("pema/string").StringType>;
|
|
11
|
-
port: import("pema").DefaultType<import("pema/uint").UintType<"u32">, 5432>;
|
|
12
|
-
username: import("pema").OptionalType<import("pema/string").StringType>;
|
|
13
|
-
}>;
|
|
14
|
-
export default class PostgreSQLDatabase extends Database {
|
|
15
|
-
#private;
|
|
16
|
-
static config: typeof schema.input;
|
|
17
|
-
constructor(config?: typeof schema.input);
|
|
18
|
-
get typemap(): TypeMap<Dict>;
|
|
19
|
-
close(): Promise<void>;
|
|
20
|
-
get schema(): {
|
|
21
|
-
create: (name: string, store: StoreSchema) => Promise<void>;
|
|
22
|
-
delete: (name: string) => Promise<void>;
|
|
23
|
-
};
|
|
24
|
-
create<O extends Dict>(as: As, args: {
|
|
25
|
-
record: DataDict;
|
|
26
|
-
}): Promise<O>;
|
|
27
|
-
read(as: As, args: {
|
|
28
|
-
count: true;
|
|
29
|
-
criteria: DataDict;
|
|
30
|
-
}): Promise<number>;
|
|
31
|
-
read(as: As, args: {
|
|
32
|
-
criteria: DataDict;
|
|
33
|
-
fields?: string[];
|
|
34
|
-
limit?: number;
|
|
35
|
-
sort?: Dict<"asc" | "desc">;
|
|
36
|
-
}): Promise<Dict[]>;
|
|
37
|
-
update(as: As, args: {
|
|
38
|
-
changes: DataDict;
|
|
39
|
-
criteria: DataDict;
|
|
40
|
-
}): Promise<any>;
|
|
41
|
-
delete(as: As, args: {
|
|
42
|
-
criteria: DataDict;
|
|
43
|
-
}): Promise<any>;
|
|
44
|
-
}
|
|
45
|
-
export {};
|
|
46
|
-
//# sourceMappingURL=Database.d.ts.map
|
package/lib/private/Database.js
DELETED
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
import typemap from "#typemap";
|
|
2
|
-
import Database from "@primate/core/Database";
|
|
3
|
-
import assert from "@rcompat/assert";
|
|
4
|
-
import maybe from "@rcompat/assert/maybe";
|
|
5
|
-
import pema from "pema";
|
|
6
|
-
import string from "pema/string";
|
|
7
|
-
import uint from "pema/uint";
|
|
8
|
-
import postgres from "postgres";
|
|
9
|
-
const schema = pema({
|
|
10
|
-
database: string,
|
|
11
|
-
host: string.default("localhost"),
|
|
12
|
-
password: string.optional(),
|
|
13
|
-
port: uint.port().default(5432),
|
|
14
|
-
username: string.optional(),
|
|
15
|
-
});
|
|
16
|
-
export default class PostgreSQLDatabase extends Database {
|
|
17
|
-
static config;
|
|
18
|
-
#factory;
|
|
19
|
-
#client;
|
|
20
|
-
constructor(config) {
|
|
21
|
-
super();
|
|
22
|
-
const parsed = schema.parse(config);
|
|
23
|
-
this.#factory = () => postgres({
|
|
24
|
-
db: parsed.database,
|
|
25
|
-
host: parsed.host,
|
|
26
|
-
pass: parsed.password,
|
|
27
|
-
port: parsed.port,
|
|
28
|
-
user: parsed.username,
|
|
29
|
-
});
|
|
30
|
-
}
|
|
31
|
-
#sql() {
|
|
32
|
-
if (this.#client === undefined) {
|
|
33
|
-
this.#client = this.#factory();
|
|
34
|
-
}
|
|
35
|
-
return this.#client;
|
|
36
|
-
}
|
|
37
|
-
#join(parts, sep) {
|
|
38
|
-
const sql = this.#sql();
|
|
39
|
-
if (parts.length === 0)
|
|
40
|
-
return sql ``;
|
|
41
|
-
return parts.slice(1).reduce((acc, p) => sql `${acc}${sep}${p}`, parts[0]);
|
|
42
|
-
}
|
|
43
|
-
async #new(name, store) {
|
|
44
|
-
const sql = this.#sql();
|
|
45
|
-
const table = sql(name);
|
|
46
|
-
const body = Object.entries(store).map(([k, v]) => sql `${sql(k)} ${sql.unsafe(this.column(v.datatype))}`);
|
|
47
|
-
await sql `CREATE TABLE IF NOT EXISTS ${table}
|
|
48
|
-
(${this.#join(body, sql `, `)})`;
|
|
49
|
-
}
|
|
50
|
-
async #drop(name) {
|
|
51
|
-
const sql = this.#sql();
|
|
52
|
-
await sql `DROP TABLE IF EXISTS ${sql(name)};`;
|
|
53
|
-
}
|
|
54
|
-
get typemap() {
|
|
55
|
-
return typemap;
|
|
56
|
-
}
|
|
57
|
-
async close() {
|
|
58
|
-
await this.#sql().end();
|
|
59
|
-
}
|
|
60
|
-
get schema() {
|
|
61
|
-
return {
|
|
62
|
-
create: this.#new.bind(this),
|
|
63
|
-
delete: this.#drop.bind(this),
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
async create(as, args) {
|
|
67
|
-
const sql = this.#sql();
|
|
68
|
-
const columns = Object.keys(args.record);
|
|
69
|
-
const binds = await this.bind(as.types, args.record);
|
|
70
|
-
const [result] = await sql `INSERT INTO
|
|
71
|
-
${sql(as.name)}
|
|
72
|
-
${columns.length > 0
|
|
73
|
-
? sql `(${sql(columns)}) VALUES ${sql(binds)}`
|
|
74
|
-
: sql.unsafe("DEFAULT VALUES")}
|
|
75
|
-
RETURNING id;
|
|
76
|
-
`;
|
|
77
|
-
return this.unbind(as.types, { ...args.record, id: result.id });
|
|
78
|
-
}
|
|
79
|
-
#sort(types, sort) {
|
|
80
|
-
maybe(sort).object();
|
|
81
|
-
// validate
|
|
82
|
-
this.toSort(types, sort);
|
|
83
|
-
const sql = this.#sql();
|
|
84
|
-
if (!sort)
|
|
85
|
-
return sql ``;
|
|
86
|
-
const entries = Object.entries(sort);
|
|
87
|
-
if (entries.length === 0)
|
|
88
|
-
return sql ``;
|
|
89
|
-
const items = entries.map(([field, direction]) => sql `${sql(field)} ${sql.unsafe(direction.toUpperCase())}`);
|
|
90
|
-
return sql ` ORDER BY ${this.#join(items, sql `, `)}`;
|
|
91
|
-
}
|
|
92
|
-
#select(types, fields) {
|
|
93
|
-
// validate
|
|
94
|
-
this.toSelect(types, fields);
|
|
95
|
-
const sql = this.#sql();
|
|
96
|
-
if (fields === undefined)
|
|
97
|
-
return sql.unsafe("*");
|
|
98
|
-
return sql(fields);
|
|
99
|
-
}
|
|
100
|
-
#limit(limit) {
|
|
101
|
-
maybe(limit).usize();
|
|
102
|
-
const sql = this.#sql();
|
|
103
|
-
return limit === undefined ? sql `` : sql ` LIMIT ${limit}`;
|
|
104
|
-
}
|
|
105
|
-
#where(types, criteria, nonnull) {
|
|
106
|
-
this.toWhere(types, criteria); // validate
|
|
107
|
-
const sql = this.#sql();
|
|
108
|
-
const entries = Object.entries(criteria);
|
|
109
|
-
if (entries.length === 0)
|
|
110
|
-
return sql ``;
|
|
111
|
-
const clauses = entries.map(([key, raw]) => {
|
|
112
|
-
if (raw === null)
|
|
113
|
-
return sql `${sql(key)} IS NULL`;
|
|
114
|
-
const value = nonnull[key];
|
|
115
|
-
// Handle operator objects
|
|
116
|
-
if (typeof raw === "object") {
|
|
117
|
-
if ("$like" in raw)
|
|
118
|
-
return sql `${sql(key)} LIKE ${value}`;
|
|
119
|
-
// if ("$gte" in raw) return sql`${sql(key)} >= ${nonnull[key]}`;
|
|
120
|
-
}
|
|
121
|
-
return sql `${sql(key)} = ${value}`;
|
|
122
|
-
});
|
|
123
|
-
return sql `WHERE ${this.#join(clauses, sql ` AND `)}`;
|
|
124
|
-
}
|
|
125
|
-
async read(as, args) {
|
|
126
|
-
const sql = this.#sql();
|
|
127
|
-
const table = sql(as.name);
|
|
128
|
-
const criteria = await this.bindCriteria(as.types, args.criteria);
|
|
129
|
-
const where = this.#where(as.types, args.criteria, criteria);
|
|
130
|
-
if (args.count === true) {
|
|
131
|
-
const [{ n }] = await sql `SELECT COUNT(*) AS n FROM ${table} ${where}`;
|
|
132
|
-
return Number(n);
|
|
133
|
-
}
|
|
134
|
-
const sort = this.#sort(as.types, args.sort);
|
|
135
|
-
const limit = this.#limit(args.limit);
|
|
136
|
-
const select = this.#select(as.types, args.fields);
|
|
137
|
-
const records = await sql `
|
|
138
|
-
SELECT ${select}
|
|
139
|
-
FROM ${table}
|
|
140
|
-
${where}
|
|
141
|
-
${sort}
|
|
142
|
-
${limit}
|
|
143
|
-
`;
|
|
144
|
-
return records.map(record => this.unbind(as.types, record));
|
|
145
|
-
}
|
|
146
|
-
async update(as, args) {
|
|
147
|
-
assert(Object.keys(args.criteria).length > 0, "update: no criteria");
|
|
148
|
-
const sql = this.#sql();
|
|
149
|
-
const table = sql(as.name);
|
|
150
|
-
const criteria = await this.bindCriteria(as.types, args.criteria);
|
|
151
|
-
const set_binds = await this.bind(as.types, args.changes);
|
|
152
|
-
const where = this.#where(as.types, args.criteria, criteria);
|
|
153
|
-
const set = sql({ ...set_binds });
|
|
154
|
-
return (await sql `UPDATE ${table} SET ${set} ${where} RETURNING 1;`).length;
|
|
155
|
-
}
|
|
156
|
-
async delete(as, args) {
|
|
157
|
-
assert(Object.keys(args.criteria).length > 0, "delete: no criteria");
|
|
158
|
-
const sql = this.#sql();
|
|
159
|
-
const criteria = await this.bindCriteria(as.types, args.criteria);
|
|
160
|
-
const where = this.#where(as.types, args.criteria, criteria);
|
|
161
|
-
const table = sql(as.name);
|
|
162
|
-
return (await sql `DELETE FROM ${table} ${where} RETURNING 1;`).length;
|
|
163
|
-
}
|
|
164
|
-
;
|
|
165
|
-
}
|
|
166
|
-
//# sourceMappingURL=Database.js.map
|