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
JavaScript
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