@useorbis/db-sdk
Version:
Orbis' Typescript SDK for building open-data experiences.
217 lines (216 loc) • 8.1 kB
JavaScript
import { escapeId } from "./escape.js";
class Parameters {
#values = [];
get values() {
return this.#values;
}
add(value) {
this.#values.push(value);
return `$${this.#values.length}`;
}
}
export class SqlSelectBuilder {
#params = new Parameters();
#raw;
#table;
#columns;
#where;
#orderBy;
#limit;
#offset;
#query = ["SELECT"];
constructor(query) {
if (query.$raw) {
this.#raw = query.$raw;
}
else if (!query.$table) {
throw "[QueryBuilder:select] Missing table (.from) definition.";
}
this.#table = query.$table;
this.#columns = query.$columns;
this.#where = query.$where;
this.#orderBy = query.$orderBy;
this.#limit = query.$limit;
this.#offset = query.$offset;
}
build() {
if (this.#raw) {
return {
query: this.#raw.query,
params: this.#raw.params,
};
}
// <columns>
this.#query.push(this.#buildColumns());
// FROM
this.#query.push("FROM");
// <table>
this.#query.push(escapeId(this.#table));
// WHERE <>
this.#query.push(this.#buildWhere());
// ORDER BY?
this.#query.push(this.#buildOrderBy());
// LIMIT?
this.#query.push(this.#buildLimit());
// OFFSET?
this.#query.push(this.#buildOffset());
return {
query: this.#query.filter((v) => v).join(" "),
params: this.#params.values,
};
}
#parseColumnObject(column) {
const fieldId = Object.keys(column)[0];
const operation = Object.keys(column[fieldId])[0];
const parsedOperation = operation.substring(1).toUpperCase();
if (!["SUM", "COUNT", "AS"].includes(parsedOperation)) {
throw ("[QueryBuilder:select] Invalid aggregate function " + parsedOperation);
}
const operationParams = column[fieldId][operation];
if (parsedOperation === "AS") {
if (typeof operationParams !== "string") {
throw '[QueryBuilder:select] Invalid "as" params ' + operationParams;
}
return `${escapeId(fieldId)} as ${escapeId(operationParams)}`;
}
const opField = operationParams["$expr"];
const distinct = operationParams["$distinct"];
return `${parsedOperation}(${(distinct && "DISTINCT ") || ""}${escapeId(opField)}) as ${escapeId(fieldId)}`;
}
#toParamSqlArray(array) {
const stringified = array.map((v) => this.#params.add(v)).join(", ");
return `(${stringified})`;
}
#buildColumns() {
if (!this.#columns || !this.#columns.length) {
return "*";
}
const parsedColumns = this.#columns.map((v) => typeof v === "string" ? escapeId(v) : this.#parseColumnObject(v));
return parsedColumns.join(", ");
}
#formatOperator(rawField, operator, compareValue) {
const field = escapeId(rawField);
switch (operator) {
case "$and": {
const conditions = compareValue.map((condition) => this.#parseOperator(condition));
return `(${conditions.join(" AND ")})`;
}
case "$or": {
const conditions = compareValue.map((condition) => this.#parseOperator(condition));
return `(${conditions.join(" OR ")})`;
}
case "$eq":
return `${field} = ${this.#params.add(compareValue)}`;
case "$neq":
return `${field} <> ${this.#params.add(compareValue)}`;
case "$in":
return `${field} IN ${this.#toParamSqlArray(compareValue)}`;
case "$nin":
return `${field} NOT IN ${this.#toParamSqlArray(compareValue)}`;
case "$gt":
return `${field} > $this.#params.add(compareValue)}`;
case "$gte":
return `${field} >= ${this.#params.add(compareValue)}`;
case "$lt":
return `${field} < ${this.#params.add(compareValue)}`;
case "$lte":
return `${field} <= ${this.#params.add(compareValue)}`;
case "$between":
return `${field} BETWEEN ${this.#params.add(compareValue.$min)} AND ${this.#params.add(compareValue.$max)}`;
case "$like":
return `${field} LIKE ${this.#params.add(compareValue)}`;
case "$ilike":
return `${field} ILIKE ${this.#params.add(compareValue)}`;
case "$contains": {
if (!compareValue.startsWith("%")) {
compareValue = "%" + compareValue;
}
if (compareValue.slice(-1) !== "%") {
compareValue += "%";
}
return `${field} LIKE ${this.#params.add(compareValue)}`;
}
case "$icontains": {
if (!compareValue.startsWith("%")) {
compareValue = "%" + compareValue;
}
if (compareValue.slice(-1) !== "%") {
compareValue += "%";
}
return `${field} ILIKE ${this.#params.add(compareValue)}`;
}
case "$startsWith": {
if (!compareValue.startsWith("%")) {
compareValue = "%" + compareValue;
}
return `${field} LIKE ${this.#params.add(compareValue)}`;
}
case "$istartsWith": {
if (!compareValue.startsWith("%")) {
compareValue = "%" + compareValue;
}
return `${field} ILIKE ${this.#params.add(compareValue)}`;
}
case "$endsWith": {
if (compareValue.slice(-1) !== "%") {
compareValue += "%";
}
return `${field} LIKE ${this.#params.add(compareValue)}`;
}
case "$iendsWith": {
if (compareValue.slice(-1) !== "%") {
compareValue += "%";
}
return `${field} ILIKE ${this.#params.add(compareValue)}`;
}
}
}
#parseOperator(condition) {
if (Object.keys(condition).length > 1) {
return this.#parseOperator({
$and: Object.entries(condition).map(([key, value]) => ({
[key]: value,
})),
});
}
const field = Object.keys(condition)[0];
const value = condition[field];
if (!field.startsWith("$")) {
if (["string", "number", "bigint", "boolean"].includes(typeof value) ||
value instanceof Date) {
return this.#formatOperator(field, "$eq", value);
}
return this.#formatOperator(field, Object.keys(value)[0], Object.values(value)[0]);
}
return this.#formatOperator("", field, value);
}
#buildWhere() {
const clauses = [];
if (!this.#where || !Object.entries(this.#where).length) {
return "";
}
for (const [key, value] of Object.entries(this.#where)) {
clauses.push(this.#parseOperator({ [key]: value }));
}
return `WHERE ` + clauses.join(" AND ");
}
#buildOrderBy() {
if (!this.#orderBy || !this.#orderBy.length) {
return "";
}
const orders = this.#orderBy.map(([field, direction]) => `${escapeId(field)} ${direction.toLowerCase() === "asc" ? "ASC" : "DESC"}`);
return `ORDER BY ${orders.join(", ")}`;
}
#buildLimit() {
if (typeof this.#limit !== "number") {
return "";
}
return `LIMIT ${this.#params.add(this.#limit)}`;
}
#buildOffset() {
if (typeof this.#offset !== "number") {
return "";
}
return `OFFSET ${this.#params.add(this.#offset)}`;
}
}