prepare( // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- the slots are generated placeholders, the values are bound below. "SELECT ID FROM {$wpdb->posts} WHERE post_type IN ( {$type_slots} ) AND post_status IN ( {$status_slots} ) ORDER BY ID ASC LIMIT %d", // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared array_merge( $post_types, $statuses, array( max( 1, $limit ) ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- see the class docblock: a display filter must not be able to shrink an export. return array_map( 'intval', (array) $wpdb->get_col( $sql ) ); } /** * One page of export candidates, for the picker. * * Same table, same reasoning as ids(): a display filter must not be able to * hide a row the export would have taken. * * @param string[] $post_types Post types to include. * @param string $search Title fragment, optional. * @param int $page 1-based page number. * @param int $per_page Rows per page. * @param string[] $statuses Post statuses to include. * @return array{rows: array>, total: int} */ public static function page( array $post_types, string $search = '', int $page = 1, int $per_page = 20, array $statuses = self::STATUSES ): array { global $wpdb; $post_types = array_values( array_filter( array_map( 'sanitize_key', $post_types ) ) ); $statuses = array_values( array_filter( array_map( 'sanitize_key', $statuses ) ) ); if ( array() === $post_types || array() === $statuses ) { return array( 'rows' => array(), 'total' => 0, ); } $type_slots = implode( ', ', array_fill( 0, count( $post_types ), '%s' ) ); $status_slots = implode( ', ', array_fill( 0, count( $statuses ), '%s' ) ); $values = array_merge( $post_types, $statuses ); $where = "post_type IN ( {$type_slots} ) AND post_status IN ( {$status_slots} )"; if ( '' !== $search ) { $where .= ' AND post_title LIKE %s'; $values[] = '%' . $wpdb->esc_like( $search ) . '%'; } $per_page = min( 100, max( 1, $per_page ) ); $offset = max( 0, $page - 1 ) * $per_page; // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- the slots are generated placeholders, the values are bound as one array below. $count_sql = $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE {$where}", $values ); $rows_sql = $wpdb->prepare( "SELECT ID, post_title, post_type, post_status, post_date FROM {$wpdb->posts} WHERE {$where} ORDER BY ( post_status = 'publish' ) DESC, post_date DESC, ID DESC LIMIT %d OFFSET %d", array_merge( $values, array( $per_page, $offset ) ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- see the class docblock; both strings came out of $wpdb->prepare() above with every value bound. $total = (int) $wpdb->get_var( $count_sql ); $rows = (array) $wpdb->get_results( $rows_sql, ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter return array( 'rows' => array_map( static function ( array $row ): array { return array( 'id' => (int) $row['ID'], // Titles are stored HTML-encoded; decode for display, the way the_title() does. 'title' => '' !== (string) $row['post_title'] ? html_entity_decode( (string) $row['post_title'], ENT_QUOTES, 'UTF-8' ) : __( '(no title)', 'transtrix' ), 'type' => (string) $row['post_type'], 'status' => (string) $row['post_status'], 'date' => (string) $row['post_date'], ); }, $rows ), 'total' => $total, ); } /** * Keep only ids that really are this export's candidates. * * The picker sends ids the browser chose, so they are checked against the * same table before any of them reaches the exporter. * * @param int[] $post_ids Ids to check. * @param string[] $post_types Post types the module owns. * @param string[] $statuses Post statuses to include. * @return int[] */ public static function filter_ids( array $post_ids, array $post_types, array $statuses = self::STATUSES ): array { global $wpdb; $post_ids = array_values( array_unique( array_filter( array_map( 'intval', $post_ids ) ) ) ); $post_types = array_values( array_filter( array_map( 'sanitize_key', $post_types ) ) ); $statuses = array_values( array_filter( array_map( 'sanitize_key', $statuses ) ) ); if ( array() === $post_ids || array() === $post_types || array() === $statuses ) { return array(); } $id_slots = implode( ', ', array_fill( 0, count( $post_ids ), '%d' ) ); $type_slots = implode( ', ', array_fill( 0, count( $post_types ), '%s' ) ); $status_slots = implode( ', ', array_fill( 0, count( $statuses ), '%s' ) ); $sql = $wpdb->prepare( // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- generated placeholders, values bound as one array below. "SELECT ID FROM {$wpdb->posts} WHERE ID IN ( {$id_slots} ) AND post_type IN ( {$type_slots} ) AND post_status IN ( {$status_slots} ) ORDER BY ID ASC", // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare array_merge( $post_ids, $post_types, $statuses ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- see the class docblock. return array_map( 'intval', (array) $wpdb->get_col( $sql ) ); } }