prepare() with proper placeholders. Table name ($this->table_name) is a safe class property from $wpdb->prefix. class ContaiInternalLinkRepository { /** * @var \wpdb WordPress database instance */ private $wpdb; /** * @var string Table name */ private $table_name; /** * Constructor */ public function __construct() { global $wpdb; $this->wpdb = $wpdb; $this->table_name = $wpdb->prefix . 'contai_internal_links'; } /** * Create a new internal link * * @param ContaiInternalLink $link * @return int|false Link ID or false on failure */ public function create(ContaiInternalLink $link) { $link->validate(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $result = $this->wpdb->insert( $this->table_name, [ 'source_post_id' => $link->getSourcePostId(), 'target_post_id' => $link->getTargetPostId(), 'keyword_id' => $link->getKeywordId(), 'status' => $link->getStatus(), 'created_at' => current_time('mysql'), 'updated_at' => current_time('mysql'), ], ['%d', '%d', '%d', '%s', '%s', '%s'] ); if ($result === false) { return false; } $link->setId($this->wpdb->insert_id); return $this->wpdb->insert_id; } /** * Find link by ID * * @param int $id * @return ContaiInternalLink|null */ public function findById(int $id): ?ContaiInternalLink { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $result = $this->wpdb->get_row( $this->wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "SELECT * FROM {$this->table_name} WHERE id = %d", $id ), ARRAY_A ); return $result ? new ContaiInternalLink($result) : null; } /** * Update an internal link * * @param ContaiInternalLink $link * @return bool */ public function update(ContaiInternalLink $link): bool { if (!$link->getId()) { return false; } $link->validate(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $result = $this->wpdb->update( $this->table_name, [ 'source_post_id' => $link->getSourcePostId(), 'target_post_id' => $link->getTargetPostId(), 'keyword_id' => $link->getKeywordId(), 'status' => $link->getStatus(), 'updated_at' => current_time('mysql'), ], ['id' => $link->getId()], ['%d', '%d', '%d', '%s', '%s'], ['%d'] ); return $result !== false; } /** * Delete a link by ID * * @param int $id * @return bool */ public function delete(int $id): bool { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $result = $this->wpdb->delete( $this->table_name, ['id' => $id], ['%d'] ); return $result !== false; } /** * Check if a link already exists * * @param int $source_post_id * @param int $target_post_id * @param int $keyword_id * @return bool */ public function linkExists(int $source_post_id, int $target_post_id, int $keyword_id): bool { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $result = $this->wpdb->get_var( $this->wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "SELECT COUNT(*) FROM {$this->table_name} WHERE source_post_id = %d AND target_post_id = %d AND keyword_id = %d", $source_post_id, $target_post_id, $keyword_id ) ); return (int)$result > 0; } /** * Count links by source post * * @param int $source_post_id * @param string $status Optional status filter * @return int */ public function countBySourcePost(int $source_post_id, string $status = ContaiInternalLink::STATUS_ACTIVE): int { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $result = $this->wpdb->get_var( $this->wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "SELECT COUNT(*) FROM {$this->table_name} WHERE source_post_id = %d AND status = %s", $source_post_id, $status ) ); return (int)$result; } /** * Count links by target post * * @param int $target_post_id * @param string $status Optional status filter * @return int */ public function countByTargetPost(int $target_post_id, string $status = ContaiInternalLink::STATUS_ACTIVE): int { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $result = $this->wpdb->get_var( $this->wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "SELECT COUNT(*) FROM {$this->table_name} WHERE target_post_id = %d AND status = %s", $target_post_id, $status ) ); return (int)$result; } /** * Count links by keyword * * @param int $keyword_id * @param string $status Optional status filter * @return int */ public function countByKeyword(int $keyword_id, string $status = ContaiInternalLink::STATUS_ACTIVE): int { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $result = $this->wpdb->get_var( $this->wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "SELECT COUNT(*) FROM {$this->table_name} WHERE keyword_id = %d AND status = %s", $keyword_id, $status ) ); return (int)$result; } /** * Find links by source post * * @param int $source_post_id * @param string $status Optional status filter * @return ContaiInternalLink[] */ public function findBySourcePost(int $source_post_id, string $status = ContaiInternalLink::STATUS_ACTIVE): array { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $results = $this->wpdb->get_results( $this->wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "SELECT * FROM {$this->table_name} WHERE source_post_id = %d AND status = %s ORDER BY created_at DESC", $source_post_id, $status ), ARRAY_A ); return array_map(function($row) { return new ContaiInternalLink($row); }, $results ?: []); } /** * Find links by target post * * @param int $target_post_id * @param string $status Optional status filter * @return ContaiInternalLink[] */ public function findByTargetPost(int $target_post_id, string $status = ContaiInternalLink::STATUS_ACTIVE): array { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $results = $this->wpdb->get_results( $this->wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "SELECT * FROM {$this->table_name} WHERE target_post_id = %d AND status = %s ORDER BY created_at DESC", $target_post_id, $status ), ARRAY_A ); return array_map(function($row) { return new ContaiInternalLink($row); }, $results ?: []); } /** * Find posts in same category with pagination * Uses indexed joins for performance * * @param int $category_id * @param int $exclude_post_id Post to exclude * @param int $limit * @param int $offset * @return array */ public function findPostsInCategory(int $category_id, int $exclude_post_id = 0, int $limit = 10, int $offset = 0): array { // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names from $wpdb built-in properties. $query = $this->wpdb->prepare( "SELECT p.ID, p.post_title, p.post_content, p.post_name FROM {$this->wpdb->posts} p INNER JOIN {$this->wpdb->term_relationships} tr ON p.ID = tr.object_id WHERE tr.term_taxonomy_id = %d AND p.post_status = 'publish' AND p.post_type = 'post' AND p.ID != %d ORDER BY p.post_date DESC LIMIT %d OFFSET %d", $category_id, $exclude_post_id, $limit, $offset ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return $this->wpdb->get_results($query, ARRAY_A) ?: []; } /** * Get all links with pagination * * @param int $limit * @param int $offset * @param string $status Optional status filter * @return array */ public function findAll(int $limit = 20, int $offset = 0, string $status = ''): array { if ($status) { $query = $this->wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from class property. "SELECT * FROM {$this->table_name} WHERE status = %s ORDER BY created_at DESC LIMIT %d OFFSET %d", $status, $limit, $offset ); } else { $query = $this->wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from class property. "SELECT * FROM {$this->table_name} ORDER BY created_at DESC LIMIT %d OFFSET %d", $limit, $offset ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $results = $this->wpdb->get_results($query, ARRAY_A); return array_map(function($row) { return new ContaiInternalLink($row); }, $results ?: []); } /** * Count total links * * @param string $status Optional status filter * @return int */ public function countAll(string $status = ''): int { if ($status) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $result = $this->wpdb->get_var( $this->wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from class property. "SELECT COUNT(*) FROM {$this->table_name} WHERE status = %s", $status ) ); } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from class property; no user input to prepare. $result = $this->wpdb->get_var("SELECT COUNT(*) FROM {$this->table_name}"); } return (int)$result; } /** * Get links with post details (for list view) * Uses joins to get source/target post titles * * @param int $limit * @param int $offset * @param string $status Optional status filter * @return array */ public function findAllWithPostDetails(int $limit = 20, int $offset = 0, string $status = ''): array { $keywords_table = $this->wpdb->prefix . 'contai_keywords'; $sql = "SELECT il.*, sp.post_title as source_post_title, tp.post_title as target_post_title, k.keyword as keyword_text FROM {$this->table_name} il INNER JOIN {$this->wpdb->posts} sp ON il.source_post_id = sp.ID INNER JOIN {$this->wpdb->posts} tp ON il.target_post_id = tp.ID LEFT JOIN {$keywords_table} k ON il.keyword_id = k.id"; if ($status) { $sql .= ' WHERE il.status = %s ORDER BY il.created_at DESC LIMIT %d OFFSET %d'; $query = $this->wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- Table names from $wpdb properties and class property. $sql, $status, $limit, $offset ); } else { $sql .= ' ORDER BY il.created_at DESC LIMIT %d OFFSET %d'; $query = $this->wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- Table names from $wpdb properties and class property. $sql, $limit, $offset ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return $this->wpdb->get_results($query, ARRAY_A) ?: []; } /** * Delete all links for a post (when post is deleted) * * @param int $post_id * @return bool */ public function deleteByPost(int $post_id): bool { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $result = $this->wpdb->query( $this->wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "DELETE FROM {$this->table_name} WHERE source_post_id = %d OR target_post_id = %d", $post_id, $post_id ) ); return $result !== false; } /** * Deactivate all links for a post * * @param int $post_id * @return bool */ public function deactivateByPost(int $post_id): bool { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $result = $this->wpdb->query( $this->wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "UPDATE {$this->table_name} SET status = %s, updated_at = %s WHERE source_post_id = %d OR target_post_id = %d", ContaiInternalLink::STATUS_INACTIVE, current_time('mysql'), $post_id, $post_id ) ); return $result !== false; } } // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared