diff --git a/classes/controllers/FrmWelcomeTourController.php b/classes/controllers/FrmWelcomeTourController.php index 3c9e347063..2a92fb5eba 100644 --- a/classes/controllers/FrmWelcomeTourController.php +++ b/classes/controllers/FrmWelcomeTourController.php @@ -435,7 +435,7 @@ private static function more_than_the_default_form_exists() { */ private static function check_for_form_embeds() { global $wpdb; - $result = $wpdb->get_var( "SELECT 1 FROM {$wpdb->posts} WHERE post_content LIKE '%[formidable %' LIMIT 1" ); + $result = $wpdb->get_var( $wpdb->prepare( 'SELECT 1 FROM %i WHERE post_content LIKE %s LIMIT 1', $wpdb->posts, '%[formidable %' ) ); return '1' === $result; } diff --git a/classes/helpers/FrmEmailSummaryHelper.php b/classes/helpers/FrmEmailSummaryHelper.php index 40c7147960..ccd756aef5 100644 --- a/classes/helpers/FrmEmailSummaryHelper.php +++ b/classes/helpers/FrmEmailSummaryHelper.php @@ -330,10 +330,12 @@ public static function get_top_forms( $from_date, $to_date, $limit = 5 ) { $result = $wpdb->get_results( $wpdb->prepare( - "SELECT fr.id AS form_id, fr.name AS form_name, COUNT(*) as items_count - FROM {$wpdb->prefix}frm_items AS it INNER JOIN {$wpdb->prefix}frm_forms AS fr ON it.form_id = fr.id + 'SELECT fr.id AS form_id, fr.name AS form_name, COUNT(*) as items_count + FROM %i AS it INNER JOIN %i AS fr ON it.form_id = fr.id WHERE it.created_at BETWEEN %s AND %s AND it.is_draft = 0 AND parent_form_id = 0 - GROUP BY form_id ORDER BY items_count DESC LIMIT %d", + GROUP BY form_id ORDER BY items_count DESC LIMIT %d', + $wpdb->prefix . 'frm_items', + $wpdb->prefix . 'frm_forms', $from_date, $to_date . ' 23:59:59', intval( $limit ) diff --git a/classes/helpers/FrmFormsListHelper.php b/classes/helpers/FrmFormsListHelper.php index 664a65e1b4..8890036613 100644 --- a/classes/helpers/FrmFormsListHelper.php +++ b/classes/helpers/FrmFormsListHelper.php @@ -710,8 +710,8 @@ private function query_posts_contain_form( $form ) { $like_where = implode( ' OR ', $like_where ); $where = "post_type IN ('post', 'page') AND ($like_where)"; - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared - $posts = $wpdb->get_results( "SELECT ID,post_title,post_name FROM $wpdb->posts WHERE $where" ); + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $posts = $wpdb->get_results( $wpdb->prepare( 'SELECT ID,post_title,post_name FROM %i', $wpdb->posts ) . ' WHERE ' . $where ); if ( ! is_array( $posts ) ) { return array(); diff --git a/classes/models/FrmDb.php b/classes/models/FrmDb.php index 72bb7a5903..72184c5043 100644 --- a/classes/models/FrmDb.php +++ b/classes/models/FrmDb.php @@ -823,8 +823,7 @@ public static function cache_delete_group( $group ) { public static function db_column_exists( $table, $column ) { global $wpdb; - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared - $result = $wpdb->get_results( $wpdb->prepare( 'SHOW COLUMNS FROM ' . $wpdb->prefix . $table . ' LIKE %s', $column ) ); + $result = $wpdb->get_results( $wpdb->prepare( 'SHOW COLUMNS FROM %i LIKE %s', $wpdb->prefix . $table, $column ) ); return ! empty( $result ); } } diff --git a/classes/models/FrmEntry.php b/classes/models/FrmEntry.php index e168aea121..41f94e8fce 100644 --- a/classes/models/FrmEntry.php +++ b/classes/models/FrmEntry.php @@ -383,8 +383,8 @@ public static function destroy( $id ) { */ do_action( 'frm_before_destroy_entry', $id, $entry ); - $wpdb->query( $wpdb->prepare( 'DELETE FROM ' . $wpdb->prefix . 'frm_item_metas WHERE item_id=%d', $id ) ); - $result = $wpdb->query( $wpdb->prepare( 'DELETE FROM ' . $wpdb->prefix . 'frm_items WHERE id=%d', $id ) ); + $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE item_id=%d', $wpdb->prefix . 'frm_item_metas', $id ) ); + $result = $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE id=%d', $wpdb->prefix . 'frm_items', $id ) ); self::clear_cache(); diff --git a/classes/models/FrmEntryMeta.php b/classes/models/FrmEntryMeta.php index d8f1d36058..ed438c957b 100644 --- a/classes/models/FrmEntryMeta.php +++ b/classes/models/FrmEntryMeta.php @@ -185,9 +185,10 @@ public static function update_entry_metas( $entry_id, $values ) { 'field_id' => $field_ids_to_remove, ); FrmDb::get_where_clause_and_values( $where ); + array_unshift( $where['values'], $wpdb->prefix . 'frm_item_metas' ); // Delete any leftovers - $wpdb->query( $wpdb->prepare( 'DELETE FROM ' . $wpdb->prefix . 'frm_item_metas ' . $where['where'], $where['values'] ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, SlevomatCodingStandard.Files.LineLength.LineTooLong + $wpdb->query( $wpdb->prepare( 'DELETE FROM %i ' . $where['where'], $where['values'] ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared self::clear_cache(); } @@ -226,7 +227,7 @@ public static function delete_entry_meta( $entry_id, $field_id ) { global $wpdb; self::clear_cache(); - return $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}frm_item_metas WHERE field_id=%d AND item_id=%d", $field_id, $entry_id ) ); + return $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE field_id=%d AND item_id=%d', $wpdb->prefix . 'frm_item_metas', $field_id, $entry_id ) ); } /** @@ -356,7 +357,7 @@ private static function meta_field_query( $field_id, $order, $limit, $args, arra if ( is_numeric( $field_id ) ) { $query[] = $wpdb->prepare( 'WHERE em.field_id=%d', $field_id ); } else { - $query[] = $wpdb->prepare( 'LEFT JOIN ' . $wpdb->prefix . 'frm_fields fi ON (em.field_id = fi.id) WHERE fi.field_key=%s', $field_id ); + $query[] = $wpdb->prepare( 'LEFT JOIN %i fi ON (em.field_id = fi.id) WHERE fi.field_key=%s', $wpdb->prefix . 'frm_fields', $field_id ); } if ( ! $args['is_draft'] ) { @@ -636,7 +637,7 @@ public static function search_entry_metas( $search, $field_id, $operator ) { $search = '%' . $search . '%'; } - $query = $wpdb->prepare( "SELECT DISTINCT item_id FROM {$wpdb->prefix}frm_item_metas WHERE meta_value {$operator} %s and field_id = %d", $search, $field_id ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, SlevomatCodingStandard.Files.LineLength.LineTooLong + $query = $wpdb->prepare( "SELECT DISTINCT item_id FROM %i WHERE meta_value {$operator} %s and field_id = %d", $wpdb->prefix . 'frm_item_metas', $search, $field_id ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, SlevomatCodingStandard.Files.LineLength.LineTooLong }//end if $results = $wpdb->get_col( $query, 0 ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared diff --git a/classes/models/FrmField.php b/classes/models/FrmField.php index 724c5bf0fe..a833730a66 100644 --- a/classes/models/FrmField.php +++ b/classes/models/FrmField.php @@ -836,9 +836,9 @@ public static function destroy( $id ) { self::delete_form_transient( $field->form_id ); - $wpdb->query( $wpdb->prepare( 'DELETE FROM ' . $wpdb->prefix . 'frm_item_metas WHERE field_id=%d', $id ) ); + $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE field_id=%d', $wpdb->prefix . 'frm_item_metas', $id ) ); - return $wpdb->query( $wpdb->prepare( 'DELETE FROM ' . $wpdb->prefix . 'frm_fields WHERE id=%d', $id ) ); + return $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE id=%d', $wpdb->prefix . 'frm_fields', $id ) ); } /** @@ -854,7 +854,7 @@ public static function delete_form_transient( $form_id ) { delete_transient( 'frm_form_fields_' . $form_id . 'excludeexclude' ); global $wpdb; - $wpdb->query( $wpdb->prepare( 'DELETE FROM ' . $wpdb->options . ' WHERE option_name LIKE %s OR option_name LIKE %s OR option_name LIKE %s OR option_name LIKE %s', '_transient_timeout_frm_form_fields_' . $form_id . 'ex%', '_transient_frm_form_fields_' . $form_id . 'ex%', '_transient_timeout_frm_form_fields_' . $form_id . 'in%', '_transient_frm_form_fields_' . $form_id . 'in%' ) ); // phpcs:ignore SlevomatCodingStandard.Files.LineLength.LineTooLong + $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE option_name LIKE %s OR option_name LIKE %s OR option_name LIKE %s OR option_name LIKE %s', $wpdb->options, '_transient_timeout_frm_form_fields_' . $form_id . 'ex%', '_transient_frm_form_fields_' . $form_id . 'ex%', '_transient_timeout_frm_form_fields_' . $form_id . 'in%', '_transient_frm_form_fields_' . $form_id . 'in%' ) ); // phpcs:ignore SlevomatCodingStandard.Files.LineLength.LineTooLong FrmDb::cache_delete_group( 'frm_field' ); @@ -892,7 +892,7 @@ public static function getOne( $id, $filter = false ) { global $wpdb; $where = is_numeric( $id ) ? 'id=%d' : 'field_key=%s'; - $query = $wpdb->prepare( 'SELECT * FROM ' . $wpdb->prefix . 'frm_fields WHERE ' . $where, $id ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $query = $wpdb->prepare( 'SELECT * FROM %i WHERE ' . $where, $wpdb->prefix . 'frm_fields', $id ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared $results = FrmDb::check_cache( $id, 'frm_field', $query, 'get_row', 0 ); diff --git a/classes/models/FrmForm.php b/classes/models/FrmForm.php index 8c69884741..1df6405de1 100644 --- a/classes/models/FrmForm.php +++ b/classes/models/FrmForm.php @@ -636,9 +636,10 @@ public static function set_status( $id, $status ) { 'or' => 1, ); FrmDb::get_where_clause_and_values( $where ); - array_unshift( $where['values'], $status ); + array_unshift( $where['values'], $wpdb->prefix . 'frm_forms', $status ); - $query_results = $wpdb->query( $wpdb->prepare( 'UPDATE ' . $wpdb->prefix . 'frm_forms SET status = %s ' . $where['where'], $where['values'] ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, SlevomatCodingStandard.Files.LineLength.LineTooLong + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $query_results = $wpdb->query( $wpdb->prepare( 'UPDATE %i SET status = %s ' . $where['where'], $where['values'] ) ); } else { $query_results = $wpdb->update( $wpdb->prefix . 'frm_forms', array( 'status' => $status ), array( 'id' => $id ) ); $wpdb->update( $wpdb->prefix . 'frm_forms', array( 'status' => $status ), array( 'parent_form_id' => $id ) ); @@ -729,9 +730,9 @@ public static function destroy( $id ) { } // Disconnect the fields from this form - $wpdb->query( $wpdb->prepare( 'DELETE fi FROM ' . $wpdb->prefix . 'frm_fields AS fi LEFT JOIN ' . $wpdb->prefix . 'frm_forms fr ON (fi.form_id = fr.id) WHERE fi.form_id=%d OR parent_form_id=%d', $id, $id ) ); // phpcs:ignore SlevomatCodingStandard.Files.LineLength.LineTooLong + $wpdb->query( $wpdb->prepare( 'DELETE fi FROM %i AS fi LEFT JOIN %i fr ON (fi.form_id = fr.id) WHERE fi.form_id=%d OR parent_form_id=%d', $wpdb->prefix . 'frm_fields', $wpdb->prefix . 'frm_forms', $id, $id ) ); // phpcs:ignore SlevomatCodingStandard.Files.LineLength.LineTooLong - $query_results = $wpdb->query( $wpdb->prepare( 'DELETE FROM ' . $wpdb->prefix . 'frm_forms WHERE id=%d OR parent_form_id=%d', $id, $id ) ); + $query_results = $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE id=%d OR parent_form_id=%d', $wpdb->prefix . 'frm_forms', $id, $id ) ); if ( ! $query_results ) { return $query_results; diff --git a/classes/models/FrmMigrate.php b/classes/models/FrmMigrate.php index cee115c6da..f4ca5a5bb1 100644 --- a/classes/models/FrmMigrate.php +++ b/classes/models/FrmMigrate.php @@ -302,28 +302,28 @@ private function add_composite_indexes_for_entries() { $index_name = 'idx_is_draft_created_at'; if ( ! self::index_exists( $table_name, $index_name ) ) { - $wpdb->query( "CREATE INDEX idx_is_draft_created_at ON `{$wpdb->prefix}frm_items` (is_draft, created_at)" ); + $wpdb->query( $wpdb->prepare( 'CREATE INDEX idx_is_draft_created_at ON %i (is_draft, created_at)', $table_name ) ); } $table_name = "{$wpdb->prefix}frm_item_metas"; $index_name = 'idx_field_id_item_id'; if ( ! self::index_exists( $table_name, $index_name ) ) { - $wpdb->query( "CREATE INDEX idx_field_id_item_id ON `{$wpdb->prefix}frm_item_metas` (field_id, item_id)" ); + $wpdb->query( $wpdb->prepare( 'CREATE INDEX idx_field_id_item_id ON %i (field_id, item_id)', $table_name ) ); } $table_name = "{$wpdb->prefix}frm_items"; $index_name = 'idx_form_id_is_draft'; if ( ! self::index_exists( $table_name, $index_name ) ) { - $wpdb->query( "CREATE INDEX idx_form_id_is_draft ON `{$wpdb->prefix}frm_items` (form_id, is_draft)" ); + $wpdb->query( $wpdb->prepare( 'CREATE INDEX idx_form_id_is_draft ON %i (form_id, is_draft)', $table_name ) ); } $table_name = "{$wpdb->prefix}frm_fields"; $index_name = 'idx_form_id_type'; if ( ! self::index_exists( $table_name, $index_name ) ) { - $wpdb->query( "CREATE INDEX idx_form_id_type ON `{$wpdb->prefix}frm_fields` (form_id, type(30))" ); + $wpdb->query( $wpdb->prepare( 'CREATE INDEX idx_form_id_type ON %i (form_id, type(30))', $table_name ) ); } } @@ -428,11 +428,11 @@ public function uninstall() { global $wpdb, $wp_roles; - $wpdb->query( 'DROP TABLE IF EXISTS ' . $this->fields ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared - $wpdb->query( 'DROP TABLE IF EXISTS ' . $this->forms ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared - $wpdb->query( 'DROP TABLE IF EXISTS ' . $this->entries ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared - $wpdb->query( 'DROP TABLE IF EXISTS ' . $this->entry_metas ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared - $wpdb->query( 'DROP TABLE IF EXISTS ' . $this->gated_tokens ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $this->fields ) ); + $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $this->forms ) ); + $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $this->entries ) ); + $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $this->entry_metas ) ); + $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $this->gated_tokens ) ); delete_option( 'frm_options' ); delete_option( 'frm_db_version' ); @@ -463,7 +463,7 @@ public function uninstall() { remove_action( 'before_delete_post', 'FrmProDisplaysController::before_delete_post' ); remove_action( 'deleted_post', 'FrmProEntriesController::delete_entry' ); - $post_ids = $wpdb->get_col( $wpdb->prepare( 'SELECT ID FROM ' . $wpdb->posts . ' WHERE post_type in (%s, %s, %s)', FrmFormActionsController::$action_post_type, FrmStylesController::$post_type, 'frm_display' ) ); // phpcs:ignore SlevomatCodingStandard.Files.LineLength.LineTooLong + $post_ids = $wpdb->get_col( $wpdb->prepare( 'SELECT ID FROM %i WHERE post_type in (%s, %s, %s)', $wpdb->posts, FrmFormActionsController::$action_post_type, FrmStylesController::$post_type, 'frm_display' ) ); // phpcs:ignore SlevomatCodingStandard.Files.LineLength.LineTooLong foreach ( $post_ids as $post_id ) { // Delete's each post. @@ -477,7 +477,7 @@ public function uninstall() { delete_transient( 'frmpro_options' ); delete_transient( FrmOnboardingWizardController::TRANSIENT_NAME ); - $wpdb->query( $wpdb->prepare( 'DELETE FROM ' . $wpdb->options . ' WHERE option_name LIKE %s OR option_name LIKE %s', '_transient_timeout_frm_form_fields_%', '_transient_frm_form_fields_%' ) ); // phpcs:ignore SlevomatCodingStandard.Files.LineLength.LineTooLong + $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE option_name LIKE %s OR option_name LIKE %s', $wpdb->options, '_transient_timeout_frm_form_fields_%', '_transient_frm_form_fields_%' ) ); // phpcs:ignore SlevomatCodingStandard.Files.LineLength.LineTooLong do_action( 'frm_after_uninstall' ); @@ -759,10 +759,10 @@ private function migrate_to_25() { */ private function migrate_to_23() { global $wpdb; - $exists = $wpdb->get_row( 'SHOW COLUMNS FROM ' . $this->forms . ' LIKE "parent_form_id"' ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $exists = $wpdb->get_row( $wpdb->prepare( 'SHOW COLUMNS FROM %i LIKE %s', $this->forms, 'parent_form_id' ) ); if ( ! $exists ) { - $wpdb->query( 'ALTER TABLE ' . $this->forms . ' ADD parent_form_id int(11) default 0' ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $wpdb->query( $wpdb->prepare( 'ALTER TABLE %i ADD parent_form_id int(11) default 0', $this->forms ) ); } } diff --git a/classes/models/FrmStyle.php b/classes/models/FrmStyle.php index 7cb8e3bb13..1ae46cc204 100644 --- a/classes/models/FrmStyle.php +++ b/classes/models/FrmStyle.php @@ -588,7 +588,7 @@ public function get_all( $orderby = 'title', $order = 'ASC', $limit = 99 ) { if ( ! $temp_styles ) { global $wpdb; // Make sure there wasn't a conflict with the query - $query = $wpdb->prepare( 'SELECT * FROM ' . $wpdb->posts . ' WHERE post_type=%s AND post_status=%s ORDER BY post_title ASC LIMIT 99', FrmStylesController::$post_type, 'publish' ); // phpcs:ignore SlevomatCodingStandard.Files.LineLength.LineTooLong + $query = $wpdb->prepare( 'SELECT * FROM %i WHERE post_type=%s AND post_status=%s ORDER BY post_title ASC LIMIT 99', $wpdb->posts, FrmStylesController::$post_type, 'publish' ); // phpcs:ignore SlevomatCodingStandard.Files.LineLength.LineTooLong $temp_styles = FrmDb::check_cache( 'frm_backup_style_check', 'frm_styles', $query, 'get_results' ); if ( ! $temp_styles ) { diff --git a/phpcs-sniffs/Formidable/Sniffs/Security/PreferIdentifierPlaceholderSniff.php b/phpcs-sniffs/Formidable/Sniffs/Security/PreferIdentifierPlaceholderSniff.php new file mode 100644 index 0000000000..cdd4096720 --- /dev/null +++ b/phpcs-sniffs/Formidable/Sniffs/Security/PreferIdentifierPlaceholderSniff.php @@ -0,0 +1,997 @@ +query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}frm_items WHERE id = %d", $id ) ); + * $wpdb->query( $wpdb->prepare( 'DELETE FROM ' . $wpdb->prefix . 'frm_items WHERE id = %d', $id ) ); + * $wpdb->query( 'DROP TABLE IF EXISTS ' . $this->table ); + * + * Good: + * $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE id = %d', $wpdb->prefix . 'frm_items', $id ) ); + * $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $this->table ) ); + * + * CREATE TABLE statements are exempt because they are built for dbDelta(), which cannot use prepare(). + */ +class PreferIdentifierPlaceholderSniff implements Sniff { + + /** + * SQL keywords that are directly followed by a table identifier. + * + * A bare ON is deliberately excluded because join conditions follow it with column + * references. Only the CREATE INDEX ... ON form takes a table there. + * + * @var string + */ + const TABLE_KEYWORDS = 'FROM|JOIN|INTO|UPDATE|TABLE|EXISTS|INDEX\s+\S+\s+ON'; + + /** + * Regex fragment matching one interpolated segment inside a double quoted string. + * + * @var string + */ + const INTERP_SEGMENT = '(?:\{\$[^}]+\}|\$[A-Za-z_]\w*(?:->\w+)*)'; + + /** + * $wpdb methods that receive raw SQL as their first argument. + * + * @var array + */ + private $queryMethods = array( + 'query', + 'get_var', + 'get_row', + 'get_col', + 'get_results', + ); + + /** + * Returns an array of tokens this test wants to listen for. + * + * @return array + */ + public function register() { + return array( T_VARIABLE ); + } + + /** + * Processes this test, when one of its tokens is encountered. + * + * @param File $phpcsFile The file being scanned. + * @param int $stackPtr The position of the current token in the stack passed in $tokens. + * + * @return void + */ + public function process( File $phpcsFile, $stackPtr ) { + $tokens = $phpcsFile->getTokens(); + + if ( $tokens[ $stackPtr ]['content'] !== '$wpdb' ) { + return; + } + + $objectOp = $phpcsFile->findNext( T_WHITESPACE, $stackPtr + 1, null, true ); + + if ( false === $objectOp || $tokens[ $objectOp ]['code'] !== T_OBJECT_OPERATOR ) { + return; + } + + $methodToken = $phpcsFile->findNext( T_WHITESPACE, $objectOp + 1, null, true ); + + if ( false === $methodToken || $tokens[ $methodToken ]['code'] !== T_STRING ) { + return; + } + + $methodName = $tokens[ $methodToken ]['content']; + $isPrepare = 'prepare' === $methodName; + + if ( ! $isPrepare && ! in_array( $methodName, $this->queryMethods, true ) ) { + return; + } + + $openParen = $phpcsFile->findNext( T_WHITESPACE, $methodToken + 1, null, true ); + + if ( false === $openParen || $tokens[ $openParen ]['code'] !== T_OPEN_PARENTHESIS || ! isset( $tokens[ $openParen ]['parenthesis_closer'] ) ) { + return; + } + + $closeParen = $tokens[ $openParen ]['parenthesis_closer']; + + if ( ! $isPrepare && $this->first_arg_is_wpdb_call( $phpcsFile, $openParen ) ) { + // The inner $wpdb->prepare() call is processed on its own token. + return; + } + + $argEnd = $this->find_first_arg_end( $phpcsFile, $openParen, $closeParen ); + $parts = $this->parse_concat_parts( $phpcsFile, $openParen + 1, $argEnd ); + + if ( ! $parts ) { + return; + } + + if ( $this->is_create_table_statement( $parts ) ) { + return; + } + + $refs = $this->find_table_refs( $parts ); + + if ( ! $refs ) { + return; + } + + if ( ! $isPrepare ) { + $phpcsFile->addError( + 'Table name built into SQL passed to $wpdb->%s() without prepare(). Wrap the query in $wpdb->prepare() and use the %%i placeholder for the identifier.', + $refs[0]['ptr'], + 'TableNotPrepared', + array( $methodName ) + ); + + return; + } + + $this->handle_prepare_refs( $phpcsFile, $parts, $refs, $openParen, $argEnd, $closeParen ); + } + + /** + * Checks whether the first argument of a call starts with another $wpdb method call. + * + * @param File $phpcsFile The file being scanned. + * @param int $openParen The opening parenthesis of the outer call. + * + * @return bool + */ + private function first_arg_is_wpdb_call( File $phpcsFile, $openParen ) { + $tokens = $phpcsFile->getTokens(); + $firstArg = $phpcsFile->findNext( T_WHITESPACE, $openParen + 1, null, true ); + + return false !== $firstArg && $tokens[ $firstArg ]['code'] === T_VARIABLE && $tokens[ $firstArg ]['content'] === '$wpdb'; + } + + /** + * Finds the token position that ends the first argument (the first top level comma, or the closing parenthesis). + * + * @param File $phpcsFile The file being scanned. + * @param int $openParen The opening parenthesis of the call. + * @param int $closeParen The closing parenthesis of the call. + * + * @return int + */ + private function find_first_arg_end( File $phpcsFile, $openParen, $closeParen ) { + $tokens = $phpcsFile->getTokens(); + + for ( $i = $openParen + 1; $i < $closeParen; $i++ ) { + $i = $this->skip_nested( $tokens, $i ); + + if ( $i >= $closeParen ) { + break; + } + + if ( $tokens[ $i ]['code'] === T_COMMA ) { + return $i; + } + } + + return $closeParen; + } + + /** + * Skips over nested parentheses and bracket structures. + * + * @param array $tokens The token stack. + * @param int $i The current token position. + * + * @return int The position to continue from (the closer when a nested structure starts at $i). + */ + private function skip_nested( $tokens, $i ) { + if ( $tokens[ $i ]['code'] === T_OPEN_PARENTHESIS && isset( $tokens[ $i ]['parenthesis_closer'] ) ) { + return $tokens[ $i ]['parenthesis_closer']; + } + + if ( isset( $tokens[ $i ]['bracket_closer'] ) && in_array( $tokens[ $i ]['code'], array( T_OPEN_SQUARE_BRACKET, T_OPEN_SHORT_ARRAY, T_OPEN_CURLY_BRACKET ), true ) ) { + return $tokens[ $i ]['bracket_closer']; + } + + if ( $tokens[ $i ]['code'] === T_ARRAY && isset( $tokens[ $i ]['parenthesis_closer'] ) ) { + return $tokens[ $i ]['parenthesis_closer']; + } + + return $i; + } + + /** + * Parses an expression token range into concatenation parts. + * + * Each part is array( 'type' => 'sq'|'dq'|'expr', 'start' => int, 'end' => int, 'content' => string ). + * For string parts, content is the inner string without the surrounding quotes. + * For expr parts, content is the raw PHP code. + * + * @param File $phpcsFile The file being scanned. + * @param int $start The first token of the expression. + * @param int $end The token after the last token of the expression. + * + * @return array|false + */ + private function parse_concat_parts( File $phpcsFile, $start, $end ) { + $tokens = $phpcsFile->getTokens(); + $parts = array(); + $run = array(); + + for ( $i = $start; $i < $end; $i++ ) { + $code = $tokens[ $i ]['code']; + + if ( T_WHITESPACE === $code || T_COMMENT === $code ) { + continue; + } + + if ( T_STRING_CONCAT === $code ) { + $part = $this->close_part( $phpcsFile, $run ); + + if ( ! $part ) { + return false; + } + + $parts[] = $part; + $run = array(); + continue; + } + + $next = $this->skip_nested( $tokens, $i ); + + for ( $j = $i; $j <= $next; $j++ ) { + $run[] = $j; + } + + $i = $next; + } + + $part = $this->close_part( $phpcsFile, $run ); + + if ( ! $part ) { + return false; + } + + $parts[] = $part; + + return $parts; + } + + /** + * Converts a token run into a single concatenation part. + * + * @param File $phpcsFile The file being scanned. + * @param array $run Token positions collected for this part. + * + * @return array|false + */ + private function close_part( File $phpcsFile, $run ) { + if ( ! $run ) { + return false; + } + + $tokens = $phpcsFile->getTokens(); + $start = $run[0]; + $end = $run[ count( $run ) - 1 ]; + + // PHPCS splits multiline strings into one token per line, so a string part + // is any run made up entirely of tokens of the same string type. + $isSingleQuoted = true; + $isDoubleQuoted = true; + $raw = ''; + + foreach ( $run as $ptr ) { + if ( $tokens[ $ptr ]['code'] !== T_CONSTANT_ENCAPSED_STRING ) { + $isSingleQuoted = false; + } + + if ( $tokens[ $ptr ]['code'] !== T_DOUBLE_QUOTED_STRING ) { + $isDoubleQuoted = false; + } + + $raw .= $tokens[ $ptr ]['content']; + } + + if ( $isSingleQuoted || $isDoubleQuoted ) { + $type = "'" === $raw[0] ? 'sq' : 'dq'; + + return array( + 'type' => $type, + 'start' => $start, + 'end' => $end, + 'content' => substr( $raw, 1, -1 ), + ); + } + + return array( + 'type' => 'expr', + 'start' => $start, + 'end' => $end, + 'content' => trim( $phpcsFile->getTokensAsString( $start, $end - $start + 1 ) ), + ); + } + + /** + * Checks whether the SQL starts with CREATE TABLE, which is exempt (dbDelta schema DDL). + * + * @param array $parts The concatenation parts. + * + * @return bool + */ + private function is_create_table_statement( $parts ) { + if ( 'expr' === $parts[0]['type'] ) { + return false; + } + + return (bool) preg_match( '/^\s*CREATE\s+TABLE/i', $parts[0]['content'] ); + } + + /** + * Finds table identifier references in the parsed parts. + * + * Each ref is array( + * 'type' => 'in_string'|'cross_part', + * 'ptr' => int (token to report on), + * 'part' => int (part index for in_string refs, first part index for cross_part refs), + * 'offset' => int (byte offset of the ref inside the string, in_string only), + * 'length' => int (byte length including surrounding backticks, in_string only), + * 'text' => string (the matched identifier text, in_string only), + * 'expr' => string (PHP expression to move into the prepare arguments), + * 'parts' => array (part indexes consumed, cross_part only), + * 'lead_word' => string (leading identifier characters absorbed from the following literal, cross_part only), + * ). + * + * @param array $parts The concatenation parts. + * + * @return array + */ + private function find_table_refs( $parts ) { + $refs = array(); + $count = count( $parts ); + + for ( $p = 0; $p < $count; $p++ ) { + $part = $parts[ $p ]; + + if ( 'dq' === $part['type'] ) { + $refs = array_merge( $refs, $this->find_in_string_refs( $part, $p ) ); + } + + if ( 'expr' === $part['type'] || $p + 1 >= $count || 'expr' !== $parts[ $p + 1 ]['type'] ) { + continue; + } + + if ( ! preg_match( '/\b(?:' . self::TABLE_KEYWORDS . ')\s+`?\s*$/i', $part['content'] ) ) { + continue; + } + + $ref = $this->collect_cross_part_ref( $parts, $p + 1 ); + + if ( $ref ) { + $refs[] = $ref; + } + } + + return $refs; + } + + /** + * Finds table references fully contained inside a double quoted string part. + * + * @param array $part The dq part. + * @param int $p The part index. + * + * @return array + */ + private function find_in_string_refs( $part, $p ) { + $interp = self::INTERP_SEGMENT; + $refRun = '(?:[A-Za-z0-9_]+)?(?:' . $interp . '(?:[A-Za-z0-9_]+)?)+'; + + if ( ! preg_match_all( '/\b(?:' . self::TABLE_KEYWORDS . ')\s+(`?)(' . $refRun . ')(`?)/i', $part['content'], $matches, PREG_OFFSET_CAPTURE ) ) { + return array(); + } + + $refs = array(); + + foreach ( $matches[2] as $index => $match ) { + $text = $match[0]; + $tickBefore = $matches[1][ $index ][0]; + $tickAfter = $matches[3][ $index ][0]; + $startOffset = $matches[1][ $index ][1]; + + if ( '' !== $tickBefore && '' === $tickAfter ) { + // Unbalanced backtick, the identifier continues in another part. Not safely fixable. + $tickAfter = ''; + } + + $refs[] = array( + 'type' => 'in_string', + 'ptr' => $part['start'], + 'part' => $p, + 'offset' => $startOffset, + 'length' => strlen( $tickBefore ) + strlen( $text ) + strlen( $tickAfter ), + 'text' => $text, + 'expr' => $this->segments_to_expression( $text ), + ); + } + + return $refs; + } + + /** + * Collects a table reference that spans concatenation parts, starting at an expr part. + * + * @param array $parts The concatenation parts. + * @param int $start The index of the first expr part of the reference. + * + * @return array|false + */ + private function collect_cross_part_ref( $parts, $start ) { + $count = count( $parts ); + $exprBits = array(); + $consumed = array(); + $leadWord = ''; + $p = $start; + + while ( $p < $count ) { + $part = $parts[ $p ]; + + if ( 'expr' === $part['type'] ) { + $exprBits[] = $part['content']; + $consumed[] = $p; + $p++; + continue; + } + + if ( 'sq' !== $part['type'] ) { + break; + } + + if ( ! preg_match( '/^([A-Za-z0-9_]+)/', $part['content'], $match ) ) { + break; + } + + if ( $match[1] === $part['content'] && $p + 1 < $count && 'expr' === $parts[ $p + 1 ]['type'] ) { + // The whole literal is part of the identifier and it continues with another expression. + $exprBits[] = "'" . $match[1] . "'"; + $consumed[] = $p; + $p++; + continue; + } + + $exprBits[] = "'" . $match[1] . "'"; + $leadWord = $match[1]; + break; + } + + if ( ! $exprBits ) { + return false; + } + + return array( + 'type' => 'cross_part', + 'ptr' => $parts[ $start ]['start'], + 'part' => $start, + 'parts' => $consumed, + 'lead_word' => $leadWord, + 'expr' => implode( ' . ', $exprBits ), + ); + } + + /** + * Converts an interpolated identifier text into an equivalent PHP expression. + * + * @param string $text The identifier text, e.g. "{$wpdb->prefix}frm_items". + * + * @return string + */ + private function segments_to_expression( $text ) { + preg_match_all( '/\{\$([^}]+)\}|\$[A-Za-z_]\w*(?:->\w+)*|[A-Za-z0-9_]+/', $text, $matches, PREG_SET_ORDER ); + + $bits = array(); + + foreach ( $matches as $match ) { + if ( isset( $match[1] ) && '' !== $match[1] ) { + $bits[] = '$' . $match[1]; + } elseif ( '$' === $match[0][0] ) { + $bits[] = $match[0]; + } else { + $bits[] = "'" . $match[0] . "'"; + } + } + + return implode( ' . ', $bits ); + } + + /** + * Reports and, when safe, fixes table references inside a $wpdb->prepare() call. + * + * @param File $phpcsFile The file being scanned. + * @param array $parts The concatenation parts of the first argument. + * @param array $refs The detected table references. + * @param int $openParen The opening parenthesis of the prepare() call. + * @param int $argEnd The end of the first argument (comma or closing parenthesis). + * @param int $closeParen The closing parenthesis of the prepare() call. + * + * @return void + */ + private function handle_prepare_refs( File $phpcsFile, $parts, $refs, $openParen, $argEnd, $closeParen ) { + $fixable = $this->refs_are_fixable( $parts, $refs ); + $args = $fixable ? $this->parse_args( $phpcsFile, $argEnd, $closeParen ) : false; + + if ( $fixable && false !== $args ) { + $placeholderTotal = $this->count_placeholders_in_literals( $parts ); + $fixable = count( $args ) === $placeholderTotal; + } + + if ( ! $fixable || false === $args ) { + foreach ( $refs as $ref ) { + $phpcsFile->addError( + 'Table name built into $wpdb->prepare() SQL. Use the %i placeholder and pass the identifier as a prepare() argument.', + $ref['ptr'], + 'TableInPrepare' + ); + } + + return; + } + + $fix = false; + + foreach ( $refs as $ref ) { + $fix = $phpcsFile->addFixableError( + 'Table name built into $wpdb->prepare() SQL. Use the %i placeholder and pass the identifier as a prepare() argument.', + $ref['ptr'], + 'TableInPrepare' + ) || $fix; + } + + if ( ! $fix ) { + return; + } + + $this->apply_fix( $phpcsFile, $parts, $refs, $openParen, $argEnd, $closeParen, $args ); + } + + /** + * Determines whether the detected references can be fixed automatically. + * + * Fixing is only safe when the whole first argument is made of literals plus the + * reference expressions themselves, so every placeholder can be counted and every + * prepare() argument maps positionally. + * + * @param array $parts The concatenation parts. + * @param array $refs The detected table references. + * + * @return bool + */ + private function refs_are_fixable( $parts, $refs ) { + $refPartIndexes = array(); + + foreach ( $refs as $ref ) { + if ( 'cross_part' === $ref['type'] ) { + foreach ( $ref['parts'] as $p ) { + $refPartIndexes[ $p ] = true; + } + } + } + + foreach ( $parts as $p => $part ) { + if ( 'expr' === $part['type'] && ! isset( $refPartIndexes[ $p ] ) ) { + return false; + } + + if ( 'expr' !== $part['type'] && preg_match( '/%\d+\$/', $part['content'] ) ) { + // Numbered placeholders change argument mapping. Not safely fixable. + return false; + } + + if ( 'dq' === $part['type'] && $this->dq_has_unhandled_interpolation( $parts, $refs, $p ) ) { + return false; + } + } + + return true; + } + + /** + * Checks whether a double quoted part contains interpolation that is not part of a detected reference. + * + * Leftover interpolation before a reference makes placeholder counting unreliable. + * Leftover interpolation after every reference is harmless for argument mapping only + * when it cannot contain placeholders, which cannot be known, so any leftover + * interpolation before the last reference blocks fixing. Leftover interpolation + * after the last reference in the same string is allowed because the placeholder + * positions for every reference are already determined by the literal text before them. + * + * @param array $parts The concatenation parts. + * @param array $refs The detected table references. + * @param int $p The part index to inspect. + * + * @return bool + */ + private function dq_has_unhandled_interpolation( $parts, $refs, $p ) { + $content = $parts[ $p ]['content']; + + if ( ! preg_match_all( '/' . self::INTERP_SEGMENT . '/', $content, $matches, PREG_OFFSET_CAPTURE ) ) { + return false; + } + + $lastRefEnd = -1; + + foreach ( $refs as $ref ) { + if ( 'in_string' === $ref['type'] && $ref['part'] === $p ) { + $lastRefEnd = max( $lastRefEnd, $ref['offset'] + $ref['length'] ); + } + } + + foreach ( $matches[0] as $match ) { + $offset = $match[1]; + $covered = false; + + foreach ( $refs as $ref ) { + if ( 'in_string' === $ref['type'] && $ref['part'] === $p && $offset >= $ref['offset'] && $offset < $ref['offset'] + $ref['length'] ) { + $covered = true; + break; + } + } + + if ( ! $covered && $offset < $lastRefEnd ) { + return true; + } + + if ( ! $covered && -1 === $lastRefEnd ) { + return true; + } + } + + return false; + } + + /** + * Counts value placeholders in all literal parts. + * + * @param array $parts The concatenation parts. + * + * @return int + */ + private function count_placeholders_in_literals( $parts ) { + $count = 0; + + foreach ( $parts as $part ) { + if ( 'expr' !== $part['type'] ) { + $count += $this->count_placeholders( $part['content'] ); + } + } + + return $count; + } + + /** + * Counts value placeholders in a piece of SQL text. + * + * @param string $text The SQL text. + * + * @return int + */ + private function count_placeholders( $text ) { + $text = str_replace( '%%', '', $text ); + + return preg_match_all( '/%[dfFsi]/', $text ); + } + + /** + * Splits the remaining prepare() arguments into token ranges. + * + * @param File $phpcsFile The file being scanned. + * @param int $argEnd The end of the first argument (comma or closing parenthesis). + * @param int $closeParen The closing parenthesis of the call. + * + * @return array|false Array of array( 'start' => int, 'end' => int ), or false when the arguments cannot be parsed. + */ + private function parse_args( File $phpcsFile, $argEnd, $closeParen ) { + $tokens = $phpcsFile->getTokens(); + + if ( $argEnd >= $closeParen ) { + return array(); + } + + $args = array(); + $argStart = false; + $lastReal = false; + + for ( $i = $argEnd + 1; $i < $closeParen; $i++ ) { + $code = $tokens[ $i ]['code']; + + if ( T_WHITESPACE === $code || T_COMMENT === $code ) { + continue; + } + + $next = $this->skip_nested( $tokens, $i ); + + if ( $next !== $i ) { + if ( false === $argStart ) { + $argStart = $i; + } + + $lastReal = $next; + $i = $next; + continue; + } + + if ( T_COMMA === $code ) { + if ( false === $argStart ) { + return false; + } + + $args[] = array( + 'start' => $argStart, + 'end' => $lastReal, + ); + $argStart = false; + $lastReal = false; + continue; + } + + if ( false === $argStart ) { + $argStart = $i; + } + + $lastReal = $i; + } + + if ( false !== $argStart ) { + $args[] = array( + 'start' => $argStart, + 'end' => $lastReal, + ); + } + + return $args; + } + + /** + * Applies the %i fix: rewrites the SQL argument and inserts identifier expressions into the argument list. + * + * @param File $phpcsFile The file being scanned. + * @param array $parts The concatenation parts. + * @param array $refs The detected table references. + * @param int $openParen The opening parenthesis of the call. + * @param int $argEnd The end of the first argument. + * @param int $closeParen The closing parenthesis of the call. + * @param array $args The existing argument token ranges. + * + * @return void + */ + private function apply_fix( File $phpcsFile, $parts, $refs, $openParen, $argEnd, $closeParen, $args ) { + $newParts = $this->build_new_parts( $parts, $refs ); + $insertions = $this->calculate_insertions( $parts, $refs ); + $newSql = $this->render_parts( $newParts ); + + $phpcsFile->fixer->beginChangeset(); + + $sqlStart = $parts[0]['start']; + $sqlEnd = $parts[ count( $parts ) - 1 ]['end']; + + $phpcsFile->fixer->replaceToken( $sqlStart, $newSql ); + + for ( $i = $sqlStart + 1; $i <= $sqlEnd; $i++ ) { + $phpcsFile->fixer->replaceToken( $i, '' ); + } + + // Group insertions by argument slot, keeping reference order. + $bySlot = array(); + + foreach ( $insertions as $insertion ) { + $bySlot[ $insertion['slot'] ][] = $insertion['expr']; + } + + foreach ( $bySlot as $slot => $exprs ) { + $code = implode( ', ', $exprs ); + + if ( isset( $args[ $slot ] ) ) { + $phpcsFile->fixer->addContentBefore( $args[ $slot ]['start'], $code . ', ' ); + } elseif ( $args ) { + $lastArg = $args[ count( $args ) - 1 ]; + $phpcsFile->fixer->addContent( $lastArg['end'], ', ' . $code ); + } else { + $phpcsFile->fixer->addContent( $sqlEnd, ', ' . $code ); + } + } + + $phpcsFile->fixer->endChangeset(); + } + + /** + * Builds the substituted part contents with %i in place of each reference. + * + * @param array $parts The concatenation parts. + * @param array $refs The detected table references. + * + * @return array Array of array( 'type' => 'sq'|'dq', 'content' => string ), expr parts consumed by refs are dropped. + */ + private function build_new_parts( $parts, $refs ) { + $contents = array(); + $types = array(); + $dropped = array(); + $leadTrim = array(); + + foreach ( $parts as $p => $part ) { + $contents[ $p ] = $part['content']; + $types[ $p ] = $part['type']; + } + + foreach ( $refs as $ref ) { + if ( 'in_string' === $ref['type'] ) { + continue; + } + + $firstPart = $ref['parts'][0]; + $prevPart = $firstPart - 1; + + // Replace the trailing whitespace/backtick after the keyword with a single space and %i. + $hadBacktick = (bool) preg_match( '/`\s*$/', $contents[ $prevPart ] ); + $contents[ $prevPart ] = rtrim( $contents[ $prevPart ], " \t\n`" ) . ' %i'; + + foreach ( $ref['parts'] as $p ) { + $dropped[ $p ] = true; + } + + // Strip the absorbed leading word (and a possible closing backtick) from the following literal. + $afterPart = $ref['parts'][ count( $ref['parts'] ) - 1 ] + 1; + + if ( isset( $contents[ $afterPart ] ) && ( '' !== $ref['lead_word'] || $hadBacktick ) ) { + $leadTrim[ $afterPart ] = $ref['lead_word']; + } + } + + foreach ( $leadTrim as $p => $word ) { + if ( '' === $word || 0 === strpos( $contents[ $p ], $word ) ) { + $contents[ $p ] = ltrim( substr( $contents[ $p ], strlen( $word ) ), '`' ); + } + + // The literal was fully absorbed into the identifier expression. + if ( '' === $contents[ $p ] ) { + $dropped[ $p ] = true; + } + } + + // In-string substitutions, applied right to left so offsets stay valid. + $byPart = array(); + + foreach ( $refs as $ref ) { + if ( 'in_string' === $ref['type'] ) { + $byPart[ $ref['part'] ][] = $ref; + } + } + + foreach ( $byPart as $p => $partRefs ) { + usort( + $partRefs, + function ( $a, $b ) { + return $b['offset'] - $a['offset']; + } + ); + + foreach ( $partRefs as $ref ) { + $contents[ $p ] = substr_replace( $contents[ $p ], '%i', $ref['offset'], $ref['length'] ); + } + } + + $newParts = array(); + + foreach ( $contents as $p => $content ) { + if ( isset( $dropped[ $p ] ) ) { + continue; + } + + $type = $types[ $p ]; + + if ( 'dq' === $type && ! preg_match( '/[\$\\\\\']/', $content ) ) { + $type = 'sq'; + } + + $newParts[] = array( + 'type' => $type, + 'content' => $content, + ); + } + + return $newParts; + } + + /** + * Calculates where each reference expression must be inserted in the argument list. + * + * The slot is the number of original value placeholders that appear before the + * reference in the SQL, which equals the index of the original argument the + * expression must be inserted before. + * + * @param array $parts The concatenation parts. + * @param array $refs The detected table references. + * + * @return array Array of array( 'slot' => int, 'expr' => string ) in reference order. + */ + private function calculate_insertions( $parts, $refs ) { + $insertions = array(); + + foreach ( $refs as $ref ) { + $slot = 0; + + if ( 'in_string' === $ref['type'] ) { + $limitPart = $ref['part']; + $limitOffset = $ref['offset']; + } else { + $limitPart = $ref['parts'][0]; + $limitOffset = null; + } + + foreach ( $parts as $p => $part ) { + if ( 'expr' === $part['type'] ) { + continue; + } + + if ( $p > $limitPart ) { + break; + } + + if ( $p === $limitPart ) { + if ( null !== $limitOffset ) { + $slot += $this->count_placeholders( substr( $part['content'], 0, $limitOffset ) ); + } + + break; + } + + $slot += $this->count_placeholders( $part['content'] ); + } + + $insertions[] = array( + 'slot' => $slot, + 'expr' => $ref['expr'], + ); + } + + return $insertions; + } + + /** + * Renders substituted parts back into a single PHP concatenation expression. + * + * @param array $newParts The substituted parts. + * + * @return string + */ + private function render_parts( $newParts ) { + $rendered = array(); + + foreach ( $newParts as $part ) { + if ( 'sq' === $part['type'] ) { + $code = "'" . $part['content'] . "'"; + } else { + $code = '"' . $part['content'] . '"'; + } + + $last = count( $rendered ) - 1; + + if ( $last >= 0 && 'sq' === $part['type'] && "'" === substr( $rendered[ $last ], -1 ) && "'" === $rendered[ $last ][0] ) { + $rendered[ $last ] = substr( $rendered[ $last ], 0, -1 ) . $part['content'] . "'"; + continue; + } + + $rendered[] = $code; + } + + return implode( ' . ', $rendered ); + } +} diff --git a/phpcs-sniffs/Formidable/ruleset.xml b/phpcs-sniffs/Formidable/ruleset.xml index 815677df14..1c5b3ed24b 100644 --- a/phpcs-sniffs/Formidable/ruleset.xml +++ b/phpcs-sniffs/Formidable/ruleset.xml @@ -95,6 +95,7 @@ + diff --git a/stripe/controllers/FrmStrpLiteEventsController.php b/stripe/controllers/FrmStrpLiteEventsController.php index a1d85cd8e9..1efeb29bdd 100644 --- a/stripe/controllers/FrmStrpLiteEventsController.php +++ b/stripe/controllers/FrmStrpLiteEventsController.php @@ -176,7 +176,8 @@ private function reset_customer() { } $wpdb->query( $wpdb->prepare( - "DELETE FROM $wpdb->usermeta WHERE meta_value = %s AND meta_key LIKE %s", + 'DELETE FROM %i WHERE meta_value = %s AND meta_key LIKE %s', + $wpdb->usermeta, $customer_id, '_frmstrp_customer_id%' ) diff --git a/stripe/controllers/FrmTransLiteCRUDController.php b/stripe/controllers/FrmTransLiteCRUDController.php index ab066b2276..80a583b2ae 100644 --- a/stripe/controllers/FrmTransLiteCRUDController.php +++ b/stripe/controllers/FrmTransLiteCRUDController.php @@ -65,20 +65,18 @@ public static function show( $id = 0 ) { private static function get_payment_row( $id ) { global $wpdb; - $table_name = self::table_name(); - - // @codingStandardsIgnoreStart return $wpdb->get_row( $wpdb->prepare( - "SELECT + 'SELECT p.*, e.user_id - FROM `{$wpdb->prefix}frm_{$table_name}` p - LEFT JOIN `{$wpdb->prefix}frm_items` e ON p.item_id = e.id - WHERE p.id=%d", + FROM %i p + LEFT JOIN %i e ON p.item_id = e.id + WHERE p.id=%d', + $wpdb->prefix . 'frm_' . self::table_name(), + $wpdb->prefix . 'frm_items', $id ) ); - // @codingStandardsIgnoreEnd } /** diff --git a/stripe/controllers/FrmTransLiteSubscriptionsController.php b/stripe/controllers/FrmTransLiteSubscriptionsController.php index e67ec7e6ed..be29d20fb1 100755 --- a/stripe/controllers/FrmTransLiteSubscriptionsController.php +++ b/stripe/controllers/FrmTransLiteSubscriptionsController.php @@ -55,7 +55,7 @@ public static function show_receipt_link( $subscription ) { public static function show_cancel_link( $sub, $atts = array() ) { if ( ! isset( $sub->user_id ) ) { global $wpdb; - $sub->user_id = $wpdb->get_var( $wpdb->prepare( 'SELECT user_id FROM ' . $wpdb->prefix . 'frm_items WHERE id=%d', $sub->item_id ) ); + $sub->user_id = $wpdb->get_var( $wpdb->prepare( 'SELECT user_id FROM %i WHERE id=%d', $wpdb->prefix . 'frm_items', $sub->item_id ) ); } $link = self::cancel_link( $sub, $atts ); diff --git a/stripe/helpers/FrmTransLiteListHelper.php b/stripe/helpers/FrmTransLiteListHelper.php index 8220ad6f1e..b35e970232 100755 --- a/stripe/helpers/FrmTransLiteListHelper.php +++ b/stripe/helpers/FrmTransLiteListHelper.php @@ -63,7 +63,8 @@ public function prepare_items() { $query = $this->get_table_query(); $order_query = FrmDb::esc_order( "ORDER BY p.{$orderby} $order" ); - // @codingStandardsIgnoreStart + // The table query and order query are prepared and escaped where they are built. + // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared $this->items = $wpdb->get_results( $wpdb->prepare( 'SELECT p.* ' . $query . $order_query . ' LIMIT %d, %d', @@ -72,7 +73,7 @@ public function prepare_items() { ) ); $total_items = $wpdb->get_var( 'SELECT COUNT(*) ' . $query ); - // @codingStandardsIgnoreEnd + // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared $this->set_pagination_args( array( @@ -92,17 +93,17 @@ private function get_table_query() { $form_id = FrmAppHelper::get_param( 'form', 0, 'get', 'absint' ); if ( ! $form_id ) { - return "FROM `{$wpdb->prefix}{$table_name}` p"; + return $wpdb->prepare( 'FROM %i p', $wpdb->prefix . $table_name ); } - // @codingStandardsIgnoreStart - return $wpdb->prepare( - "FROM `{$wpdb->prefix}{$table_name}` p - JOIN `{$wpdb->prefix}frm_items` i ON p.item_id = i.id - WHERE i.form_id = %d", - $form_id - ); - // @codingStandardsIgnoreEnd + return $wpdb->prepare( + 'FROM %i p + JOIN %i i ON p.item_id = i.id + WHERE i.form_id = %d', + $wpdb->prefix . $table_name, + $wpdb->prefix . 'frm_items', + $form_id + ); } /** @@ -334,17 +335,20 @@ private function get_form_ids() { } global $wpdb; - // @codingStandardsIgnoreStart - $forms = $wpdb->get_results( - "SELECT - fo.id as form_id, - fo.name, - e.id - FROM {$wpdb->prefix}frm_items e - LEFT JOIN {$wpdb->prefix}frm_forms fo ON e.form_id = fo.id - WHERE e.id in (" . implode( ',', $entry_ids ) . ')' + + $ids_placeholders = implode( ',', array_fill( 0, count( $entry_ids ), '%d' ) ); + $forms = $wpdb->get_results( + $wpdb->prepare( + 'SELECT + fo.id as form_id, + fo.name, + e.id + FROM %i e + LEFT JOIN %i fo ON e.form_id = fo.id + WHERE e.id in (' . $ids_placeholders . ')', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + array_merge( array( $wpdb->prefix . 'frm_items', $wpdb->prefix . 'frm_forms' ), $entry_ids ) + ) ); - // @codingStandardsIgnoreEnd unset( $entry_ids ); $form_ids = array(); diff --git a/stripe/models/FrmTransLiteDb.php b/stripe/models/FrmTransLiteDb.php index f2b6e2a869..a3500d2907 100755 --- a/stripe/models/FrmTransLiteDb.php +++ b/stripe/models/FrmTransLiteDb.php @@ -147,9 +147,7 @@ public function destroy( $id ) { */ do_action( 'frm_before_destroy_' . $this->singular, $id ); - // @codingStandardsIgnoreStart - return $wpdb->query( $wpdb->prepare( 'DELETE FROM ' . $wpdb->prefix . $this->table_name . ' WHERE id=%d', $id ) ); - // @codingStandardsIgnoreEnd + return $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE id=%d', $wpdb->prefix . $this->table_name, $id ) ); } /** @@ -160,14 +158,13 @@ public function destroy( $id ) { public function get_one( $id ) { global $wpdb; - // @codingStandardsIgnoreStart return $wpdb->get_row( $wpdb->prepare( - 'SELECT * FROM ' . $wpdb->prefix . $this->table_name . ' WHERE id=%d', + 'SELECT * FROM %i WHERE id=%d', + $wpdb->prefix . $this->table_name, $id ) ); - // @codingStandardsIgnoreEnd } /** @@ -183,17 +180,15 @@ public function get_one_by( $id, $field = 'receipt_id' ) { } global $wpdb; - // Can this be exploited? - $field = sanitize_text_field( $field ); - // @codingStandardsIgnoreStart + return $wpdb->get_row( $wpdb->prepare( - 'SELECT * FROM ' . $wpdb->prefix . $this->table_name - . ' WHERE ' . $field . ' = %s ORDER BY created_at DESC', + 'SELECT * FROM %i WHERE %i = %s ORDER BY created_at DESC', + $wpdb->prefix . $this->table_name, + $field, $id ) ); - // @codingStandardsIgnoreEnd } /** @@ -216,15 +211,15 @@ public function get_all_by( $value, $field = 'item_id' ) { } global $wpdb; - // @codingStandardsIgnoreStart + return $wpdb->get_results( $wpdb->prepare( - 'SELECT * FROM ' . $wpdb->prefix . $this->table_name - . ' WHERE ' . $field . ' = %s ORDER BY created_at DESC', + 'SELECT * FROM %i WHERE %i = %s ORDER BY created_at DESC', + $wpdb->prefix . $this->table_name, + $field, $value ) ); - // @codingStandardsIgnoreEnd } /** @@ -234,21 +229,22 @@ public function get_all_by( $value, $field = 'item_id' ) { */ public function get_all_for_user( $user_id ) { global $wpdb; - // @codingStandardsIgnoreStart + return $wpdb->get_results( $wpdb->prepare( 'SELECT *, e.id as entry_id, p.id as id - FROM ' . $wpdb->prefix . $this->table_name . ' p ' - . 'LEFT JOIN ' . $wpdb->prefix . 'frm_items e ON e.id = p.item_id ' - . 'WHERE e.user_id = %d ' - . 'ORDER BY p.created_at DESC', + FROM %i p + LEFT JOIN %i e ON e.id = p.item_id + WHERE e.user_id = %d + ORDER BY p.created_at DESC', + $wpdb->prefix . $this->table_name, + $wpdb->prefix . 'frm_items', $user_id ) ); - // @codingStandardsIgnoreEnd } /** @@ -265,11 +261,7 @@ public function get_all_for_entry( $id ) { */ public function get_count() { global $wpdb; - // @codingStandardsIgnoreStart - return $wpdb->get_var( - 'SELECT COUNT(*) FROM ' . $wpdb->prefix . $this->table_name - ); - // @codingStandardsIgnoreEnd + return $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $wpdb->prefix . $this->table_name ) ); } /** @@ -326,14 +318,14 @@ private function migrate_data( $old_db_version ) { */ private function migrate_to_4() { global $wpdb; - $result = $wpdb->get_results( $wpdb->prepare( 'SHOW COLUMNS FROM ' . $wpdb->prefix . 'frm_payments LIKE %s', 'completed' ) ); + $result = $wpdb->get_results( $wpdb->prepare( 'SHOW COLUMNS FROM %i LIKE %s', $wpdb->prefix . 'frm_payments', 'completed' ) ); if ( ! $result ) { return; } $payments = $wpdb->get_results( - "SELECT * FROM {$wpdb->prefix}frm_payments WHERE completed is NOT NULL AND status is NULL" + $wpdb->prepare( 'SELECT * FROM %i WHERE completed is NOT NULL AND status is NULL', $wpdb->prefix . 'frm_payments' ) ); foreach ( $payments as $payment ) { diff --git a/stripe/models/FrmTransLiteSubscription.php b/stripe/models/FrmTransLiteSubscription.php index 88e7b01366..8cd142508d 100755 --- a/stripe/models/FrmTransLiteSubscription.php +++ b/stripe/models/FrmTransLiteSubscription.php @@ -83,10 +83,11 @@ public function get_overdue_subscriptions() { global $wpdb; return $wpdb->get_results( $wpdb->prepare( - "SELECT * FROM `{$wpdb->prefix}frm_subscriptions` + "SELECT * FROM %i WHERE fail_count < %d AND next_bill_date < %s AND (status = 'active' OR status = 'future_cancel')", + $wpdb->prefix . 'frm_subscriptions', 3, gmdate( 'Y-m-d' ) ) diff --git a/tests/phpunit/database/test_FrmDb.php b/tests/phpunit/database/test_FrmDb.php index 52990f207d..9d0c2a8b0d 100644 --- a/tests/phpunit/database/test_FrmDb.php +++ b/tests/phpunit/database/test_FrmDb.php @@ -24,4 +24,13 @@ public function test_esc_order() { $this->assertSame( $expected, $actual ); } } + + /** + * @covers FrmDb::db_column_exists + */ + public function test_db_column_exists() { + $this->assertTrue( FrmDb::db_column_exists( 'frm_fields', 'field_key' ) ); + $this->assertTrue( FrmDb::db_column_exists( 'frm_items', 'is_draft' ) ); + $this->assertFalse( FrmDb::db_column_exists( 'frm_fields', 'missing_column' ) ); + } } diff --git a/tests/phpunit/emails/test_FrmEmailSummaryHelper.php b/tests/phpunit/emails/test_FrmEmailSummaryHelper.php index 813d023b4d..1ea85be6a2 100644 --- a/tests/phpunit/emails/test_FrmEmailSummaryHelper.php +++ b/tests/phpunit/emails/test_FrmEmailSummaryHelper.php @@ -181,4 +181,28 @@ function ( $pre, $parsed_args, $url ) { FrmEmailSummaryHelper::maybe_remove_recipients_from_api( $recipients ); $this->assertSame( 'recipient2@example.com', $recipients ); } + + /** + * @covers FrmEmailSummaryHelper::get_top_forms + */ + public function test_get_top_forms() { + $form_a = $this->factory->form->create_and_get(); + $form_b = $this->factory->form->create_and_get(); + + $this->factory->entry->create( $this->factory->field->generate_entry_array( $form_a ) ); + $this->factory->entry->create( $this->factory->field->generate_entry_array( $form_a ) ); + $this->factory->entry->create( $this->factory->field->generate_entry_array( $form_b ) ); + + $top_forms = FrmEmailSummaryHelper::get_top_forms( gmdate( 'Y-m-d', strtotime( '-1 day' ) ), gmdate( 'Y-m-d' ) ); + $by_form = array(); + + foreach ( $top_forms as $row ) { + $by_form[ (int) $row->form_id ] = (int) $row->items_count; + } + + $this->assertArrayHasKey( $form_a->id, $by_form ); + $this->assertArrayHasKey( $form_b->id, $by_form ); + $this->assertSame( 2, $by_form[ $form_a->id ] ); + $this->assertSame( 1, $by_form[ $form_b->id ] ); + } } diff --git a/tests/phpunit/entries/test_FrmEntryMeta.php b/tests/phpunit/entries/test_FrmEntryMeta.php index 3a4387262f..6faeeadb32 100644 --- a/tests/phpunit/entries/test_FrmEntryMeta.php +++ b/tests/phpunit/entries/test_FrmEntryMeta.php @@ -84,4 +84,86 @@ public function test_should_join_fields_table() { $this->assertFalse( $this->run_private_method( array( 'FrmEntryMeta', 'should_join_fields_table' ), array( &$where ) ) ); $this->assertSame( array( 'e.form_id' => 456 ), $where ); } + + /** + * @covers FrmEntryMeta::delete_entry_meta + */ + public function test_delete_entry_meta() { + $form = $this->factory->form->create_and_get(); + $field_id = $this->factory->field->create( + array( + 'form_id' => $form->id, + ) + ); + + $entry_data = $this->factory->field->generate_entry_array( $form ); + + $entry_data['item_meta'][ $field_id ] = 'Value to delete'; + + $entry_id = $this->factory->entry->create( $entry_data ); + + $this->assertSame( 'Value to delete', FrmEntryMeta::get_entry_meta_by_field( $entry_id, $field_id ) ); + + FrmEntryMeta::delete_entry_meta( $entry_id, $field_id ); + + $this->assertNull( FrmEntryMeta::get_entry_meta_by_field( $entry_id, $field_id ) ); + } + + /** + * @covers FrmEntryMeta::get_entry_metas_for_field + */ + public function test_get_entry_metas_for_field() { + $form = $this->factory->form->create_and_get(); + $field_id = $this->factory->field->create( + array( + 'form_id' => $form->id, + ) + ); + + $entry_data = $this->factory->field->generate_entry_array( $form ); + + $entry_data['item_meta'][ $field_id ] = 'Meta value to find'; + + $this->factory->entry->create( $entry_data ); + + // Look up by field id. + $values = FrmEntryMeta::get_entry_metas_for_field( $field_id ); + $this->assertContains( 'Meta value to find', $values ); + + // Look up by field key, which joins the fields table. + $field_key = FrmField::get_key_by_id( $field_id ); + $values = FrmEntryMeta::get_entry_metas_for_field( $field_key ); + $this->assertContains( 'Meta value to find', $values ); + } + + /** + * @covers FrmEntryMeta::search_entry_metas + */ + public function test_search_entry_metas() { + $form = $this->factory->form->create_and_get(); + $field_id = $this->factory->field->create( + array( + 'form_id' => $form->id, + ) + ); + + $entry_data = $this->factory->field->generate_entry_array( $form ); + + $entry_data['item_meta'][ $field_id ] = 'Findable value'; + + $entry_id = (int) $this->factory->entry->create( $entry_data ); + $other_entry_data = $this->factory->field->generate_entry_array( $form ); + + $other_entry_data['item_meta'][ $field_id ] = 'Something else'; + + $other_entry_id = (int) $this->factory->entry->create( $other_entry_data ); + $matches = array_map( 'intval', FrmEntryMeta::search_entry_metas( 'Findable', $field_id, 'LIKE' ) ); + + $this->assertContains( $entry_id, $matches ); + $this->assertNotContains( $other_entry_id, $matches ); + + $matches = array_map( 'intval', FrmEntryMeta::search_entry_metas( 'Something else', $field_id, '=' ) ); + + $this->assertContains( $other_entry_id, $matches ); + } } diff --git a/tests/phpunit/fields/test_FrmField.php b/tests/phpunit/fields/test_FrmField.php index f99d29a64d..099e92afc2 100644 --- a/tests/phpunit/fields/test_FrmField.php +++ b/tests/phpunit/fields/test_FrmField.php @@ -72,4 +72,34 @@ public function test_get_all_for_form() { $this->assertCount( $args['count'], $fields, 'An incorrect number of fields are retrieved with FrmField::get_all_for_form for ' . $test . '.' ); } } + + /** + * @covers FrmField::destroy + */ + public function test_destroy() { + $form = $this->factory->form->create_and_get(); + $field_id = $this->factory->field->create( + array( + 'form_id' => $form->id, + ) + ); + + $entry_data = $this->factory->field->generate_entry_array( $form ); + + $entry_data['item_meta'][ $field_id ] = 'Meta for deleted field'; + + $entry_id = $this->factory->entry->create( $entry_data ); + + $this->assertSame( 'Meta for deleted field', FrmEntryMeta::get_entry_meta_by_field( $entry_id, $field_id ) ); + + FrmField::destroy( $field_id ); + + $this->assertNull( FrmField::getOne( $field_id ) ); + + global $wpdb; + $meta_value = $wpdb->get_var( + $wpdb->prepare( 'SELECT meta_value FROM %i WHERE item_id = %d AND field_id = %d', $wpdb->prefix . 'frm_item_metas', $entry_id, $field_id ) + ); + $this->assertNull( $meta_value ); + } } diff --git a/tests/phpunit/forms/test_FrmForm.php b/tests/phpunit/forms/test_FrmForm.php index 0a77f8c17e..8ed55c3945 100644 --- a/tests/phpunit/forms/test_FrmForm.php +++ b/tests/phpunit/forms/test_FrmForm.php @@ -69,6 +69,27 @@ public function test_destroy() { } } + /** + * @covers FrmForm::set_status + */ + public function test_set_status() { + $form_id_1 = $this->factory->form->create(); + $form_id_2 = $this->factory->form->create(); + + FrmForm::set_status( $form_id_1, 'draft' ); + FrmForm::set_status( $form_id_2, 'draft' ); + + $this->assertEquals( 'draft', FrmForm::getOne( $form_id_1 )->status ); + $this->assertEquals( 'draft', FrmForm::getOne( $form_id_2 )->status ); + + // An array of ids runs a single prepared query. + $result = FrmForm::set_status( array( $form_id_1, $form_id_2 ), 'published' ); + + $this->assertNotFalse( $result ); + $this->assertEquals( 'published', FrmForm::getOne( $form_id_1 )->status ); + $this->assertEquals( 'published', FrmForm::getOne( $form_id_2 )->status ); + } + /** * @group visibility * diff --git a/tests/phpunit/forms/test_FrmFormsListHelper.php b/tests/phpunit/forms/test_FrmFormsListHelper.php new file mode 100644 index 0000000000..c16c0a4933 --- /dev/null +++ b/tests/phpunit/forms/test_FrmFormsListHelper.php @@ -0,0 +1,40 @@ +factory->form->create_and_get(); + + $post_with_form = $this->factory->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_content' => 'Before [formidable id=' . $form->id . '] after', + ) + ); + + $post_without_form = $this->factory->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_content' => 'No form embedded here', + ) + ); + + $list_helper = new FrmFormsListHelper( + array( 'params' => FrmForm::get_admin_params( $form->id ) ) + ); + + $posts = $this->run_private_method( array( $list_helper, 'query_posts_contain_form' ), array( $form ) ); + $post_ids = array_map( 'intval', wp_list_pluck( $posts, 'ID' ) ); + + $this->assertContains( $post_with_form, $post_ids ); + $this->assertNotContains( $post_without_form, $post_ids ); + } +} diff --git a/tests/phpunit/misc/fixtures/prefer-identifier-placeholder-ruleset.xml b/tests/phpunit/misc/fixtures/prefer-identifier-placeholder-ruleset.xml new file mode 100644 index 0000000000..5f12cf7ea5 --- /dev/null +++ b/tests/phpunit/misc/fixtures/prefer-identifier-placeholder-ruleset.xml @@ -0,0 +1,5 @@ + + + Runs only the PreferIdentifierPlaceholder sniff for its unit tests. + + diff --git a/tests/phpunit/misc/test_FrmPreferIdentifierPlaceholderSniff.php b/tests/phpunit/misc/test_FrmPreferIdentifierPlaceholderSniff.php new file mode 100644 index 0000000000..25e17f9f7d --- /dev/null +++ b/tests/phpunit/misc/test_FrmPreferIdentifierPlaceholderSniff.php @@ -0,0 +1,314 @@ +process(); + + return $file; + } + + /** + * Collects error sources and fixable flags from a processed file. + * + * @param \PHP_CodeSniffer\Files\DummyFile $file The processed file. + * + * @return array Array of array( 'source' => string, 'fixable' => bool ). + */ + private function get_error_list( $file ) { + $list = array(); + foreach ( $file->getErrors() as $line => $cols ) { + foreach ( $cols as $col => $errors ) { + foreach ( $errors as $error ) { + $list[] = array( + 'source' => $error['source'], + 'fixable' => $error['fixable'], + ); + } + } + } + + return $list; + } + + /** + * Asserts the sniff fixes $code into $expected. + * + * @param string $code The offending code. + * @param string $expected The expected fixed code. + * + * @return void + */ + private function assert_fixed( $code, $expected ) { + $file = $this->process_code( $code ); + $errors = $this->get_error_list( $file ); + + $this->assertNotEmpty( $errors, 'Expected the sniff to flag: ' . $code ); + + foreach ( $errors as $error ) { + $this->assertSame( 'Formidable.Security.PreferIdentifierPlaceholder.TableInPrepare', $error['source'] ); + $this->assertTrue( $error['fixable'], 'Expected a fixable error for: ' . $code ); + } + + $file->fixer->fixFile(); + $this->assertSame( "fixer->getContents() ); + } + + /** + * Asserts the sniff flags $code with $source and cannot auto-fix it. + * + * @param string $code The offending code. + * @param string $source The expected error source. + * + * @return void + */ + private function assert_flagged_only( $code, $source ) { + $file = $this->process_code( $code ); + $errors = $this->get_error_list( $file ); + + $this->assertNotEmpty( $errors, 'Expected the sniff to flag: ' . $code ); + + foreach ( $errors as $error ) { + $this->assertSame( $source, $error['source'] ); + $this->assertFalse( $error['fixable'], 'Expected a non-fixable error for: ' . $code ); + } + } + + /** + * Asserts the sniff stays silent for $code. + * + * @param string $code The clean code. + * + * @return void + */ + private function assert_clean( $code ) { + $file = $this->process_code( $code ); + $this->assertSame( 0, $file->getErrorCount(), 'Expected no errors for: ' . $code ); + } + + public function test_fixes_interpolated_prefix_table() { + $this->assert_fixed( + '$wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}frm_item_metas WHERE field_id=%d AND item_id=%d", $field_id, $entry_id ) );', + "\$wpdb->query( \$wpdb->prepare( 'DELETE FROM %i WHERE field_id=%d AND item_id=%d', \$wpdb->prefix . 'frm_item_metas', \$field_id, \$entry_id ) );" + ); + } + + public function test_fixes_concatenated_prefix_table() { + $this->assert_fixed( + "\$wpdb->query( \$wpdb->prepare( 'DELETE FROM ' . \$wpdb->prefix . 'frm_item_metas WHERE field_id=%d', \$id ) );", + "\$wpdb->query( \$wpdb->prepare( 'DELETE FROM %i WHERE field_id=%d', \$wpdb->prefix . 'frm_item_metas', \$id ) );" + ); + } + + public function test_fixes_wpdb_property_table() { + $this->assert_fixed( + "\$ids = \$wpdb->get_col( \$wpdb->prepare( 'SELECT ID FROM ' . \$wpdb->posts . ' WHERE post_type in (%s, %s)', \$a, \$b ) );", + "\$ids = \$wpdb->get_col( \$wpdb->prepare( 'SELECT ID FROM %i WHERE post_type in (%s, %s)', \$wpdb->posts, \$a, \$b ) );" + ); + } + + public function test_fixes_dynamic_table_variable() { + $this->assert_fixed( + "\$result = \$wpdb->get_results( \$wpdb->prepare( 'SHOW COLUMNS FROM ' . \$wpdb->prefix . \$table . ' LIKE %s', \$column ) );", + "\$result = \$wpdb->get_results( \$wpdb->prepare( 'SHOW COLUMNS FROM %i LIKE %s', \$wpdb->prefix . \$table, \$column ) );" + ); + } + + public function test_fixes_two_tables_in_one_query() { + $this->assert_fixed( + "\$wpdb->query( \$wpdb->prepare( 'DELETE fi FROM ' . \$wpdb->prefix . 'frm_fields AS fi LEFT JOIN ' . \$wpdb->prefix" + . " . 'frm_forms fr ON (fi.form_id = fr.id) WHERE fi.form_id=%d OR parent_form_id=%d', \$id, \$id ) );", + "\$wpdb->query( \$wpdb->prepare( 'DELETE fi FROM %i AS fi LEFT JOIN %i fr ON (fi.form_id = fr.id) WHERE fi.form_id=%d OR parent_form_id=%d'," + . " \$wpdb->prefix . 'frm_fields', \$wpdb->prefix . 'frm_forms', \$id, \$id ) );" + ); + } + + public function test_fixes_table_and_keeps_other_interpolation() { + $this->assert_fixed( + '$query = $wpdb->prepare( "SELECT DISTINCT item_id FROM {$wpdb->prefix}frm_item_metas WHERE meta_value' + . ' {$operator} %s and field_id = %d", $search, $field_id );', + '$query = $wpdb->prepare( "SELECT DISTINCT item_id FROM %i WHERE meta_value {$operator} %s and field_id = %d",' + . ' $wpdb->prefix . \'frm_item_metas\', $search, $field_id );' + ); + } + + public function test_fixes_backticked_interpolated_table() { + $this->assert_fixed( + '$sub = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM `{$wpdb->prefix}frm_subscriptions` WHERE id = %d", $id ) );', + "\$sub = \$wpdb->get_row( \$wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', \$wpdb->prefix . 'frm_subscriptions', \$id ) );" + ); + } + + public function test_fixes_prepare_without_existing_args() { + $this->assert_fixed( + '$rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}frm_payments WHERE completed is NOT NULL" ) );', + "\$rows = \$wpdb->get_results( \$wpdb->prepare( 'SELECT * FROM %i WHERE completed is NOT NULL', \$wpdb->prefix . 'frm_payments' ) );" + ); + } + + public function test_fixes_double_interpolated_table_name() { + $this->assert_fixed( + '$results = $wpdb->get_results( $wpdb->prepare( "SELECT p.* FROM `{$wpdb->prefix}frm_{$table_name}` p' + . ' LEFT JOIN `{$wpdb->prefix}frm_items` e ON p.item_id = e.id WHERE e.id = %d", $id ) );', + "\$results = \$wpdb->get_results( \$wpdb->prepare( 'SELECT p.* FROM %i p LEFT JOIN %i e ON p.item_id = e.id WHERE e.id = %d'," + . " \$wpdb->prefix . 'frm_' . \$table_name, \$wpdb->prefix . 'frm_items', \$id ) );" + ); + } + + public function test_fixes_create_index_on_table() { + $this->assert_fixed( + '$wpdb->query( $wpdb->prepare( "CREATE INDEX idx_is_draft_created_at ON {$wpdb->prefix}frm_items (is_draft, created_at)" ) );', + "\$wpdb->query( \$wpdb->prepare( 'CREATE INDEX idx_is_draft_created_at ON %i (is_draft, created_at)', \$wpdb->prefix . 'frm_items' ) );" + ); + } + + public function test_fixes_multiline_interpolated_tables() { + $code = implode( + "\n", + array( + '$result = $wpdb->get_results(', + "\t\$wpdb->prepare(", + "\t\t\"SELECT COUNT(*) as items_count", + "\t\t\tFROM {\$wpdb->prefix}frm_items AS it INNER JOIN {\$wpdb->prefix}frm_forms AS fr ON it.form_id = fr.id", + "\t\t\tWHERE it.created_at BETWEEN %s AND %s\",", + "\t\t\$from_date,", + "\t\t\$to_date", + "\t)", + ');', + ) + ); + + $expected = implode( + "\n", + array( + '$result = $wpdb->get_results(', + "\t\$wpdb->prepare(", + "\t\t'SELECT COUNT(*) as items_count", + "\t\t\tFROM %i AS it INNER JOIN %i AS fr ON it.form_id = fr.id", + "\t\t\tWHERE it.created_at BETWEEN %s AND %s',", + "\t\t\$wpdb->prefix . 'frm_items', \$wpdb->prefix . 'frm_forms', \$from_date,", + "\t\t\$to_date", + "\t)", + ');', + ) + ); + + $this->assert_fixed( $code, $expected ); + } + + public function test_flags_unknown_sql_fragment_without_fixing() { + $this->assert_flagged_only( + "\$wpdb->query( \$wpdb->prepare( 'UPDATE ' . \$wpdb->prefix . 'frm_forms SET status = %s ' . \$where['where'], \$where['values'] ) );", + 'Formidable.Security.PreferIdentifierPlaceholder.TableInPrepare' + ); + } + + public function test_flags_variable_alias_without_fixing() { + $this->assert_flagged_only( + "\$sql = \$wpdb->prepare( ' LEFT JOIN ' . \$wpdb->prefix . 'frm_item_metas em' . \$o_key . ' ON em' . \$o_key . '.field_id=%d ', \$o_field->id );", + 'Formidable.Security.PreferIdentifierPlaceholder.TableInPrepare' + ); + } + + public function test_flags_unprepared_query() { + $this->assert_flagged_only( + "\$wpdb->query( 'DROP TABLE IF EXISTS ' . \$this->fields );", + 'Formidable.Security.PreferIdentifierPlaceholder.TableNotPrepared' + ); + } + + public function test_flags_unprepared_interpolated_query() { + $this->assert_flagged_only( + '$found = $wpdb->get_var( "SELECT 1 FROM {$wpdb->posts} WHERE post_content LIKE \'%[formidable %\' LIMIT 1" );', + 'Formidable.Security.PreferIdentifierPlaceholder.TableNotPrepared' + ); + } + + public function test_ignores_create_table_ddl() { + $this->assert_clean( '$wpdb->query( "CREATE TABLE {$wpdb->prefix}frm_payments ( id BIGINT )" );' ); + } + + public function test_ignores_existing_identifier_placeholder() { + $this->assert_clean( "\$wpdb->query( \$wpdb->prepare( 'DELETE FROM %i WHERE id = %d', \$wpdb->prefix . 'frm_items', \$id ) );" ); + } + + public function test_ignores_literal_table_name() { + $this->assert_clean( "\$wpdb->query( \$wpdb->prepare( 'DELETE FROM wp_frm_items WHERE id = %d', \$id ) );" ); + } + + public function test_ignores_frmdb_helper_table_argument() { + $this->assert_clean( "\$count = FrmDb::get_var( 'frm_forms', array( 'id' => \$id ), 'COUNT(*)' );" ); + } + + public function test_ignores_interpolated_alias_after_on() { + $this->assert_clean( '$x = $wpdb->prepare( "SELECT * FROM %i em{$o_key} WHERE em{$o_key}.field_id = %d", $wpdb->prefix . \'frm_item_metas\', $o_field->id );' ); + } + + public function test_ignores_interpolated_alias_in_join_condition() { + $this->assert_clean( + '$y = $wpdb->prepare( "SELECT * FROM %i pm{$o_key} INNER JOIN %i it ON pm{$o_key}.post_id = it.post_id' + . ' WHERE it.id = %d", $wpdb->postmeta, $wpdb->prefix . \'frm_items\', $id );' + ); + } +} diff --git a/tests/phpunit/misc/test_FrmWelcomeTourController.php b/tests/phpunit/misc/test_FrmWelcomeTourController.php new file mode 100644 index 0000000000..eaa421e086 --- /dev/null +++ b/tests/phpunit/misc/test_FrmWelcomeTourController.php @@ -0,0 +1,31 @@ +assertFalse( $this->check_for_form_embeds() ); + + $this->factory->post->create( + array( + 'post_content' => 'Before [formidable id=5] after', + ) + ); + + $this->assertTrue( $this->check_for_form_embeds() ); + } + + /** + * Calls the private FrmWelcomeTourController::check_for_form_embeds method. + * + * @return bool + */ + private function check_for_form_embeds() { + return $this->run_private_method( array( 'FrmWelcomeTourController', 'check_for_form_embeds' ), array() ); + } +} diff --git a/tests/phpunit/stripe/test_FrmStrpLiteEventsController.php b/tests/phpunit/stripe/test_FrmStrpLiteEventsController.php new file mode 100644 index 0000000000..c89a29ee00 --- /dev/null +++ b/tests/phpunit/stripe/test_FrmStrpLiteEventsController.php @@ -0,0 +1,27 @@ +factory->user->create( array( 'role' => 'subscriber' ) ); + + update_user_meta( $user_id, '_frmstrp_customer_id_test', 'cus_abc123' ); + update_user_meta( $user_id, 'unrelated_meta', 'cus_abc123' ); + + $controller = new FrmStrpLiteEventsController(); + $this->set_private_property( $controller, 'invoice', (object) array( 'id' => 'cus_abc123' ) ); + $this->run_private_method( array( $controller, 'reset_customer' ), array() ); + + // The customer meta is deleted with a direct query, so drop the cached values. + clean_user_cache( $user_id ); + + $this->assertSame( '', get_user_meta( $user_id, '_frmstrp_customer_id_test', true ) ); + $this->assertSame( 'cus_abc123', get_user_meta( $user_id, 'unrelated_meta', true ) ); + } +} diff --git a/tests/phpunit/stripe/test_FrmTransLiteCRUDController.php b/tests/phpunit/stripe/test_FrmTransLiteCRUDController.php new file mode 100644 index 0000000000..012a45a696 --- /dev/null +++ b/tests/phpunit/stripe/test_FrmTransLiteCRUDController.php @@ -0,0 +1,42 @@ +upgrade(); + + $user_id = $this->factory->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $user_id ); + + $form = $this->factory->form->create_and_get(); + $entry = $this->factory->entry->create_and_get( $this->factory->field->generate_entry_array( $form ) ); + $payment = new FrmTransLitePayment(); + $payment_id = $payment->create( + array( + 'receipt_id' => 'rcpt_123', + 'item_id' => $entry->id, + 'action_id' => 1, + 'amount' => 25.00, + 'status' => 'complete', + 'paysys' => 'stripe', + 'created_at' => gmdate( 'Y-m-d H:i:s' ), + 'begin_date' => gmdate( 'Y-m-d' ), + 'expire_date' => gmdate( 'Y-m-d' ), + ) + ); + + $this->assertGreaterThan( 0, $payment_id ); + + $row = $this->run_private_method( array( 'FrmTransLiteCRUDController', 'get_payment_row' ), array( $payment_id ) ); + + $this->assertEquals( $payment_id, $row->id ); + $this->assertSame( 'rcpt_123', $row->receipt_id ); + $this->assertEquals( $entry->user_id, $row->user_id ); + } +} diff --git a/tests/phpunit/stripe/test_FrmTransLiteListHelper.php b/tests/phpunit/stripe/test_FrmTransLiteListHelper.php new file mode 100644 index 0000000000..9e23862b1d --- /dev/null +++ b/tests/phpunit/stripe/test_FrmTransLiteListHelper.php @@ -0,0 +1,57 @@ + array() ) ); + } + + /** + * @covers FrmTransLiteListHelper::get_table_query + */ + public function test_get_table_query() { + global $wpdb; + + $list_helper = $this->get_list_helper(); + $query = $this->run_private_method( array( $list_helper, 'get_table_query' ), array() ); + $this->assertStringContainsString( 'FROM `' . $wpdb->prefix . 'frm_payments` p', $query ); + + $form_id = $this->factory->form->create(); + $_GET['form'] = $form_id; + $query = $this->run_private_method( array( $list_helper, 'get_table_query' ), array() ); + + unset( $_GET['form'], $_REQUEST['trans_type'] ); + + $this->assertStringContainsString( 'FROM `' . $wpdb->prefix . 'frm_payments` p', $query ); + $this->assertStringContainsString( 'JOIN `' . $wpdb->prefix . 'frm_items` i ON p.item_id = i.id', $query ); + $this->assertStringContainsString( 'i.form_id = ' . $form_id, $query ); + } + + /** + * @covers FrmTransLiteListHelper::get_form_ids + */ + public function test_get_form_ids() { + $form = $this->factory->form->create_and_get(); + $entry = $this->factory->entry->create_and_get( $this->factory->field->generate_entry_array( $form ) ); + $list_helper = $this->get_list_helper(); + $list_helper->items = array( + (object) array( 'item_id' => $entry->id ), + ); + + $form_ids = $this->run_private_method( array( $list_helper, 'get_form_ids' ), array() ); + + unset( $_REQUEST['trans_type'] ); + + $this->assertArrayHasKey( $entry->id, $form_ids ); + $this->assertEquals( $form->id, $form_ids[ $entry->id ]->form_id ); + } +} diff --git a/tests/phpunit/stripe/test_FrmTransLiteSubscription.php b/tests/phpunit/stripe/test_FrmTransLiteSubscription.php new file mode 100644 index 0000000000..cf629d4746 --- /dev/null +++ b/tests/phpunit/stripe/test_FrmTransLiteSubscription.php @@ -0,0 +1,118 @@ +upgrade(); + } + + /** + * Creates a subscription row and returns its id. + * + * @param array $values Values to override the defaults. + * + * @return int + */ + private function create_subscription( $values = array() ) { + $subscription = new FrmTransLiteSubscription(); + $defaults = array( + 'sub_id' => 'sub_test', + 'item_id' => 1, + 'action_id' => 1, + 'amount' => 10.00, + 'first_amount' => 10.00, + 'interval_count' => 1, + 'time_interval' => 'month', + 'fail_count' => 0, + 'end_count' => 9999, + 'next_bill_date' => gmdate( 'Y-m-d', strtotime( '-2 days' ) ), + 'status' => 'active', + 'paysys' => 'stripe', + 'created_at' => gmdate( 'Y-m-d H:i:s' ), + ); + + return $subscription->create( array_merge( $defaults, $values ) ); + } + + /** + * @covers FrmTransLiteSubscription::get_overdue_subscriptions + */ + public function test_get_overdue_subscriptions() { + $overdue_active_id = $this->create_subscription(); + $overdue_cancel_id = $this->create_subscription( array( 'status' => 'future_cancel' ) ); + $failed_id = $this->create_subscription( array( 'fail_count' => 3 ) ); + $future_id = $this->create_subscription( array( 'next_bill_date' => gmdate( 'Y-m-d', strtotime( '+2 days' ) ) ) ); + $canceled_id = $this->create_subscription( array( 'status' => 'canceled' ) ); + + $this->assertGreaterThan( 0, $overdue_active_id ); + + $subscription = new FrmTransLiteSubscription(); + $overdue_ids = array_map( 'intval', wp_list_pluck( $subscription->get_overdue_subscriptions(), 'id' ) ); + + $this->assertContains( $overdue_active_id, $overdue_ids ); + $this->assertContains( $overdue_cancel_id, $overdue_ids ); + $this->assertNotContains( $failed_id, $overdue_ids ); + $this->assertNotContains( $future_id, $overdue_ids ); + $this->assertNotContains( $canceled_id, $overdue_ids ); + } + + /** + * @covers FrmTransLiteDb::get_one + * @covers FrmTransLiteDb::get_one_by + * @covers FrmTransLiteDb::get_all_by + * @covers FrmTransLiteDb::get_count + * @covers FrmTransLiteDb::update + * @covers FrmTransLiteDb::destroy + */ + public function test_subscription_crud() { + $subscription = new FrmTransLiteSubscription(); + $id = $this->create_subscription( + array( + 'sub_id' => 'sub_crud', + 'item_id' => 42, + ) + ); + + $this->assertGreaterThan( 0, $id ); + + $row = $subscription->get_one( $id ); + $this->assertSame( 'sub_crud', $row->sub_id ); + $this->assertSame( 'active', $row->status ); + + $row = $subscription->get_one_by( 'sub_crud', 'sub_id' ); + $this->assertEquals( $id, $row->id ); + + $rows = $subscription->get_all_by( 42, 'item_id' ); + $this->assertCount( 1, $rows ); + $this->assertEquals( $id, $rows[0]->id ); + + $this->assertGreaterThanOrEqual( 1, (int) $subscription->get_count() ); + + $subscription->update( $id, array( 'status' => 'canceled' ) ); + $this->assertSame( 'canceled', $subscription->get_one( $id )->status ); + + $this->set_user_by_role( 'administrator' ); + $subscription->destroy( $id ); + $this->assertNull( $subscription->get_one( $id ) ); + } + + /** + * @covers FrmTransLiteDb::get_all_for_user + */ + public function test_get_all_for_user() { + $user_id = $this->factory->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $user_id ); + + $form = $this->factory->form->create_and_get(); + $entry = $this->factory->entry->create_and_get( $this->factory->field->generate_entry_array( $form ) ); + $sub_id = $this->create_subscription( array( 'item_id' => $entry->id ) ); + $subscription = new FrmTransLiteSubscription(); + $rows = $subscription->get_all_for_user( $entry->user_id ); + + $this->assertContains( $sub_id, array_map( 'intval', wp_list_pluck( $rows, 'id' ) ) ); + } +} diff --git a/tests/phpunit/stripe/test_FrmTransLiteSubscriptionsController.php b/tests/phpunit/stripe/test_FrmTransLiteSubscriptionsController.php new file mode 100644 index 0000000000..872a350fbc --- /dev/null +++ b/tests/phpunit/stripe/test_FrmTransLiteSubscriptionsController.php @@ -0,0 +1,33 @@ +factory->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $user_id ); + + $form = $this->factory->form->create_and_get(); + $entry = $this->factory->entry->create_and_get( $this->factory->field->generate_entry_array( $form ) ); + + $sub = (object) array( + 'id' => 5, + 'item_id' => $entry->id, + 'status' => 'active', + 'paysys' => 'stripe', + ); + + ob_start(); + FrmTransLiteSubscriptionsController::show_cancel_link( $sub ); + $output = ob_get_clean(); + + $this->assertTrue( property_exists( $sub, 'user_id' ) ); + $this->assertEquals( $entry->user_id, $sub->user_id ); + $this->assertNotEmpty( $output ); + } +} diff --git a/tests/phpunit/styles/test_FrmStyle.php b/tests/phpunit/styles/test_FrmStyle.php index 5c8bb689b8..29446f8b06 100644 --- a/tests/phpunit/styles/test_FrmStyle.php +++ b/tests/phpunit/styles/test_FrmStyle.php @@ -156,4 +156,38 @@ private function trim_braces( $value ) { $frm_style = new FrmStyle(); return $this->run_private_method( array( $frm_style, 'trim_braces' ), array( $value ) ); } + + /** + * @covers FrmStyle::get_all + */ + public function test_get_all() { + $frm_style = new FrmStyle(); + $styles = $frm_style->get_all(); + + $this->assertNotEmpty( $styles ); + + foreach ( $styles as $style ) { + $this->assertSame( FrmStylesController::$post_type, $style->post_type ); + } + + // With every style removed, a new default style is created on the fly. + $style_posts = get_posts( + array( + 'post_type' => FrmStylesController::$post_type, + 'post_status' => 'any', + 'numberposts' => -1, + ) + ); + + foreach ( $style_posts as $style_post ) { + wp_delete_post( $style_post->ID, true ); + } + + wp_cache_flush(); + + $styles = $frm_style->get_all(); + + $this->assertNotEmpty( $styles ); + $this->assertSame( FrmStylesController::$post_type, reset( $styles )->post_type ); + } }