Jake Vanderwerf
2026-08-30 a58a2e7ca4700d3e23341fde767060a4c2c704a3
inc/managers/CustomTable.php
@@ -2,6 +2,7 @@
namespace JVBase\managers;
use Exception;
use wpdb;
if (!defined('ABSPATH')) {
   exit;
@@ -19,8 +20,11 @@
 */
class CustomTable
{
   protected \wpdb $wpdb;
   private static ?wpdb $wpdb = null;
   private static string $prefix;
   protected string $tableName;
   protected bool $table_exists = false;
   protected string $definition;
   protected string $fullTableName;
   protected bool $useTransactions;
@@ -38,26 +42,35 @@
   protected static string $termIDType;
   protected static string $postIDType;
   protected static string $postTable;
   protected ?CustomTable $karmaTable = null;
   protected ?CustomTable $favouritesTable = null;
   protected Cache $cache;
   /**
    * Fluent factory method
    *
    * @param string $tableName Table name without prefix/BASE
    * @param string $tableName
    * @param bool $user
    * @param bool $auth
    * @param bool $useTransactions
    * @return self
    *
    * @example CustomTable::for('favourites')->insert($data);
    */
   public static function for(string $tableName): self
   public static function for(string $tableName, bool $user = false, bool $auth = false, bool $useTransactions = false): self
   {
      if (!isset(self::$instances[$tableName])) {
         self::$instances[$tableName] = new self($tableName);
         self::$instances[$tableName] = new self($tableName, $user, $auth, $useTransactions);
         self::$instances[$tableName]->setFlags();
      }
      return self::$instances[$tableName];
   }
      protected function setFlags():void
      {
         $this->table_exists = !is_null(self::$wpdb->get_var("SHOW TABLES LIKE '{$this->fullTableName}'"));
      }
   public static function destroyInstance(string $tableName):void
   {
      if (isset(self::$instances[$tableName])) {
@@ -72,6 +85,15 @@
      return self::$instances[$tableName];
   }
   public function setKarma(CustomTable $karma):void
   {
      $this->karmaTable = $karma;
   }
   public function setFavourite(CustomTable $favourites):void
   {
      $this->favouritesTable = $favourites;
   }
   public function ensureDefined() {
      $this->ensureUserTable();
      $this->ensureUserIDType();
@@ -81,20 +103,20 @@
      protected function ensureUserTable():void
      {
         if (!isset(static::$userTable)) {
            static::$userTable = is_multisite() ? $this->getMultisiteUsersTable() : $this->wpdb->users;
            static::$userTable = is_multisite() ? $this->getMultisiteUsersTable() : self::$wpdb->users;
         }
      }
         protected function getMultisiteUsersTable():string
         {
            $siteUsersTable = $this->wpdb->prefix . 'users';
            $siteExists = $this->wpdb->get_var(
               $this->wpdb->prepare("SHOW TABLES LIKE %s", $siteUsersTable)
            $siteUsersTable = self::$wpdb->prefix . 'users';
            $siteExists = self::$wpdb->get_var(
               self::$wpdb->prepare("SHOW TABLES LIKE %s", $siteUsersTable)
            );
            if ($siteExists) {
               return $siteUsersTable;
            }
            //fallback to main one
            return $this->wpdb->users;
            return self::$wpdb->users;
         }
      protected function ensureUserIDType():void
      {
@@ -107,27 +129,27 @@
      protected function ensureTermIDType():void
      {
         if (!isset(static::$termIDType)) {
            static::$termIDType = $this->getColumnType($this->wpdb->terms, 'term_id');
            static::$termIDType = $this->getColumnType(self::$wpdb->terms, 'term_id');
         }
      }
      protected function ensureTermTable():void
      {
         if (!isset(static::$termTable)) {
            static::$termTable = $this->wpdb->terms;
            static::$termTable = self::$wpdb->terms;
         }
      }
      protected function ensurePostIDType():void
      {
         if (!isset(static::$postIDType)) {
            static::$postIDType = $this->getColumnType($this->wpdb->posts, 'ID');
            static::$postIDType = $this->getColumnType(self::$wpdb->posts, 'ID');
         }
      }
      protected function ensurePostTable():void
      {
         if (!isset(static::$postTable)) {
            static::$postTable = $this->wpdb->posts;
            static::$postTable = self::$wpdb->posts;
         }
      }
@@ -142,6 +164,11 @@
      return $this;
   }
   public function getColumnKeys():array
   {
      return array_keys($this->columns);
   }
   /**
    * @param array $keys An array of {string} $keys. If a $key is an array, you can define a custom $key => $value, example: 'UNIQUE' => $value, or 'PRIMARY' => $value
    * @return $this
@@ -209,7 +236,7 @@
      require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
      foreach (self::$instances as $instance) {
         if (!$instance->wpdb->get_var("SHOW TABLES LIKE '{$instance->fullTableName}'")) {
         if (!$instance->table_exists) {
            $instance->createTable();
         }
      }
@@ -221,29 +248,29 @@
         error_log('[CustomTable]No definition set for '.$this->tableName);
         return;
      }
      if ($this->wpdb->get_var("SHOW TABLES LIKE '{$this->fullTableName}'")) {
      if ($this->table_exists) {
         return;
      }
      require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
      error_log('Creating Database Table: '. $this->tableName);
      $charset = self::$charsetCollate;
      $schema = "CREATE TABLE IF NOT EXISTS {$this->fullTableName} {$this->definition} {$charset}";
      $this->wpdb->flush();
      self::$wpdb->flush();
      $hasForeignKey = stripos($schema, 'FOREIGN KEY') !== false;
      if ($hasForeignKey) {
         $result = $this->wpdb->query($schema);
         $success = ($result !== false && !$this->wpdb->last_error);
         $result = self::$wpdb->query($schema);
         $success = ($result !== false && !self::$wpdb->last_error);
      } else {
         dbDelta($schema.';');
         $success = !$this->wpdb->last_error;
         $success = !self::$wpdb->last_error;
      }
      if (!$success) {
         $error_msg = "SQL Error creating table {$this->tableName}: " . $this->wpdb->last_error;
         $error_msg = "SQL Error creating table {$this->tableName}: " . self::$wpdb->last_error;
         error_log($error_msg);
         error_log("Failed SQL Query: " . $schema);
      } elseif ($this->wpdb->get_var("SHOW TABLES LIKE '{$this->fullTableName}'")) {
      } else {
         error_log("Successfully created table: {$this->fullTableName}");
      }
   }
@@ -256,23 +283,40 @@
      self::$instances = [];
   }
   protected function checkWPDB():void
   {
      if (is_null(self::$wpdb)) {
         global $wpdb;
         self::$wpdb = $wpdb;
         self::$prefix = $wpdb->prefix;
         $usersStatus = self::$wpdb->get_row("SHOW TABLE STATUS LIKE '{self::$wpdb->users}'");
         $parentCollation = $usersStatus->Collation ?? 'utf8mb4_general_ci';
         self::$charsetCollate = "DEFAULT CHARACTER SET utf8mb4 COLLATE {$parentCollation}";
      }
   }
   /**
    * @param string $tableName Table name without prefix/BASE (e.g., 'favourites', 'notifications')
    * @param bool $useTransactions Whether to auto-wrap operations in transactions
    */
   public function __construct(string $tableName, bool $useTransactions = false)
   private function __construct(string $tableName, bool $user = false, bool $auth = false, bool $useTransactions = false)
   {
      global $wpdb;
      $this->wpdb = $wpdb;
      $this->checkWPDB();
      $this->tableName = $tableName;
      $this->fullTableName = $wpdb->prefix . apply_filters('jvb_base', BASE) . $tableName;
      $this->fullTableName = self::$prefix . apply_filters('jvb_base', BASE) . $tableName;
      $this->useTransactions = $useTransactions;
      $this->cache = Cache::for($tableName);
      if ($user) {
         $this->cache->user();
      }
      if ($auth) {
         $this->cache->auth();
      }
      $usersStatus = $this->wpdb->get_row("SHOW TABLE STATUS LIKE '{$this->wpdb->users}'");
      $parentCollation = $usersStatus->Collation ?? 'utf8mb4_general_ci';
      self::$charsetCollate = "DEFAULT CHARACTER SET utf8mb4 COLLATE {$parentCollation}";
   }
@@ -311,17 +355,37 @@
      return $this;
   }
   public function orderByKarma(string $metric = 'karmic_score', string $direction = 'DESC'): self
   {
      if (!$this->karmaTable || !in_array($metric, ['upvotes', 'downvotes', 'karmic_score'])) {
         return $this->orderBy('created_at');
      }
      $this->builder['orderby'] = $metric;
      $this->builder['order'] = strtoupper($direction);
      return $this;
   }
   public function orderByFavourites(string $direction = 'DESC'): self
   {
      if (!$this->favouritesTable) {
         return $this->orderBy('created_at');
      }
      $this->builder['orderby'] = 'favourites_count';
      $this->builder['order'] = strtoupper($direction);
      return $this;
   }
   /**
    * Set LIMIT
    * Set pagination
    *
    * @param int $limit Number of records
    * @param int $offset Optional offset
    * @param int $perPage Number of records per page
    * @param int $page Page number (1-based)
    * @return self
    */
   public function limit(int $limit, int $offset = 0): self
   public function limit(int $perPage, int $page = 1): self
   {
      $this->builder['limit'] = $limit;
      $this->builder['offset'] = $offset;
      $this->builder['per_page'] = $perPage;
      $this->builder['page'] = $page;
      return $this;
   }
@@ -436,7 +500,7 @@
         $data['created_at'] = current_time('mysql');
      }
      $result = $this->wpdb->insert(
      $result = self::$wpdb->insert(
         $this->fullTableName,
         $data,
         $format
@@ -449,7 +513,7 @@
      $this->cache->flush();
      return $this->wpdb->insert_id;
      return self::$wpdb->insert_id;
   }
   /**
@@ -467,17 +531,15 @@
   /**
    * Find or create a record
    *
    * @param array $searchData Data to search for
    * @param array $createData Optional additional data for creation
    * @return array ['id' => int, 'created' => bool, 'record' => object]
    * @return int|false The ID of the created record, or false
    *
    * @example
    * $result = CustomTable::for('favourites')->findOrCreate(
    *     ['user_id' => 1, 'target_id' => 123],
    *     ['type' => 'tattoo']
    * );
    * // Returns: ['id' => 456, 'created' => false, 'record' => object]
    *  $result = CustomTable::for('favourites')->findOrCreate(
    *      ['user_id' => 1, 'target_id' => 123],
    *      ['type' => 'tattoo']
    *  );
    */
   public function findOrCreate(array $searchData, array $createData = []):int|false
   {
@@ -543,7 +605,7 @@
              (" . implode(',', $columns_escaped) . ")
              VALUES " . implode(',', $placeholders);
      $result = $this->wpdb->query($this->wpdb->prepare($query, $values));
      $result = self::$wpdb->query(self::$wpdb->prepare($query, $values));
      if ($result === false) {
         $this->logError('bulkInsert', ['rows' => count($rows)]);
@@ -570,37 +632,36 @@
   public function get(array $where, string $output = OBJECT): object|array|null
   {
      return $this->cache->remember(
         $this->cache->generateKey(array_merge(['output' => $output], $where)),
         array_merge(['output' => $output], $where),
         function() use ($where, $output) {
            $query = "SELECT * FROM {$this->fullTableName}";
            $where = $this->buildWhereClause($where);
            $query .= " WHERE {$where['sql']}";
            $values = $where['values'];
            return $this->wpdb->get_row($this->wpdb->prepare($query, $values), $output);
            return self::$wpdb->get_row(self::$wpdb->prepare($query, $values), $output);
         }
      );
   }
   /**
    * Get multiple records
    *
    * @param array $args Query arguments: where, orderby, order, limit, offset
    * @param bool $itemsOnly
    * @param string $output OBJECT, ARRAY_A, or ARRAY_N
    * @return array
    *
    * @example
    * $favs = $table->getMany([
    *     'where' => ['user_id' => 1],
    *     'orderby' => 'date_added',
    *     'order' => 'DESC',
    *     'limit' => 20
    * ]);
    *  $favs = $table->getMany([
    *      'where' => ['user_id' => 1],
    *      'orderby' => 'date_added',
    *      'order' => 'DESC',
    *      'per_page' => 20
    *  ]);
    */
   public function getMany(array $args = [], string $output = OBJECT): array
   public function getMany(array $args = [], bool $itemsOnly = true, string $output = OBJECT): array
   {
      return $this->cache->remember(
         $this->cache->generateKey(array_merge($args, ['output' => $output])),
      $items = $this->cache->remember(
         array_merge($args, ['output' => $output]),
         function () use ($args, $output) {
            $query = "SELECT * FROM {$this->fullTableName}";
            $values = [];
@@ -615,32 +676,75 @@
            // ORDER BY
            if (!empty($args['orderby'])) {
               $orderby = sanitize_sql_orderby($args['orderby']);
               $orderby = $this->resolveOrderby($args['orderby']);
               $order = (!empty($args['order']) && strtoupper($args['order']) === 'ASC') ? 'ASC' : 'DESC';
               $query .= " ORDER BY {$orderby} {$order}";
            }
            // LIMIT
            if (!empty($args['limit'])) {
               $limit = absint($args['limit']);
               $offset = !empty($args['offset']) ? absint($args['offset']) : 0;
               $query .= " LIMIT {$offset}, {$limit}";
            if (array_key_exists('limit', $args)) {
               error_log('[CustomTable]::getMany deprecated key \'limit\' - use \'per_page\' instead. '.print_r($args, true));
               $args['per_page'] = $args['limit'];
            }
            if (array_key_exists('offset', $args)) {
               error_log('[CustomTable]::getMany deprecated key \'offset\' - use \'page\' instead. '.print_r($args, true));
               $args['page'] = $args['offset'] / $args['limit'];
            }
            if (!empty($args['per_page'])) {
               $perPage = absint($args['per_page']);
               $page    = !empty($args['page']) ? absint($args['page']) : 1;
               $offset  = ($page - 1) * $perPage;
               $query  .= " LIMIT {$offset}, {$perPage}";
            }
            if (empty($values)) {
               return $this->wpdb->get_results($query, $output);
               return self::$wpdb->get_results($query, $output);
            }
            return $this->wpdb->get_results($this->wpdb->prepare($query, $values), $output);
            return self::$wpdb->get_results(self::$wpdb->prepare($query, $values), $output);
         }
      );
      if ($itemsOnly) {
         return $items;
      }
      $page = max(1, $args['page'] ?? 1);
      $perPage = $args['per_page']??false;
      $total = $this->count($args['where']);
      return [
         'items'     => $items,
         'total'     => $total,
         'has_more'  => $perPage && ($page * $perPage) < $total,
      ];
   }
   protected function resolveOrderby(string $orderby):string
   {
      $orderby = strtolower($orderby);
      if (in_array($orderby, ['upvotes', 'downvotes', 'karmic_score']) && $this->karmaTable) {
         $tableName = $this->karmaTable->getFullTableName();
         return match ($orderby) {
            'upvotes'   => "(SELECT COUNT(*) FROM {$tableName} k WHERE k.item_id = {$this->fullTableName}.id AND k.vote = 'up')",
            'downvotes' => "(SELECT COUNT(*) FROM {$tableName} k WHERE k.item_id = {$this->fullTableName}.id AND k.vote = 'down')",
            'karmic_score'     => "(SELECT COALESCE(SUM(CASE WHEN k.vote = 'up' THEN 1 WHEN k.vote = 'down' THEN -1 ELSE 0 END), 0) FROM {$tableName} k WHERE k.item_id = {$this->fullTableName}.id)",
         };
      }
      if ($orderby === 'favourites_count' && $this->favouritesTable) {
         $tableName = $this->favouritesTable->getFullTableName();
         return "(SELECT COUNT(*) FROM {$tableName} f WHERE f.target_id = {$this->fullTableName}.id)";
      }
      return sanitize_sql_orderby($orderby);
   }
   /**
    * Get a specific column value from all matches
    * @param string $column
    * @param array $where
    * @param string|null $orderby
    * @param string|null $order
    * @param int|null $limit
    * @return array
    */
   public function pluck(string $column, array $where = [], ?string $orderby = null, ?string $order = null, ?int $limit = null): array
@@ -648,7 +752,7 @@
      $key = array_merge($where, ['column' => $column, 'orderby' => $orderby, 'order' => $order, 'limit' => $limit]);
      return $this->cache->remember(
         $this->cache->generateKey($key),
         $key,
         function() use ($column, $where, $orderby, $order, $limit) {
            if (!empty($this->columns) && !array_key_exists($column, $this->columns)) {
               $this->logError('pluck', ['reason' => "Column '{$column}' not in definition"]);
@@ -656,13 +760,13 @@
            }
            $args = ['where' => $where];
            if ($orderby) {
               $args['orderby'] = $orderby;
               $args['orderby'] = $this->resolveOrderby($orderby);
            }
            if ($order) {
               $args['order'] = $order;
            }
            if ($limit) {
               $args['limit'] = $limit;
               $args['per_page'] = $limit;
            }
            return array_column($this->getMany($args), $column);
@@ -680,7 +784,7 @@
   public function count(array $where = []): int
   {
      return $this->cache->remember(
         $this->cache->generateKey(array_merge(['source' => 'count'], $where)),
         array_merge(['source' => 'count'], $where),
         function() use ($where) {
            $query = "SELECT COUNT(*) FROM {$this->fullTableName}";
            $values = [];
@@ -692,10 +796,10 @@
            }
            if (empty($values)) {
               return (int) $this->wpdb->get_var($query);
               return (int) self::$wpdb->get_var($query);
            }
            return (int) $this->wpdb->get_var($this->wpdb->prepare($query, $values));
            return (int) self::$wpdb->get_var(self::$wpdb->prepare($query, $values));
         }
      );
@@ -738,7 +842,7 @@
         $data['updated_at'] = current_time('mysql');
      }
      $result = $this->wpdb->update(
      $result = self::$wpdb->update(
         $this->fullTableName,
         $data,
         $where,
@@ -770,7 +874,7 @@
    */
   public function delete(array $where, ?array $where_format = null): int|false
   {
      $result = $this->wpdb->delete(
      $result = self::$wpdb->delete(
         $this->fullTableName,
         $where,
         $where_format
@@ -793,7 +897,7 @@
    *
    * @param string $query SQL query (use {table} as placeholder)
    * @param array $values Values for prepare()
    * @return mixed Query result
    * @return int|bool|\mysqli_result|null Query result
    *
    * @example
    * $results = $table->query(
@@ -801,15 +905,15 @@
    *     [1, 'pending', 'active']
    * );
    */
   public function query(string $query, array $values = []): mixed
   public function query(string $query, array $values = []): int|bool|null|\mysqli_result
   {
      $query = str_replace('{table}', $this->fullTableName, $query);
      if (empty($values)) {
         return $this->wpdb->query($query);
         return self::$wpdb->query($query);
      }
      return $this->wpdb->query($this->wpdb->prepare($query, $values));
      return self::$wpdb->query(self::$wpdb->prepare($query, $values));
   }
   /**
@@ -825,10 +929,10 @@
      $query = str_replace('{table}', $this->fullTableName, $query);
      if (empty($values)) {
         return $this->wpdb->get_results($query, $output);
         return self::$wpdb->get_results($query, $output);
      }
      return $this->wpdb->get_results($this->wpdb->prepare($query, $values), $output);
      return self::$wpdb->get_results(self::$wpdb->prepare($query, $values), $output);
   }
   /**
@@ -836,17 +940,17 @@
    *
    * @param string $query SQL query (use {table} as placeholder)
    * @param array $values Values for prepare()
    * @return mixed
    * @return string|null
    */
   public function queryVar(string $query, array $values = []): mixed
   public function queryVar(string $query, array $values = []): ?string
   {
      $query = str_replace('{table}', $this->fullTableName, $query);
      if (empty($values)) {
         return $this->wpdb->get_var($query);
         return self::$wpdb->get_var($query);
      }
      return $this->wpdb->get_var($this->wpdb->prepare($query, $values));
      return self::$wpdb->get_var(self::$wpdb->prepare($query, $values));
   }
   // =========================================================================
@@ -858,7 +962,7 @@
    */
   public function startTransaction(): void
   {
      $this->wpdb->query('START TRANSACTION');
      self::$wpdb->query('START TRANSACTION');
   }
   /**
@@ -866,7 +970,7 @@
    */
   public function commit(): void
   {
      $this->wpdb->query('COMMIT');
      self::$wpdb->query('COMMIT');
   }
   /**
@@ -874,7 +978,7 @@
    */
   public function rollback(): void
   {
      $this->wpdb->query('ROLLBACK');
      self::$wpdb->query('ROLLBACK');
   }
   /**
@@ -923,7 +1027,7 @@
    */
   public function getInsertId(): int
   {
      return $this->wpdb->insert_id;
      return self::$wpdb->insert_id;
   }
   /**
@@ -931,7 +1035,7 @@
    */
   public function getLastError(): string
   {
      return $this->wpdb->last_error;
      return self::$wpdb->last_error;
   }
   /**
@@ -939,7 +1043,7 @@
    */
   public function getAffectedRows(): int
   {
      return $this->wpdb->rows_affected;
      return self::$wpdb->rows_affected;
   }
   public function getUserIDType():string
@@ -977,16 +1081,16 @@
   // PRIVATE HELPERS
   // =========================================================================
   private function getColumnType(string $table, string $column):string|false {
      $tableExists = $this->wpdb->get_var(
         $this->wpdb->prepare("SHOW TABLES LIKE %s", $table)
      $tableExists = self::$wpdb->get_var(
         self::$wpdb->prepare("SHOW TABLES LIKE %s", $table)
      );
      if (!$tableExists) {
         error_log("[CustomTable] Table {$table} does not exist for getColumnType");
         return 'bigint(20) unsigned'; // safe fallback
      }
      $result = $this->wpdb->get_row(
         $this->wpdb->prepare(
      $result = self::$wpdb->get_row(
         self::$wpdb->prepare(
            "SELECT COLUMN_TYPE
            FROM INFORMATION_SCHEMA.COLUMNS
            WHERE TABLE_SCHEMA = DATABASE()
@@ -1005,7 +1109,7 @@
   private function tableExists():bool
   {
      return !is_null($this->wpdb->get_var($this->wpdb->prepare("SHOW TABLES LIKE %s", $this->fullTableName)));
      return !is_null(self::$wpdb->get_var(self::$wpdb->prepare("SHOW TABLES LIKE %s", $this->fullTableName)));
   }
   /**
    * Build WHERE clause from associative array
@@ -1017,16 +1121,34 @@
      foreach ($where as $column => $value) {
         $column_safe = esc_sql($column);
         // OR grouping: ['OR' => [ [...cond1], [...cond2] ]]
         if ($column === 'OR' && is_array($value)) {
            $orConditions = [];
            foreach ($value as $orWhere) {
               $orClause = $this->buildWhereClause($orWhere);
               $orConditions[] = $orClause['sql'];
               $values = array_merge($values, $orClause['values']);
            }
            $conditions[] = '(' . implode(' OR ', $orConditions) . ')';
            continue;
         }
         if ($value === null) {
            $conditions[] = "`{$column_safe}` IS NULL";
         } elseif (is_array($value) && count($value) === 2 && is_string($value[0])) {
            [$operator, $operand] = $value;
            $operator = strtoupper(trim($operator));
            if (in_array($operator, ['IN', 'NOT IN']) && is_array($operand)) {
            if ($operator === 'IS NOT NULL') {
               $conditions[] = "`{$column_safe}` IS NOT NULL";
            } elseif (in_array($operator, ['IN', 'NOT IN']) && is_array($operand)) {
               $placeholders = implode(',', array_map([$this, 'getPlaceholder'], $operand));
               $conditions[] = "`{$column_safe}` {$operator} ({$placeholders})";
               $values       = array_merge($values, $operand);
            } elseif (in_array($operator, ['BETWEEN', 'NOT BETWEEN']) && is_array($operand) && count($operand) === 2) {
               $conditions[] = "`{$column_safe}` {$operator} " . $this->getPlaceholder($operand[0]) . " AND " . $this->getPlaceholder($operand[1]);
               $values[]     = $operand[0];
               $values[]     = $operand[1];
            } else {
               // <, >, <=, >=, !=, LIKE, etc.
               $conditions[] = "`{$column_safe}` {$operator} " . $this->getPlaceholder($operand);
@@ -1063,7 +1185,7 @@
      static $cache = [];
      if (!isset($cache[$this->tableName])) {
         $columns = $this->wpdb->get_col("DESCRIBE {$this->fullTableName}");
         $columns = self::$wpdb->get_col("DESCRIBE {$this->fullTableName}");
         $cache[$this->tableName] = array_flip($columns);
      }
@@ -1076,14 +1198,19 @@
   private function logError(string $operation, array $context = []): void
   {
      if (function_exists('JVB')) {
         JVB()->error()->log(
         Errors::getInstance()->log(
            $this->tableName,
            "CustomTable {$operation} failed: " . $this->wpdb->last_error,
            $context,
            'error'
            "CustomTable {$operation} failed: " . self::$wpdb->last_error,
            $context
         );
      } else {
         error_log("[CustomTable:{$this->tableName}] {$operation} failed: " . $this->wpdb->last_error);
         error_log("[CustomTable:{$this->tableName}] {$operation} failed: " . self::$wpdb->last_error);
      }
   }
   public function sum(string $column, array $where):float
   {
      $rows = $this->pluck($column, $where);
      return array_sum($rows);
   }
}