query(
"DELETE FROM {$wpdb->options}
WHERE option_name LIKE '_transient_psa_search_%'
OR option_name LIKE '_transient_timeout_psa_search_%'"
);
}
/** Register REST API route for external/headless access. */
public static function register_rest_routes(): void {
register_rest_route(
'peptides/v1',
'/search',
array(
'methods' => 'GET',
'callback' => array( __CLASS__, 'rest_search' ),
'permission_callback' => '__return_true',
'args' => array(
'q' => array(
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => function ( $param ) {
return is_string( $param ) && strlen( $param ) >= PSA_Config::MIN_QUERY_LENGTH;
},
),
),
)
);
}
/**
* REST search callback — returns published peptides only (no generation trigger).
*
* Uses dual-layer rate limiting (transient + object cache) for resilience.
*
* @param WP_REST_Request $request The REST request.
* @return WP_REST_Response
*/
public static function rest_search( $request ) {
$ip = psa_get_client_ip();
$rate_key = 'psa_rest_rate_' . md5( $ip );
$count_transient = (int) get_transient( $rate_key );
$count_cache = (int) wp_cache_get( $rate_key, 'psa_rate_limits' );
$count = max( $count_transient, $count_cache );
if ( $count >= PSA_Config::REST_RATE_LIMIT ) {
return new WP_REST_Response( array( 'error' => 'Rate limit exceeded. Please try again later.' ), 429 );
}
set_transient( $rate_key, $count + 1, HOUR_IN_SECONDS );
wp_cache_set( $rate_key, $count + 1, 'psa_rate_limits', HOUR_IN_SECONDS );
$query = $request->get_param( 'q' );
if ( mb_strlen( $query ) > PSA_Config::MAX_QUERY_LENGTH ) {
return new WP_REST_Response(
array( 'error' => 'Search query must not exceed ' . PSA_Config::MAX_QUERY_LENGTH . ' characters.' ),
400
);
}
return new WP_REST_Response( self::search_peptides( $query ), 200 );
}
/**
* Render the [peptide_search] shortcode.
*
* Uses class-based selectors (no IDs) to support multiple instances per page.
*
* @param array $atts Shortcode attributes.
* @return string HTML output.
*/
public static function render_search_form( $atts ): string {
++self::$instance_count;
$instance = self::$instance_count;
$atts = shortcode_atts(
array( 'placeholder' => 'Search for a peptide (e.g., BPC-157, Thymosin Beta-4)...' ),
$atts,
'peptide_search'
);
ob_start();
?>
Searching our database...
⏳
This peptide is currently being added to our database. Please check back again later.
esc_like( $query ) . '%';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$post_id = $wpdb->get_var(
$wpdb->prepare(
"SELECT p.ID
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} src ON p.ID = src.post_id
AND src.meta_key = 'psa_source' AND src.meta_value = 'pending'
LEFT JOIN {$wpdb->postmeta} alias ON p.ID = alias.post_id
AND alias.meta_key = 'psa_aliases'
WHERE p.post_type = 'peptide'
AND p.post_status = 'draft'
AND ( p.post_title LIKE %s OR alias.meta_value LIKE %s )
ORDER BY p.post_date DESC
LIMIT 1",
$like,
$like
)
);
return $post_id ? get_post( $post_id ) : null;
}
/**
* Core search — queries CPT by title and aliases (published only).
*
* Includes transient caching with generation-counter invalidation.
*
* @param string $query Search term.
* @return array{results: array, total: int, query: string}
*/
public static function search_peptides( string $query ): array {
global $wpdb;
$cache_key = 'psa_search_' . self::get_cache_generation() . '_' . md5( sanitize_text_field( $query ) );
$cached = get_transient( $cache_key );
if ( false !== $cached ) {
return $cached;
}
$like = '%' . $wpdb->esc_like( $query ) . '%';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$post_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT DISTINCT p.ID
FROM {$wpdb->posts} p
LEFT JOIN {$wpdb->postmeta} m ON p.ID = m.post_id AND m.meta_key = 'psa_aliases'
WHERE p.post_type = 'peptide'
AND p.post_status = 'publish'
AND ( p.post_title LIKE %s OR m.meta_value LIKE %s )
ORDER BY p.post_title ASC
LIMIT 20",
$like,
$like
)
);
if ( ! empty( $post_ids ) ) {
_prime_post_caches( $post_ids );
update_meta_cache( 'post', $post_ids );
}
$results = array();
foreach ( $post_ids as $id ) {
$results[] = self::format_peptide( (int) $id );
}
$response = array(
'results' => $results,
'total' => count( $results ),
'query' => $query,
);
set_transient( $cache_key, $response, 6 * HOUR_IN_SECONDS );
return apply_filters( 'psa_search_results', $response, $query );
}
/**
* Format a peptide post for JSON response.
*
* @param int $post_id The post ID.
* @return array
*/
public static function format_peptide( int $post_id ): array {
$post = get_post( $post_id );
if ( ! $post ) {
return array();
}
return array(
'id' => $post->ID,
'title' => $post->post_title,
'url' => get_permalink( $post->ID ),
'excerpt' => wp_trim_words( $post->post_content, 30 ),
'sequence' => get_post_meta( $post->ID, 'psa_sequence', true ),
'molecular_weight' => get_post_meta( $post->ID, 'psa_molecular_weight', true ),
'molecular_formula' => get_post_meta( $post->ID, 'psa_molecular_formula', true ),
'source' => get_post_meta( $post->ID, 'psa_source', true ),
);
}
// ── Backward-compatible proxies ─────────────────────────────────────
/** @see PSA_Search_Handler::ajax_search() */
public static function ajax_search(): void {
PSA_Search_Handler::ajax_search(); }
/** @see PSA_Search_Handler::create_pending_placeholder() */
public static function create_pending_placeholder( string $peptide_name ) {
return PSA_Search_Handler::create_pending_placeholder( $peptide_name ); }
/** @see PSA_Search_Handler::schedule_background_generation() */
public static function schedule_background_generation( int $post_id, string $peptide_name ): void {
PSA_Search_Handler::schedule_background_generation( $post_id, $peptide_name ); }
}