Jake Vanderwerf
9 days ago 47e77f9fac1155c536b2b87fec552c7fcce66fa6
inc/managers/CustomTable.php
@@ -21,11 +21,25 @@
{
   protected \wpdb $wpdb;
   protected string $tableName;
   protected string $definition;
   protected string $fullTableName;
   protected bool $useTransactions;
   protected array $columns;
   protected array $keys = [];
   protected array $constraints =[];
   /** @var array<string, self> Instance cache for fluent interface */
   protected static array $instances = [];
   protected static string $charsetCollate;
   protected static string $userTable;
   protected static string $userIDType;
   protected static string $termTable;
   protected static string $termIDType;
   protected static string $postIDType;
   protected static string $postTable;
   protected Cache $cache;
   /**
    * Fluent factory method
@@ -41,8 +55,198 @@
         self::$instances[$tableName] = new self($tableName);
      }
      return self::$instances[$tableName];
   }
   public static function destroyInstance(string $tableName):void
   {
      if (isset(self::$instances[$tableName])) {
         unset(self::$instances[$tableName]);
      }
   }
   public static function getInstance(string $tableName):self|false
   {
      if (!isset(self::$instances[$tableName])) {
         return false;
      }
      return self::$instances[$tableName];
   }
   public function ensureDefined() {
      $this->ensureUserTable();
      $this->ensureUserIDType();
      $this->ensureTermIDType();
      $this->ensurePostIDType();
   }
      protected function ensureUserTable():void
      {
         if (!isset(static::$userTable)) {
            static::$userTable = is_multisite() ? $this->getMultisiteUsersTable() : $this->wpdb->users;
         }
      }
         protected function getMultisiteUsersTable():string
         {
            $siteUsersTable = $this->wpdb->prefix . 'users';
            $siteExists = $this->wpdb->get_var(
               $this->wpdb->prepare("SHOW TABLES LIKE %s", $siteUsersTable)
            );
            if ($siteExists) {
               return $siteUsersTable;
            }
            //fallback to main one
            return $this->wpdb->users;
         }
      protected function ensureUserIDType():void
      {
         if (!isset(static::$userIDType)) {
            $this->ensureUserTable();
            static::$userIDType = $this->getColumnType(static::$userTable, 'ID');
         }
      }
      protected function ensureTermIDType():void
      {
         if (!isset(static::$termIDType)) {
            static::$termIDType = $this->getColumnType($this->wpdb->terms, 'term_id');
         }
      }
      protected function ensureTermTable():void
      {
         if (!isset(static::$termTable)) {
            static::$termTable = $this->wpdb->terms;
         }
      }
      protected function ensurePostIDType():void
      {
         if (!isset(static::$postIDType)) {
            static::$postIDType = $this->getColumnType($this->wpdb->posts, 'ID');
         }
      }
      protected function ensurePostTable():void
      {
         if (!isset(static::$postTable)) {
            static::$postTable = $this->wpdb->posts;
         }
      }
   /**
    *
    * @param array $columns An array of $columnName => $columnDefinition
    * @return $this
    */
   public function setColumns(array $columns):self
   {
      $this->columns = $columns;
      return $this;
   }
   /**
    * @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
    */
   public function setKeys(array $keys):self
   {
      $this->keys = $keys;
      return $this;
   }
   /**
    * @param array $constraints an array of arrays, each value a $constraint => $references
    * @return $this
    */
   public function setConstraints(array $constraints):self
   {
      $this->constraints = $constraints;
      return $this;
   }
   public function defineTable(): self
   {
      if (empty($this->columns)) {
         error_log('[CustomTable] No columns defined for ' . $this->tableName);
         return $this;
      }
      $parts = [];
      // Columns
      foreach ($this->columns as $name => $type) {
         $parts[] = "`{$name}` {$type}";
      }
      // Keys
      if (empty($this->keys)) {
         $parts[] = 'PRIMARY KEY (`' . array_key_first($this->columns) . '`)';
      } else {
         foreach ($this->keys as $key) {
            if (is_array($key)) {
               $value = $key['value'];
               // Ensure value is wrapped in parentheses
               if (!str_contains(trim($value), '(`')) {
                  $value = '(`' . $value . '`)';
               }
               $parts[] = $key['key'] . ' KEY ' . $value;
            } else {
               $parts[] = 'KEY ' . $key;
            }
         }
      }
      // Constraints
      foreach ($this->constraints as $constraint) {
         $parts[] = $constraint;
      }
      $this->definition = "(\n    " . implode(",\n    ", $parts) . "\n)";
      $this->createTable();
      return $this;
   }
   public static function ensureTables():void
   {
      require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
      foreach (self::$instances as $instance) {
         if (!$instance->wpdb->get_var("SHOW TABLES LIKE '{$instance->fullTableName}'")) {
            $instance->createTable();
         }
      }
   }
   protected function createTable():void
   {
      if (!$this->definition && empty($this->definition)) {
         error_log('[CustomTable]No definition set for '.$this->tableName);
         return;
      }
      if ($this->wpdb->get_var("SHOW TABLES LIKE '{$this->fullTableName}'")) {
         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();
      $hasForeignKey = stripos($schema, 'FOREIGN KEY') !== false;
      if ($hasForeignKey) {
         $result = $this->wpdb->query($schema);
         $success = ($result !== false && !$this->wpdb->last_error);
      } else {
         dbDelta($schema.';');
         $success = !$this->wpdb->last_error;
      }
      if (!$success) {
         $error_msg = "SQL Error creating table {$this->tableName}: " . $this->wpdb->last_error;
         error_log($error_msg);
         error_log("Failed SQL Query: " . $schema);
      } elseif ($this->wpdb->get_var("SHOW TABLES LIKE '{$this->fullTableName}'")) {
         error_log("Successfully created table: {$this->fullTableName}");
      }
   }
   /**
    * Clear instance cache (useful for testing)
@@ -61,8 +265,15 @@
      global $wpdb;
      $this->wpdb = $wpdb;
      $this->tableName = $tableName;
      $this->fullTableName = $wpdb->prefix . BASE . $tableName;
      $this->fullTableName = $wpdb->prefix . apply_filters('jvb_base', BASE) . $tableName;
      $this->useTransactions = $useTransactions;
      $this->cache = Cache::for($tableName);
      $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}";
   }
   // =========================================================================
@@ -236,6 +447,8 @@
         return false;
      }
      $this->cache->flush();
      return $this->wpdb->insert_id;
   }
@@ -266,26 +479,19 @@
    * );
    * // Returns: ['id' => 456, 'created' => false, 'record' => object]
    */
   public function findOrCreate(array $searchData, array $createData = []): array
   public function findOrCreate(array $searchData, array $createData = []):int|false
   {
      $record = $this->get($searchData);
      if ($record) {
         return [
            'id' => $record->id ?? 0,
            'created' => false,
            'record' => $record
         ];
         if (!empty($createData)) {
            return $this->update($createData, $searchData);
         }
         return $record->id;
      }
      $data = array_merge($searchData, $createData);
      $id = $this->insert($data);
      return [
         'id' => $id,
         'created' => true,
         'record' => $this->get(['id' => $id])
      ];
      return $this->insert($data);
   }
   /**
@@ -363,10 +569,17 @@
    */
   public function get(array $where, string $output = OBJECT): object|array|null
   {
      $query = "SELECT * FROM {$this->fullTableName} WHERE " . $this->buildWhereClause($where);
      $values = array_values($where);
      return $this->cache->remember(
         $this->cache->generateKey(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 $this->wpdb->get_row($this->wpdb->prepare($query, $values), $output);
         }
      );
   }
   /**
@@ -386,34 +599,76 @@
    */
   public function getMany(array $args = [], string $output = OBJECT): array
   {
      $query = "SELECT * FROM {$this->fullTableName}";
      $values = [];
      return $this->cache->remember(
         $this->cache->generateKey(array_merge($args, ['output' => $output])),
         function () use ($args, $output) {
            $query = "SELECT * FROM {$this->fullTableName}";
            $values = [];
      // WHERE clause
      if (!empty($args['where'])) {
         $query .= " WHERE " . $this->buildWhereClause($args['where']);
         $values = array_merge($values, array_values($args['where']));
      }
            // WHERE clause
            if (!empty($args['where'])) {
               $clause = $this->buildWhereClause($args['where']);
      // ORDER BY
      if (!empty($args['orderby'])) {
         $orderby = sanitize_sql_orderby($args['orderby']);
         $order = (!empty($args['order']) && strtoupper($args['order']) === 'ASC') ? 'ASC' : 'DESC';
         $query .= " ORDER BY {$orderby} {$order}";
      }
               $query .= " WHERE {$clause['sql']}";
               $values = array_merge($values, $clause['values']);
            }
      // LIMIT
      if (!empty($args['limit'])) {
         $limit = absint($args['limit']);
         $offset = !empty($args['offset']) ? absint($args['offset']) : 0;
         $query .= " LIMIT {$offset}, {$limit}";
      }
            // ORDER BY
            if (!empty($args['orderby'])) {
               $orderby = sanitize_sql_orderby($args['orderby']);
               $order = (!empty($args['order']) && strtoupper($args['order']) === 'ASC') ? 'ASC' : 'DESC';
               $query .= " ORDER BY {$orderby} {$order}";
            }
      if (empty($values)) {
         return $this->wpdb->get_results($query, $output);
      }
            // LIMIT
            if (!empty($args['limit'])) {
               $limit = absint($args['limit']);
               $offset = !empty($args['offset']) ? absint($args['offset']) : 0;
               $query .= " LIMIT {$offset}, {$limit}";
            }
      return $this->wpdb->get_results($this->wpdb->prepare($query, $values), $output);
            if (empty($values)) {
               return $this->wpdb->get_results($query, $output);
            }
            return $this->wpdb->get_results($this->wpdb->prepare($query, $values), $output);
         }
      );
   }
   /**
    * Get a specific column value from all matches
    * @param string $column
    * @param array $where
    * @return array
    */
   public function pluck(string $column, array $where = [], ?string $orderby = null, ?string $order = null, ?int $limit = null): array
   {
      $key = array_merge($where, ['column' => $column, 'orderby' => $orderby, 'order' => $order, 'limit' => $limit]);
      return $this->cache->remember(
         $this->cache->generateKey($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"]);
               return [];
            }
            $args = ['where' => $where];
            if ($orderby) {
               $args['orderby'] = $orderby;
            }
            if ($order) {
               $args['order'] = $order;
            }
            if ($limit) {
               $args['limit'] = $limit;
            }
            return array_column($this->getMany($args), $column);
         }
      );
   }
   /**
@@ -424,19 +679,26 @@
    */
   public function count(array $where = []): int
   {
      $query = "SELECT COUNT(*) FROM {$this->fullTableName}";
      $values = [];
      return $this->cache->remember(
         $this->cache->generateKey(array_merge(['source' => 'count'], $where)),
         function() use ($where) {
            $query = "SELECT COUNT(*) FROM {$this->fullTableName}";
            $values = [];
      if (!empty($where)) {
         $query .= " WHERE " . $this->buildWhereClause($where);
         $values = array_values($where);
      }
            if (!empty($where)) {
               $clause = $this->buildWhereClause($where);
               $query .= " WHERE {$clause['sql']}";
               $values = $clause['values'];
            }
      if (empty($values)) {
         return (int) $this->wpdb->get_var($query);
      }
            if (empty($values)) {
               return (int) $this->wpdb->get_var($query);
            }
      return (int) $this->wpdb->get_var($this->wpdb->prepare($query, $values));
            return (int) $this->wpdb->get_var($this->wpdb->prepare($query, $values));
         }
      );
   }
   /**
@@ -487,6 +749,7 @@
      if ($result === false) {
         $this->logError('update', ['data' => $data, 'where' => $where]);
      }
      $this->cache->flush();
      return $result;
   }
@@ -516,6 +779,7 @@
      if ($result === false) {
         $this->logError('delete', ['where' => $where]);
      }
      $this->cache->flush();
      return $result;
   }
@@ -638,7 +902,7 @@
      } catch (Exception $e) {
         $this->rollback();
         $this->logError('transaction', ['error' => $e->getMessage()]);
         throw $e;
         return false;
      }
   }
@@ -678,25 +942,103 @@
      return $this->wpdb->rows_affected;
   }
   public function getUserIDType():string
   {
      $this->ensureUserIdType();
      return static::$userIDType;
   }
   public function getUserTable():string
   {
      $this->ensureUserTable();
      return static::$userTable;
   }
   public function getTermIDType():string
   {
      $this->ensureTermIDType();
      return static::$termIDType;
   }
   public function getTermTable():string
   {
      $this->ensureTermTable();
      return static::$termTable;
   }
   public function getPostIDType():string
   {
      $this->ensurePostIDType();
      return static::$postIDType;
   }
   public function getPostTable():string
   {
      $this->ensurePostTable();
      return static::$postTable;
   }
   // =========================================================================
   // PRIVATE HELPERS
   // =========================================================================
   private function getColumnType(string $table, string $column):string|false {
      $tableExists = $this->wpdb->get_var(
         $this->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(
            "SELECT COLUMN_TYPE
            FROM INFORMATION_SCHEMA.COLUMNS
            WHERE TABLE_SCHEMA = DATABASE()
            AND TABLE_NAME = %s
            AND COLUMN_NAME = %s",
            $table,
            $column
         )
      );
      if ($result && isset($result->COLUMN_TYPE)) {
         return $result->COLUMN_TYPE;
      }
      error_log("[CustomTable] Could not determine column type for {$table}.{$column}");
      return 'bigint(20) unsigned';
   }
   private function tableExists():bool
   {
      return !is_null($this->wpdb->get_var($this->wpdb->prepare("SHOW TABLES LIKE %s", $this->fullTableName)));
   }
   /**
    * Build WHERE clause from associative array
    */
   private function buildWhereClause(array $where): string
   private function buildWhereClause(array $where):array
   {
      $conditions = [];
      $values = [];
      foreach ($where as $column => $value) {
         $column_safe = esc_sql($column);
         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)) {
               $placeholders = implode(',', array_map([$this, 'getPlaceholder'], $operand));
               $conditions[] = "`{$column_safe}` {$operator} ({$placeholders})";
               $values       = array_merge($values, $operand);
            } else {
               // <, >, <=, >=, !=, LIKE, etc.
               $conditions[] = "`{$column_safe}` {$operator} " . $this->getPlaceholder($operand);
               $values[]     = $operand;
            }
         } else {
            $conditions[] = "`{$column_safe}` = " . $this->getPlaceholder($value);
            $values[]     = $value;
         }
      }
      return implode(' AND ', $conditions);
      return ['sql' => implode(' AND ', $conditions), 'values' => $values];
   }
   /**