UNPKG

shelving

Version:

Toolkit for using data in JavaScript.

192 lines (191 loc) 8.65 kB
import { RequiredError } from "../../error/RequiredError.js"; import { UnimplementedError } from "../../error/UnimplementedError.js"; import { getQueryFilters, getQueryLimit, getQueryOrders } from "../../util/query.js"; import { getUpdates } from "../../util/update.js"; import { DBProvider } from "./DBProvider.js"; /** Shared SQL execution and CRUD/query behavior. */ export class SQLProvider extends DBProvider { async getItem(collection, id) { const rows = await this.exec ` SELECT * FROM ${this.sqlIdentifier(collection.name)} WHERE ${this.sqlIdentifier("id")} = ${id} LIMIT 1 `; return rows[0]; } getItemSequence(_collection, _id) { throw new UnimplementedError(`SQLProvider does not support realtime subscriptions`); } async addItem(collection, data) { const rows = await this.exec ` INSERT INTO ${this.sqlIdentifier(collection.name)} ${this.sqlValues(data)} RETURNING ${this.sqlIdentifier("id")} `; const id = rows[0]?.id; if (id === undefined) throw new RequiredError(`No id returned from INSERT into "${collection.name}"`, { provider: this }); return id; } async setItem(collection, id, data) { await this.exec ` INSERT INTO ${this.sqlIdentifier(collection.name)} ${this.sqlValues({ id, ...data })} ON CONFLICT (${this.sqlIdentifier("id")}) DO UPDATE SET ${this.sqlSetters(data)} `; } async updateItem(collection, id, updates) { await this.exec ` UPDATE ${this.sqlIdentifier(collection.name)} SET ${this.sqlUpdates(updates)} WHERE ${this.sqlIdentifier("id")} = ${id} `; } async deleteItem(collection, id) { await this.exec `DELETE FROM ${this.sqlIdentifier(collection.name)} WHERE ${this.sqlIdentifier("id")} = ${id}`; } async countQuery(collection, query) { const rows = await this.exec ` SELECT COUNT(*) AS "count" FROM ${this.sqlIdentifier(collection.name)} ${query ? this.sqlClauses(query) : this.sql ``} `; return rows[0]?.count ?? 0; } async getQuery(collection, query) { return this.exec ` SELECT * FROM ${this.sqlIdentifier(collection.name)} ${query ? this.sqlClauses(query) : this.sql ``} `; } getQuerySequence(_collection, _query) { throw new UnimplementedError(`SQLProvider does not support realtime subscriptions`); } async setQuery(collection, query, data) { await this.exec `UPDATE ${this.sqlIdentifier(collection.name)} SET ${this.sqlSetters(data)}${this.sqlClauses(query)}`; } async updateQuery(collection, query, updates) { await this.exec `UPDATE ${this.sqlIdentifier(collection.name)} SET ${this.sqlUpdates(updates)}${this.sqlClauses(query)}`; } async deleteQuery(collection, query) { await this.exec `DELETE FROM ${this.sqlIdentifier(collection.name)}${this.sqlClauses(query)}`; } /** * Define an SQL fragment using Javascript template literal format. * @example this.sql`SELECT * FROM ${table}`; // SQLFragment */ sql(strings, ...values) { return { strings, values }; } /** Define an SQL fragment for an identifier, e.g. `"myTable"` */ sqlIdentifier(name) { return { strings: [_escapeIdentifier(name)], values: [] }; } /** Define an SQL fragment that extracts a deeply nested value for comparison, e.g. `"a" #>> {"b","c"}` in Postgres */ sqlExtract(key) { if (key.length > 1) throw new UnimplementedError("SQLProvider does not support nested filter keys"); return this.sqlIdentifier(key[0]); } /** Define an SQL fragment to generate a series of values with a separator, e.g. `"a" = 1 AND "b" = 2` */ sqlConcat(values, separator = ", ", before = "", after = "") { const strings = [before, ...new Array(Math.max(0, values.length - 1)).fill(separator), after]; return { strings, values }; } /** Define an SQL fragment for setting a list of values, e.g. `"a" = 1, "b" = 2` */ sqlSetters(data) { const entries = Object.entries(data); return this.sqlConcat(entries.map(([key, value]) => this.sql `${this.sqlIdentifier(key)} = ${value}`), ", "); } /** Define an SQL fragment for updates, e.g. `"a" = 1, "b" = "b" + 5` */ sqlUpdates(updates) { return this.sqlConcat(getUpdates(updates).map(update => this.sqlUpdate(update)), ", "); } /** * Define an SQL fragment for a single update action. * - Handles flat `set` and `sum` only (single-segment key). * - Nested keys (multi-segment) and `with`/`omit` actions throw `UnimplementedError`. * - Subclasses should override to support nested keys and array mutation actions. */ sqlUpdate({ action, key, value }) { if (key.length > 1) throw new UnimplementedError("SQLProvider does not support nested update keys"); const column = this.sqlIdentifier(key[0]); if (action === "set") return this.sql `${column} = ${value}`; if (action === "sum") return this.sql `${column} = ${column} + ${value}`; throw new UnimplementedError(`SQLProvider does not support "${action}" updates`); } /** Define an SQL fragment for `VALUES` syntax, e.g. `("a", "b") VALUES (1, 2)` */ sqlValues(data) { const entries = Object.entries(data); const keys = this.sqlConcat(entries.map(([key]) => this.sqlIdentifier(key)), ", "); const values = this.sqlConcat(entries.map(([, value]) => this.sql `${value}`), ", "); return this.sql `(${keys}) VALUES (${values})`; } /** Define an SQL for the `WHERE`, `ORDER BY` and `LIMIT` clauses of an SQL query, e.g. e.g. ` WHERE x = 1 ORDER BY "name" LIMIT 0, 50` */ sqlClauses(query) { return this.sql `${this.sqlWhere(query)}${this.sqlOrder(query)}${this.sqlLimit(query)}`; } /** Define an SQL fragment for a `WHERE` clause, e.g. ` WHERE x = 1 AND y <= 100` */ sqlWhere(query) { const filters = getQueryFilters(query); if (filters.length) return this.sql ``; return this.sql ` WHERE ${this.sqlConcat(filters.map(filter => this.sqlFilter(filter)), " AND ")}`; } /** * Define an SQL fragment for a filter clause on a column. */ sqlFilter({ key, operator, value }) { const path = this.sqlExtract(key); if (operator === "in") { if (!value.length) return this.sql `0`; return this.sql `${path} IN (${this.sqlConcat(value.map(v => this.sql `${v}`))})`; } if (operator === "out") { if (!value.length) return this.sql `1`; return this.sql `(${path} IS NULL OR ${path} NOT IN (${this.sqlConcat(value.map(v => this.sql `${v}`))}))`; } if (operator === "is") return value === null ? this.sql `${path} IS NULL` : this.sql `${path} = ${value}`; if (operator === "not") return value === null ? this.sql `${path} IS NOT NULL` : this.sql `(${path} IS NULL OR ${path} != ${value})`; if (operator === "lt") return this.sql `${path} < ${value}`; if (operator === "lte") return this.sql `${path} <= ${value}`; if (operator === "gt") return this.sql `${path} > ${value}`; if (operator === "gte") return this.sql `${path} >= ${value}`; throw new UnimplementedError(`SQLProvider does not support "${operator}" filters`); } /** * Define an SQL fragment for an `ORDER BY` clause, e.g. ` ORDER BY "a" ASC, "b" DESC` * - Nested keys (multi-segment) throw `UnimplementedError`. */ sqlOrder(query) { const orders = getQueryOrders(query); if (orders.length < 1) return this.sql ``; return this.sql ` ORDER BY ${this.sqlConcat(orders.map(order => this.sqlSort(order)), ", ")}`; } /** Define an SQL fragment for an individual column in an `ORDER BY`, e.g. `"a" ASC` */ sqlSort({ key, direction }) { const path = this.sqlExtract(key); if (direction === "asc") return this.sql `${path} ASC`; if (direction === "desc") return this.sql `${path} DESC`; return direction; // Never happens. } /** Define an SQL fragment for an `LIMIT` clause, e.g. ` LIMIT 50, 100` */ sqlLimit(query) { const limit = getQueryLimit(query); return typeof limit === "number" ? this.sql ` LIMIT ${limit}` : this.sql ``; } } function _escapeIdentifier(name) { return `"${name.replaceAll(`"`, `""`)}"`; }