<?php
|
namespace JVBase\meta;
|
|
use Exception;
|
use JVBase\managers\Cache;
|
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
|
/**
|
* Handles persistence of meta values to WordPress
|
* Pure storage layer - no validation or sanitization
|
*/
|
class Storage
|
{
|
protected \wpdb $wpdb;
|
|
public function __construct()
|
{
|
global $wpdb;
|
$this->wpdb = $wpdb;
|
}
|
|
// ─────────────────────────────────────────────────────────────
|
// Single Item Operations
|
// ─────────────────────────────────────────────────────────────
|
|
/**
|
* Load a single field value from database
|
*/
|
public function get(Item $item, string $name): mixed
|
{
|
if ($item->isWpDefault($name)) {
|
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) {
|
'post' => get_post_meta($item->id, $metaKey, true),
|
'term' => get_term_meta($item->id, $metaKey, true),
|
'user', 'integrations' => get_user_meta($item->id, $metaKey, true),
|
'options' => $this->getOption($item, $name),
|
default => ''
|
};
|
}
|
|
/**
|
* Load multiple field values for single item
|
*/
|
public function getAll(Item $item, array $fieldNames): array
|
{
|
if (empty($fieldNames) || (!$item->id && $item->objectType !== 'options')) {
|
return [];
|
}
|
|
$defaults = Item::WP_DEFAULTS[$item->objectType] ?? [];
|
$wpFields = array_intersect($defaults, $fieldNames);
|
|
// 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 = [];
|
|
if (!empty($metaFields)) {
|
$values = $this->bulkGetMeta($item, $metaFields);
|
}
|
|
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
|
*/
|
public function saveField(Item $item, Field $field): bool
|
{
|
if ($field->isWpDefault()) {
|
return $this->saveWpDefault($item, $field);
|
}
|
|
if ($field->isTaxonomy()) {
|
error_log('Saving Taxonomy field with set_object_terms');
|
return $this->saveTaxonomyField($item, $field);
|
}
|
|
$metaKey = BASE . $field->name;
|
$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 a single item
|
*/
|
public function save(Item $item, bool $updateTimestamp = true): bool
|
{
|
$dirty = $item->getDirtyFields();
|
|
if (empty($dirty)) {
|
return true;
|
}
|
|
$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();
|
}
|
|
$this->wpdb->query('COMMIT');
|
|
// Update post modified timestamp
|
Cache::invalidateItem($item->objectType, $item->id);
|
$this->clearCache($item);
|
|
return true;
|
|
} catch (Exception $e) {
|
$this->wpdb->query('ROLLBACK');
|
JVB()->error()->log('meta_storage', $e->getMessage(), [
|
'item_id' => $item->id,
|
'object_type' => $item->objectType,
|
'fields' => array_keys($dirty)
|
], 'error');
|
return false;
|
}
|
}
|
|
/**
|
* Delete a field value
|
*/
|
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) {
|
'post' => delete_post_meta($item->id, $metaKey),
|
'term' => delete_term_meta($item->id, $metaKey),
|
'user', 'integrations' => delete_user_meta($item->id, $metaKey),
|
'options' => delete_option($this->optionKey($item, $name)),
|
default => false
|
};
|
}
|
|
// ─────────────────────────────────────────────────────────────
|
// 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
|
{
|
if (in_array($name, ['featured_image', 'post_thumbnail'])) {
|
return get_post_thumbnail_id($item->id);
|
}
|
|
return match ($item->objectType) {
|
'post' => $this->getPostField($item, $name),
|
'term' => $this->getTermField($item, $name),
|
'user' => $this->getUserField($item, $name),
|
default => ''
|
};
|
}
|
|
protected function getPostField(Item $item, string $name): mixed
|
{
|
return match ($name) {
|
'post_title' => get_the_title($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 ?? ''
|
};
|
}
|
|
protected function getTermField(Item $item, string $name): mixed
|
{
|
return match ($name) {
|
'name' => get_term_field('name', $item->id),
|
'description' => get_term_field('description', $item->id),
|
default => ''
|
};
|
}
|
|
protected function getUserField(Item $item, string $name): mixed
|
{
|
return match ($name) {
|
'display_name' => get_the_author_meta('display_name', $item->id),
|
'user_email' => get_the_author_meta('user_email', $item->id),
|
'first_name' => get_the_author_meta('first_name', $item->id),
|
'last_name' => get_the_author_meta('last_name', $item->id),
|
default => $item->wpObject->$name ?? ''
|
};
|
}
|
|
protected function saveWpDefault(Item $item, Field $field): bool
|
{
|
$name = $field->name;
|
$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, [
|
$name => $value,
|
'slug' => $name === 'term_name' ? sanitize_title($value) : null
|
])),
|
'user' => wp_update_user(['ID' => $item->id, $name => $value]) !== 0,
|
default => false
|
};
|
}
|
|
/**
|
* 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((string)$value))) {
|
wp_set_object_terms($item->id, [], $taxonomy, false);
|
return true;
|
}
|
|
$termIds = array_map('intval', array_filter(explode(',', $value)));
|
$result = wp_set_object_terms($item->id, $termIds, $taxonomy, false);
|
|
return !is_wp_error($result);
|
}
|
|
protected function bulkGetMeta(Item $item, array $fields): array
|
{
|
[$table, $idColumn] = $this->getTableInfo($item->objectType);
|
|
if (!$table) {
|
return [];
|
}
|
|
$metaKeys = array_map(fn($f) => BASE . $f, $fields);
|
$placeholders = implode(',', array_fill(0, count($metaKeys), '%s'));
|
|
$query = $this->wpdb->prepare(
|
"SELECT meta_key, meta_value FROM {$table}
|
WHERE {$idColumn} = %d AND meta_key IN ({$placeholders})",
|
array_merge([$item->id], $metaKeys)
|
);
|
|
$results = $this->wpdb->get_results($query, ARRAY_A);
|
|
$values = array_fill_keys($fields, '');
|
|
foreach ($results as $row) {
|
$key = str_replace(BASE, '', $row['meta_key']);
|
$values[$key] = maybe_unserialize($row['meta_value']);
|
}
|
|
return $values;
|
}
|
|
public function getTableInfo(string $objectType): array
|
{
|
return match ($objectType) {
|
'post' => [$this->wpdb->postmeta, 'post_id'],
|
'term' => [$this->wpdb->termmeta, 'term_id'],
|
'user', 'integrations' => [$this->wpdb->usermeta, 'user_id'],
|
default => [null, null]
|
};
|
}
|
|
protected function getOption(Item $item, string $name): mixed
|
{
|
return get_option($this->optionKey($item, $name));
|
}
|
|
protected function saveOption(Item $item, Field $field): bool
|
{
|
return update_option($this->optionKey($item, $field->name), $field->value);
|
}
|
|
public function optionKey(Item $item, string $name): string
|
{
|
return $item->baseKey
|
? BASE . $item->baseKey . '_' . $name
|
: BASE . $name;
|
}
|
|
public function clearCache(Item $item): void
|
{
|
match ($item->objectType) {
|
'post' => clean_post_cache($item->id),
|
'term' => clean_term_cache($item->id),
|
'user', 'integrations' => clean_user_cache($item->id),
|
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']);
|
}
|
}
|
}
|