<?php
|
namespace JVBase\meta;
|
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
|
/**
|
* 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 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');
|
$instance->item->baseKey = $baseKey;
|
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
|
// ─────────────────────────────────────────────────────────────
|
|
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
|
{
|
// Handle repeater subfield path
|
if (str_contains($name, ':')) {
|
return $this->getByPath($name);
|
}
|
|
// 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
|
{
|
// Handle repeater subfield path (e.g., "services:2:image")
|
if (str_contains($name, ':')) {
|
return $this->setByPath($name, $value);
|
}
|
|
$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);
|
}
|
|
protected function setByPath(string $path, mixed $value): self
|
{
|
$parts = explode(':', $path, 3);
|
if (count($parts) !== 3) {
|
return $this;
|
}
|
|
[$repeaterName, $rowIndex, $subField] = $parts;
|
$this->repeater($repeaterName)->setField((int) $rowIndex, $subField, $value);
|
|
return $this;
|
}
|
|
protected function getByPath(string $path): mixed
|
{
|
$parts = explode(':', $path, 3);
|
if (count($parts) !== 3) {
|
return null;
|
}
|
|
[$repeaterName, $rowIndex, $subField] = $parts;
|
return $this->repeater($repeaterName)->field((int) $rowIndex, $subField);
|
}
|
|
// ─────────────────────────────────────────────────────────────
|
// 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 => '',
|
};
|
}
|
|
}
|