sqlitebruv
Version:
A Simple and Efficient Query Builder for D1/Turso and Bun's SQLite
685 lines (683 loc) • 24.9 kB
JavaScript
import { readdirSync, readFileSync, unlinkSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import path, { join } from "node:path";
import { randomBytes } from "node:crypto";
export class SqliteBruv {
static migrationFolder = "./Bruv-migrations";
db;
_localFile;
_columns = ["*"];
_conditions = [];
_tableName = undefined;
_params = [];
_limit;
_offset;
_orderBy;
_logging = false;
_hotCache = {};
_turso;
_D1;
_QueryMode = false;
MAX_PARAMS = 100;
ALLOWED_OPERATORS = [
"=",
">",
"<",
">=",
"<=",
"LIKE",
"IN",
"BETWEEN",
"IS NULL",
"IS NOT NULL",
];
DANGEROUS_PATTERNS = [
/;\s*$/,
/UNION/i,
/DROP/i,
/DELETE/i,
/UPDATE/i,
/INSERT/i,
/ALTER/i,
/EXEC/i,
];
loading;
constructor({ logging, schema, D1Config, TursoConfig, localFile, QueryMode, createMigrations, }) {
if ([D1Config, TursoConfig, localFile, QueryMode].filter((v) => v).length ===
0) {
throw new Error("\nPlease pass any of \n1. LocalFile or \n2. D1Config or \n3. TursoConfig\nin SqliteBruv constructor");
}
if ([D1Config, TursoConfig, localFile, QueryMode].filter((v) => v).length > 1) {
throw new Error("\nPlease only pass one of \n1. LocalFile or \n2. D1Config or \n3. TursoConfig\nin SqliteBruv constructor");
}
schema.forEach((s) => {
s.db = this;
});
this.loading = new Promise(async (r) => {
const bun = avoidError(() => (Bun ? true : false));
let Database;
if (bun) {
Database = (await import("bun:sqlite")).Database;
}
else {
Database = (await import("node:sqlite")).DatabaseSync;
}
if (localFile) {
this._localFile = true;
this.db = new Database(localFile, {
create: true,
strict: true,
});
}
if (D1Config) {
const { accountId, databaseId, apiKey } = D1Config;
this._D1 = {
url: `https://api.cloudflare.com/client/v4/accounts/${accountId}/d1/database/${databaseId}/query`,
authToken: apiKey,
};
}
if (TursoConfig) {
this._turso = TursoConfig;
}
if (QueryMode === true) {
this._QueryMode = true;
}
if (logging === true) {
this._logging = true;
}
if (!schema?.length) {
throw new Error("Not database schema passed!");
}
else {
schema.forEach((s) => {
s.db = this;
});
}
const shouldMigrate = !process.argv
.slice(1)
.some((v) => v.includes("Bruv-migrations/migrate.ts")) &&
createMigrations !== false &&
QueryMode !== true;
if (shouldMigrate) {
const clonedSchema = schema.map((s) => s._clone());
const tempDbPath = path.join(import.meta.dirname, "./temp.sqlite");
const tempDb = new SqliteBruv({
schema: clonedSchema,
localFile: tempDbPath,
createMigrations: false,
});
clonedSchema
.map((s) => s._clone())
.forEach((s) => {
s.db = tempDb;
if (!QueryMode)
s._induce();
});
Promise.all([getSchema(this), getSchema(tempDb)])
.then(async ([currentSchema, targetSchema]) => {
const migration = await generateMigration(currentSchema || [], targetSchema || []);
await createMigrationFileIfNeeded(migration);
})
.catch((e) => {
console.log(e);
})
.finally(() => {
unlinkSync(tempDbPath);
});
}
this.loading = undefined;
r(undefined);
});
}
from(tableName) {
this._tableName = tableName;
return this;
}
select(...columns) {
this._columns = columns || ["*"];
return this;
}
validateCondition(condition) {
if (this.DANGEROUS_PATTERNS.some((pattern) => pattern.test(condition))) {
throw new Error("Invalid condition pattern detected");
}
const hasValidOperator = this.ALLOWED_OPERATORS.some((op) => condition.toUpperCase().includes(op));
if (!hasValidOperator) {
throw new Error("Invalid or missing operator in condition");
}
return true;
}
validateParams(params) {
if (params.length > this.MAX_PARAMS) {
throw new Error("Too many parameters");
}
for (const param of params) {
if (param !== null &&
!["string", "number", "boolean"].includes(typeof param)) {
throw new Error("Invalid parameter type");
}
if (typeof param === "string" && param.length > 1000) {
throw new Error("Parameter string too long");
}
}
return true;
}
where(condition, ...params) {
if (!condition || typeof condition !== "string") {
throw new Error("Condition must be a non-empty string");
}
this.validateCondition(condition);
this.validateParams(params);
this._conditions.push(`WHERE ${condition}`);
this._params.push(...params);
return this;
}
andWhere(condition, ...params) {
this.validateCondition(condition);
this.validateParams(params);
this._conditions.push(`AND ${condition}`);
this._params.push(...params);
return this;
}
orWhere(condition, ...params) {
this.validateCondition(condition);
this.validateParams(params);
this._conditions.push(`OR ${condition}`);
this._params.push(...params);
return this;
}
limit(count) {
this._limit = count;
return this;
}
offset(count) {
this._offset = count || -1;
return this;
}
orderBy(column, direction) {
this._orderBy = { column, direction };
return this;
}
invalidateCache(cacheName) {
this._hotCache[cacheName] = undefined;
return undefined;
}
get({ cacheAs } = {}) {
if (cacheAs && this._hotCache[cacheAs])
return this._hotCache[cacheAs];
const { query, params } = this.build();
return this.run(query, params, { single: false });
}
getOne({ cacheAs } = {}) {
if (cacheAs && this._hotCache[cacheAs])
return this._hotCache[cacheAs];
const { query, params } = this.build();
return this.run(query, params, { single: true });
}
insert(data) {
data.id = Id();
const attributes = Object.keys(data);
const columns = attributes.join(", ");
const placeholders = attributes.map(() => "?").join(", ");
const query = `INSERT INTO ${this._tableName} (${columns}) VALUES (${placeholders})`;
const params = Object.values(data);
this.clear();
return this.run(query, params, { single: true });
}
update(data) {
const columns = Object.keys(data)
.map((column) => `${column} = ?`)
.join(", ");
const query = `UPDATE ${this._tableName} SET ${columns} ${this._conditions.join(" AND ")}`;
const params = [...Object.values(data), ...this._params];
this.clear();
return this.run(query, params);
}
delete() {
const query = `DELETE FROM ${this._tableName} ${this._conditions.join(" AND ")}`;
const params = [...this._params];
this.clear();
return this.run(query, params);
}
count({ cacheAs } = {}) {
if (cacheAs && this._hotCache[cacheAs])
return this._hotCache[cacheAs];
const query = `SELECT COUNT(*) as count FROM ${this._tableName} ${this._conditions.join(" AND ")}`;
const params = [...this._params];
this.clear();
return this.run(query, params, { single: true });
}
async executeJsonQuery(query) {
if (!query.from) {
throw new Error("Table is required.");
}
let queryBuilder = this.from(query.from);
if (!query.action) {
if (query.invalidateCache)
return queryBuilder.invalidateCache(query.invalidateCache);
throw new Error("Action is required.");
}
if (query.select)
queryBuilder = queryBuilder.select(...query.select);
if (query.limit)
queryBuilder = queryBuilder.limit(query.limit);
if (query.offset)
queryBuilder = queryBuilder.offset(query.offset);
if (query.where) {
for (const condition of query.where) {
queryBuilder = queryBuilder.where(condition.condition, ...condition.params);
}
}
if (query.andWhere) {
for (const condition of query.andWhere) {
queryBuilder = queryBuilder.andWhere(condition.condition, ...condition.params);
}
}
if (query.orWhere) {
for (const condition of query.orWhere) {
queryBuilder = queryBuilder.orWhere(condition.condition, ...condition.params);
}
}
if (query.orderBy) {
queryBuilder = queryBuilder.orderBy(query.orderBy.column, query.orderBy.direction);
}
let result;
try {
switch (query.action) {
case "get":
result = await queryBuilder.get({ cacheAs: query.cacheAs });
break;
case "count":
result = await queryBuilder.count({ cacheAs: query.cacheAs });
break;
case "getOne":
result = await queryBuilder.getOne({ cacheAs: query.cacheAs });
break;
case "insert":
if (!query.data) {
throw new Error("Data is required for insert action.");
}
result = await queryBuilder.insert(query.data);
break;
case "update":
if (!query.data || !query.from || !query.where) {
throw new Error("Data, from, and where are required for update action.");
}
result = await queryBuilder.update(query.data);
break;
case "delete":
if (!query.from || !query.where) {
throw new Error("From and where are required for delete action.");
}
result = await queryBuilder.delete();
break;
default:
throw new Error("Invalid action specified.");
}
}
catch (error) {
console.error("Query execution failed:", error);
}
return result;
}
build() {
const query = [
`SELECT ${this._columns.join(", ")} FROM ${this._tableName}`,
...this._conditions,
this._orderBy
? `ORDER BY ${this._orderBy.column} ${this._orderBy.direction}`
: "",
this._limit ? `LIMIT ${this._limit}` : "",
this._offset ? `OFFSET ${this._offset}` : "",
]
.filter(Boolean)
.join(" ");
const params = [...this._params];
this.clear();
return { query, params };
}
clear() {
if (!this._tableName || typeof this._tableName !== "string") {
throw new Error("no table selected!");
}
this._conditions = [];
this._params = [];
this._limit = undefined;
this._offset = undefined;
this._orderBy = undefined;
this._tableName = undefined;
}
async run(query, params, { single, cacheName } = {}) {
if (this.loading)
await this.loading;
if (this._QueryMode)
return { query, params };
if (this._logging) {
console.log({ query, params });
}
if (this._turso) {
let results = await this.executeTursoQuery(query, params);
if (single) {
results = results[0];
}
if (cacheName) {
return this.cacheResponse(results, cacheName);
}
return results;
}
if (this._D1) {
const res = await fetch(this._D1.url, {
method: "POST",
headers: {
Authorization: `Bearer ${this._D1.authToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ sql: query, params }),
});
const data = await res.json();
let result;
if (data.success && data.result[0].success) {
if (single) {
result = data.result[0].results[0];
}
else {
result = data.result[0].results;
}
if (cacheName) {
return this.cacheResponse(result, cacheName);
}
return result;
}
throw new Error(JSON.stringify(data.errors));
}
if (single === true) {
if (cacheName) {
return this.cacheResponse(this.db.query(query).get(...params), cacheName);
}
return this.db.query(query).get(...params);
}
if (single === false) {
if (cacheName) {
return this.cacheResponse(this.db.prepare(query).all(...params), cacheName);
}
return this.db.prepare(query).all(...params);
}
return this.db.prepare(query).run(...params);
}
async executeTursoQuery(query, params = []) {
if (!this._turso) {
throw new Error("Turso configuration not found");
}
const response = await fetch(this._turso.url, {
method: "POST",
headers: {
Authorization: `Bearer ${this._turso.authToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
statements: [
{
q: query,
params: params,
},
],
}),
});
if (!response.ok) {
console.error(await response.text());
throw new Error(`Turso API error: ${response.statusText}`);
}
const results = (await response.json())[0];
const { columns, rows } = results?.results || {};
if (results.error) {
throw new Error(`Turso API error: ${results.error}`);
}
const transformedRows = rows.map((row) => {
const rowObject = {};
columns.forEach((column, index) => {
rowObject[column] = row[index];
});
return rowObject;
});
return transformedRows;
}
raw(raw, params = []) {
return this.run(raw, params);
}
rawAll(raw) {
return this.db.prepare(raw).all();
}
async cacheResponse(response, cacheName) {
await response;
this._hotCache[cacheName] = response;
return response;
}
}
export class Schema {
string = "";
name;
db;
columns;
constructor(def) {
this.name = def.name;
this.columns = def.columns;
}
get query() {
if (this.db?.loading) {
throw new Error("Database not loaded yet!!");
}
return this.db.from(this.name);
}
queryRaw(raw) {
return this.db?.from(this.name).raw(raw, []);
}
_induce() {
const tables = Object.keys(this.columns);
this.string = `CREATE TABLE IF NOT EXISTS ${this.name} (\n id text PRIMARY KEY NOT NULL,\n ${tables
.map((col, i) => col +
" " +
this.columns[col].type +
(this.columns[col].unique ? " UNIQUE" : "") +
(this.columns[col].required ? " NOT NULL" : "") +
(this.columns[col].target
? " REFERENCES " + this.columns[col].target + "(id)"
: "") +
(this.columns[col].check?.length
? " CHECK (" +
col +
" IN (" +
this.columns[col].check.map((c) => "'" + c + "'").join(",") +
")) "
: "") +
(this.columns[col].default
? " DEFAULT " + this.columns[col].default()
: "") +
(i + 1 !== tables.length ? ",\n " : "\n"))
.join(" ")})`;
try {
this.db?.raw(this.string);
}
catch (error) {
console.log({ err: String(error), schema: this.string });
}
}
_clone() {
return new Schema({ columns: this.columns, name: this.name });
}
async getSql() {
await this.db?.loading;
return this.string;
}
}
async function getSchema(db) {
if (db.loading)
await db.loading;
try {
let tables = {}, schema = [];
if (!db._localFile) {
tables =
(await db.run("SELECT name FROM sqlite_master WHERE type='table'", [])) || {};
schema = await Promise.all(Object.values(tables).map(async (table) => ({
name: table.name,
schema: await db.run(`SELECT sql FROM sqlite_master WHERE type='table' AND name='${table.name}'`, [], { single: false }),
})));
}
else {
tables =
(await db.db.prepare("SELECT name FROM sqlite_master WHERE type='table'")).all() || {};
schema = await Promise.all(Object.values(tables).map(async (table) => ({
name: table.name,
schema: await db.db
.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='${table.name}'`)
.get(),
})));
}
return schema;
}
catch (error) {
console.error(error);
}
}
async function generateMigration(currentSchema, targetSchema) {
if (!targetSchema?.length || targetSchema[0].name == null)
return { up: "", down: "" };
const currentTables = Object.fromEntries(currentSchema.map(({ name, schema }) => [
name,
Array.isArray(schema) ? schema[0].sql : schema.sql,
]));
const targetTables = Object.fromEntries(targetSchema.map(({ name, schema }) => [
name,
Array.isArray(schema) ? schema[0].sql : schema.sql,
]));
let upStatements = ["-- Up migration"];
let downStatements = ["-- Down migration"];
function parseSchema(sql) {
const columnRegex = /(?<column_name>\w+)\s+(?<data_type>\w+)(?:\s+(?<constraints>.*?))?(?:,|\))/gi;
const columnSectionMatch = sql.match(/\(([\s\S]+)\)/);
if (!columnSectionMatch)
return {};
const columnSection = columnSectionMatch[1];
const matches = columnSection.matchAll(columnRegex);
const columns = {};
for (const match of matches) {
const columnName = match.groups?.["column_name"] || "";
const dataType = match.groups?.["data_type"] || "";
const constraints = (match.groups?.["constraints"] || "").trim();
columns[columnName] = { type: dataType, constraints };
}
return columns;
}
let shouldMigrate = false;
for (const [tableName, currentSql] of Object.entries(currentTables)) {
const targetSql = targetTables[tableName];
if (!targetSql) {
shouldMigrate = true;
upStatements.push(`DROP TABLE ${tableName};`);
downStatements.push("-- " +
currentSql
.replace(tableName, `${tableName}_old`)
.replaceAll("\n", "\n--") +
";");
continue;
}
const currentColumns = parseSchema(currentSql);
const targetColumns = parseSchema(targetSql);
if (JSON.stringify(currentColumns) !== JSON.stringify(targetColumns)) {
shouldMigrate = true;
upStatements.push(targetSql.replace(tableName, `${tableName}_new`) + ";");
const commonColumns = Object.keys(currentColumns)
.filter((col) => targetColumns[col])
.join(", ");
upStatements.push(`INSERT INTO ${tableName}_new (${commonColumns}) SELECT ${commonColumns} FROM ${tableName};`);
upStatements.push(`DROP TABLE ${tableName};`);
upStatements.push(`ALTER TABLE ${tableName}_new RENAME TO ${tableName};`);
downStatements.push("-- " +
currentSql
.replace(tableName, `${tableName}_old`)
.replaceAll("\n", "\n--") +
";");
downStatements.push(`-- INSERT INTO ${tableName}_old (${commonColumns}) SELECT ${commonColumns} FROM ${tableName};`);
downStatements.push(`-- DROP TABLE ${tableName};`);
downStatements.push(`-- ALTER TABLE ${tableName}_old RENAME TO ${tableName};`);
}
}
for (const [tableName, targetSql] of Object.entries(targetTables)) {
if (!currentTables[tableName]) {
shouldMigrate = true;
upStatements.push(targetSql + ";");
downStatements.push(`-- DROP TABLE ${tableName};`);
}
}
return shouldMigrate
? { up: upStatements.join("\n"), down: downStatements.join("\n") }
: { up: "", down: "" };
}
async function createMigrationFileIfNeeded(migration) {
if (!migration?.up || !migration?.down)
return;
const timestamp = new Date().toString().split(" ").slice(0, 5).join("_");
const filename = `${timestamp}.sql`;
const filepath = join(SqliteBruv.migrationFolder, filename);
const filepath2 = join(SqliteBruv.migrationFolder, "migrate.ts");
const fileContent = `${migration.up}\n\n${migration.down}`;
try {
await mkdir(SqliteBruv.migrationFolder, { recursive: true }).catch((e) => { });
if (isDuplicateMigration(fileContent))
return;
await writeFile(filepath, fileContent, {});
await writeFile(filepath2, `// Don't rename this file or the directory
// import your db class correctly below and run the file to apply.
import { db } from "path/to/db-class-instance";
import { readFileSync } from "node:fs";
const filePath = "${filepath}";
const migrationQuery = readFileSync(filePath, "utf8");
const info = await db.raw(migrationQuery);
console.log(info);
// bun Bruv-migrations/migrate.ts
`);
console.log(`Created migration file: ${filename}`);
}
catch (error) {
console.error("Error during file system operations: ", error);
}
}
function isDuplicateMigration(newContent) {
const migrationFiles = readdirSync(SqliteBruv.migrationFolder);
for (const file of migrationFiles) {
const filePath = join(SqliteBruv.migrationFolder, file);
const existingContent = readFileSync(filePath, "utf8");
if (existingContent.trim() === newContent.trim()) {
return true;
}
}
return false;
}
const PROCESS_UNIQUE = randomBytes(5);
const buffer = Buffer.alloc(12);
const Id = () => {
let index = ~~(Math.random() * 0xffffff);
const time = ~~(Date.now() / 1000);
const inc = (index = (index + 1) % 0xffffff);
buffer[3] = time & 0xff;
buffer[2] = (time >> 8) & 0xff;
buffer[1] = (time >> 16) & 0xff;
buffer[0] = (time >> 24) & 0xff;
buffer[4] = PROCESS_UNIQUE[0];
buffer[5] = PROCESS_UNIQUE[1];
buffer[6] = PROCESS_UNIQUE[2];
buffer[7] = PROCESS_UNIQUE[3];
buffer[8] = PROCESS_UNIQUE[4];
buffer[11] = inc & 0xff;
buffer[10] = (inc >> 8) & 0xff;
buffer[9] = (inc >> 16) & 0xff;
return buffer.toString("hex");
};
const avoidError = (cb) => {
try {
cb();
return true;
}
catch (error) {
return false;
}
};