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,482 lines 98.6 kB
'use strict';

var Modules = require('./Modules.cjs');
var PuddySqlEngine = require('./PuddySqlEngine.cjs');
var PuddySqlTags = require('./PuddySqlTags.cjs');
var objChecker = require('./tiny-modules/basics/objChecker.cjs');

/**
 * 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 = objChecker.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) => Modules.pg.escapeLiteral(v)).join(', ')})
        )
      `.trim(),
            );
            cases.push(`WHEN ${conditions.join(' OR ')} THEN ${weight}`);
          } else if (typeof value === 'string') {
            const safeVal = Modules.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) => Modules.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 = Modules.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 (objChecker.isJsonObject(input)) {
      /** @type {string[]} */
      let result = [];

      // Processing aliases
      if (input.aliases) {
        if (!objChecker.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 (!objChecker.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) || objChecker.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 (!objChecker.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 (!objChecker.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 (objChecker.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 objChecker.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 (!objChecker.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 (!objChecker.isJsonObject(filter)) throw new Error('Invalid filter object for advancedUpdate');
    if (!objChecker.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 (!objChecker.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 && !objChecker.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 (!objChecker.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 (!objChecker.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 = objChecker.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 (!objChecker.isJsonObject(pCache) || !objChecker.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 (!objChecker.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 objChecker.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 (!objChecker.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 (!objChecker.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) &&
      !objChecker.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) &&
      !objChecker.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 (!objChecker.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 (objChecker.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 (!objChecker.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 (!objChecker.isJsonObject(criteria))
      throw new TypeError(`'searchData.q' must be a plain object or valid QueryGroup`);

    if (
      selectValue !== null &&
      typeof selectValue !== 'string' &&
      !Array.isArray(selectValue) &&
      !objChecker.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) && !objChecker.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) &&
      !objChecker.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 (!objChecker.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 (objChecker.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;
  }
}

module.exports = PuddySqlQuery;