From d7e7d248cbe41cd7a9ef9c2fb022b6c4831f99a3 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Sun, 31 May 2026 15:22:56 +0000
Subject: [PATCH] =jakevan complete
---
inc/meta/Storage.php | 539 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
1 files changed, 513 insertions(+), 26 deletions(-)
diff --git a/inc/meta/Storage.php b/inc/meta/Storage.php
index b2ea8ac..386b7b4 100644
--- a/inc/meta/Storage.php
+++ b/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']);
+ }
+ }
}
--
Gitblit v1.10.0