| | |
| | | namespace JVBase\meta; |
| | | |
| | | use Exception; |
| | | use wpdb; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | |
| | | */ |
| | | class Storage |
| | | { |
| | | protected wpdb $wpdb; |
| | | protected \wpdb $wpdb; |
| | | |
| | | public function __construct() |
| | | { |
| | |
| | | $this->wpdb = $wpdb; |
| | | } |
| | | |
| | | // ───────────────────────────────────────────────────────────── |
| | | // Single Item Operations |
| | | // ───────────────────────────────────────────────────────────── |
| | | |
| | | /** |
| | | * Load a single field value from database |
| | | */ |
| | |
| | | } |
| | | |
| | | /** |
| | | * 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 []; |
| | | } |
| | | |
| | |
| | | |
| | | $values = []; |
| | | |
| | | // Get meta fields in bulk |
| | | // Get meta fields in bulk query |
| | | if (!empty($metaFields)) { |
| | | $values = $this->bulkGetMeta($item, $metaFields); |
| | | } |
| | |
| | | } |
| | | |
| | | /** |
| | | * Save all dirty fields on an item |
| | | * Save all dirty fields on a single item |
| | | */ |
| | | public function save(Item $item, bool $updateTimestamp = true): bool |
| | | { |
| | |
| | | */ |
| | | 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) { |
| | |
| | | } |
| | | |
| | | // ───────────────────────────────────────────────────────────── |
| | | // 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 |
| | |
| | | $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; |
| | | } |
| | |
| | | return $values; |
| | | } |
| | | |
| | | protected function getTableInfo(string $objectType): array |
| | | public function getTableInfo(string $objectType): array |
| | | { |
| | | return match ($objectType) { |
| | | 'post' => [$this->wpdb->postmeta, 'post_id'], |
| | |
| | | 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), |
| | |
| | | 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']); |
| | | } |
| | | } |
| | | } |