@minatojs/sql-utils
Version:
SQL Utilities for Minato
544 lines (542 loc) • 21.2 kB
JavaScript
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/index.ts
var src_exports = {};
__export(src_exports, {
Builder: () => Builder,
escapeId: () => escapeId,
isBracketed: () => isBracketed
});
module.exports = __toCommonJS(src_exports);
var import_cosmokit = require("cosmokit");
var import_minato = require("minato");
function escapeId(value) {
return "`" + value + "`";
}
__name(escapeId, "escapeId");
function isBracketed(value) {
return value.startsWith("(") && value.endsWith(")");
}
__name(isBracketed, "isBracketed");
var Builder = class {
static {
__name(this, "Builder");
}
escapeMap = {};
escapeRegExp;
types = {};
createEqualQuery = this.comparator("=");
queryOperators;
evalOperators;
state = {};
$true = "1";
$false = "0";
modifiedTable;
_timezone = `+${(/* @__PURE__ */ new Date()).getTimezoneOffset() / -60}:00`.replace("+-", "-");
constructor(tables) {
this.state.tables = tables;
this.queryOperators = {
// logical
$or: (key, value) => this.logicalOr(value.map((value2) => this.parseFieldQuery(key, value2))),
$and: (key, value) => this.logicalAnd(value.map((value2) => this.parseFieldQuery(key, value2))),
$not: (key, value) => this.logicalNot(this.parseFieldQuery(key, value)),
// existence
$exists: (key, value) => this.createNullQuery(key, value),
// comparison
$eq: this.createEqualQuery,
$ne: this.comparator("!="),
$gt: this.comparator(">"),
$gte: this.comparator(">="),
$lt: this.comparator("<"),
$lte: this.comparator("<="),
// membership
$in: (key, value) => this.createMemberQuery(key, value, ""),
$nin: (key, value) => this.createMemberQuery(key, value, " NOT"),
// regexp
$regex: (key, value) => this.createRegExpQuery(key, value),
$regexFor: (key, value) => `${this.escape(value)} regexp ${key}`,
// bitwise
$bitsAllSet: (key, value) => `${key} & ${this.escape(value)} = ${this.escape(value)}`,
$bitsAllClear: (key, value) => `${key} & ${this.escape(value)} = 0`,
$bitsAnySet: (key, value) => `${key} & ${this.escape(value)} != 0`,
$bitsAnyClear: (key, value) => `${key} & ${this.escape(value)} != ${this.escape(value)}`,
// list
$el: (key, value) => {
if (Array.isArray(value)) {
return this.logicalOr(value.map((value2) => this.createElementQuery(key, value2)));
} else if (typeof value !== "number" && typeof value !== "string") {
throw new TypeError("query expr under $el is not supported");
} else {
return this.createElementQuery(key, value);
}
},
$size: (key, value) => {
if (!value)
return this.logicalNot(key);
if (this.state.sqlTypes?.[this.unescapeId(key)] === "json") {
return `${this.jsonLength(key)} = ${this.escape(value)}`;
} else {
return `${key} AND LENGTH(${key}) - LENGTH(REPLACE(${key}, ${this.escape(",")}, ${this.escape("")})) = ${this.escape(value)} - 1`;
}
}
};
this.evalOperators = {
// universal
$: (key) => this.getRecursive(key),
$if: (args) => `if(${args.map((arg) => this.parseEval(arg)).join(", ")})`,
$ifNull: (args) => `ifnull(${args.map((arg) => this.parseEval(arg)).join(", ")})`,
// number
$add: (args) => `(${args.map((arg) => this.parseEval(arg)).join(" + ")})`,
$multiply: (args) => `(${args.map((arg) => this.parseEval(arg)).join(" * ")})`,
$subtract: this.binary("-"),
$divide: this.binary("/"),
$modulo: this.binary("%"),
// mathemetic
$abs: (arg) => `abs(${this.parseEval(arg)})`,
$floor: (arg) => `floor(${this.parseEval(arg)})`,
$ceil: (arg) => `ceil(${this.parseEval(arg)})`,
$round: (arg) => `round(${this.parseEval(arg)})`,
$exp: (arg) => `exp(${this.parseEval(arg)})`,
$log: (args) => `log(${args.filter((x) => !(0, import_cosmokit.isNullable)(x)).map((arg) => this.parseEval(arg)).reverse().join(", ")})`,
$power: (args) => `power(${args.map((arg) => this.parseEval(arg)).join(", ")})`,
$random: () => `rand()`,
// string
$concat: (args) => `concat(${args.map((arg) => this.parseEval(arg)).join(", ")})`,
$regex: ([key, value]) => `${this.parseEval(key)} regexp ${this.parseEval(value)}`,
// logical
$or: (args) => this.logicalOr(args.map((arg) => this.parseEval(arg))),
$and: (args) => this.logicalAnd(args.map((arg) => this.parseEval(arg))),
$not: (arg) => this.logicalNot(this.parseEval(arg)),
// boolean
$eq: this.binary("="),
$ne: this.binary("!="),
$gt: this.binary(">"),
$gte: this.binary(">="),
$lt: this.binary("<"),
$lte: this.binary("<="),
// membership
$in: ([key, value]) => this.createMemberQuery(this.parseEval(key), value, ""),
$nin: ([key, value]) => this.createMemberQuery(this.parseEval(key), value, " NOT"),
// typecast
$number: (arg) => {
const value = this.parseEval(arg);
const res = this.state.sqlType === "raw" ? `(0+${value})` : this.state.sqlType === "time" ? `unix_timestamp(convert_tz(addtime('1970-01-01 00:00:00', ${value}), '${this._timezone}', '+0:00'))` : `unix_timestamp(convert_tz(${value}, '${this._timezone}', '+0:00'))`;
this.state.sqlType = "raw";
return `ifnull(${res}, 0)`;
},
// aggregation
$sum: (expr) => this.createAggr(expr, (value) => `ifnull(sum(${value}), 0)`),
$avg: (expr) => this.createAggr(expr, (value) => `avg(${value})`),
$min: (expr) => this.createAggr(expr, (value) => `min(${value})`),
$max: (expr) => this.createAggr(expr, (value) => `max(${value})`),
$count: (expr) => this.createAggr(expr, (value) => `count(distinct ${value})`),
$length: (expr) => this.createAggr(expr, (value) => `count(${value})`, (value) => {
if (this.state.sqlType === "json") {
this.state.sqlType = "raw";
return `${this.jsonLength(value)}`;
} else {
this.state.sqlType = "raw";
return `if(${value}, LENGTH(${value}) - LENGTH(REPLACE(${value}, ${this.escape(",")}, ${this.escape("")})) + 1, 0)`;
}
}),
$object: (fields) => this.groupObject(fields),
$array: (expr) => this.groupArray(this.parseEval(expr, false)),
$exec: (sel) => this.parseSelection(sel)
};
}
unescapeId(value) {
return value.slice(1, value.length - 1);
}
createNullQuery(key, value) {
return `${key} is ${value ? "not " : ""}null`;
}
createMemberQuery(key, value, notStr = "") {
if (Array.isArray(value)) {
if (!value.length)
return notStr ? this.$true : this.$false;
return `${key}${notStr} in (${value.map((val) => this.escape(val)).join(", ")})`;
} else {
const res = this.jsonContains(this.parseEval(value, false), this.jsonQuote(key, true));
this.state.sqlType = "raw";
return notStr ? this.logicalNot(res) : res;
}
}
createRegExpQuery(key, value) {
return `${key} regexp ${this.escape(typeof value === "string" ? value : value.source)}`;
}
createElementQuery(key, value) {
if (this.state.sqlTypes?.[this.unescapeId(key)] === "json") {
return this.jsonContains(key, this.quote(JSON.stringify(value)));
} else {
return `find_in_set(${this.escape(value)}, ${key})`;
}
}
comparator(operator) {
return (key, value) => {
return `${key} ${operator} ${this.escape(value)}`;
};
}
binary(operator) {
return ([left, right]) => {
return `(${this.parseEval(left)} ${operator} ${this.parseEval(right)})`;
};
}
logicalAnd(conditions) {
if (!conditions.length)
return this.$true;
if (conditions.includes(this.$false))
return this.$false;
return conditions.join(" AND ");
}
logicalOr(conditions) {
if (!conditions.length)
return this.$false;
if (conditions.includes(this.$true))
return this.$true;
return `(${conditions.join(" OR ")})`;
}
logicalNot(condition) {
return `NOT(${condition})`;
}
parseSelection(sel) {
const { args: [expr], ref, table, tables } = sel;
const restore = this.saveState({ tables });
const inner = this.get(table, true, true);
const output = this.parseEval(expr, false);
restore();
if (!sel.args[0].$) {
return `(SELECT ${output} AS value FROM ${inner} ${isBracketed(inner) ? ref : ""})`;
} else {
return `(ifnull((SELECT ${this.groupArray(output)} AS value FROM ${inner} ${isBracketed(inner) ? ref : ""}), json_array()))`;
}
}
jsonLength(value) {
return `json_length(${value})`;
}
jsonContains(obj, value) {
return `json_contains(${obj}, ${value})`;
}
jsonUnquote(value, pure = false) {
if (pure)
return `json_unquote(${value})`;
if (this.state.sqlType === "json") {
this.state.sqlType = "raw";
return `json_unquote(${value})`;
}
return value;
}
jsonQuote(value, pure = false) {
if (pure)
return `cast(${value} as json)`;
if (this.state.sqlType !== "json") {
this.state.sqlType = "json";
return `cast(${value} as json)`;
}
return value;
}
createAggr(expr, aggr, nonaggr) {
if (this.state.group) {
this.state.group = false;
const value = aggr(this.parseEval(expr, false));
this.state.group = true;
return value;
} else {
const value = this.parseEval(expr, false);
const res = nonaggr ? nonaggr(value) : `(select ${aggr(`json_unquote(${this.escapeId("value")})`)} from json_table(${value}, '$[*]' columns (value json path '$')) ${(0, import_minato.randomId)()})`;
return res;
}
}
groupObject(fields) {
const parse = /* @__PURE__ */ __name((expr) => {
const value = this.parseEval(expr, false);
return this.state.sqlType === "json" ? `json_extract(${value}, '$')` : `${value}`;
}, "parse");
const res = `json_object(` + Object.entries(fields).map(([key, expr]) => `'${key}', ${parse(expr)}`).join(",") + `)`;
this.state.sqlType = "json";
return res;
}
groupArray(value) {
this.state.sqlType = "json";
return `ifnull(json_arrayagg(${value}), json_array())`;
}
parseFieldQuery(key, query) {
const conditions = [];
if (this.modifiedTable)
key = `${this.escapeId(this.modifiedTable)}.${key}`;
if (Array.isArray(query)) {
conditions.push(this.createMemberQuery(key, query));
} else if (query instanceof RegExp) {
conditions.push(this.createRegExpQuery(key, query));
} else if ((0, import_minato.isComparable)(query)) {
conditions.push(this.createEqualQuery(key, query));
} else if ((0, import_cosmokit.isNullable)(query)) {
conditions.push(this.createNullQuery(key, false));
} else {
for (const prop in query) {
if (prop in this.queryOperators) {
conditions.push(this.queryOperators[prop](key, query[prop]));
}
}
}
return this.logicalAnd(conditions);
}
parseQuery(query) {
const conditions = [];
for (const key in query) {
if (key === "$not") {
conditions.push(this.logicalNot(this.parseQuery(query.$not)));
} else if (key === "$and") {
conditions.push(this.logicalAnd(query.$and.map(this.parseQuery.bind(this))));
} else if (key === "$or") {
conditions.push(this.logicalOr(query.$or.map(this.parseQuery.bind(this))));
} else if (key === "$expr") {
conditions.push(this.parseEval(query.$expr));
} else {
conditions.push(this.parseFieldQuery(this.escapeId(key), query[key]));
}
}
return this.logicalAnd(conditions);
}
parseEvalExpr(expr) {
this.state.sqlType = "raw";
for (const key in expr) {
if (key in this.evalOperators) {
return this.evalOperators[key](expr[key]);
}
}
return this.escape(expr);
}
transformJsonField(obj, path) {
this.state.sqlType = "json";
return `json_extract(${obj}, '$${path}')`;
}
transformKey(key, fields, prefix, fullKey) {
if (key in fields || !key.includes(".")) {
if (this.state.sqlTypes?.[key] || this.state.sqlTypes?.[fullKey]) {
this.state.sqlType = this.state.sqlTypes[key] || this.state.sqlTypes[fullKey];
}
return prefix + this.escapeId(key);
}
const field = Object.keys(fields).find((k) => key.startsWith(k + ".")) || key.split(".")[0];
const rest = key.slice(field.length + 1).split(".");
return this.transformJsonField(`${prefix}${this.escapeId(field)}`, rest.map((key2) => `.${this.escapeKey(key2)}`).join(""));
}
getRecursive(args) {
if (typeof args === "string") {
return this.getRecursive(["_", args]);
}
const [table, key] = args;
const fields = this.state.tables?.[table]?.fields || {};
const fkey = Object.keys(fields).find((field) => key === field || key.startsWith(field + "."));
if (fkey && fields[fkey]?.expr) {
if (key === fkey) {
return this.parseEvalExpr(fields[fkey]?.expr);
} else {
const field = this.parseEvalExpr(fields[fkey]?.expr);
const rest = key.slice(fkey.length + 1).split(".");
return this.transformJsonField(`${field}`, rest.map((key2) => `.${this.escapeKey(key2)}`).join(""));
}
}
const prefix = this.modifiedTable ? `${this.escapeId(this.state.tables?.[table]?.name ?? this.modifiedTable)}.` : !this.state.tables || table === "_" || key in fields || Object.keys(this.state.tables).length === 1 && table in this.state.tables ? "" : `${this.escapeId(table)}.`;
if (!(table in (this.state.tables || {})) && table in (this.state.refTables || {})) {
const fields2 = this.state.refTables?.[table]?.fields || {};
const res = fields2[key]?.expr ? this.parseEvalExpr(fields2[key]?.expr) : this.transformKey(key, fields2, `${this.escapeId(table)}.`, `${table}.${key}`);
if (this.state.wrappedSubquery) {
if (res in (this.state.refFields ?? {}))
return this.state.refFields[res];
const key2 = `minato_tvar_${(0, import_minato.randomId)()}`;
(this.state.refFields ??= {})[res] = key2;
this.state.sqlType = "json";
return this.escapeId(key2);
} else
return res;
}
return this.transformKey(key, fields, prefix, `${table}.${key}`);
}
parseEval(expr, unquote = true) {
this.state.sqlType = "raw";
if (typeof expr === "string" || typeof expr === "number" || typeof expr === "boolean" || expr instanceof Date || expr instanceof RegExp) {
return this.escape(expr);
}
return unquote ? this.jsonUnquote(this.parseEvalExpr(expr)) : this.parseEvalExpr(expr);
}
saveState(extra = {}) {
const thisState = this.state;
this.state = { refTables: { ...this.state.refTables || {}, ...this.state.tables || {} }, ...extra };
return () => {
thisState.sqlType = this.state.sqlType;
this.state = thisState;
};
}
suffix(modifier) {
const { limit, offset, sort, group, having } = modifier;
let sql = "";
if (group?.length) {
sql += ` GROUP BY ${group.map(this.escapeId).join(", ")}`;
const filter = this.parseEval(having);
if (filter !== this.$true)
sql += ` HAVING ${filter}`;
}
if (sort.length) {
sql += " ORDER BY " + sort.map(([expr, dir]) => {
return `${this.parseEval(expr)} ${dir.toUpperCase()}`;
}).join(", ");
}
if (limit < Infinity)
sql += " LIMIT " + limit;
if (offset > 0)
sql += " OFFSET " + offset;
return sql;
}
get(sel, inline = false, group = false, addref = true) {
const { args, table, query, ref, model } = sel;
let prefix;
if (typeof table === "string") {
prefix = this.escapeId(table);
this.state.sqlTypes = Object.fromEntries(Object.entries(model.fields).map(([key, field]) => {
let sqlType = "raw";
if (field.type === "json")
sqlType = "json";
else if (field.type === "list")
sqlType = "list";
else if (import_minato.Field.date.includes(field.type))
sqlType = field.type;
return [key, sqlType];
}));
} else if (table instanceof import_minato.Selection) {
prefix = this.get(table, true);
if (!prefix)
return;
} else {
const sqlTypes2 = {};
const joins = Object.entries(table).map(([key, table2]) => {
const restore = this.saveState({ tables: table2.tables });
const t = `${this.get(table2, true, false, false)} AS ${this.escapeId(key)}`;
for (const [fieldKey, fieldType] of Object.entries(this.state.sqlTypes)) {
sqlTypes2[`${key}.${fieldKey}`] = fieldType;
}
restore();
return t;
});
this.state.sqlTypes = sqlTypes2;
prefix = " " + joins[0] + joins.slice(1, -1).map((join) => ` JOIN ${join} ON ${this.$true}`).join(" ") + ` JOIN ` + joins.at(-1);
const filter2 = this.parseEval(args[0].having);
prefix += ` ON ${filter2}`;
}
const filter = this.parseQuery(query);
if (filter === this.$false)
return;
this.state.group = group || !!args[0].group;
const sqlTypes = {};
const fields = args[0].fields ?? Object.fromEntries(Object.entries(model.fields).filter(([, field]) => !field.deprecated).map(([key]) => [key, { $: [ref, key] }]));
const keys = Object.entries(fields).map(([key, value]) => {
value = this.parseEval(value, false);
sqlTypes[key] = this.state.sqlType;
return this.escapeId(key) === value ? this.escapeId(key) : `${value} AS ${this.escapeId(key)}`;
}).join(", ");
let suffix = this.suffix(args[0]);
this.state.sqlTypes = sqlTypes;
if (filter !== this.$true) {
suffix = ` WHERE ${filter}` + suffix;
}
if (inline && !args[0].fields && !suffix) {
return addref && isBracketed(prefix) ? `${prefix} ${ref}` : prefix;
}
if (!prefix.includes(" ") || isBracketed(prefix)) {
suffix = ` ${ref}` + suffix;
}
const result = `SELECT ${keys} FROM ${prefix}${suffix}`;
return inline ? `(${result})` : result;
}
define(converter) {
converter.types.forEach((type) => this.types[type] = converter);
}
dump(model, obj) {
obj = model.format(obj);
const result = {};
for (const key in obj) {
result[key] = this.stringify(obj[key], model.fields[key]);
}
return result;
}
load(model, obj) {
if (!obj) {
const converter = this.types[this.state.sqlType];
return converter ? converter.load(model) : model;
}
const result = {};
for (const key in obj) {
if (!(key in model.fields))
continue;
const { type, initial } = model.fields[key];
const converter = (this.state.sqlTypes?.[key] ?? "raw") === "raw" ? this.types[type] : this.types[this.state.sqlTypes[key]];
result[key] = converter ? converter.load(obj[key], initial) : obj[key];
}
return model.parse(result);
}
escape(value, field) {
value = this.stringify(value, field);
if ((0, import_cosmokit.isNullable)(value))
return "NULL";
switch (typeof value) {
case "boolean":
case "number":
return value + "";
case "object":
return this.quote(JSON.stringify(value));
default:
return this.quote(value);
}
}
escapeId(value) {
return escapeId(value);
}
escapeKey(value) {
return `"${value}"`;
}
stringify(value, field) {
const converter = this.types[field?.type];
return converter ? converter.dump(value) : value;
}
quote(value) {
this.escapeRegExp ??= new RegExp(`[${Object.values(this.escapeMap).join("")}]`, "g");
let chunkIndex = this.escapeRegExp.lastIndex = 0;
let escapedVal = "";
let match;
while (match = this.escapeRegExp.exec(value)) {
escapedVal += value.slice(chunkIndex, match.index) + this.escapeMap[match[0]];
chunkIndex = this.escapeRegExp.lastIndex;
}
if (chunkIndex === 0) {
return "'" + value + "'";
}
if (chunkIndex < value.length) {
return "'" + escapedVal + value.slice(chunkIndex) + "'";
}
return "'" + escapedVal + "'";
}
};
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
Builder,
escapeId,
isBracketed
});
//# sourceMappingURL=index.js.map