ttl = $ttl > 0 ? $ttl : self::DEFAULT_TTL; $this->variant = self::context_variant( $context, $version ?? self::plugin_version() ); } /** * Returns the running plugin version, or a neutral placeholder when the * constant is unavailable (e.g. the class used outside a booted plugin). * * @since 1.12.0 * * @return string */ private static function plugin_version(): string { return defined( 'POSTLINKLY_VERSION' ) ? (string) POSTLINKLY_VERSION : '0.0.0'; } /** * Reads a cached suggestion list. * * Returns `null` (cache miss) when no entry exists, when the stored * value is malformed, or when the entry has expired and the * transient layer has dropped it. * * @since 1.0.0 * * @param int $post_id Source post or term identifier. * @param int $limit The result-list cap that was originally requested. * @param string $type Optional. `post` or `term`. Default `post`. * * @return array|null Cached list, or null on miss. */ public function get( int $post_id, int $limit, string $type = 'post' ): ?array { if ( $post_id <= 0 || $limit <= 0 ) { return null; } $value = get_transient( $this->build_key( $post_id, $limit, $type ) ); if ( false === $value || ! is_array( $value ) ) { return null; } // Defensive validation: the array must contain only SuggestionModel // instances. A non-conforming payload could only arise if a third // party has poked at the transient, but missing the check would // break callers downstream with a much less obvious error. foreach ( $value as $entry ) { if ( ! $entry instanceof SuggestionModel ) { return null; } } return $value; } /** * Writes a suggestion list to the cache. * * No-op when the input is empty or when `$post_id` / `$limit` are * out of range. Lists longer than {@see MAX_LIMIT} are truncated * to the limit before storage. * * @since 1.0.0 * * @param int $post_id Source post or term identifier. * @param int $limit The result-list cap originally requested. * @param array $suggestions The ranked list to cache. * @param string $type Optional. `post` or `term`. Default `post`. * * @return void */ public function set( int $post_id, int $limit, array $suggestions, string $type = 'post' ): void { if ( $post_id <= 0 || $limit <= 0 ) { return; } // Drop anything that isn't a SuggestionModel — the cache is typed. $clean = array(); foreach ( $suggestions as $entry ) { if ( $entry instanceof SuggestionModel ) { $clean[] = $entry; } } if ( count( $clean ) > self::MAX_LIMIT ) { $clean = array_slice( $clean, 0, self::MAX_LIMIT ); } set_transient( $this->build_key( $post_id, $limit, $type ), $clean, $this->ttl ); } /** * Drops a single source post's cached suggestions. * * Invalidates every limit/type combination for the given post id, so * an editor save that changed the source content clears all stored * results regardless of which UI requested them. * * @since 1.0.0 * @since 1.5.0 Legacy prefixes are now cleared with the same LIKE * query instead of a per-limit transient-deletion loop. * * @param int $post_id Source post or term identifier. * @param string $type Optional. `post` or `term`. Default `post`. * * @return void */ public function invalidate( int $post_id, string $type = 'post' ): void { if ( $post_id <= 0 ) { return; } global $wpdb; if ( ! isset( $wpdb ) ) { return; } $type_token = ( 'term' === $type ) ? 't' : 'p'; $suffix = $type_token . $post_id . '_'; // One LIKE-based delete across the current prefix and both legacy // prefixes, replacing a per-limit deletion loop. That loop issued // up to MAX_LIMIT * 2 queries per invalidation (every post save // and link insert) to clear per-limit keys that had already // expired via TTL months ago in practice. $clauses = array(); $values = array(); foreach ( array( self::KEY_PREFIX, 'pl_sugg_v2_', 'pl_sugg_' ) as $prefix ) { $key_prefix = $prefix . $suffix; $clauses[] = 'option_name LIKE %s'; $values[] = $wpdb->esc_like( '_transient_' . $key_prefix ) . '%'; $clauses[] = 'option_name LIKE %s'; $values[] = $wpdb->esc_like( '_transient_timeout_' . $key_prefix ) . '%'; } $where = implode( ' OR ', $clauses ); $sql = "DELETE FROM {$wpdb->options} WHERE {$where}"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $where is assembled from literal 'option_name LIKE %s' clauses only (never request input); $values below supplies the escaped placeholder values consumed by prepare(). // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- Targeted cache invalidation for every runtime variant of one source item; $sql is prepared immediately above with $values as its placeholder arguments. $wpdb->query( $wpdb->prepare( $sql, $values ) ); } /** * Drops every Postlinkly suggestion cache entry. * * Used by the maintenance tools when the corpus has changed in a way * the per-post invalidations cannot cover (e.g. site URL change, * mass post import). Walks `wp_options` once for the prefix. * * @since 1.0.0 * * @return int Number of entries removed. */ public function flush_all(): int { global $wpdb; if ( ! isset( $wpdb ) ) { return 0; } // We delete options whose name begins with `_transient_` followed // by our prefix. Timeouts are removed alongside via a second LIKE // clause, matching how WordPress stores a transient's value and // its expiry as two separate options. // Also clean up the old v1 prefix (pl_sugg_) from before the cache // version bump. $prefixes = array( self::KEY_PREFIX, 'pl_sugg_v2_', 'pl_sugg_' ); $removed = 0; foreach ( $prefixes as $prefix ) { $transient_like = $wpdb->esc_like( '_transient_' . $prefix ) . '%'; $timeout_like = $wpdb->esc_like( '_transient_timeout_' . $prefix ) . '%'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Cache flush is the operation; using transient API would defeat the purpose. $removed += (int) $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", $transient_like, $timeout_like ) ); } return $removed > 0 ? $removed : 0; } /** * Builds the transient key for a post / limit / type combination. * * @since 1.0.0 * * @param int $post_id Source post or term identifier. * @param int $limit The result-list cap. * @param string $type Either `post` or `term`. * * @return string Transient key. */ private function build_key( int $post_id, int $limit, string $type ): string { $type_token = ( 'term' === $type ) ? 't' : 'p'; return self::KEY_PREFIX . $type_token . $post_id . '_' . $limit . '_' . $this->variant; } /** * Converts a runtime context and plugin version into a compact * transient-safe token. * * Folding the version in here rather than adding a key segment keeps * the key layout intact, so {@see invalidate()}'s LIKE pattern — which * matches everything after the post id — still sweeps a post's entries * whichever build wrote them. * * @since 1.5.0 * @since 1.12.0 The plugin version is part of the token. A cached list * reflects the engine that produced it, and nothing invalidates * this cache on upgrade, so entries from an earlier build must * stop being readable rather than outlive the code that made * them. Superseded entries are simply never found again and * expire on their own TTL. * * @param string $context Runtime context. * @param string $version Plugin build the entry belongs to. * * @return string Variant token. */ private static function context_variant( string $context, string $version ): string { return substr( hash( 'sha256', $version . '|' . trim( $context ) ), 0, 12 ); } }