Jake Vanderwerf
3 days ago ba1e1ccf869b818f7a7a897264dfea05563a7796
inc/meta/Storage.php
@@ -2,7 +2,7 @@
namespace JVBase\meta;
use Exception;
use wpdb;
use JVBase\managers\Cache;
if (!defined('ABSPATH')) {
   exit;
@@ -14,7 +14,7 @@
 */
class Storage
{
   protected wpdb $wpdb;
   protected \wpdb $wpdb;
   public function __construct()
   {
@@ -22,6 +22,10 @@
      $this->wpdb = $wpdb;
   }
   // ─────────────────────────────────────────────────────────────
   // Single Item Operations
   // ─────────────────────────────────────────────────────────────
   /**
    * Load a single field value from database
    */
@@ -31,6 +35,16 @@
         return $this->getWpDefault($item, $name);
      }
      // Taxonomy fields are stored in term_relationships, not meta
      $config = $item->getFieldConfig($name);
      if ($config
         && (
            ($config['type'] ?? '') === 'taxonomy'
            || (($config['type']??'') === 'selector' && ($config['subtype']??'') === 'taxonomy')
         ) && !isset($config['taxonomy_type'])) {
         return $this->getTaxonomyField($item, $config);
      }
      $metaKey = BASE . $name;
      return match ($item->objectType) {
@@ -43,33 +57,62 @@
   }
   /**
    * Load multiple field values in a single query
    * Load multiple field values for single item
    */
   public function getAll(Item $item, array $fieldNames): array
   {
      if (empty($fieldNames) || !$item->id) {
      if (empty($fieldNames) || (!$item->id && $item->objectType !== 'options')) {
         return [];
      }
      $defaults = Item::WP_DEFAULTS[$item->objectType] ?? [];
      $wpFields = array_intersect($defaults, $fieldNames);
      $metaFields = array_diff($fieldNames, $wpFields);
      // Separate taxonomy fields from regular meta fields
      $taxonomyFields = [];
      $metaFields = [];
      foreach (array_diff($fieldNames, $wpFields) as $name) {
         $config = $item->getFieldConfig($name);
         if ($config
            && (
               ($config['type'] ?? '') === 'taxonomy'
               || (($config['type']??'') === 'selector' && ($config['subtype']??'') === 'taxonomy')
            ) && (!isset($config['taxonomy_type']) || !isset($config['isReference']))) {
            $taxonomyFields[$name] = $config;
         } else {
            $metaFields[] = $name;
         }
      }
      $values = [];
      // Get meta fields in bulk
      if (!empty($metaFields)) {
         $values = $this->bulkGetMeta($item, $metaFields);
      }
      // Get WP default fields
      foreach ($wpFields as $name) {
         $values[$name] = $this->getWpDefault($item, $name);
      }
      foreach ($taxonomyFields as $name => $config) {
         $values[$name] = $this->getTaxonomyField($item, $config);
      }
      return $values;
   }
   protected function getTaxonomyField(Item $item, array $config): string
   {
      $taxonomy = jvbCheckBase($config['taxonomy']);
      $terms = wp_get_object_terms($item->id, $taxonomy, ['fields' => 'ids']);
      if (is_wp_error($terms) || empty($terms)) {
         return '';
      }
      return implode(',', $terms);
   }
   /**
    * Save a single field
    */
@@ -80,22 +123,25 @@
      }
      if ($field->isTaxonomy()) {
         error_log('Saving Taxonomy field with set_object_terms');
         return $this->saveTaxonomyField($item, $field);
      }
      $metaKey = BASE . $field->name;
      return match ($item->objectType) {
         'post' => update_post_meta($item->id, $metaKey, $field->value) !== false,
         'term' => update_term_meta($item->id, $metaKey, $field->value) !== false,
         'user', 'integrations' => update_user_meta($item->id, $metaKey, $field->value) !== false,
      $result =  match ($item->objectType) {
         'post' => (bool)update_post_meta($item->id, $metaKey, $field->value),
         'term' => (bool)update_term_meta($item->id, $metaKey, $field->value),
         'user', 'integrations' => (bool)update_user_meta($item->id, $metaKey, $field->value),
         'options' => $this->saveOption($item, $field),
         default => false
      };
      error_log('Result: '.print_r($result, true));
      return $result;
   }
   /**
    * Save all dirty fields on an item
    * Save all dirty fields on a single item
    */
   public function save(Item $item, bool $updateTimestamp = true): bool
   {
@@ -106,10 +152,10 @@
      }
      $this->wpdb->query('START TRANSACTION');
      try {
         foreach ($dirty as $field) {
            if (!$this->saveField($item, $field)) {
               error_log("Could not save field: {$field->name}");
               throw new Exception("Failed to save field: {$field->name}");
            }
            $field->markClean();
@@ -118,10 +164,7 @@
         $this->wpdb->query('COMMIT');
         // Update post modified timestamp
         if ($updateTimestamp && $item->objectType === 'post' && $item->id) {
            wp_update_post(['ID' => $item->id]);
         }
         Cache::invalidateItem($item->objectType, $item->id);
         $this->clearCache($item);
         return true;
@@ -142,6 +185,14 @@
    */
   public function delete(Item $item, string $name): bool
   {
      // Handle taxonomy fields
      $config = $item->getFieldConfig($name);
      if ($config && ($config['type'] ?? '') === 'taxonomy' && !isset($config['taxonomy_type'])) {
         $taxonomy = jvbCheckBase($config['taxonomy']);
         wp_set_object_terms($item->id, [], $taxonomy, false);
         return true;
      }
      $metaKey = BASE . $name;
      return match ($item->objectType) {
@@ -154,7 +205,215 @@
   }
   // ─────────────────────────────────────────────────────────────
   // Protected helpers
   // Bulk Operations
   // ─────────────────────────────────────────────────────────────
   /**
    * Save multiple Meta instances in optimized transaction
    * @param Meta[] $metas Array of Meta instances
    * @return array<int, bool> Results keyed by item ID
    */
   public static function saveBulk(array $metas, bool $updateTimestamp = true): array
   {
      global $wpdb;
      $results = [];
      $postIdsToUpdate = [];
      $wpdb->query('START TRANSACTION');
      try {
         // Group by object type for efficient processing
         $grouped = [];
         foreach ($metas as $meta) {
            $item = $meta->item();
            $type = $item->objectType;
            $grouped[$type][] = ['meta' => $meta, 'item' => $item];
         }
         foreach ($grouped as $objectType => $group) {
            $storage = new self();
            [$table, $idColumn] = $storage->getTableInfo($objectType);
            if (!$table && $objectType !== 'options') {
               continue;
            }
            // Collect all operations
            $metaInserts = [];
            $wpDefaultUpdates = [];
            $taxonomyUpdates = [];
            $optionUpdates = [];
            foreach ($group as $entry) {
               $item = $entry['item'];
               $dirty = $item->getDirtyFields();
               if (empty($dirty)) {
                  $results[$item->id] = true;
                  continue;
               }
               foreach ($dirty as $field) {
                  if ($objectType === 'options') {
                     $optionUpdates[] = [
                        'key' => $storage->optionKey($item, $field->name),
                        'value' => $field->value
                     ];
                  } elseif ($field->isWpDefault()) {
                     $wpDefaultUpdates[$item->id][$field->name] = $field->value;
                  } elseif ($field->isTaxonomy()) {
                     $taxonomyUpdates[] = [
                        'object_id' => $item->id,
                        'taxonomy' => jvbCheckBase($field->config['taxonomy']),
                        'value' => $field->value
                     ];
                  } else {
                     $metaInserts[] = [
                        'id' => $item->id,
                        'key' => BASE . $field->name,
                        'value' => maybe_serialize($field->value)
                     ];
                  }
               }
               if ($updateTimestamp && $objectType === 'post') {
                  $postIdsToUpdate[] = $item->id;
               }
            }
            // Execute bulk operations
            if (!empty($metaInserts)) {
               self::bulkUpsertMeta($table, $idColumn, $metaInserts);
            }
            if (!empty($wpDefaultUpdates)) {
               self::batchUpdateWpDefaults($objectType, $wpDefaultUpdates);
            }
            if (!empty($taxonomyUpdates)) {
               self::batchUpdateTaxonomies($taxonomyUpdates);
            }
            if (!empty($optionUpdates)) {
               self::batchUpdateOptions($optionUpdates);
            }
            // Mark all fields clean
            foreach ($group as $entry) {
               $entry['item']->markAllClean();
               $results[$entry['item']->id ?? 'options'] = true;
            }
         }
         $wpdb->query('COMMIT');
         // Update post timestamps in single query
         if (!empty($postIdsToUpdate)) {
            self::batchTouchPosts(array_unique($postIdsToUpdate));
         }
         // Clear caches
         foreach ($metas as $meta) {
            (new self())->clearCache($meta->item());
         }
         return $results;
      } catch (Exception $e) {
         $wpdb->query('ROLLBACK');
         foreach ($metas as $meta) {
            $results[$meta->item()->id ?? 'options'] = false;
         }
         JVB()->error()->log('meta_storage', 'Bulk save failed: ' . $e->getMessage(), [], 'error');
         return $results;
      }
   }
   /**
    * Bulk load meta for multiple items
    * @param array $ids Object IDs
    * @param string $objectType post, term, user
    * @param array $fields Field names to load
    * @return array<int, array<string, mixed>> Values keyed by ID then field name
    */
   public static function getBulkValues(array $ids, string $objectType, array $fields): array
   {
      if (empty($ids) || empty($fields)) {
         return [];
      }
      global $wpdb;
      $storage = new self();
      [$table, $idColumn] = $storage->getTableInfo($objectType);
      if (!$table) {
         return [];
      }
      // Separate WP defaults from custom meta
      $defaults = Item::WP_DEFAULTS[$objectType] ?? [];
      $wpFields = array_intersect($defaults, $fields);
      $metaFields = array_diff($fields, $wpFields);
      // Initialize results
      $values = [];
      foreach ($ids as $id) {
         $values[$id] = array_fill_keys($fields, '');
      }
      // Bulk get custom meta
      if (!empty($metaFields)) {
         $metaKeys = array_map(fn($f) => BASE . $f, $metaFields);
         $idPlaceholders = implode(',', array_fill(0, count($ids), '%d'));
         $keyPlaceholders = implode(',', array_fill(0, count($metaKeys), '%s'));
         $query = $wpdb->prepare(
            "SELECT {$idColumn} as object_id, meta_key, meta_value
                 FROM {$table}
                 WHERE {$idColumn} IN ({$idPlaceholders})
                 AND meta_key IN ({$keyPlaceholders})",
            array_merge($ids, $metaKeys)
         );
         $results = $wpdb->get_results($query, ARRAY_A);
         foreach ($results as $row) {
            $objectId = (int)$row['object_id'];
            $fieldName = str_replace(BASE, '', $row['meta_key']);
            $values[$objectId][$fieldName] = maybe_unserialize($row['meta_value']);
         }
      }
      // Get WP default fields (requires individual lookups unfortunately)
      if (!empty($wpFields)) {
         foreach ($ids as $id) {
            $tempItem = new Item($id, $objectType);
            // Load WP object for defaults
            $tempItem->wpObject = match ($objectType) {
               'post' => get_post($id),
               'term' => get_term($id),
               'user' => get_user_by('id', $id),
               default => null
            };
            foreach ($wpFields as $field) {
               $values[$id][$field] = $storage->getWpDefault($tempItem, $field);
            }
         }
      }
      return $values;
   }
   // ─────────────────────────────────────────────────────────────
   // Protected Helpers - Single Item
   // ─────────────────────────────────────────────────────────────
   protected function getWpDefault(Item $item, string $name): mixed
@@ -175,7 +434,7 @@
   {
      return match ($name) {
         'post_title' => get_the_title($item->id),
         'post_excerpt' => get_the_excerpt($item->id),
         'post_excerpt' => has_excerpt($item->id) ? get_the_excerpt($item->id):'',
         'post_content' => get_post_field('post_content', $item->id),
         default => $item->wpObject->$name ?? ''
      };
@@ -184,7 +443,7 @@
   protected function getTermField(Item $item, string $name): mixed
   {
      return match ($name) {
         'term_name' => get_term_field('name', $item->id),
         'name' => get_term_field('name', $item->id),
         'description' => get_term_field('description', $item->id),
         default => ''
      };
@@ -207,9 +466,17 @@
      $value = $field->value;
      if (in_array($name, ['featured_image', 'post_thumbnail'])) {
         if (empty($value)) {
            return delete_post_thumbnail($item->id);
         }
         return set_post_thumbnail($item->id, $value) !== false;
      }
      // Special handling for post_status (trash/delete require specific functions)
      if ($item->objectType === 'post' && $name === 'post_status') {
         return $this->updatePostStatus($item->id, $value);
      }
      return match ($item->objectType) {
         'post' => wp_update_post(['ID' => $item->id, $name => $value]) !== 0,
         'term' => !is_wp_error(wp_update_term($item->id, $item->wpObject->taxonomy, [
@@ -221,12 +488,60 @@
      };
   }
   /**
    * Update post status with proper WordPress functions
    *
    * WordPress doesn't handle trash/delete via wp_update_post():
    * - wp_trash_post() required for trashing
    * - wp_delete_post() required for deletion
    * - 'delete' is not even a valid post_status value
    *
    * @param int $postId Post ID
    * @param string $status New status (trash, delete, publish, draft, etc.)
    * @return bool Success
    */
   protected function updatePostStatus(int $postId, string $status): bool
   {
      // Handle trash status
      if ($status === 'trash') {
         $result = wp_trash_post($postId);
         if ($result === false || $result === null) {
            error_log("[Storage] Failed to trash post {$postId}");
            return false;
         }
         return true;
      }
      // Handle permanent deletion
      if ($status === 'delete') {
         $result = wp_delete_post($postId, true); // true = force delete, bypass trash
         if ($result === false || $result === null) {
            error_log("[Storage] Failed to delete post {$postId}");
            return false;
         }
         return true;
      }
      // Handle all other statuses (publish, draft, pending, private, future)
      $result = wp_update_post([
         'ID' => $postId,
         'post_status' => $status
      ]);
      if ($result === 0 || is_wp_error($result)) {
         $error = is_wp_error($result) ? $result->get_error_message() : 'Unknown error';
         error_log("[Storage] Failed to update post {$postId} status to {$status}: {$error}");
         return false;
      }
      return true;
   }
   protected function saveTaxonomyField(Item $item, Field $field): bool
   {
      $taxonomy = jvbCheckBase($field->config['taxonomy']);
      $value = $field->value;
      if (empty(trim($value))) {
      if (empty(trim((string)$value))) {
         wp_set_object_terms($item->id, [], $taxonomy, false);
         return true;
      }
@@ -266,7 +581,7 @@
      return $values;
   }
   protected function getTableInfo(string $objectType): array
   public function getTableInfo(string $objectType): array
   {
      return match ($objectType) {
         'post' => [$this->wpdb->postmeta, 'post_id'],
@@ -286,14 +601,14 @@
      return update_option($this->optionKey($item, $field->name), $field->value);
   }
   protected function optionKey(Item $item, string $name): string
   public function optionKey(Item $item, string $name): string
   {
      return $item->baseKey
         ? BASE . $item->baseKey . '_' . $name
         : BASE . $name;
   }
   protected function clearCache(Item $item): void
   public function clearCache(Item $item): void
   {
      match ($item->objectType) {
         'post' => clean_post_cache($item->id),
@@ -302,4 +617,176 @@
         default => null
      };
   }
   // ─────────────────────────────────────────────────────────────
   // Protected Helpers - Bulk Operations
   // ─────────────────────────────────────────────────────────────
   /**
    * Bulk upsert meta using INSERT ... ON DUPLICATE KEY UPDATE
    */
   protected static function bulkUpsertMeta(string $table, string $idColumn, array $inserts): void
   {
      global $wpdb;
      if (empty($inserts)) {
         return;
      }
      // MySQL's ON DUPLICATE KEY requires a unique index
      // For meta tables, we need to check existing and do update/insert
      $existing = [];
      $toInsert = [];
      $toUpdate = [];
      // Check which meta keys exist
      $checks = [];
      foreach ($inserts as $row) {
         $checks[] = $wpdb->prepare("(%d, %s)", $row['id'], $row['key']);
      }
      $existingQuery = "SELECT {$idColumn}, meta_key FROM {$table}
                          WHERE ({$idColumn}, meta_key) IN (" . implode(',', $checks) . ")";
      $existingRows = $wpdb->get_results($existingQuery, ARRAY_A);
      foreach ($existingRows as $row) {
         $existing[$row[$idColumn] . '_' . $row['meta_key']] = true;
      }
      // Separate inserts and updates
      foreach ($inserts as $row) {
         $key = $row['id'] . '_' . $row['key'];
         if (isset($existing[$key])) {
            $toUpdate[] = $row;
         } else {
            $toInsert[] = $row;
         }
      }
      // Batch insert new records
      if (!empty($toInsert)) {
         $values = [];
         $placeholders = [];
         foreach ($toInsert as $row) {
            $placeholders[] = "(%d, %s, %s)";
            $values[] = $row['id'];
            $values[] = $row['key'];
            $values[] = $row['value'];
         }
         $sql = "INSERT INTO {$table} ({$idColumn}, meta_key, meta_value) VALUES " . implode(', ', $placeholders);
         $wpdb->query($wpdb->prepare($sql, $values));
      }
      // Batch update existing records
      if (!empty($toUpdate)) {
         foreach ($toUpdate as $row) {
            $wpdb->update(
               $table,
               ['meta_value' => $row['value']],
               [$idColumn => $row['id'], 'meta_key' => $row['key']],
               ['%s'],
               ['%d', '%s']
            );
         }
      }
   }
   /**
    * Batch update post timestamps
    */
   protected static function batchTouchPosts(array $postIds): void
   {
      global $wpdb;
      if (empty($postIds)) {
         return;
      }
      $now = current_time('mysql');
      $nowGmt = current_time('mysql', true);
      $ids = implode(',', array_map('intval', $postIds));
      $wpdb->query(
         "UPDATE {$wpdb->posts}
             SET post_modified = '{$now}', post_modified_gmt = '{$nowGmt}'
             WHERE ID IN ({$ids})"
      );
   }
   /**
    * Batch update taxonomy relationships
    */
   protected static function batchUpdateTaxonomies(array $updates): void
   {
      foreach ($updates as $update) {
         $termIds = empty(trim((string)$update['value']))
            ? []
            : array_map('intval', array_filter(explode(',', $update['value'])));
         wp_set_object_terms($update['object_id'], $termIds, $update['taxonomy'], false);
      }
   }
   /**
    * Batch update WordPress default fields
    */
   protected static function batchUpdateWpDefaults(string $objectType, array $updates): void
   {
      foreach ($updates as $id => $fields) {
         // Handle post_thumbnail separately
         if (isset($fields['post_thumbnail'])) {
            set_post_thumbnail($id, $fields['post_thumbnail']);
            unset($fields['post_thumbnail']);
         }
         if (isset($fields['post_thumbnail'])) {
            if (empty($fields['post_thumbnail'])) {
               delete_post_thumbnail($id);
            } else {
               set_post_thumbnail($id, $fields['post_thumbnail']);
            }
            unset($fields['post_thumbnail']);
         }
         if (isset($fields['featured_image'])) {
            if (empty($fields['featured_image'])) {
               delete_post_thumbnail($id);
            } else {
               set_post_thumbnail($id, $fields['featured_image']);
            }
            unset($fields['featured_image']);
         }
         if (empty($fields)) {
            continue;
         }
         // Handle post_date conversion
         if (isset($fields['post_date'])) {
            $datetime = strtotime($fields['post_date']);
            if ($datetime !== false) {
               $fields['post_date'] = date('Y-m-d H:i:s', $datetime);
               $fields['post_date_gmt'] = get_gmt_from_date($fields['post_date']);
               $fields['edit_date'] = true;
            }
         }
         match ($objectType) {
            'post' => wp_update_post(array_merge(['ID' => $id], $fields)),
            'user' => wp_update_user(array_merge(['ID' => $id], $fields)),
            'term' => null, // Terms need taxonomy, handled separately
            default => null
         };
      }
   }
   /**
    * Batch update options
    */
   protected static function batchUpdateOptions(array $updates): void
   {
      foreach ($updates as $update) {
         update_option($update['key'], $update['value']);
      }
   }
}