<?php
|
namespace JVBase\managers;
|
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
|
/**
|
* Manages HTTP cache timestamps and relationship-based invalidation
|
*
|
* Data caching: Use wrapper methods or wp_cache_get/set directly
|
* HTTP caching: This class manages timestamps for ETag/Last-Modified headers
|
*/
|
class CacheManager
|
{
|
private string $prefix = BASE;
|
private string $group;
|
private int $cache_ttl;
|
private static ?bool $use_object_cache = null;
|
private static array $instances = []; // Cache instances per type
|
private static array $http_timestamps = []; // Request-level memory cache
|
private static array $relationships = []; // Type => [related types]
|
private static bool $relationships_loaded = false;
|
|
/**
|
* Private constructor - use for() factory method instead
|
*/
|
private function __construct(string $group, ?int $ttl = null)
|
{
|
$this->group = jvbNoBase($group);
|
$this->cache_ttl = $ttl ?: 3600;
|
|
if (is_null(static::$use_object_cache)) {
|
static::$use_object_cache = wp_using_ext_object_cache();
|
}
|
}
|
|
/**
|
* Get or create a cache manager instance for a content type
|
*
|
* @param string $type Content type (tattoo, style, etc.)
|
* @param int|null $ttl Optional TTL override
|
* @return self Fluent interface
|
*/
|
public static function for(string $type, ?int $ttl = null): self
|
{
|
$type = jvbNoBase($type);
|
$key = $type . ($ttl ? "_{$ttl}" : '');
|
|
if (!isset(self::$instances[$key])) {
|
self::$instances[$key] = new self($type, $ttl);
|
}
|
|
return self::$instances[$key];
|
}
|
|
/**
|
* Get cache manager for a specific user
|
* Each user gets their own cache group for complete isolation
|
*
|
* @param int $user_id User ID
|
* @param int|null $ttl Optional TTL
|
* @return self
|
*/
|
public static function forUser(int $user_id, ?int $ttl = null): self
|
{
|
return self::for("user_{$user_id}", $ttl);
|
}
|
|
/**
|
* Get HTTP cache timestamp for content type(s)
|
* Used for ETag and Last-Modified header generation
|
*
|
* @param string|array $types Single type or array of types
|
* @return int Latest timestamp (Unix time)
|
*/
|
public static function getTimestamp(string|array $types): int
|
{
|
// Multiple types - return latest
|
if (is_array($types)) {
|
$latest = 0;
|
foreach ($types as $type) {
|
$timestamp = self::getTimestamp($type);
|
if ($timestamp > $latest) {
|
$latest = $timestamp;
|
}
|
}
|
return $latest ?: time();
|
}
|
|
$type = jvbNoBase($types);
|
|
// Check request-level cache
|
if (isset(self::$http_timestamps[$type])) {
|
return self::$http_timestamps[$type];
|
}
|
|
// Load from cache (Redis or transient - wp_cache handles it)
|
$timestamp = (int)wp_cache_get("http_ts_{$type}", 'jvb_timestamps') ?: time();
|
|
// Cache in memory for this request
|
self::$http_timestamps[$type] = $timestamp;
|
|
return $timestamp;
|
}
|
|
/**
|
* Update HTTP cache timestamp (marks content as modified)
|
*
|
* @param string $type Content type
|
* @return int The new timestamp
|
*/
|
public static function updateTimestamp(string $type): int
|
{
|
$type = jvbNoBase($type);
|
$timestamp = time();
|
|
// Store (Redis or transient - wp_cache handles it)
|
wp_cache_set("http_ts_{$type}", $timestamp, 'jvb_timestamps', WEEK_IN_SECONDS);
|
|
// Update request cache
|
self::$http_timestamps[$type] = $timestamp;
|
|
do_action('jvb_http_timestamp_updated', $type, $timestamp);
|
|
return $timestamp;
|
}
|
|
/**
|
* Invalidate cache for a content type with automatic cascade
|
*
|
* @param string $type Content type to invalidate
|
* @param mixed $context Post/Term object or array with relationship data (for cascade)
|
* @param string|array|null $specific_keys Optional specific key(s) to delete without flushing group
|
* @return void
|
*/
|
public static function invalidateAll(string $type, $context = null, $specific_keys = null): void
|
{
|
$type = jvbNoBase($type);
|
|
// Update HTTP timestamp
|
self::updateTimestamp($type);
|
|
// If specific keys provided, only delete those (don't flush whole group)
|
if ($specific_keys !== null) {
|
$instance = self::for($type);
|
if (is_array($specific_keys)) {
|
foreach ($specific_keys as $key) {
|
$instance->delete($key);
|
}
|
} else {
|
$instance->delete($specific_keys);
|
}
|
} else {
|
// No specific keys - flush the entire group
|
if (function_exists('wp_cache_flush_group')) {
|
wp_cache_flush_group($type);
|
} else {
|
// Fallback for older WP
|
wp_cache_flush();
|
}
|
}
|
|
// Cascade to related types if context provided
|
if ($context !== null) {
|
self::cascadeInvalidation($type, $context);
|
}
|
|
do_action('jvb_cache_invalidated', $type, $context);
|
}
|
|
/**
|
* Invalidate only specific keys for a type (doesn't flush group or update timestamp)
|
* Use this when you want surgical cache invalidation
|
*
|
* @param string $type Content type
|
* @param string|array $keys Key(s) to delete
|
* @return void
|
*/
|
public static function invalidateKeys(string $type, string|array $keys): void
|
{
|
$instance = self::for($type);
|
|
if (is_array($keys)) {
|
foreach ($keys as $key) {
|
$instance->delete($key);
|
}
|
} else {
|
$instance->delete($keys);
|
}
|
}
|
|
/**
|
* Fluent instance method to invalidate this cache type
|
* Allows chaining: CacheManager::for('tattoo')->invalidate()->clear()
|
*
|
* @param mixed $context Optional context for cascade
|
* @param string|array|null $specific_keys Optional specific key(s)
|
* @return self For chaining
|
*/
|
public function invalidate($context = null, $specific_keys = null): self
|
{
|
self::invalidateAll($this->group, $context, $specific_keys);
|
return $this;
|
}
|
|
/**
|
* Get the HTTP timestamp for this instance's type
|
*
|
* @return int
|
*/
|
public function timestamp(): int
|
{
|
return self::getTimestamp($this->group);
|
}
|
|
/**
|
* Update the HTTP timestamp for this instance's type
|
*
|
* @return self For chaining
|
*/
|
public function touch(): self
|
{
|
self::updateTimestamp($this->group);
|
return $this;
|
}
|
|
/**
|
* Get a value from the cache
|
* @param string|array $key The key to look up (auto-generates key from array of key=>values)
|
* @param string|null $group The group to get from. Defaults to current group
|
* @return mixed
|
*/
|
public function get(string|array $key, ?string $group = null): mixed
|
{
|
$group = $group ?: $this->group;
|
$key = $this->normalizeKey($key);
|
$cache_key = $this->buildKey($key);
|
|
return wp_cache_get($cache_key, $group);
|
}
|
|
/**
|
* Store a value in cache
|
* @param string|array $key The key to look up (auto-generates key from array of key=>values)
|
* @param mixed $value The Value to set
|
* @param int|null $ttl The ttl (defaults to current set ttl)
|
* @param string|null $group The group to add cache to (defaults to current group))
|
* @return bool
|
*/
|
public function set(string|array $key, mixed $value, ?int $ttl = null, ?string $group = null): bool
|
{
|
$ttl = $ttl ?: $this->cache_ttl;
|
$group = $group ?: $this->group;
|
$key = $this->normalizeKey($key);
|
$cache_key = $this->buildKey($key);
|
|
// Update timestamp when setting new data
|
self::updateTimestamp($this->group);
|
|
return wp_cache_set($cache_key, $value, $group, $ttl);
|
}
|
|
/**
|
* Delete a cached value
|
* @param string|array $key The key to look up (auto-generates key from array of key=>values)
|
* @param string|null $group The group to delete from (defaults to current group)
|
* @return bool
|
*/
|
public function delete(string|array $key, ?string $group = null): bool
|
{
|
$group = $group ?: $this->group;
|
$key = $this->normalizeKey($key);
|
$cache_key = $this->buildKey($key);
|
|
return wp_cache_delete($cache_key, $group);
|
}
|
|
/**
|
* Clear all cache for this group
|
* @return bool
|
*/
|
public function clear(): bool
|
{
|
try {
|
if (function_exists('wp_cache_flush_group')) {
|
wp_cache_flush_group($this->group);
|
self::updateTimestamp($this->group);
|
return true;
|
}
|
return false;
|
} catch (\Exception $e) {
|
return false;
|
}
|
}
|
|
/**
|
* Helper to generateKey from array if applicable
|
* @param string|array $key
|
* @return string
|
*/
|
private function normalizeKey(string|array $key): string
|
{
|
return is_array($key) ? $this->generateKey($key) : $key;
|
}
|
|
/**
|
* Generate a cache key from parameters
|
* @param array $params An array of key/values that differentiates this cache item from others
|
* @return string
|
*/
|
public function generateKey(array $params): string
|
{
|
// Sort params for consistent key generation
|
ksort($params);
|
return md5(serialize($params));
|
}
|
|
/**
|
* The workhorse shorthand of CacheManager. Tests the cache, and calls the callback if nothing is found.
|
* @param string|array $key The key to look up (auto-generates key from array of key=>values)
|
* @param callable $callback The callback to generate the value for this key
|
* @param int|null $ttl The time-to-live for the cache. Defaults to constructor
|
* @param string|null $group The group to save cache to. Defaults to constructor
|
* @return mixed
|
*/
|
public function remember(string|array $key, callable $callback, ?int $ttl = null, ?string $group = null): mixed
|
{
|
$group = $group ?: $this->group;
|
$ttl = $ttl ?: $this->cache_ttl;
|
$key = $this->normalizeKey($key);
|
|
$value = $this->get($key, $group);
|
|
if ($value === false) {
|
$value = $callback();
|
if ($value !== false && $value !== null) {
|
$this->set($key, $value, $ttl, $group);
|
}
|
}
|
|
return $value;
|
}
|
|
/**
|
* Build the cache key
|
* @param string $key
|
* @return string
|
*/
|
private function buildKey(string $key): string
|
{
|
return $this->prefix . $key;
|
}
|
|
/**
|
* Get instance group name (for debugging)
|
*/
|
public function getGroup(): string
|
{
|
return $this->group;
|
}
|
|
// ===== RELATIONSHIP MANAGEMENT =====
|
|
/**
|
* Register cache relationship
|
* When $type is invalidated, these related types are also invalidated
|
*
|
* @param string $type Primary type
|
* @param array $config Relationship configuration
|
* - 'author' => bool - Invalidate user content caches
|
* - 'taxonomies' => array - List of taxonomy types to invalidate
|
* - 'content_types' => array - List of content types to invalidate
|
* - 'related' => array - Generic related types to invalidate
|
* - 'cascade' => callable - Custom cascade function
|
*/
|
public static function registerRelationship(string $type, array $config): void
|
{
|
$type = jvbNoBase($type);
|
|
// Merge with existing relationships
|
self::$relationships[$type] = array_merge(
|
self::$relationships[$type] ?? [],
|
$config
|
);
|
|
// Build reverse relationships for bidirectional linking
|
self::buildReverseRelationships($type, $config);
|
}
|
|
/**
|
* Build reverse relationships (if A relates to B, B should know about A)
|
*
|
* @param string $type The type being registered
|
* @param array $config Its relationship config
|
*/
|
private static function buildReverseRelationships(string $type, array $config): void
|
{
|
// If this type relates to taxonomies, those taxonomies should know about this type
|
if (!empty($config['taxonomies'])) {
|
foreach ($config['taxonomies'] as $taxonomy) {
|
$taxonomy = jvbNoBase($taxonomy);
|
self::$relationships[$taxonomy]['content_types'] =
|
array_unique(array_merge(
|
self::$relationships[$taxonomy]['content_types'] ?? [],
|
[$type]
|
));
|
}
|
}
|
|
// If this type relates to content_types, those types should know about this taxonomy
|
if (!empty($config['content_types'])) {
|
foreach ($config['content_types'] as $content_type) {
|
$content_type = jvbNoBase($content_type);
|
self::$relationships[$content_type]['related'] =
|
array_unique(array_merge(
|
self::$relationships[$content_type]['related'] ?? [],
|
[$type]
|
));
|
}
|
}
|
}
|
|
/**
|
* Load relationships from JVB_CONTENT and JVB_TAXONOMY
|
*/
|
private static function loadRelationships(): void
|
{
|
if (self::$relationships_loaded) {
|
return;
|
}
|
|
// Load post type relationships
|
if (defined('JVB_CONTENT')) {
|
foreach (JVB_CONTENT as $slug => $config) {
|
$relationships = [];
|
|
// Author relationship
|
if (!($config['no_author'] ?? false)) {
|
$relationships['author'] = true;
|
}
|
|
// Taxonomy relationships
|
if (!empty($config['taxonomies'])) {
|
$relationships['taxonomies'] = array_map('jvbNoBase', $config['taxonomies']);
|
}
|
|
// Custom relationships from config
|
if (!empty($config['cache_relationships'])) {
|
$relationships = array_merge($relationships, $config['cache_relationships']);
|
}
|
|
if (!empty($relationships)) {
|
self::registerRelationship($slug, $relationships);
|
}
|
}
|
}
|
|
// Load taxonomy relationships
|
if (defined('JVB_TAXONOMY')) {
|
foreach (JVB_TAXONOMY as $slug => $config) {
|
$relationships = [];
|
|
// Content type relationships
|
if (!empty($config['for_content'])) {
|
$relationships['content_types'] = array_map('jvbNoBase', $config['for_content']);
|
}
|
|
// Always include generic 'terms' cache
|
$relationships['related'] = ['terms'];
|
|
// Custom relationships from config
|
if (!empty($config['cache_relationships'])) {
|
$relationships = array_merge($relationships, $config['cache_relationships']);
|
}
|
|
if (!empty($relationships)) {
|
self::registerRelationship($slug, $relationships);
|
}
|
}
|
}
|
|
self::$relationships_loaded = true;
|
|
do_action('jvb_cache_relationships_loaded', self::$relationships);
|
}
|
|
/**
|
* Get relationships for a type (for debugging)
|
*
|
* @param string|null $type Specific type or null for all
|
* @return array Relationships
|
*/
|
public static function getRelationships(?string $type = null): array
|
{
|
self::loadRelationships();
|
|
if ($type !== null) {
|
return self::$relationships[jvbNoBase($type)] ?? [];
|
}
|
|
return self::$relationships;
|
}
|
|
/**
|
* Cascade invalidation to related types based on relationships
|
*
|
* @param string $type Primary type being invalidated
|
* @param mixed $context Context with relationship data
|
*/
|
/**
|
* Cascade invalidation to related types based on relationships
|
*/
|
private static function cascadeInvalidation(string $type, $context): void
|
{
|
self::loadRelationships();
|
|
$relationships = self::$relationships[$type] ?? [];
|
if (empty($relationships)) {
|
return;
|
}
|
|
$data = self::extractContext($context);
|
|
// Author relationship - SIMPLIFIED
|
if (!empty($relationships['author'])) {
|
$user_ids = self::extractUserIds($data, $relationships['author']);
|
|
foreach ($user_ids as $user_id) {
|
// Single clean call - handles content, profile, everything
|
self::invalidateAll("user_{$user_id}");
|
}
|
}
|
|
// Taxonomy relationships
|
if (!empty($relationships['taxonomies']) && !empty($data['ID'])) {
|
foreach ($relationships['taxonomies'] as $taxonomy) {
|
$taxonomy_full = jvbCheckBase($taxonomy);
|
$terms = wp_get_post_terms($data['ID'], $taxonomy_full, ['fields' => 'ids']);
|
|
if (!empty($terms) && !is_wp_error($terms)) {
|
self::updateTimestamp($taxonomy);
|
wp_cache_flush_group($taxonomy);
|
}
|
}
|
}
|
|
// Content type relationships (for taxonomies)
|
if (!empty($relationships['content_types'])) {
|
foreach ($relationships['content_types'] as $content_type) {
|
self::updateTimestamp($content_type);
|
wp_cache_flush_group($content_type);
|
}
|
}
|
|
// Generic related caches
|
if (!empty($relationships['related'])) {
|
foreach ($relationships['related'] as $related_type) {
|
self::updateTimestamp($related_type);
|
wp_cache_flush_group($related_type);
|
}
|
}
|
|
// Custom cascade function
|
if (!empty($relationships['cascade']) && is_callable($relationships['cascade'])) {
|
call_user_func($relationships['cascade'], $type, $data);
|
}
|
}
|
|
/**
|
* Extract user IDs from context based on relationship config
|
* Supports multiple authors, contributors, etc.
|
*
|
* @param array $data Context data
|
* @param mixed $config Author relationship config (bool or array)
|
* @return array User IDs to invalidate
|
*/
|
private static function extractUserIds(array $data, $config): array
|
{
|
$user_ids = [];
|
|
// Simple case: 'author' => true
|
if ($config === true) {
|
if (!empty($data['post_author'])) {
|
$user_ids[] = $data['post_author'];
|
}
|
return array_filter($user_ids);
|
}
|
|
// Advanced case: 'author' => ['post_author', 'contributors', 'linked_user']
|
if (is_array($config)) {
|
foreach ($config as $field) {
|
// Handle meta fields
|
if (str_starts_with($field, 'meta:') && !empty($data['ID'])) {
|
$meta_key = substr($field, 5);
|
$value = get_post_meta($data['ID'], BASE . $meta_key, true);
|
|
if (is_array($value)) {
|
$user_ids = array_merge($user_ids, $value);
|
} elseif ($value) {
|
$user_ids[] = $value;
|
}
|
}
|
// Handle direct data fields
|
elseif (!empty($data[$field])) {
|
if (is_array($data[$field])) {
|
$user_ids = array_merge($user_ids, $data[$field]);
|
} else {
|
$user_ids[] = $data[$field];
|
}
|
}
|
}
|
}
|
|
// Callable: 'author' => function($data) { return [...user_ids]; }
|
if (is_callable($config)) {
|
$result = call_user_func($config, $data);
|
if (is_array($result)) {
|
$user_ids = array_merge($user_ids, $result);
|
} elseif ($result) {
|
$user_ids[] = $result;
|
}
|
}
|
|
return array_unique(array_filter(array_map('intval', $user_ids)));
|
}
|
|
/**
|
* Extract context data from various formats
|
* Converts WP objects to arrays with relevant data
|
*
|
* @param mixed $context Post/Term object, array, or ID
|
* @return array Normalized context data
|
*/
|
private static function extractContext($context): array
|
{
|
if (is_array($context)) {
|
return $context;
|
}
|
|
if ($context instanceof \WP_Post) {
|
return [
|
'ID' => $context->ID,
|
'post_author' => $context->post_author,
|
'post_type' => $context->post_type,
|
'post_status' => $context->post_status,
|
];
|
}
|
|
if ($context instanceof \WP_Term) {
|
return [
|
'term_id' => $context->term_id,
|
'taxonomy' => $context->taxonomy,
|
'parent' => $context->parent,
|
];
|
}
|
|
if (is_numeric($context)) {
|
$post = get_post($context);
|
if ($post) {
|
return self::extractContext($post);
|
}
|
}
|
|
return [];
|
}
|
}
|