shelving
Version:
Toolkit for using data in JavaScript.
192 lines (191 loc) • 8.65 kB
JavaScript
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(`"`, `""`)}"`;
}