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); } $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); $metaFields = array_diff($fieldNames, $wpFields); $values = []; // Get meta fields in bulk query if (!empty($metaFields)) { $values = $this->bulkGetMeta($item, $metaFields); } // Get WP default fields foreach ($wpFields as $name) { $values[$name] = $this->getWpDefault($item, $name); } return $values; } /** * Save a single field */ public function saveField(Item $item, Field $field): bool { if ($field->isWpDefault()) { return $this->saveWpDefault($item, $field); } if ($field->isTaxonomy()) { 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, 'options' => $this->saveOption($item, $field), default => false }; } /** * 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)) { throw new Exception("Failed to save field: {$field->name}"); } $field->markClean(); } $this->wpdb->query('COMMIT'); // Update post modified timestamp if ($updateTimestamp && $item->objectType === 'post' && $item->id) { wp_update_post(['ID' => $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 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> 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' => 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) { 'term_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'])) { 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['featured_image'])) { 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']); } } }