UNPKG

puddysql

Version:

🍮 Powerful SQL toolkit for Node.js, built with flexibility and structure in mind. Easily manage SQLite3/PostgreSQL, advanced queries, smart tag systems, and full JSON-friendly filters.

1,057 lines 104 kB
import { isJsonObject } from 'tiny-essentials'; import { pg } from './Modules.mjs'; import PuddySqlEngine from './PuddySqlEngine.mjs'; import PuddySqlTags from './PuddySqlTags.mjs'; /** * Defines the schema structure used to create or modify SQL tables programmatically. * * Each entry in the array represents a single column definition as a 4-item tuple: * [columnName, columnType, columnOptions, columnMeta] * * - `columnName` (`string`) – The name of the column (e.g., `"id"`, `"username"`). * - `columnType` (`string`) – The SQL data type (e.g., `"TEXT"`, `"INTEGER"`, `"BOOLEAN"`). * - `columnOptions` (`string`) – SQL options like `NOT NULL`, `PRIMARY KEY`, `DEFAULT`, etc. * - `columnMeta` (`any`) – Arbitrary metadata related to the column (e.g., for UI, descriptions, tags). * * @typedef {Array<[string, string, string, string]>} SqlTableConfig */ /** * Represents the result of a paginated SQL query to locate the exact position of a specific item. * * @typedef {Object} FindResult * @property {number} page - The current page number where the item is located (starting from 1). * @property {number} pages - The total number of pages available in the dataset. * @property {number} total - The total number of items in the dataset. * @property {number} position - The exact index position of the item in the entire dataset (starting from 0). * @property {FreeObj} [item] - The actual item found, if included in the result. */ /** * Tag group definition used to build dynamic SQL clauses for tag filtering. * * @typedef {Object} TagCriteria - Tag group definition to build the clause from. * @property {string} [group.column] - SQL column name for tag data (defaults to `this.getColumnName()`). * @property {string} [group.tableName] - Optional table name used (defaults to `this.defaultTableName`). * @property {boolean} [group.allowWildcards=false] - Whether wildcards are allowed in matching. * @property {Array<string|string[]>} [group.include=[]] - Tag values or grouped OR conditions to include. */ /** * Represents the result of a paginated query. * * @typedef {Object} PaginationResult * @property {any[]} items - Array of items returned for the current page. * @property {number} totalPages - Total number of available pages based on the query and per-page limit. * @property {number} totalItems - Total number of items matching the query without pagination. */ /** * Represents a flexible select query input, allowing for different forms. * * @typedef {( * string | * string[] | * { * aliases?: Record<string, string>; // Mapping of display names to real column names. * values?: string[]; // List of column names to select. * boost?: { // Boost configuration for weighted ranking. * alias?: string; // The alias to associate with the boost configuration. * value?: BoostValue[]; // List of boost rules to apply. * }; * } | * null * )} SelectQuery */ /** * Parameter cache used to build the WHERE clause. * * @typedef {Object} Pcache - Parameter cache used to build the WHERE clause. * @property {number} [pCache.index=1] - Starting parameter index for SQL placeholders (e.g., `$1`, `$2`...). * @property {any[]} [pCache.values=[]] - Collected values for SQL query binding. */ /** * Represents a free-form object with unknown values and arbitrary keys. * * @typedef {Record<string | number | symbol, any>} FreeObj * * An object type where keys can be strings, numbers, or symbols, and values can be any unknown type. * Useful for generic data containers where the structure is not strictly defined. */ /** * Represents conditions used in a SQL WHERE clause. * * @typedef {Object} WhereConditions * @property {'OR'|'AND'|'or'|'and'} [group] - Logical operator to combine conditions (`AND`/`OR`). Case-insensitive. * Only used when `conditions` is provided. * @property {QueryGroup[]} [conditions] - Array of grouped `WhereConditions` or `QueryGroup` entries. * Used for nesting logical clauses. * * @property {string|null|undefined} [funcName] - Optional function name applied to the column (e.g., UPPER, LOWER). * @property {string|null|undefined} [operator] - Comparison operator (e.g., '=', 'LIKE', 'IN'). * @property {string|null|undefined} [value] - Value to compare against. * @property {string|null|undefined} [valType] - Custom function for value transformation (e.g., for SOUNDEX). * @property {'left'|'right'|null|undefined} [lPos] - Logical position indicator (e.g., 'left', 'right') for chaining. * @property {string|null|undefined} [newOp] - Replacement operator, used to override the main one. * @property {string|null|undefined} [column] - Name of the column to apply the condition on. */ /** * Represents a flexible condition group used in dynamic SQL WHERE clause generation. * * A `QueryGroup` can take two forms: * * 1. **Single condition object** — represents a single `WhereConditions` instance: * ```js * { * column: 'name', * operator: '=', * value: 'pudding' * } * ``` * * 2. **Named group of conditions** — an object mapping condition names or keys * to individual `WhereConditions` objects: * ```js * { * searchByName: { * column: 'name', * operator: 'ILIKE', * value: '%fluttershy%' * }, * searchByType: { * column: 'type', * operator: '=', * value: 'pegasus' * } * } * ``` * * This structure allows dynamic grouping of multiple WHERE conditions * (useful for advanced filters, tag clauses, or scoped searches). * * @typedef {WhereConditions | Record<string, WhereConditions>} QueryGroup */ /** * Represents a boosting rule for weighted query ranking. * * @typedef {Object} BoostValue * @property {string[]} [columns] - List of columns to apply the boost on. * @property {string} [operator='LIKE'] - Operator used in the condition (e.g., '=', 'LIKE'). * @property {string|string[]} [value] - Value to match in the condition. * @property {number} [weight=1] - Weight factor to boost results matching the condition. */ /** * Each join object must contain: * - `table`: The name of the table to join. * - `compare`: The ON clause condition. * - `type` (optional): One of the supported JOIN types (e.g., 'left', 'inner'). Defaults to 'left'. * * @typedef {{ table: string; compare: string; type?: string; }} JoinObj */ /** * @typedef {Object} TableSettings * @property {string} [name] * @property {SelectQuery} [select='*'] - SELECT clause configuration. Can be simplified; complex expressions are auto-formatted. * @property {string|null} [join=null] - Optional JOIN table name. * @property {string|null} [joinCompare='t.key = j.key'] - Condition used to match JOIN tables. * @property {string|null} [order=null] - Optional ORDER BY clause. * @property {string} [id='key'] - Primary key column name. * @property {string|null} [subId=null] - Optional secondary key column name. */ /** * Configuration settings for a SQL entity, defining how it should be queried and joined. * * @typedef {Object} Settings * @property {string} select - The default columns to select in a query (e.g., `"*"`, or `"id, name"`). * @property {string} name - The name of the main table or view. * @property {string} id - The primary key column name. * @property {string|null} joinCompare - Optional column used to match in JOIN conditions (e.g., `"main.id = sub.fk_id"`). * @property {string|null} order - Default column used to order results (e.g., `"created_at DESC"`). * @property {string|null} subId - Secondary identifier column name (for composite keys or scoped tables). * @property {string|null} join - SQL JOIN clause to apply (e.g., `"LEFT JOIN profiles ON users.id = profiles.user_id"`). */ /** * A function that takes a WhereConditions object and returns a modified WhereConditions object. * Typically used to append or transform SQL WHERE clauses. * * @typedef {(conditions: WhereConditions) => WhereConditions} WhereConditionsFunc */ /** * A map of condition identifiers to their associated transformation functions. * Each key represents a named SQL condition function. * * @typedef {Record<string, WhereConditionsFunc>} SqlConditions */ /** * TinySQLQuery is a queries operating system developed to operate in a specific table. */ class PuddySqlQuery { /** @type {SqlConditions} */ #conditions = {}; /** @type {Record<string, function(string) : string>} */ #customValFunc = {}; /** @type {PuddySqlEngine|null} */ #db = null; /** * @type {Settings} */ #settings = { joinCompare: '', select: '', name: '', id: '', order: null, subId: null, join: null, }; /** * @type {Record<string, { * type: string|null, * options: string|null, * }>} */ #table = {}; /** @type {Record<string, PuddySqlTags>} */ #tagColumns = {}; /** * Safely retrieves the internal database instance. * * This method ensures that the current internal `#db` is a valid instance of `PuddySqlEngine`. * If the internal value is invalid or was not properly initialized, an error is thrown. * * @returns {PuddySqlEngine} The internal database instance. * @throws {Error} If the internal database is not a valid `PuddySqlEngine`. */ getDb() { // @ts-ignore if (this.#db === null || !(this.#db instanceof PuddySqlEngine)) { throw new Error('Database instance is invalid or uninitialized. Expected an instance of PuddySqlEngine.'); } return this.#db; } constructor() { // Predefined condition operator mappings used in searches this.addCondition('LIKE', (condition) => ({ operator: 'LIKE', value: `${typeof condition.lPos !== 'string' || condition.lPos === 'left' ? '%' : ''}` + `${condition.value}` + `${typeof condition.lPos !== 'string' || condition.lPos === 'right' ? '%' : ''}`, })); this.addCondition('NOT', '!='); this.addCondition('=', '='); this.addCondition('!=', '!='); this.addCondition('>=', '>='); this.addCondition('<=', '<='); this.addCondition('>', '>'); this.addCondition('<', '<'); // Soundex with custom value handler this.addConditionV2('SOUNDEX', true); // Performs phonetic comparison based on how words sound. Example: SOUNDEX(name) = SOUNDEX('rainbow') // Case conversion this.addConditionV2('LOWER'); // Converts all characters in the column to lowercase. Example: LOWER(username) = 'fluttershy' this.addConditionV2('UPPER'); // Converts all characters in the column to uppercase. Example: UPPER(username) = 'FLUTTERSHY' // Trimming whitespace this.addConditionV2('TRIM'); // Removes leading and trailing whitespace. Example: TRIM(title) = 'pony party' this.addConditionV2('LTRIM'); // Removes leading whitespace only. Example: LTRIM(title) = 'pony party' this.addConditionV2('RTRIM'); // Removes trailing whitespace only. Example: RTRIM(title) = 'pony party' // String and value length this.addConditionV2('LENGTH'); // Returns the number of characters in the column. Example: LENGTH(comment) > 100 // Mathematical operations this.addConditionV2('ABS'); // Compares the absolute value of a column. Example: ABS(score) = 10 this.addConditionV2('ROUND'); // Rounds the numeric value of the column. Example: ROUND(rating) = 4 this.addConditionV2('CEIL', false, '>='); // Rounds the value up before comparison. Example: CEIL(price) >= 50 this.addConditionV2('FLOOR', false, '<='); // Rounds the value down before comparison. Example: FLOOR(price) <= 49 // Null and fallback handling this.addConditionV2('COALESCE'); // Uses a fallback value if the column is NULL. Example: COALESCE(nickname) = 'anonymous' // String formatting this.addConditionV2('HEX'); // Converts value to hexadecimal string. Example: HEX(id) = '1A3F' this.addConditionV2('QUOTE'); // Returns the string quoted. Example: QUOTE(title) = "'hello world'" // Character and Unicode this.addConditionV2('UNICODE'); // Gets the Unicode of the first character. Example: UNICODE(letter) = 9731 this.addConditionV2('CHAR'); // Converts a code point to its character. Example: CHAR(letter_code) = 'A' // Type inspection this.addConditionV2('TYPEOF'); // Returns the data type of the value. Example: TYPEOF(data_field) = 'text' // Date and time extraction this.addConditionV2('DATE'); // Extracts the date part. Example: DATE(timestamp) = '2025-04-15' this.addConditionV2('TIME'); // Extracts the time part. Example: TIME(timestamp) = '15:30:00' this.addConditionV2('DATETIME'); // Converts to full datetime. Example: DATETIME(created_at) = '2025-04-15 14:20:00' this.addConditionV2('JULIANDAY'); // Converts to Julian day number. Example: JULIANDAY(date_column) = 2460085.5 } /** * Checks whether a specific SQL condition function is registered. * * @param {string} key - The condition identifier to look up. * @returns {boolean} - Returns true if the condition exists, otherwise false. */ hasCondition(key) { if (!this.#conditions[key]) return false; return true; } /** * Retrieves a registered SQL condition function by its identifier. * * @param {string} key - The condition identifier to retrieve. * @returns {WhereConditionsFunc} - The associated condition function. * @throws {Error} If the condition does not exist. */ getCondition(key) { if (!this.hasCondition(key)) throw new Error('Condition not found: ' + key); return this.#conditions[key]; } /** * Returns a shallow copy of all registered SQL condition functions. * * @returns {SqlConditions} - An object containing all condition functions mapped by key. */ getConditions() { return { ...this.#conditions }; } /** * Registers a new condition under a unique key to be used in query generation. * * The `conditionHandler` determines how the condition will behave. It can be: * - A **string**, representing a SQL operator (e.g., '=', '!=', 'LIKE'); * - An **object**, which must include an `operator` key (e.g., { operator: '>=' }); * - A **function**, which receives a `condition` object and returns a full condition definition. * * If a `valueHandler` is provided, it must be a function that handles value transformation, * and will be stored under the same key in the internal value function map. * * This method does not allow overwriting an existing key in either condition or value handlers. * * @param {string} key - Unique identifier for the new condition type. * @param {string|WhereConditions|WhereConditionsFunc} conditionHandler - Defines the logic or operator of the condition. * @param {(function(string): string)|null} [valueHandler=null] - Optional custom function for value transformation (e.g., for SOUNDEX). * * @throws {Error} If the key is not a non-empty string. * @throws {Error} If the key already exists in either conditions or value handlers. * @throws {Error} If conditionHandler is not a string, object with `operator`, or function. * @throws {Error} If valueHandler is provided but is not a function. */ addCondition(key, conditionHandler, valueHandler = null) { if (typeof key !== 'string' || key.trim() === '') { throw new Error(`Condition key must be a non-empty string.`); } if (this.#conditions[key] || this.#customValFunc[key]) { throw new Error(`Condition key "${key}" already exists.`); } const isFunc = typeof conditionHandler === 'function'; const isStr = typeof conditionHandler === 'string'; const isObj = isJsonObject(conditionHandler); if (!isFunc && !isStr && !isObj) { throw new Error(`Condition handler must be a string (operator), an object with an "operator", or a function.`); } if (isObj) { if (typeof conditionHandler.operator !== 'string' || !conditionHandler.operator.trim()) { throw new Error(`When using an object as condition handler, it must contain a non-empty string "operator" field.`); } } if (valueHandler !== null && typeof valueHandler !== 'function') throw new Error(`Custom value handler must be a function if provided.`); // Add condition this.#conditions[key] = isStr ? () => ({ operator: conditionHandler }) : isObj ? () => ({ ...conditionHandler }) // Clone the object : conditionHandler; // function // Add value handler if provided if (valueHandler) this.#customValFunc[key] = valueHandler; } /** * Registers a SQL function-based condition with optional operator and value transformation. * * This helper wraps a SQL column in a function (e.g., `LOWER(column)`) and optionally * transforms the parameter using the same function (e.g., `LOWER($1)`), depending on config. * * It integrates with the dynamic condition system that uses: * - `#conditions[name]` for SQL structure generation * - `#customValFunc[valType]` for optional value transformations * * @param {string} funcName - SQL function name to wrap around the column (e.g., `LOWER`, `SOUNDEX`). * @param {boolean} [editParamByDefault=false] - If true, also applies the SQL function to the parameter by default. * @param {string} [operator='='] - Default SQL comparison operator (e.g., `=`, `!=`, `>`, `<`). * * ----------------------------------------------------- * * Runtime Behavior: * - Uses `group.newOp` (if provided) to override the default operator. * - Uses `group.funcName` (if string) to override the default function name used in `valType`. * - If `funcName !== null` and `editParamByDefault === true`, the function will also apply to the param. * - The final SQL looks like: FUNC(column) OP FUNC($n), if both sides use the same function. * * * The `group` object passed at runtime may include: * @param {Object} group * @param {string} group.column - The column name to apply the function on. * @param {string} [group.newOp] - Optional override for the comparison operator. * @param {string|null} [group.funcName] - Optional override for the SQL function name * (affects both SQL column and valType used in `#customValFunc`). * * @throws {TypeError} If `funcName` is not a non-empty string. * @throws {TypeError} If `editParamByDefault` is provided and is not a boolean. * @throws {TypeError} If `operator` is not a non-empty string. * * -------------------------------------------------------------------------------- * How it's used in the system: * * ```js * const result = this.#conditions[group.operator](group); * const param = typeof this.#customValFunc[result.valType] === 'function' * ? this.#customValFunc[result.valType](`$1`) * : `$1`; * const sql = `${result.column} ${result.operator} ${param}`; * ``` * * ----------------------------------------------------- * @example * // Registers a ROUND() comparison with "!=" * addConditionV2('ROUND', false, '!='); * * ----------------------------------------------------- * @example * // Registers LOWER() with editParamByDefault * addConditionV2('LOWER', true); * * // Parses as: LOWER(username) = LOWER($1) * parse({ column: 'username', value: 'fluttershy', operator: 'LOWER' }); * * ----------------------------------------------------- * @example * // Registers UPPER() = ? without editParamByDefault * addConditionV2('UPPER'); * * // Parses as: UPPER(username) = $1 * parse({ column: 'username', value: 'rarity', operator: 'UPPER' }); * * ----------------------------------------------------- * @example * // Can be overridden at runtime: * addConditionV2('CEIL', true); * * parse({ * column: 'price', * value: 3, * newOp: '>', * operator: 'CEIL', * funcName: null * }); * * // Result: CEIL(price) > 3 */ addConditionV2 = (funcName, editParamByDefault = false, operator = '=') => { if (typeof funcName !== 'string' || funcName.trim() === '') throw new TypeError(`funcName must be a non-empty string. Received: ${funcName}`); if (typeof editParamByDefault !== 'boolean') throw new TypeError(`editParamByDefault must be a boolean. Received: ${editParamByDefault}`); if (typeof operator !== 'string' || operator.trim() === '') throw new TypeError(`operator must be a non-empty string. Received: ${operator}`); return this.addCondition(funcName, (condition) => ({ operator: typeof condition.newOp === 'string' ? condition.newOp : operator, valType: typeof condition.funcName === 'string' ? condition.funcName : editParamByDefault && condition.funcName !== null ? funcName : null, column: `${funcName}(${condition.column})`, }), (param) => `${funcName}(${param})`); }; /** * Generates a SELECT clause based on the input, supporting SQL expressions, aliases, * and boosts using CASE statements. * * This method supports the following input formats: * * - `null` or `undefined`: returns '*' * - `string`: returns the parsed column/expression (with optional aliasing if `AS` is present) * - `string[]`: returns a comma-separated list of parsed columns * - `object`: supports structured input with: * - `aliases`: key-value pairs of column names and aliases * - `values`: array of column names or expressions * - `boost`: object describing a weighted relevance score using CASE statements * - Must include `alias` (string) and `value` (array of boost rules) * - Each boost rule supports: * - `columns` (string|string[]): target columns to apply the condition on (optional) * - `value` (string|array): value(s) to compare, or a raw SQL condition if `columns` is omitted * - `operator` (string): SQL comparison operator (default: 'LIKE', supports 'IN', '=', etc.) * - `weight` (number): numeric weight applied when condition matches (default: 1) * - If `columns` is omitted, the `value` is treated as a raw SQL condition inserted directly into the CASE. * * Escaping of all values is handled by `pg.escapeLiteral()` for SQL safety (PostgreSQL). * * @param {SelectQuery} [input = '*'] - Select clause definition. * @returns {string} - A valid SQL SELECT clause string. * * @throws {TypeError} If the input is of an invalid type. * @throws {Error} If `boost.alias` is missing or not a string. * @throws {Error} If `boost.value` is present but not an array. * * @example * this.selectGenerator(); * // returns '*' * * this.selectGenerator('COUNT(*) AS total'); * // returns 'COUNT(*) AS total' * * this.selectGenerator(['id', 'username']); * // returns 'id, username' * * this.selectGenerator({ * aliases: { * id: 'image_id', * uploader: 'user_name' * }, * values: ['created_at', 'score'] * }); * // returns 'id AS image_id, uploader AS user_name, created_at, score' * * this.selectGenerator({ * aliases: { * id: 'image_id', * uploader: 'user_name' * }, * values: ['created_at'], * boost: { * alias: 'relevance', * value: [ * { * columns: ['tags', 'description'], * value: 'fluttershy', * weight: 2 * }, * { * columns: 'tags', * value: 'pinkie pie', * operator: 'LIKE', * weight: 1.5 * }, * { * columns: 'tags', * value: 'oc', * weight: -1 * }, * { * value: "score > 100 AND views < 1000", * weight: 5 * } * ] * } * }); * // returns something like: * // CASE * // WHEN tags LIKE '%fluttershy%' OR description LIKE '%fluttershy%' THEN 2 * // WHEN tags LIKE '%pinkie pie%' THEN 1.5 * // WHEN tags LIKE '%oc%' THEN -1 * // WHEN score > 100 AND views < 1000 THEN 5 * // ELSE 0 * // END AS relevance, id AS image_id, uploader AS user_name, created_at */ selectGenerator(input = '*') { // If input is a string, treat it as a custom SQL expression if (typeof input === 'string') return this.parseColumn(input); /** * Boost parser helper * * @param {BoostValue[]} boostArray * @param {string} alias * @returns {string} */ const parseAdvancedBoosts = (boostArray, alias) => { if (!Array.isArray(boostArray)) throw new Error(`Boost 'value' must be an array. Received: ${typeof boostArray}`); if (typeof alias !== 'string') throw new Error(`Boost 'alias' must be an string. Received: ${typeof alias}`); const cases = []; // Boost for (const boost of boostArray) { // Validator const { columns, operator = 'LIKE', value, weight = 1 } = boost; if (typeof operator !== 'string') throw new Error(`operator requires an string value. Got: ${typeof operator}`); const opValue = operator.toUpperCase(); if (typeof weight !== 'number' || Number.isNaN(weight)) throw new Error(`Boost 'weight' must be a valid number. Got: ${weight}`); // No columns mode if (!columns) { if (typeof value !== 'string') throw new Error(`Boost with no columns must provide a raw SQL string condition. Got: ${typeof value}`); // No columns: treat value as raw condition cases.push(`WHEN ${value} THEN ${weight}`); continue; } // Check columns if (!Array.isArray(columns) || columns.some((col) => typeof col !== 'string')) throw new Error(`Boost 'columns' must be a string or array of strings. Got: ${columns}`); // In mode if (opValue === 'IN') { if (!Array.isArray(value)) throw new Error(`'${opValue}' operator requires an array value. Got: ${typeof value}`); const conditions = columns.map((col) => { const inList = value.map((v) => pg.escapeLiteral(v)).join(', '); return `${col} IN (${inList})`; }); cases.push(`WHEN ${conditions.join(' OR ')} THEN ${weight}`); } // Other modes else { if (typeof value !== 'string') throw new Error(`'${opValue}' operator requires an string value. Got: ${typeof value}`); const safeVal = pg.escapeLiteral(['LIKE', 'ILIKE'].includes(opValue) ? `%${value}%` : value); const conditions = columns.map((col) => `${col} ${operator} ${safeVal}`); cases.push(`WHEN ${conditions.join(' OR ')} THEN ${weight}`); } } return `CASE ${cases.join(' ')} ELSE 0 END AS ${alias}`; }; // If input is an array, join all columns if (Array.isArray(input)) { return (input .map((col) => this.parseColumn(col)) .filter(Boolean) .join(', ') || '*'); } // If input is an object, handle key-value pairs for aliasing (with boosts support) else if (isJsonObject(input)) { /** @type {string[]} */ let result = []; // Processing aliases if (input.aliases) { if (!isJsonObject(input.aliases)) throw new TypeError(`'aliases' must be an object. Got: ${typeof input.aliases}`); result = result.concat(Object.entries(input.aliases).map(([col, alias]) => this.parseColumn(col, alias))); } // If input is an array, join all columns if (input.values) { if (!Array.isArray(input.values)) throw new TypeError(`'values' must be an array. Got: ${typeof input.values}`); result.push(...input.values.map((col) => this.parseColumn(col))); } // Processing boosts if (input.boost) { if (!isJsonObject(input.boost)) throw new TypeError(`'boost' must be an object. Got: ${typeof input.boost}`); if (typeof input.boost.alias !== 'string') throw new Error('Missing or invalid boost.alias in selectGenerator'); if (input.boost.value) result.push(parseAdvancedBoosts(input.boost.value, input.boost.alias)); } // Complete if (result.length > 0) return result.join(', '); else throw new Error(`Invalid input object keys for selectGenerator. Expected non-empty string.`); } // Nothing else throw new Error(`Invalid input type for selectGenerator. Expected string, array, or object but received: ${typeof input}`); } /** * Helper function to parse individual columns or SQL expressions. * Supports aliasing and complex expressions. * * @param {string} column - Column name or SQL expression. * @param {string} [alias] - Alias for the column (optional). * @returns {string} - A valid SQL expression for SELECT clause. */ parseColumn(column, alias) { if (typeof column !== 'string') throw new TypeError(`column key must be string. Got: ${column}.`); if (typeof alias !== 'undefined' && typeof alias !== 'string') throw new TypeError(`Alias key must be string. Got: ${alias}.`); // If column contains an alias if (alias) { return `${column} AS ${alias}`; } return column; } // Helpers for JSON operations within SQL queries (SQLite-compatible) /** * @param {any} value * @returns {string} */ #sqlOpStringVal = (value) => { if (typeof value !== 'string') throw new Error(`SQL Op value must be string. Got: ${typeof value}.`); return value; }; // Example: WHERE json_extract(data, '$.name') = 'Rainbow Queen' /** * Extracts the value of a key from a JSON object using SQLite's json_extract function. * @param {string} where - The JSON column to extract from. * @param {string} name - The key or path to extract (dot notation). * @returns {string} SQL snippet to extract a value from JSON. */ getJsonExtract = (where = '', name = '') => `json_extract(${this.#sqlOpStringVal(where)}, '$.${this.#sqlOpStringVal(name)}')`; /** * Expands each element in a JSON array or each property in a JSON object into separate rows. * Intended for use in the FROM clause. * @param {string} source - JSON column or expression to expand. * @returns {string} SQL snippet calling json_each. */ getJsonEach = (source = '') => `json_each(${this.#sqlOpStringVal(source)})`; // Example: FROM json_each(json_extract(data, '$.tags')) /** * Unrolls a JSON array from a specific key inside a JSON column using json_each. * Ideal for iterating over array elements in a FROM clause. * @param {string} where - The JSON column containing the array. * @param {string} name - The key of the JSON array. * @returns {string} SQL snippet to extract and expand a JSON array. */ getArrayExtract = (where = '', name = '') => this.getJsonEach(this.getJsonExtract(where, name)); // Example: WHERE CAST(json_extract(data, '$.level') AS INTEGER) > 10 /** * Extracts a key from a JSON object and casts it to a given SQLite type (INTEGER, TEXT, REAL, etc.). * @param {string} where - The JSON column to extract from. * @param {string} name - The key or path to extract. * @param {string} type - The type to cast to (e.g., 'INTEGER', 'TEXT', 'REAL'). * @returns {string} SQL snippet with cast applied. */ getJsonCast = (where = '', name = '', type = 'NULL') => `CAST(${this.getJsonExtract(where, name)} AS ${this.#sqlOpStringVal(type).toUpperCase()})`; /** * Updates the table by adding, removing, modifying or renaming columns. * @param {SqlTableConfig} changes - An array of changes to be made to the table. * Each change is defined by an array, where: * - To add a column: ['ADD', 'columnName', 'columnType', 'columnOptions'] * - To remove a column: ['REMOVE', 'columnName'] * - To modify a column: ['MODIFY', 'columnName', 'newColumnType', 'newOptions'] * - To rename a column: ['RENAME', 'oldColumnName', 'newColumnName'] * @returns {Promise<void>} * * @throws {TypeError} If `changes` is not an array of arrays. * @throws {Error} If any change has missing or invalid parameters. */ async updateTable(changes) { const db = this.getDb(); if (!Array.isArray(changes)) throw new TypeError(`Expected 'changes' to be an array of arrays. Got: ${typeof changes}`); const tableName = this.#settings?.name; if (!tableName) throw new Error('Missing table name in settings'); for (const change of changes) { const [action, ...args] = change; if (!Array.isArray(change)) throw new TypeError(`Expected 'change value' to be an array of arrays. Got: ${typeof change}`); if (typeof action !== 'string') throw new TypeError(`Action type must be a string. Got: ${typeof action}`); switch (action.toUpperCase()) { case 'ADD': { const [colName, colType, colOptions = ''] = args; if (typeof colName !== 'string' || typeof colType !== 'string') throw new Error(`Invalid parameters for ADD: ${JSON.stringify(args)}`); const query = `ALTER TABLE ${tableName} ADD COLUMN ${colName} ${colType} ${colOptions}`; try { await db.run(query, undefined, 'updateTable - ADD'); } catch (err) { console.error('[sql] [updateTable - ADD] Error adding column:', err); } break; } case 'REMOVE': { const [colName] = args; if (typeof colName !== 'string') throw new Error(`Invalid parameters for REMOVE: ${JSON.stringify(args)}`); const query = `ALTER TABLE ${tableName} DROP COLUMN IF EXISTS ${colName}`; try { await db.run(query, undefined, 'updateTable - REMOVE'); } catch (err) { console.error('[sql] [updateTable - REMOVE] Error removing column:', err); } break; } case 'MODIFY': { const [colName, newType, newOptions] = args; if (typeof colName !== 'string' || typeof newType !== 'string' || (typeof newOptions !== 'undefined' && typeof newOptions !== 'string')) throw new Error(`Invalid parameters for MODIFY: ${JSON.stringify(args)}`); const query = `ALTER TABLE ${tableName} ALTER COLUMN ${colName} TYPE ${newType}${newOptions ? `, ALTER COLUMN ${colName} SET ${newOptions}` : ''}`; try { await db.run(query, undefined, 'updateTable - MODIFY'); } catch (err) { console.error('[sql] [updateTable - MODIFY] Error modifying column:', err); } break; } case 'RENAME': { const [oldName, newName] = args; if (typeof oldName !== 'string' || typeof newName !== 'string') throw new Error(`Invalid parameters for RENAME: ${JSON.stringify(args)}`); const query = `ALTER TABLE ${tableName} RENAME COLUMN ${oldName} TO ${newName}`; try { await db.run(query, undefined, 'updateTable - RENAME'); } catch (err) { console.error('[sql] [updateTable - RENAME] Error renaming column:', err); } break; } default: console.warn(`[sql] [updateTable] Unknown updateTable action: ${action}`); } } } /** * Drops the current table if it exists. * * This method executes a `DROP TABLE` query using the table name defined in `this.#settings.name`. * It's useful for resetting or cleaning up the database schema dynamically. * If the query fails due to connection issues (like `SQLITE_CANTOPEN` or `ECONNREFUSED`), * it rejects with the error; otherwise, it resolves with `false` to indicate failure. * On success, it resolves with `true`. * * @returns {Promise<boolean>} Resolves with `true` if the table was dropped, or `false` if there was an issue (other than connection errors). * @throws {Error} If there is an issue with the database or settings, or if the table can't be dropped. */ async dropTable() { const db = this.getDb(); return new Promise((resolve, reject) => { const query = `DROP TABLE ${this.#settings.name};`; db.run(query, undefined, 'dropTable') .then(() => resolve(true)) .catch((err) => { if (db.isConnectionError(err)) reject(err); // Rejects on connection-related errors else resolve(false); // Resolves with false on other errors }); }); } /** * Creates a table in the database based on provided column definitions. * Also stores the column structure in this.#table as an object keyed by column name. * If a column type is "TAGS", it will be replaced with "JSON" for SQL purposes, * and registered in #tagColumns using a PuddySqlTags instance, * but the original "TAGS" value will be preserved in this.#table. * @param {SqlTableConfig} columns - An array of column definitions. * Each column is defined by an array containing the column name, type, and optional configurations. * @returns {Promise<void>} * * @throws {TypeError} If any column definition is malformed. * @throws {Error} If table name is not defined in settings. */ async createTable(columns) { const db = this.getDb(); const tableName = this.#settings?.name; if (!tableName || typeof tableName !== 'string') throw new Error('Table name not defined in this.#settings.name'); if (!Array.isArray(columns)) throw new TypeError(`Expected columns to be an array. Got: ${typeof columns}`); // Start building the query let query = `CREATE TABLE IF NOT EXISTS ${tableName} (`; // Internal processing for SQL only (preserve original for #table) const sqlColumns = columns.map((column, i) => { if (!Array.isArray(column)) throw new TypeError(`Column definition at index ${i} must be an array. Got: ${typeof column}`); const col = [...column]; // shallow clone to avoid mutating original // Prepare to detect custom column type if (col.length >= 2 && typeof col[1] === 'string') { const [name, type] = col; if (typeof name !== 'string') throw new Error(`Expected 'name' to be string in index "${i}", got ${typeof name}`); if (typeof type !== 'string') throw new Error(`Expected 'type' to be string in index "${i}", got ${typeof type}`); // Tags if (type.toUpperCase() === 'TAGS') { col[1] = 'JSON'; this.#tagColumns[name] = new PuddySqlTags(name); this.#tagColumns[name].setIsPgMode(db.getSqlEngine() === 'postgre'); } } // If the column definition contains more than two items, it's a full definition if (col.length === 3) { if (typeof col[0] !== 'string') throw new Error(`Expected 'col[0]' to be string in index "${i}", got ${typeof col[0]}`); if (typeof col[1] !== 'string') throw new Error(`Expected 'col[1]' to be string in index "${i}", got ${typeof col[1]}`); if (typeof col[2] !== 'string') throw new Error(`Expected 'col[2]' to be string in index "${i}", got ${typeof col[2]}`); return `${col[0]} ${col[1]} ${col[2]}`; } // If only two items are provided, it's just the name and type (no additional configuration) else if (col.length === 2) { if (typeof col[0] !== 'string') throw new Error(`Expected 'col[0]' to be string in index "${i}", got ${typeof col[0]}`); if (typeof col[1] !== 'string') throw new Error(`Expected 'col[1]' to be string in index "${i}", got ${typeof col[1]}`); return `${col[0]} ${col[1]}`; } // If only one item is provided, it's a table setting (e.g., PRIMARY KEY) else if (col.length === 1) { if (typeof col[0] !== 'string') throw new Error(`Expected 'col[0]' to be string in index "${i}", got ${typeof col[0]}`); return col[0]; } throw new TypeError(`Invalid column definition at index ${i}: ${JSON.stringify(col)}`); }); // Join all column definitions into a single string query += sqlColumns.join(', ') + ')'; // Execute the SQL query to create the table using db.run await db.run(query, undefined, 'createTable'); // Save the table structure using an object with column names as keys this.#table = {}; for (const i in columns) { const column = columns[i]; if (column.length >= 2) { const [name, type, options] = column; if (typeof name !== 'string') throw new Error(`Invalid name of column definition at index ${i}: ${JSON.stringify(column)}`); if (typeof type !== 'undefined' && typeof type !== 'string') throw new Error(`Invalid type of column definition at index ${i}: ${JSON.stringify(column)}`); if (typeof options !== 'undefined' && typeof options !== 'string') throw new Error(`Invalid options of column definition at index ${i}: ${JSON.stringify(column)}`); this.#table[name] = { type: typeof type === 'string' ? type.toUpperCase().trim() : null, options: typeof options === 'string' ? options.toUpperCase().trim() : null, }; } } } /** * Checks whether a column is associated with a tag editor. * Tag editors are used for managing tag-based columns in SQL. * * @param {string} name - The column name to check. * @returns {boolean} - Returns true if the column has an associated tag editor. */ hasTagEditor(name) { if (this.#tagColumns[name]) return true; return false; } /** * Retrieves the PuddySqlTags instance associated with a specific column. * Used when the column was defined as a "TAGS" column in the SQL table definition. * * @param {string} name - The column name to retrieve the tag editor for. * @returns {PuddySqlTags} - The tag editor instance. * @throws {Error} If the column is not associated with a tag editor. */ getTagEditor(name) { if (typeof name !== 'string' || name.length < 1 || !this.hasTagEditor(name)) throw new Error('Tag editor not found for column: ' + name); return this.#tagColumns[name]; } /** * Returns a shallow copy of all column-to-tag-editor mappings. * * @returns {Record<string, PuddySqlTags>} - All tag editor instances mapped by column name. */ getTagEditors() { return { ...this.#tagColumns }; } /** * Utility functions to sanitize and convert raw database values * into proper JavaScript types for JSON compatibility and safe parsing. * * @type {Record<string, function(any) : unknown>} */ #jsonEscape = { /** * Converts truthy values to boolean `true`. * Accepts: true, "true", 1, "1" */ boolean: (raw) => raw === true || raw === 'true' || raw === 1 || raw === '1', /** * Converts values into BigInt. * Returns `null` if parsing fails or value is invalid. */ bigInt: (raw) => { if (typeof raw === 'bigint') return raw; else { let result; try { result = BigInt(raw); } catch { result = null; } return result; } }, /** * Converts values to integers using `parseInt`. * Floats are truncated if given as numbers. * Returns `null` on NaN. */ int: (raw) => { let result; try { result = typeof raw === 'number' ? raw : parseInt(raw); result = Math.trunc(result); if (Number.isNaN(result)) result = null; } catch { result = null; } return result; }, /** * Parses values as floating-point numbers. * Returns `null` if value is not a valid float. */ float: (raw) => { let result; try { result = typeof raw === 'number' ? raw : parseFloat(raw); if (Number.isNaN(result)) result = null; } catch { result = null; } return result; }, /** * Attempts to parse a string as JSON. * If already an object or array, returns the value as-is. * Otherwise returns `null` on failure. */ json: (raw) => { if (typeof raw === 'string') { let result; try { result = JSON.parse(raw); } catch { result = null; } return result; } else if (Array.isArray(raw) || isJsonObject(raw)) return raw; return null; }, /** * Parses or sanitizes tag input to ensure it is a valid array of strings. * - If the input is a JSON string, attempts to parse it as an array. * - If the input is already an array, ensures all elements are strings; non-string elements are set to `null`. * - Returns `null` if the input is neither a string nor an array, or if parsing fails. */ tags: (raw) => { let result; if (typeof raw === 'string') { try { result = JSON.parse(raw); } catch { result = null; } } if (Array.isArray(result)) { for (const index in result) if (typeof result[index] !== 'string') result[index] = null; return result; } return null; }, /** * Validates that the value is a string, otherwise returns `null`. */ text: (raw) => (typeof raw === 'string' ? raw : null), /** * Converts the value into a valid Date object. * Returns