UNPKG

sqlitebruv

Version:

A Simple and Efficient Query Builder for D1/Turso and Bun's SQLite

685 lines (683 loc) 24.9 kB
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; } };