price = 150; * $meta->save(); * * Meta::forPost($id)->set('price', 150)->set('style', 'traditional')->save(); */ class Meta { protected Item $item; protected Storage $storage; protected Validator $validator; protected Sanitizer $sanitizer; protected MetaTypeManager $typeManager; protected bool $autoValidate = true; protected bool $autoSanitize = true; /** @var array */ protected array $onChangeCallbacks = []; /** @var array */ 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'); $instance->item->baseKey = $baseKey; return $instance; } /** * Bulk load multiple posts with optional field preloading * @return array */ 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 */ 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 */ public static function bulkForUsers(array $ids, array $preloadFields = []): array { return self::bulkFor($ids, 'user', $preloadFields); } /** * Generic bulk loader * @return array */ 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 */ 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 // ───────────────────────────────────────────────────────────── public function __construct(int|string|null $id, string $type) { $this->storage = new Storage(); $this->validator = new Validator(); $this->sanitizer = new Sanitizer(); $this->typeManager = new MetaTypeManager(); $this->item = $this->buildItem($id, $type); } protected function buildItem(int|string|null $id, string $type): Item { $contentType = null; $wpObject = null; if ($id && $type !== 'options') { [$wpObject, $contentType] = match ($type) { 'post' => [get_post($id), jvbNoBase(get_post_type($id))], 'term' => [get_term($id), jvbNoBase(get_term($id)->taxonomy)], 'user', 'integrations' => [get_user_by('id', $id), jvbUserRole($id)], default => [null, null] }; } $item = new Item($id, $type, $contentType); $item->wpObject = $wpObject; $item->fieldConfigs = $this->loadFieldConfigs($contentType, $type); // Mark WP defaults in configs $defaults = Item::WP_DEFAULTS[$type] ?? []; foreach ($defaults as $name) { if (!isset($item->fieldConfigs[$name])) { $item->fieldConfigs[$name] = ['type' => 'text', '_wp_default' => true]; } else { $item->fieldConfigs[$name]['_wp_default'] = true; } } return $item; } protected function loadFieldConfigs(?string $contentType, string $objectType): array { if (!$contentType && $objectType !== 'options') { return []; } return jvbGetFields($contentType ?? 'options', $objectType); } // ───────────────────────────────────────────────────────────── // Magic Methods for Fluent Access // ───────────────────────────────────────────────────────────── public function __get(string $name): mixed { return $this->get($name); } public function __set(string $name, mixed $value): void { $this->set($name, $value); } public function __isset(string $name): bool { return $this->item->hasField($name) || isset($this->computed[$name]); } // ───────────────────────────────────────────────────────────── // Core API // ───────────────────────────────────────────────────────────── /** * Get a field value */ 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(); } // Load from storage $value = $this->storage->get($this->item, $name); $config = $this->item->getFieldConfig($name) ?? ['type' => 'text']; $field = new Field($name, $value, $config); $this->item->setField($field); return $value; } /** * Set a field value (validates & sanitizes by default) */ public function set(string $name, mixed $value): self { $config = $this->item->getFieldConfig($name); if (!$config) { // Allow setting unknown fields with minimal config $config = ['type' => 'text', 'name' => $name]; } // Validate if ($this->autoValidate && !$this->validator->validate($value, $config)) { $field = $this->item->getField($name) ?? new Field($name, $value, $config); $field->addError("Validation failed for {$name}"); $this->item->setField($field); return $this; } // Sanitize if ($this->autoSanitize) { $value = $this->sanitizer->sanitize($value, $config); } // Get or create field $field = $this->item->getField($name); $oldValue = null; if ($field) { $oldValue = $field->value; $field->set($value); } else { // 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; } /** * Get multiple fields */ public function getAll(array $fields = []): array { if (empty($fields) || $fields === ['all']) { $fields = array_keys($this->item->fieldConfigs); } // Load all from storage $values = $this->storage->getAll($this->item, $fields); // Create Field instances foreach ($values as $name => $value) { if (!$this->item->getField($name)) { $config = $this->item->getFieldConfig($name) ?? ['type' => 'text']; $this->item->setField(new Field($name, $value, $config)); } } return $values; } /** * Set multiple fields */ public function setAll(array $data): self { foreach ($data as $name => $value) { $this->set($name, $value); } return $this; } /** * Save all dirty fields to database */ public function save(bool $updateTimestamp = true): bool { if (!$this->item->isValid()) { JVB()->error()->log('meta', 'Cannot save: validation errors exist', [ 'fields' => array_keys($this->item->getInvalidFields()) ], 'warning'); return false; } // Check for field overrides before saving foreach ($this->item->getDirtyFields() as $field) { if ($this->checkOverrides($field)) { $field->markClean(); } } return $this->storage->save($this->item, $updateTimestamp); } /** * Delete a field value */ public function delete(string $name): bool { $result = $this->storage->delete($this->item, $name); if ($result && $field = $this->item->getField($name)) { $field->set($this->getDefaultValue($name)); $field->markClean(); } 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) field values */ public function getDirty(): array { return array_map( fn(Field $f) => $f->value, $this->item->getDirtyFields() ); } /** * Check if any fields have changed */ public function isDirty(): bool { return $this->item->hasDirtyFields(); } /** * Discard all unsaved changes */ public function reset(): self { $this->item->resetAll(); return $this; } /** * Get validation errors */ public function getErrors(): array { $errors = []; foreach ($this->item->getInvalidFields() as $name => $field) { $errors[$name] = $field->errors; } return $errors; } /** * Check if valid (no validation errors) */ public function isValid(): bool { return $this->item->isValid(); } /** * Disable auto-validation for bulk operations */ public function withoutValidation(): self { $this->autoValidate = false; return $this; } /** * Disable auto-sanitization */ public function withoutSanitization(): self { $this->autoSanitize = false; return $this; } /** * 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 { return $this->item; } /** * Get field configuration */ public function config(string $name): ?array { return $this->item->getFieldConfig($name); } /** * Get all field configurations */ public function configs(): array { 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 // ───────────────────────────────────────────────────────────── /** * Check for field update overrides */ public function checkOverrides(Field $field): bool { $name = $field->name; $type = $field->type(); $value = $field->value; do_action('jvb_meta_update', $name, $value, $this->item->objectType); $overrides = [ BASE . 'update_' . $name, BASE . 'update_' . $type, 'jvb_update_' . $name, 'jvb_update_' . $type, ]; foreach ($overrides as $override) { if (function_exists($override)) { $override($this->item->id, $value); return true; } } return false; } /** * Get default value for a field type */ protected function getDefaultValue(string $name): mixed { $config = $this->item->getFieldConfig($name); $type = $config['type'] ?? 'text'; return match ($this->typeManager->getMetaType($type)) { 'object', 'array' => [], 'boolean' => false, 'integer' => 0, default => '', }; } }