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.

2,213 lines 106 kB
import { pg } from './Modules.mjs';
import PuddySqlEngine from './PuddySqlEngine.mjs';
import PuddySqlTags from './PuddySqlTags.mjs';
import { isJsonObject } from './tiny-modules/basics/objChecker.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 {''|'LIKE'|'ILIKE'} [operator=''] - Operator used in the condition (e.g., '=', 'LIKE').
 * @property {string|string[]} [value] - Value to match in the condition.
 * @property {boolean} [array=false] - When true, performs matching using `json_each()` for JSON/ARRAY columns instead of text comparison.
 * @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 TypeError(`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 TypeError(`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 TypeError(`When using an object as condition handler, it must contain a non-empty string "operator" field.`);
            }
        }
        if (valueHandler !== null && typeof valueHandler !== 'function')
            throw new TypeError(`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 TypeError(`Boost 'value' must be an array. Received: ${typeof boostArray}`);
            if (typeof alias !== 'string')
                throw new TypeError(`Boost 'alias' must be an string. Received: ${typeof alias}`);
            const cases = [];
            // Boost
            for (const boost of boostArray) {
                // Validator
                const { columns, operator = '', value, weight = 1, array = false } = boost;
                if (typeof operator !== 'string')
                    throw new TypeError(`operator requires an string value. Got: ${typeof operator}`);
                const opValue = operator.toUpperCase();
                if (typeof weight !== 'number' || Number.isNaN(weight))
                    throw new TypeError(`Boost 'weight' must be a valid number. Got: ${weight}`);
                if (['LIKE', 'ILIKE', ''].indexOf(opValue) < 0)
                    throw new TypeError(`Invalid operator '${opValue}'. Only 'LIKE', 'ILIKE', or empty string '' are allowed.`);
                // No columns mode
                if (!columns) {
                    if (typeof value !== 'string')
                        throw new TypeError(`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 TypeError(`Boost 'columns' must be a string or array of strings. Got: ${columns}`);
                // JSON/ARRAY Mode
                if (array === true) {
                    if (Array.isArray(value)) {
                        const conditions = columns.map((col) => `
        EXISTS (
          SELECT 1 FROM json_each(${col})
          WHERE json_each.value IN (${value.map((v) => pg.escapeLiteral(v)).join(', ')})
        )
      `.trim());
                        cases.push(`WHEN ${conditions.join(' OR ')} THEN ${weight}`);
                    }
                    else if (typeof value === 'string') {
                        const safeVal = pg.escapeLiteral(value);
                        const conditions = columns.map((col) => `
        EXISTS (
          SELECT 1 FROM json_each(${col})
          WHERE json_each.value = ${safeVal}
        )
      `.trim());
                        cases.push(`WHEN ${conditions.join(' OR ')} THEN ${weight}`);
                    }
                    else {
                        throw new TypeError(`'array' mode requires string or array value. Got: ${typeof value}`);
                    }
                    continue;
                }
                // IN Mode
                if (opValue === 'IN') {
                    if (!Array.isArray(value))
                        throw new TypeError(`'${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}`);
                    continue;
                }
                // Other modes (LIKE, =, etc.)
                if (typeof value !== 'string')
                    throw new TypeError(`'${opValue}' operator requires a 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 TypeError('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 TypeError(`Invalid input object keys for selectGenerator. Expected non-empty string.`);
        }
        // Nothing
        else
            throw new TypeError(`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 TypeError(`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 TypeError('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 TypeError(`Expected 'name' to be string in index "${i}", got ${typeof name}`);
                if (typeof type !== 'string')
                    throw new TypeError(`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 TypeError(`Expected 'col[0]' to be string in index "${i}", got ${typeof col[0]}`);
                if (typeof col[1] !== 'string')
                    throw new TypeError(`Expected 'col[1]' to be string in index "${i}", got ${typeof col[1]}`);
                if (typeof col[2] !== 'string')
                    throw new TypeError(`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 TypeError(`Expected 'col[0]' to be string in index "${i}", got ${typeof col[0]}`);
                if (typeof col[1] !== 'string')
                    throw new TypeError(`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 TypeError(`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 TypeError(`Invalid name of column definition at index ${i}: ${JSON.stringify(column)}`);
                if (typeof type !== 'undefined' && typeof type !== 'string')
                    throw new TypeError(`Invalid type of column definition at index ${i}: ${JSON.stringify(column)}`);
                if (typeof options !== 'undefined' && typeof options !== 'string')
                    throw new TypeError(`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 the original date if already valid,
         * or a new Date instance if parsable.
         * Returns `null` if parsing fails.
         */
        date: (raw) => {
            let date;
            try {
                date = raw instanceof Date ? raw : new Date(raw);
            }
            catch {
                date = null;
            }
            if (date !== null)
                return Number.isNaN(date.getTime()) ? null : date; // Valid date
            return null;
        },
    };
    /**
     * Maps SQL data types (as returned from metadata or schema)
     * to the appropriate conversion function from #jsonEscape.
     *
     * @type {Record<string, function(any) : unknown>}
     */
    #jsonEscapeAlias = {
        // Boolean aliases
        BOOLEAN: (raw) => this.#jsonEscape.boolean(raw),
        BOOL: (raw) => this.#jsonEscape.boolean(raw),
        // BigInt-compatible numeric types
        BIGINT: (raw) => this.#jsonEscape.bigInt(raw),
        DECIMAL: (raw) => this.#jsonEscape.bigInt(raw),
        NUMERIC: (raw) => this.#jsonEscape.bigInt(raw),
        // Integer aliases
        INTEGER: (raw) => this.#jsonEscape.int(raw),
        INT: (raw) => this.#jsonEscape.int(raw),
        SMALLINT: (raw) => this.#jsonEscape.int(raw),
        TINYINT: (raw) => this.#jsonEscape.int(raw),
        // Floating-point types
        REAL: (raw) => this.#jsonEscape.float(raw),
        FLOAT: (raw) => this.#jsonEscape.float(raw),
        DOUBLE: (raw) => this.#jsonEscape.float(raw),
        // JSON-compatible field
        JSON: (raw) => this.#jsonEscape.json(raw),
        TAGS: (raw) => this.#jsonEscape.tags(raw),
        // Textual representations
        TEXT: (raw) => this.#jsonEscape.text(raw),
        CHAR: (raw) => this.#jsonEscape.text(raw),
        VARCHAR: (raw) => this.#jsonEscape.text(raw),
        CLOB: (raw) => this.#jsonEscape.text(raw),
        // Date/time types
        DATE: (raw) => this.#jsonEscape.date(raw),
        DATETIME: (raw) => this.#jsonEscape.date(raw),
        TIMESTAMP: (raw) => this.#jsonEscape.date(raw),
        TIME: (raw) => this.#jsonEscape.date(raw),
    };
    /**
     * Parses and validates fields from result rows based on SQL types in this.#table.
     * Converts known SQL types to native JS types.
     *
     * Supported types: BOOLEAN, INTEGER, BIGINT, FLOAT, TEXT, JSON, DATE, TIMESTAMP, etc.
     *
     * @param {any} result - The result row to check.
     * @returns {FreeObj}
     */
    resultChecker(result) {
        if (!isJsonObject(result))
            return result;
        for (const item in result) {
            const column = this.#table?.[item];
            if (!column || result[item] == null)
                continue;
            const type = column.type ?? '';
            const raw = result[item];
            if (typeof this.#jsonEscapeAlias[type] === 'function')
                result[item] = this.#jsonEscapeAlias[type](raw);
        }
        return result;
    }
    /**
     * Escapes values inside the valueObj using type definitions from this.#table.
     * Only modifies the values that have a matching column in the table.
     * Uses the appropriate parser from #jsonEscapeAlias.
     * @param {FreeObj} valueObj - The object containing values to be escaped.
     * @returns {FreeObj} The same valueObj with its values escaped according to table definitions.
     */
    escapeValues(valueObj = {}) {
        for (const key in valueObj) {
            if (!valueObj.hasOwnProperty(key))
                continue;
            const columnDef = this.#table[key];
            if (columnDef && columnDef.type) {
                const type = columnDef.type.toUpperCase();
                const escapeFn = this.#jsonEscapeAlias[type];
                if (typeof escapeFn === 'function') {
                    valueObj[key] = escapeFn.call(this, valueObj[key]);
                }
            }
        }
        return valueObj;
    }
    /**
     * Set or update database settings by merging with existing ones.
     * This function ensures safe fallback values and formats the SELECT clause.
     *
     * @param {TableSettings} [settings={}] - Partial configuration to apply. Will be merged with current settings.
     * @param {PuddySqlEngine} [db] - PuddySql Instance.
     */
    setDb(settings = {}, db) {
        if (!isJsonObject(settings))
            throw new TypeError('Settings must be a plain object.');
        if (!(db instanceof PuddySqlEngine))
            throw new Error('Invalid type for db. Expected a PuddySql.');
        this.#db = db;
        const selectValue = typeof settings.select !== 'undefined'
            ? this.selectGenerator(settings.select)
            : this.#settings?.select || '*';
        /** @type {Settings} */
        const newSettings = {
            ...this.#settings,
            ...settings,
            select: '',
        };
        newSettings.select = selectValue;
        if (typeof newSettings.join !== 'string')
            newSettings.join = null;
        if (typeof newSettings.joinCompare !== 'string' && newSettings.join)
            newSettings.joinCompare = 't.key = j.key';
        if (typeof newSettings.order !== 'string')
            newSettings.order = null;
        if (typeof newSettings.id !== 'string')
            newSettings.id = 'key';
        if (typeof newSettings.subId !== 'string')
            newSettings.subId = null;
        this.#settings = newSettings;
    }
    /**
     * Maps database engines to the corresponding property used
     * to check the number of affected rows after a write operation.
     *
     * This is used to abstract the difference between drivers like:
     * - SQLite (uses `changes`)
     * - PostgreSQL (uses `rowCount`)
     *
     * @type {Record<string, string>}
     */
    #resultCounts = {
        sqlite3: 'changes',
        postgre: 'rowCount',
    };
    /**
     * Retrieves the number of affected rows from a database operation result.
     *
     * This method abstracts differences between database engines, such as:
     * - SQLite: returns `result.changes`
     * - PostgreSQL: returns `result.rowCount`
     * - Fallback: `result.rowsAffected`, if defined
     *
     * @param {FreeObj|null} result - The result object returned by the database driver.
     * @returns {number} The number of affected rows, or null if it can't be determined.
     */
    getResultCount(result) {
        const sqlEngine = this.getDb().getSqlEngine();
        if (isJsonObject(result))
            return sqlEngine.length > 0 && typeof result[this.#resultCounts[sqlEngine]] === 'number'
                ? // @ts-ignore
                    result[this.#resultCounts[sqlEngine]]
                : typeof result.rowsAffected === 'number'
                    ? result.rowsAffected
                    : 0;
        return 0;
    }
    /**
     * Check if a row with the given ID (and optional subId) exists.
     * @param {string|number} id - Primary key value.
     * @param {string|number} [subId] - Optional sub-ID for composite key.
     * @returns {Promise<boolean>}
     */
    async has(id, subId) {
        if (typeof id !== 'string' && typeof id !== 'number')
            throw new TypeError(`Expected 'id' to be string or number, got ${typeof id}`);
        if (typeof subId !== 'undefined' && typeof subId !== 'string' && typeof subId !== 'number')
            throw new TypeError(`Expected 'subId' to be string or number, got ${typeof subId}`);
        if (!this.#settings?.name || !this.#settings?.id)
            throw new Error('Invalid table settings: name and id must be defined.');
        const db = this.getDb();
        const useSub = this.#settings.subId && (typeof subId === 'string' || typeof subId === 'number')
            ? true
            : false;
        const params = [id];
        const query = `SELECT COUNT(*) FROM ${this.#settings.name} WHERE ${this.#settings.id} = $1${useSub ? ` AND ${this.#settings.subId} = $2` : ''} LIMIT 1`;
        // @ts-ignore
        if (useSub)
            params.push(subId);
        const result = await db.get(query, params, 'has');
        return isJsonObject(result) && result['COUNT(*)'] === 1 ? true : false;
    }
    /**
     * Type-specific value transformers for preparing data before insertion or update.
     * This object maps column types to functions that transform values accordingly.
     * Used internally by escapeValuesFix.
     *
     * @type {Record<string, function(any) : string>}
     */
    #jsonEscapeFix = {
        // Serializes any value into a JSON string.
        JSON: (raw) => JSON.stringify(raw),
        TAGS: (raw) => {
            const result = [];
            for (const tag of raw) {
                if (typeof tag === 'string')
                    result.push(tag);
                else
                    throw new TypeError('Invalid tag format: each tag must be a string.');
            }
            return JSON.stringify(result);
        },
    };
    /**
     * Applies type-specific escaping to a single value based on the table's column definition.
     * @param {any} v - The raw value to be escaped.
     * @param {string} name - The column name associated with the value.
     * @returns {any} The escaped value if a valid type and handler exist; otherwise, the original value.
     */
    escapeValuesFix(v, name) {
        const column = this.#table?.[name];
        if (!isJsonObject(column))
            throw new Error(`Column "${name}" does not exist in the table definition.`);
        const type = column.type ?? '';
        const func = this.#jsonEscapeFix[type];
        if (typeof func !== 'function')
            return v;
        else
            return func(v);
    }
    /**
     * Updates records based on a complex WHERE clause defined by a filter object.
     * Instead of relying solely on an ID (or subId), this method uses parseWhere to
     * generate the conditions, and updates the given fields in valueObj.
     *
     * @param {FreeObj} valueObj - An object representing the columns and new values for the update.
     * @param {QueryGroup} filter - An object containing the conditions for the WHERE clause.
     * @returns {Promise<number>} - Count of rows that were updated.
     */
    async advancedUpdate(valueObj = {}, filter = {}) {
        const db = this.getDb();
        // Validate parameters
        if (!isJsonObject(filter))
            throw new Error('Invalid filter object for advancedUpdate');
        if (!isJsonObject(valueObj) || Object.keys(valueObj).length === 0)
            throw new Error('No update values provided for advancedUpdate');
        // Set the SET clause and its parameters
        const columns = Object.keys(valueObj);
        const updateValues = Object.values(valueObj).map((v, index) => this.escapeValuesFix(v, columns[index]));
        const setClause = columns.map((col, index) => `${col} = $${index + 1}`).join(', ');
        // Creates a parameter cache for WHERE.
        // The initial index should be equal to updateValues.length + 1 to maintain the correct sequence.
        const whereCache = { index: updateValues.length + 1, values: [] };
        const whereClause = this.parseWhere(whereCache, filter);
        if (!whereClause) {
            throw new Error('Empty WHERE clause — update aborted for safety');
        }
        // Build the complete query
        const query = `UPDATE ${this.#settings.name} SET ${setClause} WHERE ${whereClause}`;
        const params = [...updateValues, ...whereCache.values];
        const result = await db.run(query, params, 'advancedUpdate');
        return this.getResultCount(result);
    }
    /**
     * Update an existing record with given data.
     * Will not insert if the record doesn't exist.
     * @param {string|number} id - Primary key value.
     * @param {FreeObj} valueObj - Data to update.
     * @returns {Promise<number>} Count of rows were updated.
     */
    async update(id, valueObj = {}) {
        const db = this.getDb();
        if (typeof id !== 'string' && typeof id !== 'number')
            throw new TypeError(`Expected 'id' to be string or number, got ${typeof id}`);
        if (!isJsonObject(valueObj) || Object.keys(valueObj).length === 0)
            throw new Error('No update values provided for update');
        const columns = Object.keys(valueObj);
        const values = Object.values(valueObj).map((v, index) => this.escapeValuesFix(v, columns[index]));
        const setClause = columns.map((col, index) => `${col} = $${index + 1}`).join(', ');
        const useSub = this.#settings.subId && typeof valueObj[this.#settings.subId] !== 'undefined';
        const query = `UPDATE ${this.#settings.name} SET ${setClause} WHERE ${this.#settings.id} = $${columns.length + 1}${useSub ? ` AND ${this.#settings.subId} = $${columns.length + 2}` : ''}`;
        const params = [...values, id];
        // @ts-ignore
        if (useSub)
            params.push(valueObj[this.#settings.subId]);
        const result = await db.run(query, params, 'update');
        return this.getResultCount(result);
    }
    /**
     * Insert or update one or more records with given data.
     *
     * ⚠️ **Important:** The table must have both `id` and `subId` configured as a composite **PRIMARY KEY**
     * (or as a **UNIQUE constraint**) for the upsert operation to work correctly with conflict resolution.
     *
     * If `valueObj` is an array, `id` must also be an array of the same length.
     * All objects inside the array must have identical keys.
     *
     * @param {string|number|Array<string|number>} id - Primary key value(s) for each record.
     * @param {FreeObj|FreeObj[]} valueObj - A single object or an array of objects containing the data to store.
     * @param {boolean} [onlyIfNew=false] - If true, only insert if the record(s) do not already exist.
     * @returns {Promise<FreeObj|FreeObj[]|null>} - Generated values will be returned, or null if nothing was generated.
     * @throws {Error} If `valueObj` is an array and `id` is not an array of the same length,
     *                 or if objects in `valueObj` array have mismatched keys.
     */
    async set(id, valueObj = {}, onlyIfNew = false) {
        const db = this.getDb();
        // Validate 'onlyIfNew'
        if (typeof onlyIfNew !== 'boolean')
            throw new TypeError(`Expected 'onlyIfNew' to be a boolean, but got ${typeof onlyIfNew}`);
        // Validate 'valueObj'
        const isValidArray = Array.isArray(valueObj);
        if (!isValidArray && !isJsonObject(valueObj))
            throw new TypeError(`Expected 'valueObj' to be an object or array of objects. Got: ${typeof valueObj}`);
        // Validate empty object
        if (!isValidArray && Object.keys(valueObj).length === 0)
            throw new Error(`No update values provided for 'set()'`);
        // Array form validations
        if (isValidArray) {
            if (!Array.isArray(id))
                throw new TypeError(`When 'valueObj' is an array, 'id' must also be an array (got ${typeof id})`);
            if (id.length !== valueObj.length)
                throw new Error(`Length mismatch: 'id' has ${id.length} items, but 'valueObj' has ${valueObj.length}`);
            // Validate that all entries in valueObj are valid objects with the same keys
            const expectedKeys = Object.keys(valueObj[0] ?? {});
            for (let i = 0; i < valueObj.length; i++) {
                const obj = valueObj[i];
                if (!isJsonObject(obj))
                    throw new TypeError(`Item at index ${i} in 'valueObj' is not a valid object`);
                const keys = Object.keys(obj);
                if (keys.length !== expectedKeys.length || !keys.every((k) => expectedKeys.includes(k)))
                    throw new Error(`Mismatched keys in 'valueObj' at index ${i}. Expected: [${expectedKeys.join(', ')}], got: [${keys.join(', ')}]`);
            }
        }
        else {
            // Single ID mode
            if (typeof id !== 'string' && typeof id !== 'number')
                throw new TypeError(`Expected 'id' to be a string or number when using single value mode`);
        }
        // Prepare validator
        const isArray = Array.isArray(valueObj);
        const objects = isArray ? valueObj : [valueObj];
        const ids = isArray ? (Array.isArray(id) ? id : []) : [id];
        // Check if all objects have the same id amount
        if (objects.length === 0)
            return null;
        if (isArray && ids.length !== objects.length)
            throw new Error('When valueObj is an array, id must also be an array of the same length');
        const columns = Object.keys(objects[0]);
        // Check if all objects have the same keys
        for (let i = 1; i < objects.length; i++) {
            const keys = Object.keys(objects[i]);
            if (keys.length !== columns.length || !columns.every((col) => keys.includes(col))) {
                throw new Error('All objects in valueObj array must have the same keys');
            }
        }
        // Prepare values
        const allParams = [];
        const valuePlaceholders = [];
        // Insert content
        for (let i = 0; i < objects.length; i++) {
            const obj = objects[i];
            const rowId = isArray ? ids[i] : ids[0];
            const values = [rowId, ...columns.map((col) => this.escapeValuesFix(obj[col], col))];
            allParams.push(...values);
            const offset = i * (columns.length + 1); // +1 for ID
            const placeholders = values.map((_, idx) => `$${offset + idx + 1}`).join(', ');
            valuePlaceholders.push(`(${placeholders})`);
        }
        let query = `INSERT INTO ${this.#settings.name} (${this.#settings.id}, ${columns.join(', ')}) 
                   VALUES ${valuePlaceholders.join(', ')}`;
        if (!onlyIfNew) {
            const updateClause = columns.map((col) => `${col} = excluded.${col}`).join(', ');
            query += ` ON CONFLICT(${this.#settings.id}${this.#settings.subId ? `, ${this.#settings.subId}` : ''}) 
                   DO UPDATE SET ${updateClause}`;
        }
        else {
            query += ` ON CONFLICT(${this.#settings.id}${this.#settings.subId ? `, ${this.#settings.subId}` : ''}) DO NOTHING`;
        }
        // Add returning ids to generated keys
        const genIds = [];
        let returnIds = '';
        for (const item in this.#table) {
            const column = this.#table?.[item];
            if (typeof objects[0][item] !== 'undefined')
                continue;
            const options = column.options || '';
            if (options.includes('PRIMARY KEY') ||
                options.includes('GENERATED ') ||
                options.includes('DEFAULT ')) {
                if (returnIds.length > 0)
                    returnIds += ', ';
                returnIds += item;
                genIds.push(item);
            }
        }
        if (genIds.length > 0)
            query += ` RETURNING ${returnIds}`;
        // Complete!
        const result = await (isArray
            ? db.all(query, allParams, 'multi-set')
            : db.get(query, allParams, 'set'));
        return result || null;
    }
    /**
     * Get a record by its ID (and optional subId).
     * @param {string|number} id - Primary key value.
     * @param {string|number} [subId] - Optional sub-ID for composite key.
     * @returns {Promise<FreeObj|null>}
     */
    async get(id, subId) {
        if (typeof id !== 'string' && typeof id !== 'number')
            throw new TypeError(`Expected 'id' to be string or number, got ${typeof id}`);
        if (typeof subId !== 'undefined' && typeof subId !== 'string' && typeof subId !== 'number')
            throw new TypeError(`Expected 'subId' to be string or number, got ${typeof subId}`);
        const db = this.getDb();
        const useSub = this.#settings.subId && (typeof subId === 'string' || typeof subId === 'number')
            ? true
            : false;
        const params = [id];
        const query = `SELECT ${this.#settings.select} FROM ${this.#settings.name} t 
                     ${this.insertJoin()} WHERE t.${this.#settings.id} = $1${useSub ? ` AND t.${this.#settings.subId} = $2` : ''}`;
        // @ts-ignore
        if (useSub)
            params.push(subId);
        const result = this.resultChecker(await db.get(query, params, 'get'));
        if (!result)
            return null;
        return result;
    }
    /**
     * Delete records based on a complex WHERE clause using a filter object.
     *
     * Uses the internal parseWhere method to build a flexible condition set.
     *
     * @param {QueryGroup} filter - An object containing the WHERE condition(s).
     * @returns {Promise<number>} - Number of rows deleted.
     */
    async advancedDelete(filter = {}) {
        const db = this.getDb();
        if (!isJsonObject(filter)) {
            throw new Error('Invalid filter object for advancedDelete');
        }
        /** @type {Pcache} */
        const pCache = { index: 1, values: [] };
        const whereClause = this.parseWhere(pCache, filter);
        if (!whereClause)
            throw new Error('Empty WHERE clause — deletion aborted for safety');
        const query = `DELETE FROM ${this.#settings.name} WHERE ${whereClause}`;
        const result = await db.run(query, pCache.values, 'advancedDelete');
        return this.getResultCount(result);
    }
    /**
     * Delete a record by its ID (and optional subId).
     * @param {string|number} id - Primary key value.
     * @param {string|number} [subId] - Optional sub-ID for composite key.
     * @returns {Promise<number>} - Count of rows were updated.
     */
    async delete(id, subId) {
        if (typeof id !== 'string' && typeof id !== 'number')
            throw new TypeError(`Expected 'id' to be string or number, got ${typeof id}`);
        if (typeof subId !== 'undefined' && typeof subId !== 'string' && typeof subId !== 'number')
            throw new TypeError(`Expected 'subId' to be string or number, got ${typeof subId}`);
        const db = this.getDb();
        const useSub = this.#settings.subId && (typeof subId === 'string' || typeof subId === 'number')
            ? true
            : false;
        const query = `DELETE FROM ${this.#settings.name} WHERE ${this.#settings.id} = $1${useSub ? ` AND ${this.#settings.subId} = $2` : ''}`;
        const params = [id];
        // @ts-ignore
        if (useSub)
            params.push(subId);
        const result = await db.run(query, params, 'delete');
        return this.getResultCount(result);
    }
    /**
     * Get a limited number of rows from the database.
     * If an ID is provided, returns only the matching record(s) up to the specified count.
     * @param {number} count - Number of rows to retrieve.
     * @param {string|number|null} [filterId=null] - Optional ID to filter by.
     * @param {SelectQuery} [selectValue='*'] - Defines which columns or expressions should be selected in the query.
     * @returns {Promise<FreeObj[]>}
     */
    async getAmount(count, filterId = null, selectValue = '*') {
        const db = this.getDb();
        if (typeof count !== 'number')
            throw new TypeError(`Expected 'count' to be number, got ${typeof count}`);
        if (filterId !== null && typeof filterId !== 'string' && typeof filterId !== 'number')
            throw new TypeError(`Expected 'filterId' to be string or number, got ${typeof filterId}`);
        const orderClause = this.#settings.order ? `ORDER BY ${this.#settings.order}` : '';
        const whereClause = filterId !== null ? `WHERE t.${this.#settings.id} = $1` : '';
        const limitClause = `LIMIT $${filterId !== null ? 2 : 1}`;
        const query = `SELECT ${this.selectGenerator(selectValue)} FROM ${this.#settings.name} t 
                   ${this.insertJoin()} 
                   ${whereClause}
                   ${orderClause} ${limitClause}`.trim();
        const params = filterId !== null ? [filterId, count] : [count];
        const results = await db.all(query, params, 'getAmount');
        for (const index in results)
            this.resultChecker(results[index]);
        return results;
    }
    /**
     * Get all records from the table.
     * If an ID is provided, returns only the matching record(s).
     * @param {string|number|null} [filterId=null] - Optional ID to filter by.
     * @param {SelectQuery} [selectValue='*'] - Defines which columns or expressions should be selected in the query.
     * @returns {Promise<FreeObj[]>}
     */
    async getAll(filterId = null, selectValue = '*') {
        if (filterId !== null && typeof filterId !== 'string' && typeof filterId !== 'number')
            throw new TypeError(`Expected 'filterId' to be string or number, got ${typeof filterId}`);
        const db = this.getDb();
        const orderClause = this.#settings.order ? `ORDER BY ${this.#settings.order}` : '';
        const whereClause = filterId !== null ? `WHERE t.${this.#settings.id} = $1` : '';
        const query = `SELECT ${this.selectGenerator(selectValue)} FROM ${this.#settings.name} t 
                   ${this.insertJoin()} 
                   ${whereClause}
                   ${orderClause}`.trim();
        const results = await db.all(query, filterId !== null ? [filterId] : [], 'getAll');
        for (const index in results)
            this.resultChecker(results[index]);
        return results;
    }
    /**
     * Executes a paginated query and returns results, total pages, and total item count.
     *
     * @param {string} query - The base SQL query (should not include LIMIT or OFFSET).
     * @param {any[]} params - The parameters for the SQL query.
     * @param {number} perPage - The number of items per page.
     * @param {number} page - The current page number (starting from 1).
     * @param {string} queryName - The query name to insert into the sql debug.
     * @returns {Promise<PaginationResult>}
     */
    async execPagination(query, params, perPage, page, queryName = '') {
        if (typeof query !== 'string')
            throw new TypeError(`Expected 'query' to be a string, got ${typeof query}`);
        if (!Array.isArray(params))
            throw new TypeError(`Expected 'params' to be an array, got ${typeof params}`);
        if (!Number.isInteger(perPage) || perPage < 0)
            throw new RangeError(`'perPage' must be a non-negative integer. Received: ${perPage}`);
        if (!Number.isInteger(page) || page < 1)
            throw new RangeError(`'page' must be an integer >= 1. Received: ${page}`);
        if (typeof queryName !== 'string')
            throw new TypeError(`Expected 'queryName' to be a string, got ${typeof queryName}`);
        const db = this.getDb();
        const offset = (page - 1) * perPage;
        const isZero = perPage < 1;
        // Count total items
        const countQuery = `SELECT COUNT(*) as total FROM (${query}) AS count_wrapper`;
        const countResult = !isZero
            ? await db.get(countQuery, params, `pagination-${queryName}`)
            : { total: 0 };
        const total = isJsonObject(countResult)
            ? typeof countResult.total === 'number' &&
                !Number.isNaN(countResult.total) &&
                Number.isFinite(countResult.total) &&
                countResult.total >= 0
                ? countResult.total
                : 0
            : 0;
        // Fetch paginated items
        const paginatedQuery = `${query} LIMIT ? OFFSET ?`;
        const items = !isZero
            ? await db.all(paginatedQuery, [...params, perPage, offset], `pagination-${queryName}`)
            : [];
        const totalPages = !isZero ? Math.ceil(total / perPage) : 0;
        for (const index in items)
            this.resultChecker(items[index]);
        return {
            items,
            totalPages,
            totalItems: total,
        };
    }
    /**
     * Builds a SQL WHERE clause from a nested or flat condition structure.
     *
     * This internal helper method parses logical groupings (AND/OR) and formats the conditions into
     * SQL syntax, while managing parameter placeholders and values.
     *
     * It supports:
     * - Nested condition groups via `group` and `conditions`.
     * - Flat object-based filtering (legacy/fallback support).
     * - Single-condition objects.
     * - Dynamic operators through the internal `#conditions` handler.
     *
     * @param {Pcache} [pCache={ index: 1, values: [] }] - Placeholder cache object.
     * @param {QueryGroup} [group={}] - Grouped or single filter condition.
     * @returns {string} SQL-formatted WHERE clause (without the "WHERE" keyword).
     *
     * @example
     * const pCache = { index: 1, values: [] };
     * const clause = this.parseWhere(pCache, {
     *   group: 'OR',
     *   conditions: [
     *     { column: 'status', value: 'active' },
     *     { column: 'role', value: 'admin', operator: '=' }
     *   ]
     * });
     * // clause: "(status = $1) OR (role = $2)"
     * // pCache.values: ['active', 'admin']
     */
    parseWhere(pCache = { index: 1, values: [] }, group = {}) {
        if (!isJsonObject(pCache) || !isJsonObject(group))
            return '';
        if (typeof pCache.index !== 'number')
            pCache.index = 1;
        if (!Array.isArray(pCache.values))
            pCache.values = [];
        if (Array.isArray(group.conditions)) {
            const logic = typeof group.group === 'string' && group.group.toUpperCase() === 'OR' ? 'OR' : 'AND';
            const innerConditions = group.conditions.map((cond) => {
                return `(${this.parseWhere(pCache, cond)})`;
            });
            return innerConditions.join(` ${logic} `);
        }
        /**
         * @param {*} valType
         * @returns {string}
         */
        const getParamResult = (valType) => {
            if (typeof pCache.index !== 'number')
                throw new Error('Invalid pCache index');
            const newIndex = pCache.index++;
            return typeof this.#customValFunc[valType] === 'function'
                ? this.#customValFunc[valType](`$${newIndex}`)
                : `$${newIndex}`;
        };
        // Flat object fallback for backward compatibility
        if (!group.column) {
            const entries = Object.entries(group);
            const logic = 'AND';
            const innerConditions = entries.map(([newCol, cond]) => {
                if (!isJsonObject(cond))
                    throw new Error(`Invalid parseWhere to col ${newCol}.`);
                let col = newCol;
                let operator = '=';
                let value = cond.value;
                let valType = cond.valType;
                if (typeof cond.operator === 'string') {
                    const selected = cond.operator.toUpperCase();
                    if (typeof this.#conditions[selected] === 'function') {
                        const result = this.#conditions[selected](cond);
                        if (typeof result.operator === 'string')
                            operator = result.operator;
                        if (typeof result.value !== 'undefined')
                            value = result.value;
                        if (typeof result.column === 'string')
                            col = result.column;
                        if (typeof result.valType === 'string')
                            valType = result.valType;
                    }
                }
                if (!Array.isArray(pCache.values))
                    throw new Error('Invalid pCache values');
                pCache.values.push(value);
                return `(${col} ${operator} $${getParamResult(valType)})`;
            });
            return innerConditions.join(` ${logic} `);
        }
        // If it's a single condition
        let col = group.column;
        let operator = '=';
        let value = group.value;
        let valType = group.valType;
        if (typeof group.operator === 'string') {
            const selected = group.operator.toUpperCase();
            if (typeof this.#conditions[selected] === 'function') {
                const result = this.#conditions[selected](group);
                if (typeof result.operator === 'string')
                    operator = result.operator;
                if (typeof result.column === 'string')
                    col = result.column;
                if (typeof result.valType === 'string')
                    valType = result.valType;
                if (typeof result.value !== 'undefined')
                    value = result.value;
            }
        }
        pCache.values.push(value);
        return `${col} ${operator} ${getParamResult(valType)}`;
    }
    /**
     * Generates a default LEFT JOIN clause based on internal settings.
     *
     * This method is used as a fallback when no custom join is provided.
     * It expects `this.#settings.join` to be a string containing the table name,
     * and `this.#settings.joinCompare` to be the ON condition.
     *
     * @returns {string} The default LEFT JOIN SQL snippet, or an empty string if no join is configured.
     */
    insertJoin() {
        return typeof this.#settings.join === 'string'
            ? `LEFT JOIN ${this.#settings.join} j ON ${this.#settings.joinCompare ?? ''}`
            : '';
    }
    /**
     * An object containing standard SQL JOIN types.
     * Each property represents a commonly used SQL JOIN keyword.
     * These JOINs define how to combine rows from two or more tables.
     */
    #joinTypes = {
        /**
         * INNER JOIN:
         * Returns only the rows where there is a match in both tables.
         * This is the most commonly used JOIN.
         *
         * Example:
         * SELECT * FROM table1
         * INNER JOIN table2 ON table1.id = table2.fk_id;
         */
        inner: 'INNER JOIN',
        /**
         * LEFT JOIN (or LEFT OUTER JOIN):
         * Returns all rows from the left table, and matched rows from the right table.
         * If there is no match, the result will contain NULLs for the right table.
         *
         * Example:
         * SELECT * FROM table1
         * LEFT JOIN table2 ON table1.id = table2.fk_id;
         */
        left: 'LEFT JOIN',
        /**
         * RIGHT JOIN (or RIGHT OUTER JOIN):
         * Returns all rows from the right table, and matched rows from the left table.
         * If there is no match, the result will contain NULLs for the left table.
         *
         * Example:
         * SELECT * FROM table1
         * RIGHT JOIN table2 ON table1.id = table2.fk_id;
         */
        right: 'RIGHT JOIN',
        /**
         * FULL JOIN (or FULL OUTER JOIN):
         * Returns all rows from both tables.
         * If there is no match, NULLs will be returned for the missing side.
         *
         * Example:
         * SELECT * FROM table1
         * FULL OUTER JOIN table2 ON table1.id = table2.fk_id;
         */
        full: 'FULL JOIN',
        /**
         * CROSS JOIN:
         * Returns the Cartesian product of both tables.
         * Every row from the first table is combined with every row from the second table.
         *
         * Example:
         * SELECT * FROM table1
         * CROSS JOIN table2;
         */
        cross: 'CROSS JOIN',
        /**
         * JOIN (default syntax, behaves like INNER JOIN):
         * Equivalent to INNER JOIN when used without LEFT/RIGHT/FULL keywords.
         * This is just a shorthand and often used in quick queries.
         *
         * Example:
         * SELECT * FROM table1
         * JOIN table2 ON table1.id = table2.fk_id;
         */
        join: 'JOIN',
    };
    /**
     * Parses and generates JOIN clauses based on the provided configuration.
     *
     * Supports multiple formats:
     * - If `join` is a single object: returns a single JOIN clause.
     * - If `join` is an array of objects: generates multiple JOINs with aliases (`j1`, `j2`, ...).
     * - If `join` is invalid or empty: falls back to `insertJoin()` using internal settings.
     *
     * @param {JoinObj|JoinObj[]|string|null} [join] - The join configuration(s).
     * @returns {string} One or more JOIN SQL snippets.
     */
    parseJoin(join) {
        /**
         * @param {JoinObj} j
         * @param {number} idx
         * @returns {string}
         */
        const insertJoin = (j, idx) => {
            const alias = `j${idx + 1}`;
            const typeKey = typeof j.type === 'string' ? j.type.toLowerCase() : 'left';
            // @ts-ignore
            const joinType = this.#joinTypes[typeKey];
            if (typeof joinType !== 'string') {
                throw new Error(`Invalid JOIN type: '${j.type}'. Supported types: ${Object.keys(this.#joinTypes).join(', ')}`);
            }
            return `${joinType} ${j.table} ${alias} ON ${j.compare}`;
        };
        return isJsonObject(join)
            ? [join].map(insertJoin).join(' ')
            : Array.isArray(join)
                ? join.map(insertJoin).join(' ')
                : typeof join === 'string'
                    ? join
                    : this.insertJoin();
    }
    /**
     * Finds the first item matching the filter, along with its position, page, and total info.
     * Uses a single SQL query to calculate everything efficiently.
     *
     * If selectValue is null, it only returns the pagination/position data, not the item itself.
     *
     * @param {Object} [searchData={}] - Main search configuration.
     * @param {QueryGroup} [searchData.q={}] - Nested criteria object.
     * @param {TagCriteria[]|TagCriteria|null} [searchData.tagCriteria] - One or multiple tag criteria groups.
     * @param {string[]} [searchData.tagCriteriaOps] - Optional logical operators between tag groups (e.g., ['AND', 'OR']).
     * @param {boolean} [searchData.isFlatTags=false] - Use the parseWhereFlat mode to tags.
     * @param {number} [searchData.perPage] - Number of items per page.
     * @param {SelectQuery} [searchData.select='*'] - Which columns to select. Set to null to skip item data.
     * @param {string} [searchData.order] - SQL ORDER BY clause. Defaults to configured order.
     * @param {string|JoinObj|JoinObj[]} [searchData.join] - JOIN definitions with table, compare, and optional type.
     * @returns {{ query: string; values: any[] | undefined; perPage: number; selectValue: SelectQuery; }}
     * @throws {Error} If searchData has invalid structure or values.
     */
    findQuery(searchData = {}) {
        // --- Validate searchData types ---
        if (!isJsonObject(searchData))
            throw new TypeError(`'searchData' must be a object`);
        const criteria = searchData.q ?? {};
        const tagCriteria = searchData.tagCriteria ?? null;
        const isFlatTags = searchData.isFlatTags ?? false;
        const tagCriteriaOps = Array.isArray(searchData.tagCriteriaOps)
            ? searchData.tagCriteriaOps
            : [];
        const selectValue = searchData.select ?? '*';
        const perPage = searchData.perPage ?? null;
        const order = searchData.order ?? this.#settings.order;
        const joinConfig = searchData.join ?? null;
        if (!isJsonObject(criteria))
            throw new TypeError(`'searchData.q' must be a plain object or nested QueryGroup`);
        if (perPage == null || typeof perPage !== 'number' || !Number.isInteger(perPage) || perPage < 1)
            throw new TypeError(`'searchData.perPage' must be a positive integer (≥ 1), got: ${perPage}`);
        if (selectValue !== null &&
            typeof selectValue !== 'string' &&
            !Array.isArray(selectValue) &&
            !isJsonObject(selectValue))
            throw new TypeError(`'searchData.select' must be a string, array, object or null`);
        if (order !== undefined && order !== null && typeof order !== 'string')
            throw new TypeError(`'searchData.order' must be a string if defined`);
        if (joinConfig !== null &&
            typeof joinConfig !== 'string' &&
            !Array.isArray(joinConfig) &&
            !isJsonObject(joinConfig))
            throw new TypeError(`'searchData.join' must be a string, object, array, or null`);
        /** @type {Pcache} */
        const pCache = { index: 1, values: [] };
        const whereParts = [];
        // Apply base criteria
        if (Object.keys(criteria).length) {
            whereParts.push(this.parseWhere(pCache, criteria));
        }
        // Apply tagCriteria logic
        if (Array.isArray(tagCriteria)) {
            tagCriteria.forEach((group, i) => {
                if (!isJsonObject(group) || typeof group.column !== 'string')
                    throw new TypeError(`Each item in 'tagCriteria' must be an object`);
                if (typeof group.column !== 'undefined' && typeof group.column !== 'string')
                    throw new TypeError(`'group.column' must be a string if defined`);
                const tag = this.getTagEditor(group.column);
                const clause = !isFlatTags
                    ? tag.parseWhere(group, pCache)
                    : tag.parseWhereFlat(group, pCache);
                if (!clause)
                    return;
                const op = i > 0 ? tagCriteriaOps[i - 1] || 'AND' : null;
                if (op)
                    whereParts.push(op);
                whereParts.push(clause);
            });
        }
        else if (isJsonObject(tagCriteria) && typeof tagCriteria.column === 'string') {
            if (typeof tagCriteria.column !== 'undefined' && typeof tagCriteria.column !== 'string')
                throw new TypeError(`'tagCriteria.column' must be a string if defined`);
            const tag = this.getTagEditor(tagCriteria.column);
            const clause = !isFlatTags
                ? tag.parseWhere(tagCriteria, pCache)
                : tag.parseWhereFlat(tagCriteria, pCache);
            if (clause)
                whereParts.push(clause);
        }
        const whereClause = whereParts.length ? `WHERE ${whereParts.join(' ')}` : '';
        const orderClause = order ? `ORDER BY ${order}` : '';
        // Avoid selecting data if selectValue is null
        const selectedColumns = selectValue === null ? '' : `${this.selectGenerator(selectValue)},`;
        const query = `
    WITH matched AS (
      SELECT ${selectedColumns}
             ROW_NUMBER() OVER (${orderClause || 'ORDER BY (SELECT 1)'}) AS rn,
             COUNT(*) OVER () AS total
      FROM ${this.#settings.name} t
      ${this.parseJoin(joinConfig)}
      ${whereClause}
    )
    SELECT *, rn AS position, CEIL(CAST(total AS FLOAT) / ${perPage}) AS pages
    FROM matched
    WHERE rn = 1
  `.trim();
        return { query, values: pCache.values, perPage, selectValue };
    }
    /**
     * Finds the first item matching the filter, along with its position, page, and total info.
     * Uses a single SQL query to calculate everything efficiently.
     *
     * If selectValue is null, it only returns the pagination/position data, not the item itself.
     *
     * @param {Object} [searchData={}] - Main search configuration.
     * @param {QueryGroup} [searchData.q={}] - Nested criteria object.
     * @param {TagCriteria[]|TagCriteria|null} [searchData.tagCriteria] - One or multiple tag criteria groups.
     * @param {string[]} [searchData.tagCriteriaOps] - Optional logical operators between tag groups (e.g., ['AND', 'OR']).
     * @param {number} [searchData.perPage] - Number of items per page.
     * @param {SelectQuery} [searchData.select='*'] - Which columns to select. Set to null to skip item data.
     * @param {string} [searchData.order] - SQL ORDER BY clause. Defaults to configured order.
     * @param {string|JoinObj|JoinObj[]} [searchData.join] - JOIN definitions with table, compare, and optional type.
     * @returns {Promise<FindResult | null>}
     * @throws {Error} If searchData has invalid structure or values.
     */
    async find(searchData = {}) {
        const db = this.getDb();
        const { query, values, perPage, selectValue } = this.findQuery(searchData);
        const row = await db.get(query, values, 'find');
        if (!row)
            return null;
        const total = parseInt(row.total);
        const pages = parseInt(row.pages);
        const position = parseInt(row.position);
        const page = Math.floor((position - 1) / perPage) + 1;
        /** @type {FindResult} */
        const response = { page, pages, total, position };
        // If selectValue is NOT null, return the item
        if (selectValue !== null) {
            delete row.rn;
            delete row.total;
            delete row.pages;
            delete row.position;
            this.resultChecker(row);
            response.item = row;
        }
        // Complete
        return response;
    }
    /**
     * Perform a filtered search with advanced nested criteria, pagination, and customizable settings.
     *
     * Supports complex logical groupings (AND/OR), flat condition style, custom ordering, and single or multiple joins.
     * Pagination can be enabled using `perPage`, and additional settings like `order`, `join`, and `limit` can be passed inside `searchData`.
     *
     * @param {Object} [searchData={}] - Main search configuration.
     * @param {QueryGroup} [searchData.q={}] - Nested criteria object.
     *        Can be a flat object style or grouped with `{ group: 'AND'|'OR', conditions: [...] }`.
     * @param {TagCriteria[]|TagCriteria|null} [searchData.tagsQ] - One or multiple tag criteria groups.
     * @param {string[]} [searchData.tagsOpsQ] - Optional logical operators between tag groups (e.g., ['AND', 'OR']).
     * @param {SelectQuery} [searchData.select='*'] - Defines which columns or expressions should be selected in the query.
     * @param {number|null} [searchData.perPage=null] - Number of results per page. If set, pagination is applied.
     * @param {boolean} [searchData.isFlatTags=false] - Use the parseWhereFlat mode to tags.
     * @param {number} [searchData.page=1] - Page number to retrieve when `perPage` is used.
     * @param {string} [searchData.order] - Custom `ORDER BY` clause (e.g. `'created_at DESC'`).
     * @param {string|JoinObj|JoinObj[]} [searchData.join] - A string for single join or array of objects for multiple joins.
     *        Each object should contain `{ table: 'name', compare: 'ON clause' }`.
     * @param {number} [searchData.limit] - Max number of results to return (ignored when `perPage` is used).
     * @returns {{ query: string; perPage: number | null; values: any[]; page: number; }}
     * @throws {Error} If searchData has invalid structure or values.
     *
     * @example
     * // Flat search:
     * await table.search({ q: { status: { value: 'active' } } });
     *
     * // Grouped search:
     * await table.search({
     *   q: {
     *     group: 'AND',
     *     conditions: [
     *       { column: 'status', value: 'active' },
     *       {
     *         group: 'OR',
     *         conditions: [
     *           { column: 'role', value: 'admin' },
     *           { column: 'role', value: 'mod' }
     *         ]
     *       }
     *     ]
     *   }
     * });
     *
     * // With pagination and custom joins:
     * await table.search({
     *   q: { status: { value: 'active' } },
     *   select: '*',
     *   perPage: 10,
     *   page: 2,
     *   join: [
     *     { type: 'left', table: 'profiles', compare: 't.profile_id = j1.id' },
     *     { type: 'left', table: 'roles', compare: 'j1.role_id = j2.id' }
     *   ],
     *   order: 'created_at DESC'
     * });
     */
    searchQuery(searchData = {}) {
        if (!isJsonObject(searchData))
            throw new TypeError(`'searchData' must be a object`);
        const order = searchData.order ?? this.#settings.order;
        const join = searchData.join ?? this.#settings.join;
        const limit = searchData.limit ?? null;
        const selectValue = searchData.select ?? '*';
        const perPage = searchData.perPage ?? null;
        const page = searchData.page ?? 1;
        const criteria = searchData.q ?? {};
        const tagCriteria = searchData.tagsQ ?? {};
        const isFlatTags = searchData.isFlatTags ?? false;
        const tagCriteriaOps = searchData.tagsOpsQ;
        // --- Validate searchData types ---
        if (!isJsonObject(criteria))
            throw new TypeError(`'searchData.q' must be a plain object or valid QueryGroup`);
        if (selectValue !== null &&
            typeof selectValue !== 'string' &&
            !Array.isArray(selectValue) &&
            !isJsonObject(selectValue))
            throw new TypeError(`'searchData.select' must be a string, array, object or null`);
        if (order !== undefined && order !== null && typeof order !== 'string')
            throw new TypeError(`'searchData.order' must be a string if defined`);
        if (join !== null && typeof join !== 'string' && !Array.isArray(join) && !isJsonObject(join))
            throw new TypeError(`'searchData.join' must be a string, array, object or null`);
        if (limit !== null && typeof limit !== 'number')
            throw new TypeError(`'searchData.limit' must be a number if defined`);
        if (perPage !== null &&
            (typeof perPage !== 'number' || !Number.isInteger(perPage) || perPage < 1))
            throw new TypeError(`'searchData.perPage' must be a positive integer if defined`);
        if (typeof page !== 'number' || !Number.isInteger(page) || page < 1)
            throw new TypeError(`'searchData.page' must be a positive integer`);
        if (tagCriteria !== undefined &&
            tagCriteria !== null &&
            !Array.isArray(tagCriteria) &&
            !isJsonObject(tagCriteria))
            throw new TypeError(`'searchData.tagsQ' must be an array, object or null`);
        if (tagCriteriaOps !== undefined && tagCriteriaOps !== null && !Array.isArray(tagCriteriaOps))
            throw new TypeError(`'searchData.tagsOpsQ' must be an array if defined`);
        /** @type {Pcache} */
        const pCache = { index: 1, values: [] };
        // Where
        const whereParts = [];
        if (Object.keys(criteria).length) {
            whereParts.push(this.parseWhere(pCache, criteria));
        }
        if (Array.isArray(tagCriteria)) {
            const operators = Array.isArray(tagCriteriaOps) ? tagCriteriaOps : [];
            tagCriteria.forEach((group, i) => {
                if (!isJsonObject(group) || typeof group.column !== 'string')
                    throw new TypeError(`Each item in 'tagsQ' must be a valid object`);
                const tag = this.getTagEditor(group.column);
                const clause = !isFlatTags
                    ? tag.parseWhere(group, pCache)
                    : tag.parseWhereFlat(group, pCache);
                if (!clause)
                    return;
                const op = i > 0 ? operators[i - 1] || 'AND' : null;
                if (op)
                    whereParts.push(op);
                whereParts.push(clause);
            });
        }
        else if (isJsonObject(tagCriteria) && typeof tagCriteria.column === 'string') {
            const tag = this.getTagEditor(tagCriteria.column);
            const clause = !isFlatTags
                ? tag.parseWhere(tagCriteria, pCache)
                : tag.parseWhereFlat(tagCriteria, pCache);
            if (clause)
                whereParts.push(clause);
        }
        const whereClause = whereParts.length ? `WHERE ${whereParts.join(' ')}` : '';
        const { values } = pCache;
        if (!Array.isArray(values))
            throw new Error('Invalid pCache.values');
        // Order by
        const orderClause = order ? `ORDER BY ${order}` : '';
        // Limit
        const limitClause = typeof perPage === 'number' ? '' : typeof limit === 'number' ? `LIMIT ${limit}` : '';
        // Query
        const query = `SELECT ${this.selectGenerator(selectValue)} FROM ${this.#settings.name} t 
                       ${this.parseJoin(join)} 
                       ${whereClause} 
                       ${orderClause} 
                       ${limitClause}`.trim();
        return { query, perPage, values, page };
    }
    /**
     * Perform a filtered search with advanced nested criteria, pagination, and customizable settings.
     *
     * Supports complex logical groupings (AND/OR), flat condition style, custom ordering, and single or multiple joins.
     * Pagination can be enabled using `perPage`, and additional settings like `order`, `join`, and `limit` can be passed inside `searchData`.
     *
     * @param {Object} [searchData={}] - Main search configuration.
     * @param {QueryGroup} [searchData.q={}] - Nested criteria object.
     *        Can be a flat object style or grouped with `{ group: 'AND'|'OR', conditions: [...] }`.
     * @param {TagCriteria[]|TagCriteria|null} [searchData.tagsQ] - One or multiple tag criteria groups.
     * @param {string[]} [searchData.tagsOpsQ] - Optional logical operators between tag groups (e.g., ['AND', 'OR']).
     * @param {SelectQuery} [searchData.select='*'] - Defines which columns or expressions should be selected in the query.
     * @param {number|null} [searchData.perPage=null] - Number of results per page. If set, pagination is applied.
     * @param {number} [searchData.page=1] - Page number to retrieve when `perPage` is used.
     * @param {string} [searchData.order] - Custom `ORDER BY` clause (e.g. `'created_at DESC'`).
     * @param {string|JoinObj|JoinObj[]} [searchData.join] - A string for single join or array of objects for multiple joins.
     *        Each object should contain `{ table: 'name', compare: 'ON clause' }`.
     * @param {number} [searchData.limit] - Max number of results to return (ignored when `perPage` is used).
     * @returns {Promise<FreeObj[]|PaginationResult>} - Result rows matching the query.
     * @throws {Error} If searchData has invalid structure or values.
     */
    async search(searchData = {}) {
        const db = this.getDb();
        const { query, values, perPage, page } = this.searchQuery(searchData);
        // Results
        let results;
        // Pagination
        if (typeof perPage === 'number' && perPage > -1)
            results = await this.execPagination(query, values, perPage, page, 'search');
        // Normal
        else {
            results = await db.all(query, values, 'search');
            for (const index in results)
                this.resultChecker(results[index]);
        }
        // Complete
        return results;
    }
}
export default PuddySqlQuery;