From e4301e7496e5920c2f335b3f029a7f4ebd241821 Mon Sep 17 00:00:00 2001 From: Steve Jones Date: Sun, 4 Jan 2026 01:02:00 -0500 Subject: [PATCH 1/4] Fix: update database schema to use selector as unique identifier for issues --- accessibility-checker.php | 2 +- admin/class-ajax.php | 16 +++++--- admin/class-insert-rule-data.php | 21 +++++++--- admin/class-update-database.php | 29 +++++++++++++ tests/phpunit/Admin/InsertRuleDataTest.php | 47 ++++++++++++++++++++++ 5 files changed, 104 insertions(+), 11 deletions(-) diff --git a/accessibility-checker.php b/accessibility-checker.php index 4211547a8..d1c7c8c90 100755 --- a/accessibility-checker.php +++ b/accessibility-checker.php @@ -41,7 +41,7 @@ // Current database version. if ( ! defined( 'EDAC_DB_VERSION' ) ) { - define( 'EDAC_DB_VERSION', '1.0.4' ); + define( 'EDAC_DB_VERSION', '1.0.5' ); } // Plugin Folder Path. diff --git a/admin/class-ajax.php b/admin/class-ajax.php index 91c68b863..40bb2961f 100644 --- a/admin/class-ajax.php +++ b/admin/class-ajax.php @@ -739,20 +739,26 @@ function ( $value ) { $ignre_comment = ( 'enable' === $action && isset( $_REQUEST['comment'] ) ) ? sanitize_textarea_field( wp_unslash( $_REQUEST['comment'] ) ) : null; $ignore_global = ( 'enable' === $action && isset( $_REQUEST['ignore_global'] ) ) ? sanitize_textarea_field( wp_unslash( $_REQUEST['ignore_global'] ) ) : 0; - // If largeBatch is set and 'true', we need to perform an update using the 'object' + // If largeBatch is set and 'true', we need to perform an update using the 'selector' // instead of IDs. It is a much less efficient query than by IDs - but many IDs run // into request size limits which caused this to not function at all. + // We use selector as the unique identifier instead of object to properly handle + // duplicate code objects in different locations. if ( isset( $_REQUEST['largeBatch'] ) && 'true' === $_REQUEST['largeBatch'] ) { - // Get the 'object' from the first id. + // Get the 'selector' and 'rule' from the first id. $first_id = $ids[0]; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- We need to get the latest value, not a cached value. - $object = $wpdb->get_var( $wpdb->prepare( 'SELECT object FROM %i WHERE id = %d', $table_name, $first_id ) ); + $row = $wpdb->get_row( $wpdb->prepare( 'SELECT selector, rule FROM %i WHERE id = %d', $table_name, $first_id ), ARRAY_A ); - if ( ! $object ) { + if ( ! $row || ! $row['selector'] ) { wp_send_json_error( new \WP_Error( '-2', __( 'No ignore data to return', 'accessibility-checker' ) ) ); } + + $selector = $row['selector']; + $rule = $row['rule']; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Safe variable used for table name, caching not required for one time operation. - $wpdb->query( $wpdb->prepare( 'UPDATE %i SET ignre = %d, ignre_user = %d, ignre_date = %s, ignre_comment = %s, ignre_global = %d WHERE siteid = %d and object = %s', $table_name, $ignre, $ignre_user, $ignre_date, $ignre_comment, $ignore_global, $siteid, $object ) ); + $wpdb->query( $wpdb->prepare( 'UPDATE %i SET ignre = %d, ignre_user = %d, ignre_date = %s, ignre_comment = %s, ignre_global = %d WHERE siteid = %d and selector = %s and rule = %s', $table_name, $ignre, $ignre_user, $ignre_date, $ignre_comment, $ignore_global, $siteid, $selector, $rule ) ); } else { // For small batches of IDs, we can just loop through. foreach ( $ids as $id ) { diff --git a/admin/class-insert-rule-data.php b/admin/class-insert-rule-data.php index bccc97703..31a42d008 100644 --- a/admin/class-insert-rule-data.php +++ b/admin/class-insert-rule-data.php @@ -12,6 +12,14 @@ /** * Class for inserting rule data into the database * + * The unique identifier for issues changed in version 1.0.5 of the database schema. + * Previously, issues were identified by: postid + rule + object + type + siteid + * Now, issues are identified by: postid + rule + selector + type + siteid + * + * This change allows duplicate code objects (e.g., two empty paragraphs) to be + * stored as separate issues when they appear in different locations on the page. + * The selector field provides the unique location identifier for each issue. + * * @since 1.10.0 */ class Insert_Rule_Data { @@ -73,15 +81,17 @@ public function insert( object $post, string $rule, string $ruletype, string $ru } // Check if exists. + // Use selector as the unique identifier instead of object to allow duplicate code objects + // with different selectors (e.g., two empty paragraphs in different locations). // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Using direct query for adding data to database, caching not required for one time operation. $results = $wpdb->get_results( $wpdb->prepare( - 'SELECT postid, ignre FROM %i where type = %s and postid = %d and rule = %s and object = %s and siteid = %d', + 'SELECT postid, ignre FROM %i where type = %s and postid = %d and rule = %s and selector = %s and siteid = %d', $table_name, $rule_data['type'], $rule_data['postid'], $rule_data['rule'], - $rule_data['object'], + $rule_data['selector'], $rule_data['siteid'] ), ARRAY_A @@ -97,22 +107,23 @@ public function insert( object $post, string $rule, string $ruletype, string $ru } // update existing record. + // Use selector for WHERE clause instead of object to match on unique identifier. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Using direct query for adding data to database, caching not required for one time operation. $wpdb->query( $wpdb->prepare( - 'UPDATE %i SET recordcheck = %d, landmark = %s, landmark_selector = %s, selector = %s, ancestry = %s, xpath = %s, ignre = %d WHERE siteid = %d and postid = %d and rule = %s and object = %s and type = %s', + 'UPDATE %i SET recordcheck = %d, landmark = %s, landmark_selector = %s, object = %s, ancestry = %s, xpath = %s, ignre = %d WHERE siteid = %d and postid = %d and rule = %s and selector = %s and type = %s', $table_name, 1, $rule_data['landmark'], $rule_data['landmark_selector'], - $rule_data['selector'], + $rule_data['object'], $rule_data['ancestry'], $rule_data['xpath'], $rule_data['ignre'], $rule_data['siteid'], $rule_data['postid'], $rule_data['rule'], - $rule_data['object'], + $rule_data['selector'], $rule_data['type'] ) ); diff --git a/admin/class-update-database.php b/admin/class-update-database.php index 66d932816..7480e3d0d 100644 --- a/admin/class-update-database.php +++ b/admin/class-update-database.php @@ -74,9 +74,38 @@ public function edac_update_database() { require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta( $sql ); + // Run migration for selector-based unique identifiers if upgrading from older versions. + if ( version_compare( $db_version, '1.0.5', '<' ) ) { + $this->migrate_to_selector_based_unique_id(); + } } // Update database version option. update_option( 'edac_db_version', sanitize_text_field( EDAC_DB_VERSION ) ); } + + /** + * Migrate existing records to use selector-based unique identifiers. + * + * This migration handles records that were created before the selector field + * was used as the unique identifier. Records with NULL selectors will have + * a fallback identifier generated based on their ID to ensure uniqueness. + * + * @since 1.0.5 + * @return void + */ + private function migrate_to_selector_based_unique_id() { + global $wpdb; + $table_name = $wpdb->prefix . 'accessibility_checker'; + + // Find records with NULL or empty selectors and update them with a fallback value. + // Using the record ID ensures each record has a unique selector for backward compatibility. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time migration query. + $wpdb->query( + $wpdb->prepare( + "UPDATE %i SET selector = CONCAT('legacy-id-', id) WHERE selector IS NULL OR selector = ''", + $table_name + ) + ); + } } diff --git a/tests/phpunit/Admin/InsertRuleDataTest.php b/tests/phpunit/Admin/InsertRuleDataTest.php index 1ce42dd59..23fc6efd3 100644 --- a/tests/phpunit/Admin/InsertRuleDataTest.php +++ b/tests/phpunit/Admin/InsertRuleDataTest.php @@ -83,4 +83,51 @@ public function testRuleInserterReturnLogic() { $current_row_count = $wpdb->get_var( "SELECT COUNT(*) FROM $this->table_name" ); // phpcs:ignore WordPress.DB -- caching not required for one time operation. $this->assertEquals( $initial_row_count + 1, $current_row_count ); } + + /** + * Tests that duplicate objects with different selectors are stored separately. + * This test verifies the fix for the issue where duplicate code objects + * (like two empty paragraphs) were only flagged as one issue. + */ + public function testDuplicateObjectsWithDifferentSelectors() { + $post = $this->factory()->post->create_and_get(); + $rule = 'empty_paragraph_tag'; + $ruletype = 'warning'; + $rule_obj = '

'; + + global $wpdb; + + $rule_inserter = new Insert_Rule_Data(); + $initial_row_count = $wpdb->get_var( "SELECT COUNT(*) FROM $this->table_name" ); // phpcs:ignore WordPress.DB -- caching not required for one time operation. + + // Insert first empty paragraph with selector 1. + $selectors_1 = [ + 'selector' => [ 'div.content > p:nth-child(1)' ], + 'ancestry' => [ 'div.content', 'p' ], + 'xpath' => [ '/html/body/div/p[1]' ], + ]; + $result_1 = $rule_inserter->insert( $post, $rule, $ruletype, $rule_obj, null, null, $selectors_1 ); + $this->assertIsInt( $result_1 ); + + // Insert second empty paragraph with different selector - should be stored as separate issue. + $selectors_2 = [ + 'selector' => [ 'div.content > p:nth-child(5)' ], + 'ancestry' => [ 'div.content', 'p' ], + 'xpath' => [ '/html/body/div/p[5]' ], + ]; + $result_2 = $rule_inserter->insert( $post, $rule, $ruletype, $rule_obj, null, null, $selectors_2 ); + $this->assertIsInt( $result_2 ); + + // Verify two separate records were created. + $current_row_count = $wpdb->get_var( "SELECT COUNT(*) FROM $this->table_name" ); // phpcs:ignore WordPress.DB -- caching not required for one time operation. + $this->assertEquals( $initial_row_count + 2, $current_row_count ); + + // Verify inserting the same object with same selector is treated as duplicate. + $result_3 = $rule_inserter->insert( $post, $rule, $ruletype, $rule_obj, null, null, $selectors_1 ); + $this->assertEquals( null, $result_3 ); + + // Verify row count hasn't changed. + $final_row_count = $wpdb->get_var( "SELECT COUNT(*) FROM $this->table_name" ); // phpcs:ignore WordPress.DB -- caching not required for one time operation. + $this->assertEquals( $initial_row_count + 2, $final_row_count ); + } } From 1cf7688e5dfaacb536d3f68017a4eed80eea18f4 Mon Sep 17 00:00:00 2001 From: Steve Jones Date: Tue, 6 Jan 2026 19:20:04 -0500 Subject: [PATCH 2/4] Updated: comment on add_ignore query for better clearity --- admin/class-ajax.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/admin/class-ajax.php b/admin/class-ajax.php index 40bb2961f..356889dae 100644 --- a/admin/class-ajax.php +++ b/admin/class-ajax.php @@ -760,7 +760,8 @@ function ( $value ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Safe variable used for table name, caching not required for one time operation. $wpdb->query( $wpdb->prepare( 'UPDATE %i SET ignre = %d, ignre_user = %d, ignre_date = %s, ignre_comment = %s, ignre_global = %d WHERE siteid = %d and selector = %s and rule = %s', $table_name, $ignre, $ignre_user, $ignre_date, $ignre_comment, $ignore_global, $siteid, $selector, $rule ) ); } else { - // For small batches of IDs, we can just loop through. + // Small-batch updates use explicit IDs for efficiency. IDs are unique and avoid + // selector collisions, making selector-based updates unnecessary here. foreach ( $ids as $id ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Safe variable used for table name, caching not required for one time operation. $wpdb->query( $wpdb->prepare( 'UPDATE %i SET ignre = %d, ignre_user = %d, ignre_date = %s, ignre_comment = %s, ignre_global = %d WHERE siteid = %d and id = %d', $table_name, $ignre, $ignre_user, $ignre_date, $ignre_comment, $ignore_global, $siteid, $id ) ); From 86da581f6ca873f4d59ec0da84d03ba790aae31a Mon Sep 17 00:00:00 2001 From: pattonwebz Date: Wed, 21 Jan 2026 14:20:42 +0000 Subject: [PATCH 3/4] Remove some custom handling for empty_paragraph_tag that made them unique This is no longer required as we have a new method of determining unique items. You can test it by pasting 2 or more empty paragraph tags on a page and seeing them both be stored. Removing this code also ensures a new test that was added is actually validating the real stored values --- includes/classes/class-rest-api.php | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/includes/classes/class-rest-api.php b/includes/classes/class-rest-api.php index 72d812e1c..70316a225 100644 --- a/includes/classes/class-rest-api.php +++ b/includes/classes/class-rest-api.php @@ -264,15 +264,7 @@ public function clear_issues_for_post( $request ) { * * @return string */ - public function filter_js_validation_html( string $html, string $rule_id, array $violation ): string { - // Add the selector to the violation message as empty paragraphs are almost always - // duplicate html fragments. Adding the selector makes it unique, so it can be saved. - if ( 'empty_paragraph_tag' === $rule_id ) { - $html .= $violation['selector'][0] - ? '// {{ ' . $violation['selector'][0] . ' }}' - : ''; - } - + public function filter_js_validation_html( string $html, string $rule_id, array $violation ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- the variable was used previously and will be used in future most likely. // Use just the opening and closing tag, prevents storing entire page as the affected code. if ( 'html-has-lang' === $rule_id || 'document-title' === $rule_id ) { $html = preg_replace( '/^.*().*(<\/html>).*$/s', '$1...$2', $html ); From 6fcb88f5f3f55970970517041e83031547f26297 Mon Sep 17 00:00:00 2001 From: pattonwebz Date: Thu, 22 Jan 2026 14:31:00 +0000 Subject: [PATCH 4/4] Reset the class-ajax file back to what was previously here The changes here didn't allow large batch ignores to work - those need to continue working on object, not selector --- admin/class-ajax.php | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/admin/class-ajax.php b/admin/class-ajax.php index 356889dae..91c68b863 100644 --- a/admin/class-ajax.php +++ b/admin/class-ajax.php @@ -739,29 +739,22 @@ function ( $value ) { $ignre_comment = ( 'enable' === $action && isset( $_REQUEST['comment'] ) ) ? sanitize_textarea_field( wp_unslash( $_REQUEST['comment'] ) ) : null; $ignore_global = ( 'enable' === $action && isset( $_REQUEST['ignore_global'] ) ) ? sanitize_textarea_field( wp_unslash( $_REQUEST['ignore_global'] ) ) : 0; - // If largeBatch is set and 'true', we need to perform an update using the 'selector' + // If largeBatch is set and 'true', we need to perform an update using the 'object' // instead of IDs. It is a much less efficient query than by IDs - but many IDs run // into request size limits which caused this to not function at all. - // We use selector as the unique identifier instead of object to properly handle - // duplicate code objects in different locations. if ( isset( $_REQUEST['largeBatch'] ) && 'true' === $_REQUEST['largeBatch'] ) { - // Get the 'selector' and 'rule' from the first id. + // Get the 'object' from the first id. $first_id = $ids[0]; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- We need to get the latest value, not a cached value. - $row = $wpdb->get_row( $wpdb->prepare( 'SELECT selector, rule FROM %i WHERE id = %d', $table_name, $first_id ), ARRAY_A ); + $object = $wpdb->get_var( $wpdb->prepare( 'SELECT object FROM %i WHERE id = %d', $table_name, $first_id ) ); - if ( ! $row || ! $row['selector'] ) { + if ( ! $object ) { wp_send_json_error( new \WP_Error( '-2', __( 'No ignore data to return', 'accessibility-checker' ) ) ); } - - $selector = $row['selector']; - $rule = $row['rule']; - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Safe variable used for table name, caching not required for one time operation. - $wpdb->query( $wpdb->prepare( 'UPDATE %i SET ignre = %d, ignre_user = %d, ignre_date = %s, ignre_comment = %s, ignre_global = %d WHERE siteid = %d and selector = %s and rule = %s', $table_name, $ignre, $ignre_user, $ignre_date, $ignre_comment, $ignore_global, $siteid, $selector, $rule ) ); + $wpdb->query( $wpdb->prepare( 'UPDATE %i SET ignre = %d, ignre_user = %d, ignre_date = %s, ignre_comment = %s, ignre_global = %d WHERE siteid = %d and object = %s', $table_name, $ignre, $ignre_user, $ignre_date, $ignre_comment, $ignore_global, $siteid, $object ) ); } else { - // Small-batch updates use explicit IDs for efficiency. IDs are unique and avoid - // selector collisions, making selector-based updates unnecessary here. + // For small batches of IDs, we can just loop through. foreach ( $ids as $id ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Safe variable used for table name, caching not required for one time operation. $wpdb->query( $wpdb->prepare( 'UPDATE %i SET ignre = %d, ignre_user = %d, ignre_date = %s, ignre_comment = %s, ignre_global = %d WHERE siteid = %d and id = %d', $table_name, $ignre, $ignre_user, $ignre_date, $ignre_comment, $ignore_global, $siteid, $id ) );