Jake Vanderwerf
2026-02-04 2127b1bdd73ecd2423e443992da4b442f5a3c1a3
inc/meta/Meta.php
@@ -1,8 +1,6 @@
<?php
namespace JVBase\meta;
use Exception;
if (!defined('ABSPATH')) {
   exit;
}
@@ -10,37 +8,62 @@
/**
 * Main facade for meta operations
 * Fluent API for getting/setting meta values with validation & sanitization
 *
 * Usage:
 *   $meta = Meta::forPost($id);
 *   $meta->price = 150;
 *   $meta->save();
 *
 *   Meta::forPost($id)->set('price', 150)->set('style', 'traditional')->save();
 */
class Meta
{
   protected Item $item;
   protected Storage $storage;
   protected MetaValidator $validator;
   protected MetaSanitizer $sanitizer;
   protected Validator $validator;
   protected Sanitizer $sanitizer;
   protected MetaTypeManager $typeManager;
   protected bool $autoValidate = true;
   protected bool $autoSanitize = true;
   /** @var array<string, callable[]> */
   protected array $onChangeCallbacks = [];
   /** @var array<string, callable> */
   protected array $computed = [];
   // ─────────────────────────────────────────────────────────────
   // Factory Methods
   // ─────────────────────────────────────────────────────────────
   /**
    * Create Meta instance for a post
    */
   public static function forPost(int $id): self
   {
      return new self($id, 'post');
   }
   /**
    * Create Meta instance for a term
    */
   public static function forTerm(int $id): self
   {
      return new self($id, 'term');
   }
   /**
    * Create Meta instance for a user
    */
   public static function forUser(int $id): self
   {
      return new self($id, 'user');
   }
   /**
    * Create Meta instance for options
    */
   public static function forOptions(?string $baseKey = null): self
   {
      $instance = new self($baseKey, 'options');
@@ -48,6 +71,129 @@
      return $instance;
   }
   /**
    * Bulk load multiple posts with optional field preloading
    * @return array<int, Meta>
    */
   public static function bulkForPosts(array $ids, array $preloadFields = []): array
   {
      return self::bulkFor($ids, 'post', $preloadFields);
   }
   /**
    * Bulk load multiple terms with optional field preloading
    * @return array<int, Meta>
    */
   public static function bulkForTerms(array $ids, array $preloadFields = []): array
   {
      return self::bulkFor($ids, 'term', $preloadFields);
   }
   /**
    * Bulk load multiple users with optional field preloading
    * @return array<int, Meta>
    */
   public static function bulkForUsers(array $ids, array $preloadFields = []): array
   {
      return self::bulkFor($ids, 'user', $preloadFields);
   }
   /**
    * Generic bulk loader
    * @return array<int, Meta>
    */
   protected static function bulkFor(array $ids, string $type, array $preloadFields = []): array
   {
      if (empty($ids)) {
         return [];
      }
      $metas = [];
      // Create instances
      foreach ($ids as $id) {
         $metas[$id] = new self($id, $type);
      }
      // Preload fields if specified
      if (!empty($preloadFields)) {
         self::bulkPreload($metas, $type, $preloadFields);
      }
      return $metas;
   }
   /**
    * Bulk preload fields for multiple Meta instances
    * @param Meta[] $metas
    */
   protected static function bulkPreload(array $metas, string $objectType, array $fields): void
   {
      if (empty($metas) || empty($fields)) {
         return;
      }
      $ids = array_keys($metas);
      $values = Storage::getBulkValues($ids, $objectType, $fields);
      // Distribute results to Meta instances
      foreach ($values as $id => $fieldValues) {
         if (!isset($metas[$id])) {
            continue;
         }
         $meta = $metas[$id];
         foreach ($fieldValues as $name => $value) {
            $config = $meta->config($name) ?? ['type' => 'text'];
            $field = new Field($name, $value, $config);
            $meta->item()->setField($field);
         }
      }
   }
   /**
    * Save multiple Meta instances efficiently
    * @param Meta[] $metas
    * @return array<int, bool>
    */
   public static function saveBulk(array $metas, bool $updateTimestamp = true): array
   {
      // Validate all first
      $invalid = [];
      foreach ($metas as $id => $meta) {
         if (!$meta->isValid()) {
            $invalid[$id] = $meta->getErrors();
         }
      }
      if (!empty($invalid)) {
         JVB()->error()->log('meta', 'Bulk save has validation errors', [
            'invalid_items' => $invalid
         ], 'warning');
      }
      // Filter to only valid metas
      $validMetas = array_filter($metas, fn($m) => $m->isValid());
      // Check overrides before bulk save
      foreach ($validMetas as $meta) {
         foreach ($meta->item()->getDirtyFields() as $field) {
            if ($meta->checkOverrides($field)) {
               $field->markClean();
            }
         }
      }
      $results = Storage::saveBulk($validMetas, $updateTimestamp);
      // Mark invalid ones as failed
      foreach ($invalid as $id => $errors) {
         $results[$id] = false;
      }
      return $results;
   }
   // ─────────────────────────────────────────────────────────────
   // Constructor
   // ─────────────────────────────────────────────────────────────
@@ -55,8 +201,8 @@
   public function __construct(int|string|null $id, string $type)
   {
      $this->storage = new Storage();
      $this->validator = new MetaValidator();
      $this->sanitizer = new MetaSanitizer();
      $this->validator = new Validator();
      $this->sanitizer = new Sanitizer();
      $this->typeManager = new MetaTypeManager();
      $this->item = $this->buildItem($id, $type);
@@ -118,7 +264,7 @@
   public function __isset(string $name): bool
   {
      return $this->item->hasField($name);
      return $this->item->hasField($name) || isset($this->computed[$name]);
   }
   // ─────────────────────────────────────────────────────────────
@@ -130,6 +276,11 @@
    */
   public function get(string $name): mixed
   {
      // Check computed fields first
      if (isset($this->computed[$name])) {
         return ($this->computed[$name])($this);
      }
      // Return from loaded field if exists
      if ($field = $this->item->getField($name)) {
         return $field->get();
@@ -146,7 +297,7 @@
   }
   /**
    * Set a field value (validates & sanitizes)
    * Set a field value (validates & sanitizes by default)
    */
   public function set(string $name, mixed $value): self
   {
@@ -157,8 +308,6 @@
         $config = ['type' => 'text', 'name' => $name];
      }
      $config['name'] = $name;
      // Validate
      if ($this->autoValidate && !$this->validator->validate($value, $config)) {
         $field = $this->item->getField($name) ?? new Field($name, $value, $config);
@@ -174,17 +323,27 @@
      // Get or create field
      $field = $this->item->getField($name);
      $oldValue = null;
      if ($field) {
         $oldValue = $field->value;
         $field->set($value);
      } else {
         // Need to load original to track dirty state
         // Load original to track dirty state
         $original = $this->storage->get($this->item, $name);
         $oldValue = $original;
         $field = new Field($name, $original, $config);
         $field->set($value);
         $this->item->setField($field);
      }
      // Fire change callbacks
      if (isset($this->onChangeCallbacks[$name]) && $oldValue !== $value) {
         foreach ($this->onChangeCallbacks[$name] as $callback) {
            $callback($value, $oldValue, $this);
         }
      }
      return $this;
   }
@@ -259,12 +418,36 @@
      return $result;
   }
   /**
    * Delete multiple field values
    */
   public function deleteAll(array $names): array
   {
      $results = [];
      foreach ($names as $name) {
         $results[$name] = $this->delete($name);
      }
      return $results;
   }
   // ─────────────────────────────────────────────────────────────
   // Repeater Access
   // ─────────────────────────────────────────────────────────────
   /**
    * Get repeater accessor for fluent repeater operations
    */
   public function repeater(string $name): Repeater
   {
      return new Repeater($this, $name);
   }
   // ─────────────────────────────────────────────────────────────
   // Utility Methods
   // ─────────────────────────────────────────────────────────────
   /**
    * Get all dirty (changed) fields
    * Get all dirty (changed) field values
    */
   public function getDirty(): array
   {
@@ -287,9 +470,7 @@
    */
   public function reset(): self
   {
      foreach ($this->item->fields as $field) {
         $field->reset();
      }
      $this->item->resetAll();
      return $this;
   }
@@ -332,7 +513,17 @@
   }
   /**
    * Get the underlying item (for rendering, etc)
    * Re-enable validation and sanitization
    */
   public function withDefaults(): self
   {
      $this->autoValidate = true;
      $this->autoSanitize = true;
      return $this;
   }
   /**
    * Get the underlying Item
    */
   public function item(): Item
   {
@@ -355,11 +546,77 @@
      return $this->item->fieldConfigs;
   }
   /**
    * Get item ID
    */
   public function id(): int|string|null
   {
      return $this->item->id;
   }
   /**
    * Get object type (post, term, user, options)
    */
   public function objectType(): string
   {
      return $this->item->objectType;
   }
   /**
    * Get content type (tattoo, artist, etc)
    */
   public function contentType(): ?string
   {
      return $this->item->contentType;
   }
   /**
    * Eager load all fields
    */
   public function eager(): self
   {
      $this->getAll();
      return $this;
   }
   /**
    * Convert loaded fields to array
    */
   public function toArray(): array
   {
      return $this->item->toArray();
   }
   // ─────────────────────────────────────────────────────────────
   // Event Callbacks
   // ─────────────────────────────────────────────────────────────
   /**
    * Register callback for field changes
    */
   public function onChange(string $field, callable $callback): self
   {
      $this->onChangeCallbacks[$field][] = $callback;
      return $this;
   }
   /**
    * Register computed/virtual field
    */
   public function computed(string $name, callable $getter): self
   {
      $this->computed[$name] = $getter;
      return $this;
   }
   // ─────────────────────────────────────────────────────────────
   // Protected Helpers
   // ─────────────────────────────────────────────────────────────
   protected function checkOverrides(Field $field): bool
   /**
    * Check for field update overrides
    */
   public function checkOverrides(Field $field): bool
   {
      $name = $field->name;
      $type = $field->type();
@@ -384,6 +641,9 @@
      return false;
   }
   /**
    * Get default value for a field type
    */
   protected function getDefaultValue(string $name): mixed
   {
      $config = $this->item->getFieldConfig($name);
@@ -396,4 +656,5 @@
         default => '',
      };
   }
}