PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.8
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.8
2.12.8 2.12.7 2.12.6 2.12.5 2.12.4 2.12.3 2.12.2 2.12.1 2.12.0 2.11.1 2.11.0 2.10.1 2.10.0 2.9.1 2.9.0 2.8.2 2.8.1 2.7.0 2.7.1 2.8.0 trunk 0.0.10 0.0.11 0.0.12 0.0.13 All 98 releases
← All changes | inc/database/base.php +508 -67 0.0.13 → 2.12.8 View file →
@@ -20,9 +20,8 @@
20 20 *
21 21 * @since 0.0.10
22 22 */
23 23 abstract class Base {
24 -
25 24 /**
26 25 * WordPress Database class instance.
27 26 *
28 27 * @var \wpdb
@@ -70,9 +69,9 @@
70 69 /**
71 70 * Whether or not the current database table is upgradable.
72 71 * Determines on the basis of the table version.
73 72 *
74 - * @var boolean
73 + * @var bool
75 74 * @since 0.0.13
76 75 */
77 76 private $db_upgradable;
78 77
@@ -84,8 +83,16 @@
84 83 */
85 84 private $caches = [];
86 85
87 86 /**
87 + * Allowed operators for the database.
88 + *
89 + * @var array<string>
90 + * @since 1.8.0
91 + */
92 + private $allowed_where_operators = [ 'LIKE', 'IN', 'NOT IN', '=', '!=', '>', '<', '>=', '<=' ];
93 +
94 + /**
88 95 * Init class.
89 96 *
90 97 * @since 0.0.10
91 98 * @return void
@@ -143,14 +150,14 @@
143 150 /**
144 151 * Array of columns that needs to be renamed to new column name. It will be used by maybe_rename_columns() method.
145 152 * Format:
146 153 * [
147 - [
148 - 'from' => 'old_column_name',
149 - 'to' => 'new_column_name',
150 - 'type' => 'column type definition eg: LONGTEXT', // Optional.
151 - ],
152 - ]
154 + * [
155 + * 'from' => 'old_column_name',
156 + * 'to' => 'new_column_name',
157 + * 'type' => 'column type definition eg: LONGTEXT', // Optional.
158 + * ],
159 + * ]
153 160 *
154 161 * @since 0.0.13
155 162 * @return array<array<string,string>>
156 163 */
@@ -183,9 +190,9 @@
183 190 /**
184 191 * Stop the database upgrade process.
185 192 *
186 193 * @since 0.0.13
187 - * @return boolean Returns true on success.
194 + * @return bool Returns true on success.
188 195 */
189 196 public function stop_db_upgrade() {
190 197 if ( ! $this->db_upgradable ) {
191 198 // Only upgrade when it is needed.
@@ -204,9 +211,9 @@
204 211 /**
205 212 * Check if current table's DB is upgradable or not.
206 213 *
207 214 * @since 0.0.13
208 - * @return boolean True or false depending if DB is upgradable or not.
215 + * @return bool True or false depending if DB is upgradable or not.
209 216 */
210 217 public function is_db_upgradable() {
211 218 return $this->db_upgradable;
212 219 }
@@ -221,47 +228,194 @@
221 228 return $this->table_name;
222 229 }
223 230
224 231 /**
225 - * Retrieve a cached value by its key.
232 + * Whether this table currently exists in the database.
226 233 *
227 - * @param string $key The cache key.
228 - * @since 0.0.10
229 - * @return mixed|null The cached value if it exists, or null if the key does not exist in the cache.
234 + * Deliberately `SHOW TABLES LIKE` rather than the existing get_columns():
235 + * `SHOW COLUMNS FROM <missing table>` is a MySQL error, so it pollutes
236 + * $wpdb->last_error, prints under WP_DEBUG_DISPLAY, and cannot tell "the table
237 + * is gone" apart from "SHOW is denied". This returns a clean empty set instead.
238 + *
239 + * esc_like() matters because $wpdb->prefix contains `_`, which is a LIKE
240 + * wildcard — without it `wp_srfm_entries` would also match `wpXsrfm_entries`.
241 + * The comparison is against the real, unescaped name so the match stays exact.
242 + *
243 + * Fails safe: any DB-level error reports the table as present. A false "your
244 + * database needs updating" on a transient connection blip is worse than a
245 + * missed one, because the notice it drives asks the user to alter their schema.
246 + *
247 + * @since 2.12.6
248 + * @return bool True when the table exists, or when existence cannot be determined.
230 249 */
231 - protected function cache_get( $key ) {
232 - $key = md5( $key );
233 - if ( ! isset( $this->caches[ $key ] ) ) {
234 - return null;
250 + public function table_exists() {
251 + $wpdb = $this->wpdb;
252 + $table = $this->get_tablename();
253 +
254 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema lookup; the caller owns caching, and a cached answer here would defeat the check.
255 + $found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) );
256 +
257 + if ( ! empty( $wpdb->last_error ) ) {
258 + return true;
235 259 }
236 - return $this->caches[ $key ];
260 +
261 + return $found === $table;
237 262 }
238 263
239 264 /**
240 - * Store a value in the cache with the specified key.
265 + * A table holding this table's data under a different prefix, if there is one.
241 266 *
242 - * @param string $key The cache key.
243 - * @param mixed $value The value to store in the cache.
244 - * @since 0.0.10
245 - * @return mixed The stored value.
267 + * Changing `$table_prefix` — a manual edit, a restored dump from a site with a
268 + * different prefix, or a security plugin that renames tables and misses the ones
269 + * it does not know about — leaves our data behind under the old name while the
270 + * plugin looks for the new one. Creating a fresh empty table there would strand
271 + * every stored entry, so look for the old one first and adopt it instead.
272 + *
273 + * Refuses to guess. Returns '' unless exactly one credible candidate exists, and
274 + * only when that candidate carries every column this table's schema declares —
275 + * an unrelated table that merely ends in the same words is never touched.
276 + *
277 + * On multisite, other blogs' tables are legitimate and belong to those blogs.
278 + * Anything matching the `{base_prefix}{digits}_` pattern, or the base prefix
279 + * itself, is excluded so a subsite can never adopt another subsite's data.
280 + *
281 + * @since 2.12.6
282 + * @return string Full table name to adopt, or '' when there is nothing safe to adopt.
246 283 */
247 - protected function cache_set( $key, $value ) {
248 - $key = md5( $key );
249 - $this->caches[ $key ] = $value;
250 - return $value;
284 + public function find_adoptable_table() {
285 + $wpdb = $this->wpdb;
286 + $correct = $this->get_tablename();
287 + $needle = 'srfm_' . $this->table_suffix;
288 +
289 + // Wildcard on the left only: the name must *end* at the suffix, so a
290 + // deliberate copy such as `wp_srfm_entries_backup` is never a candidate.
291 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema lookup; a cached answer would defeat the check.
292 + $found = $wpdb->get_col( $wpdb->prepare( 'SHOW TABLES LIKE %s', '%' . $wpdb->esc_like( $needle ) ) );
293 +
294 + if ( ! empty( $wpdb->last_error ) || ! is_array( $found ) ) {
295 + return '';
296 + }
297 +
298 + $base = $wpdb->base_prefix;
299 + $blog_table = '/^' . preg_quote( $base, '/' ) . '\d+_' . preg_quote( $needle, '/' ) . '$/';
300 + $candidates = [];
301 +
302 + foreach ( $found as $table ) {
303 + $table = (string) $table;
304 +
305 + // The table we are looking for, another blog's table, or the network's
306 + // main-site table — none of these are ours to rename.
307 + if ( $table === $correct || $base . $needle === $table || preg_match( $blog_table, $table ) ) {
308 + continue;
309 + }
310 +
311 + $candidates[] = $table;
312 + }
313 +
314 + // More than one and we cannot tell which holds the real data. Refuse rather
315 + // than pick, and let the caller fall back to creating an empty table.
316 + if ( 1 !== count( $candidates ) ) {
317 + return '';
318 + }
319 +
320 + return $this->has_expected_columns( $candidates[0] ) ? $candidates[0] : '';
251 321 }
252 322
253 323 /**
254 - * Reset the cache by clearing all stored values.
324 + * Rename a differently-prefixed table into this table's expected name.
255 325 *
256 - * @since 0.0.10
326 + * RENAME rather than create-and-copy: it is atomic, needs no second copy of the
327 + * data, and cannot half-succeed and leave rows in two places.
328 + *
329 + * @param string $from Full name of the table to adopt.
330 + * @since 2.12.6
331 + * @return bool True when the table is in place afterwards.
332 + */
333 + public function adopt_table( $from ) {
334 + $wpdb = $this->wpdb;
335 + $to = $this->get_tablename();
336 +
337 + if ( empty( $from ) || $from === $to ) {
338 + return false;
339 + }
340 +
341 + // Never rename over an existing table; the one already in place wins.
342 + if ( $this->table_exists() ) {
343 + return true;
344 + }
345 +
346 + $query = $wpdb->prepare( 'RENAME TABLE %1s TO %2s', str_replace( '`', '', $from ), str_replace( '`', '', $to ) ); // phpcs:ignore -- Same complex-placeholder pattern as create(): identifiers must not be quoted, and both names come from SHOW TABLES / $wpdb->prefix.
347 +
348 + if ( ! $query ) {
349 + // prepare() returned nothing usable; do not fall through to a raw query.
350 + return false;
351 + }
352 +
353 + $wpdb->query( $query ); // phpcs:ignore -- We are already using prepare above, and one-off DDL has nothing to cache.
354 +
355 + if ( ! empty( $wpdb->last_error ) ) {
356 + /** This action is documented in inc/database/base.php */
357 + do_action( 'srfm_db_upgrade_query_failed', $wpdb->last_error, 'RENAME TABLE', $to );
358 + }
359 +
360 + return $this->table_exists();
361 + }
362 +
363 + /**
364 + * Stamp this site's owner signature onto a table's MySQL comment.
365 + *
366 + * Best-effort: a host that refuses ALTER simply leaves the table unstamped,
367 + * which later reads as "ownership unproven" — the safe direction.
368 + *
369 + * @param string $table Full table name; defaults to this table's own name.
370 + * @since 2.12.6
257 371 * @return void
258 372 */
259 - protected function cache_reset() {
260 - $this->caches = [];
373 + public function stamp_owner_signature( $table = '' ) {
374 + $wpdb = $this->wpdb;
375 + $table = '' === $table ? $this->get_tablename() : $table;
376 +
377 + $query = $wpdb->prepare( 'ALTER TABLE %1s COMMENT = %s', str_replace( '`', '', $table ), $this->get_owner_signature() ); // phpcs:ignore -- Identifier must not be quoted; the comment value is a bound, quoted string.
378 +
379 + if ( ! $query ) {
380 + return;
381 + }
382 +
383 + $wpdb->query( $query ); // phpcs:ignore -- Prepared above; one-off DDL with nothing to cache.
261 384 }
262 385
263 386 /**
387 + * Whether a table carries this site's owner signature.
388 + *
389 + * Gates adoption: on shared hosting a different install's identically-named,
390 + * same-schema table can be the only candidate, and renaming it in would destroy
391 + * that site's data. Deny by default — anything but an exact signature match
392 + * (including a read error, an empty comment, or a legacy table stamped before
393 + * this plugin wrote signatures) returns false.
394 + *
395 + * @param string $table Full table name to inspect.
396 + * @since 2.12.6
397 + * @return bool
398 + */
399 + public function table_belongs_to_site( $table ) {
400 + $wpdb = $this->wpdb;
401 + $bare = str_replace( '`', '', (string) $table );
402 +
403 + if ( '' === $bare ) {
404 + return false;
405 + }
406 +
407 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema lookup; a cached answer would defeat the check.
408 + $comment = $wpdb->get_var( $wpdb->prepare( 'SELECT TABLE_COMMENT FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $bare ) );
409 +
410 + if ( ! empty( $wpdb->last_error ) || ! is_string( $comment ) || '' === $comment ) {
411 + return false;
412 + }
413 +
414 + return hash_equals( $this->get_owner_signature(), $comment );
415 + }
416 +
417 + /**
264 418 * Conditionally returns current database charset or collate.
265 419 *
266 420 * @since 0.0.10
267 421 * @return string
@@ -318,10 +472,31 @@
318 472
319 473 if ( false === $result ) {
320 474 // Stop DB alteration if we have any error.
321 475 $this->db_upgradable = false;
476 +
477 + /**
478 + * Fires when a table could not be created.
479 + *
480 + * Column changes have announced their failures since 2.11.0 but table
481 + * creation never did — so the one failure that leaves a site with no
482 + * table at all, a host denying CREATE TABLE, was the only silent one.
483 + * Same signature as the ALTER case so one listener can handle both.
484 + *
485 + * @param string $last_error The database error.
486 + * @param string $query The query that failed.
487 + * @param string $table_name The table it was for.
488 + * @since 2.12.6
489 + */
490 + do_action( 'srfm_db_upgrade_query_failed', $wpdb->last_error, $query, $this->get_tablename() );
322 491 }
323 492
493 + if ( false !== $result ) {
494 + // Stamp our own table so a future adoption can prove it belongs to this
495 + // site before renaming it in. See stamp_owner_signature().
496 + $this->stamp_owner_signature();
497 + }
498 +
324 499 return $result;
325 500 }
326 501
327 502 /**
@@ -428,9 +603,9 @@
428 603 continue;
429 604 }
430 605
431 606 preg_match( '/(\w+)\s/', $column_definition, $column_matches );
432 - $column_name = $column_matches[1];
607 + $column_name = $column_matches[1] ?? '';
433 608
434 609 // If the column does not exist, add it.
435 610 if ( ! isset( $existing_columns[ $column_name ] ) ) {
436 611 $alter_queries[] = $wpdb->prepare( 'ADD COLUMN %1s', $column_definition ); // phpcs:ignore -- We don't need quote here.
@@ -438,9 +613,9 @@
438 613 }
439 614
440 615 if ( $alter_queries ) {
441 616 $query = $wpdb->prepare(
442 - "ALTER TABLE %1s %2s", // phpcs:ignore -- We don't want to quote the value strings for the query.
617 + 'ALTER TABLE %1s %2s', // phpcs:ignore -- We don't want to quote the value strings for the query.
443 618 $this->get_tablename(),
444 619 implode( ', ', $alter_queries )
445 620 );
446 621
@@ -452,10 +627,22 @@
452 627 // Execute the query.
453 628 $result = $wpdb->query( $query ); // phpcs:ignore -- It is okay. We are already using prepare above and we need to do DB query directly here.
454 629
455 630 if ( false === $result ) {
456 - // Stop DB alteration if we have any error.
631 + // Stop DB alteration if we have any error. A failed ALTER leaves the table
632 + // version un-bumped, so it retries on every request — expose the underlying
633 + // error so persistent failures are diagnosable (hook for logging/monitoring).
457 634 $this->db_upgradable = false;
635 +
636 + /**
637 + * Fires when a SureForms DB schema-upgrade query fails.
638 + *
639 + * @since 2.11.0
640 + * @param string $last_error The DB error message ( $wpdb->last_error ).
641 + * @param string $query The ALTER query that failed.
642 + * @param string $table The table being altered.
643 + */
644 + do_action( 'srfm_db_upgrade_query_failed', $this->wpdb->last_error, $query, $this->get_tablename() );
458 645 }
459 646
460 647 return $result;
461 648 }
@@ -499,9 +686,9 @@
499 686 */
500 687 public function get_indexes() {
501 688 $wpdb = $this->wpdb;
502 689
503 - $indexes = $wpdb->get_results( $wpdb->prepare( "SHOW INDEX FROM %1s", $this->get_tablename() ), ARRAY_A ); // phpcs:ignore -- We don't need quote here so this is fine.
690 + $indexes = $wpdb->get_results( $wpdb->prepare( 'SHOW INDEX FROM %1s', $this->get_tablename() ), ARRAY_A ); // phpcs:ignore -- We don't need quote here so this is fine.
504 691
505 692 if ( empty( $indexes ) ) {
506 693 return [];
507 694 }
@@ -527,9 +714,9 @@
527 714 * A format is one of '%d', '%f', '%s' (integer, float, string).
528 715 * If omitted, all values in `$data` will be treated as strings unless otherwise
529 716 * specified in wpdb::$field_types. Default null.
530 717 * @since 0.0.10
531 - * @return int|false The number of rows inserted, or false on error.
718 + * @return int|false The id of the inserted entry, or false on error.
532 719 */
533 720 public function use_insert( $data, $format = null ) {
534 721 $prepared_data = $this->prepare_data( $data );
535 722
@@ -536,14 +723,19 @@
536 723 if ( is_null( $format ) ) {
537 724 /**
538 725 * Use formats from schema if not provided explicitly.
539 726 *
540 - * @var array<string>|string|null
727 + * @var array<string>|string|null $format Format specifier for the data.
541 728 */
542 729 $format = $prepared_data['format'];
543 730 }
544 731
545 - return $this->wpdb->insert( $this->get_tablename(), $prepared_data['data'], $format );
732 + $result = $this->wpdb->insert( $this->get_tablename(), $prepared_data['data'], $format );
733 +
734 + // Reset cache so subsequent queries in the same request include the new row.
735 + $this->cache_reset();
736 +
737 + return $result ? $this->wpdb->insert_id : false;
546 738 }
547 739
548 740 /**
549 741 * Update a row data of current table. Basically, a wrapper method for wpdb::update.
@@ -565,15 +757,18 @@
565 757
566 758 /**
567 759 * Data format specifier.
568 760 *
569 - * @var array<string>|string|null
761 + * @var array<string>|string|null $format Format specifier for the data.
570 762 */
571 763 $format = $prepared_data['format'];
572 764
765 + // Reset the cache on update.
766 + $this->cache_reset();
767 +
573 768 return $this->wpdb->update(
574 769 $this->get_tablename(),
575 - $data,
770 + $prepared_data['data'],
576 771 $where,
577 772 $format
578 773 );
579 774 }
@@ -580,23 +775,28 @@
580 775
581 776 /**
582 777 * Delete a row data of current table. Basically, a wrapper method for wpdb::delete.
583 778 *
584 - * @param array<string,mixed> $where A named array of WHERE clauses (in column => value pairs).
585 - * Multiple clauses will be joined with ANDs.
586 - * Both $where columns and $where values should be "raw".
587 - * Sending a null value will create an IS NULL comparison - the corresponding
588 - * format will be ignored in this case.
589 - * @param string[]|string $where_format Optional. An array of formats to be mapped to each of the values in $where.
590 - * If string, that format will be used for all of the items in $where.
591 - * A format is one of '%d', '%f', '%s' (integer, float, string).
592 - * If omitted, all values in $data will be treated as strings unless otherwise
593 - * specified in wpdb::$field_types. Default null.
779 + * @param array<string,mixed> $where A named array of WHERE clauses (in column => value pairs).
780 + * Multiple clauses will be joined with ANDs.
781 + * Both $where columns and $where values should be "raw".
782 + * Sending a null value will create an IS NULL comparison - the corresponding
783 + * format will be ignored in this case.
784 + * @param array<string>|string $where_format Optional. An array of formats to be mapped to each of the values in $where.
785 + * If string, that format will be used for all of the items in $where.
786 + * A format is one of '%d', '%f', '%s' (integer, float, string).
787 + * If omitted, all values in $data will be treated as strings unless otherwise
788 + * specified in wpdb::$field_types. Default null.
594 789 * @since 0.0.13
595 790 * @return int|false The number of rows deleted, or false on error.
596 791 */
597 792 public function use_delete( $where, $where_format = null ) {
598 - return $this->wpdb->delete( $this->get_tablename(), $where, $where_format );
793 + $result = $this->wpdb->delete( $this->get_tablename(), $where, $where_format );
794 +
795 + // Reset cache so subsequent queries in the same request exclude the deleted row.
796 + $this->cache_reset();
797 +
798 + return $result;
599 799 }
600 800
601 801 /**
602 802 * Retrieve results from the database based on the given WHERE clauses and selected columns.
@@ -610,9 +810,9 @@
610 810 * Example: ['column1' => 'value1', 'column2' => ['value2', 'value3']].
611 811 * Default is an empty array.
612 812 * @param string $columns Optional. A string specifying which columns to select. Defaults to '*' (all columns).
613 813 * @param array<string> $extra_queries Optional. Array of extra queries to append at the end of main query.
614 - * @param boolean $decode Optional. Whether to decode the results by datatype. Default is true.
814 + * @param bool $decode Optional. Whether to decode the results by datatype. Default is true.
615 815 * @since 0.0.10
616 816 * @return array<mixed> An associative array of results where each element represents a row, or an empty array if no results are found.
617 817 */
618 818 public function get_results( $where_clauses = [], $columns = '*', $extra_queries = [], $decode = true ) {
@@ -633,10 +833,12 @@
633 833 // Add a semicolon at the end of the query.
634 834 $query = rtrim( trim( $query ), ';' ) . ';';
635 835
636 836 $cached_results = $this->cache_get( $query );
637 - if ( $cached_results ) {
638 - // Return the cached data if exists.
837 + if ( null !== $cached_results ) {
838 + // Return the cached data if exists. Tested against null rather than
839 + // truthiness: an empty result set is a real answer, and re-running the
840 + // query for it means every no-match lookup runs once per caller.
639 841 return Helper::get_array_value( $cached_results );
640 842 }
641 843
642 844 // phpcs:ignore
@@ -652,8 +854,57 @@
652 854 return Helper::get_array_value( $this->cache_set( $query, $results ) );
653 855 }
654 856
655 857 /**
858 + * Retrieves a list of records based on the provided arguments.
859 + *
860 + * This method fetches results from the database, allowing for various
861 + * customization options such as filtering, pagination, and sorting.
862 + *
863 + * @param array<string,mixed> $args {
864 + * Optional. An array of arguments to customize the query.
865 + *
866 + * @type array $where An associative array of conditions to filter the results.
867 + * @type int $limit The maximum number of results to return. Default is 10.
868 + * @type int $offset The number of records to skip before starting to collect results. Default is 0.
869 + * @type string $orderby The column by which to order the results. Default is 'created_at'.
870 + * @type string $order The direction of the order (ASC or DESC). Default is 'DESC'.
871 + * }
872 + * @param bool $set_limit Whether to set the limit on the query. Default is true.
873 + *
874 + * @since 1.13.0
875 + * @return array<mixed> The results of the query, typically an array of objects or associative arrays.
876 + */
877 + public function get_records_by_args( $args = [], $set_limit = true ) {
878 + $_args = wp_parse_args(
879 + $args,
880 + [
881 + 'where' => [],
882 + 'columns' => '*',
883 + 'limit' => 10,
884 + 'offset' => 0,
885 + 'orderby' => 'created_at',
886 + 'order' => 'DESC',
887 + ]
888 + );
889 + $allowed_orderby = $this->get_allowed_orderby_columns();
890 + $orderby = in_array( $_args['orderby'], $allowed_orderby, true ) ? $_args['orderby'] : 'created_at';
891 + $order = 'ASC' === strtoupper( Helper::get_string_value( $_args['order'] ) ) ? 'ASC' : 'DESC';
892 + $extra_queries = [
893 + sprintf( 'ORDER BY `%1$s` %2$s', $orderby, $order ),
894 + ];
895 +
896 + if ( $set_limit ) {
897 + $extra_queries[] = sprintf( 'LIMIT %1$d, %2$d', absint( $_args['offset'] ), absint( $_args['limit'] ) );
898 + }
899 + return $this->get_results(
900 + $_args['where'],
901 + $_args['columns'],
902 + $extra_queries
903 + );
904 + }
905 +
906 + /**
656 907 * Get the total number of rows in the table.
657 908 *
658 909 * @param array<mixed> $where_clauses Optional. An associative array of WHERE clauses for the SQL query.
659 910 * @since 0.0.13
@@ -673,10 +924,12 @@
673 924 // Add a semicolon at the end of the query.
674 925 $query = rtrim( trim( $query ), ';' ) . ';';
675 926
676 927 $cached_results = $this->cache_get( $query );
677 - if ( $cached_results ) {
678 - // Return the cached data if exists.
928 + if ( null !== $cached_results ) {
929 + // Return the cached data if exists. Tested against null rather than
930 + // truthiness: a count of zero is a real answer, and the editor exclusion
931 + // makes zero the common case rather than the exception.
679 932 return Helper::get_integer_value( $cached_results );
680 933 }
681 934
682 935 // phpcs:ignore
@@ -686,8 +939,109 @@
686 939 return Helper::get_integer_value( $this->cache_set( $query, $results ) );
687 940 }
688 941
689 942 /**
943 + * The signature this plugin stamps on tables it owns on this site.
944 + *
945 + * A random per-site token, generated once and stored in options. Embedded in
946 + * the table's MySQL comment at creation time; the comment survives RENAME, so a
947 + * table that moved under a different prefix still carries it, while an unrelated
948 + * install sharing the same database carries a different one.
949 + *
950 + * @since 2.12.6
951 + * @return string
952 + */
953 + protected function get_owner_signature() {
954 + $token = get_option( 'srfm_db_owner_token' );
955 +
956 + if ( ! is_string( $token ) || '' === $token ) {
957 + $token = wp_generate_password( 20, false );
958 + update_option( 'srfm_db_owner_token', $token, false );
959 + }
960 +
961 + return 'srfm-owner:' . $token;
962 + }
963 +
964 + /**
965 + * Whether a table carries every column this table's schema declares.
966 + *
967 + * Guards adoption: a same-named table from an unrelated source should never be
968 + * renamed into place just because its name matches.
969 + *
970 + * @param string $table Full table name to inspect.
971 + * @since 2.12.6
972 + * @return bool
973 + */
974 + protected function has_expected_columns( $table ) {
975 + $wpdb = $this->wpdb;
976 +
977 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema lookup; a cached answer would defeat the check.
978 + $columns = $wpdb->get_col( $wpdb->prepare( 'SHOW COLUMNS FROM %1s', str_replace( '`', '', $table ) ) ); // phpcs:ignore -- Same complex-placeholder pattern as create(): an identifier must not be quoted, and the name comes from SHOW TABLES on this connection.
979 +
980 + if ( ! empty( $wpdb->last_error ) || ! is_array( $columns ) ) {
981 + return false;
982 + }
983 +
984 + foreach ( array_keys( $this->get_schema() ) as $column ) {
985 + if ( ! in_array( $column, $columns, true ) ) {
986 + return false;
987 + }
988 + }
989 +
990 + return true;
991 + }
992 +
993 + /**
994 + * Get the allowed column names for ORDER BY clauses.
995 + * Child classes may override this method to restrict orderable columns further.
996 + *
997 + * @since 2.6.0
998 + * @return array<string>
999 + */
1000 + protected function get_allowed_orderby_columns() {
1001 + return array_merge( array_keys( $this->get_schema() ), [ 'updated_at' ] );
1002 + }
1003 +
1004 + /**
1005 + * Retrieve a cached value by its key.
1006 + *
1007 + * @param string $key The cache key.
1008 + * @since 0.0.10
1009 + * @return mixed|null The cached value if it exists, or null if the key does not exist in the cache.
1010 + */
1011 + protected function cache_get( $key ) {
1012 + $key = md5( $key );
1013 + if ( ! isset( $this->caches[ $key ] ) ) {
1014 + return null;
1015 + }
1016 + return $this->caches[ $key ];
1017 + }
1018 +
1019 + /**
1020 + * Store a value in the cache with the specified key.
1021 + *
1022 + * @param string $key The cache key.
1023 + * @param mixed $value The value to store in the cache.
1024 + * @since 0.0.10
1025 + * @return mixed The stored value.
1026 + */
1027 + protected function cache_set( $key, $value ) {
1028 + $key = md5( $key );
1029 + $this->caches[ $key ] = $value;
1030 + return $value;
1031 + }
1032 +
1033 + /**
1034 + * Reset the cache by clearing all stored values.
1035 + *
1036 + * @since 0.0.10
1037 + * @return void
1038 + */
1039 + protected function cache_reset() {
1040 + $this->caches = [];
1041 + }
1042 +
1043 + /**
690 1044 * Prepares WHERE clauses for a SQL query based on the provided conditions.
691 1045 *
692 1046 * This method constructs a WHERE statement by iterating through the
693 1047 * specified conditions, appending them with the appropriate SQL syntax.
@@ -706,8 +1060,10 @@
706 1060 * @type string $RELATION Optional. The logical relation ('AND' or 'OR').
707 1061 * }
708 1062 * }
709 1063 *
1064 + * @since 2.12.7 -- Added support for "NOT IN" compare.
1065 + * @since 1.1.1 -- Added support for "IN" compare.
710 1066 * @since 0.0.13
711 1067 * @return string The prepared SQL WHERE clause with placeholders, or an empty string if no clauses were provided.
712 1068 */
713 1069 protected function prepare_where_clauses( $where_clauses = [] ) {
@@ -718,9 +1074,9 @@
718 1074 $wpdb = $this->wpdb;
719 1075
720 1076 // If there are WHERE clauses, prepare and append them to the query.
721 1077 if ( is_array( $where_clauses ) ) {
722 - $where = '';
1078 + $groups = [];
723 1079 $values = [];
724 1080 $schema = $this->get_schema();
725 1081
726 1082 foreach ( $where_clauses as $key => $value ) {
@@ -725,20 +1081,92 @@
725 1081
726 1082 foreach ( $where_clauses as $key => $value ) {
727 1083
728 1084 $relation = ! empty( $value['RELATION'] ) ? trim( $value['RELATION'] ) : 'AND';
1085 + $relation = in_array( strtoupper( $relation ), [ 'AND', 'OR' ], true ) ? strtoupper( $relation ) : 'AND';
729 1086
730 1087 if ( is_int( $key ) ) {
1088 + $clause_parts = [];
731 1089 foreach ( $value as $_key => $_value ) {
732 1090 if ( is_int( $_key ) ) {
733 - if ( 'LIKE' === $_value['compare'] ) {
734 - $where .= ' ' . $_value['key'] . ' ' . $_value['compare'] . ' "%%' . $this->get_format_by_datatype( Helper::get_string_value( $schema[ $_value['key'] ]['type'] ) ) . '%%" ' . $relation;
735 - } else {
736 - $where .= ' ' . $_value['key'] . ' ' . $_value['compare'] . ' ' . $this->get_format_by_datatype( Helper::get_string_value( $schema[ $_value['key'] ]['type'] ) ) . ' ' . $relation;
1091 + // Normalised before the allowlist test. Payments'
1092 + // builder upper-cases and trims, this one compared
1093 + // strictly -- so a caller writing 'not in' was honoured
1094 + // by one and silently dropped by the other. A dropped
1095 + // condition used to be harmless; now that NOT IN is the
1096 + // exclusion primitive, dropping it disables the
1097 + // exclusion without a word.
1098 + $compare = strtoupper( trim( Helper::get_string_value( $_value['compare'] ) ) );
1099 +
1100 + // Check if the operator is allowed.
1101 + if ( ! in_array( $compare, $this->allowed_where_operators, true ) ) {
1102 + continue;
737 1103 }
738 - $values[] = $_value['value'];
1104 +
1105 + // Skip if key is not in schema.
1106 + if ( ! isset( $schema[ $_value['key'] ] ) ) {
1107 + continue;
1108 + }
1109 +
1110 + switch ( $compare ) {
1111 + case 'LIKE':
1112 + // Single quotes to match WP core. Under a MySQL session with
1113 + // ANSI_QUOTES set (not in WP's incompatible_modes list, which
1114 + // only names the compound ANSI mode) a double-quoted pattern
1115 + // parses as an identifier and the query hard-fails, taking out
1116 + // both the listing and its COUNT(*).
1117 + $clause_parts[] = $_value['key'] . ' ' . $compare . " '%%" . $this->get_format_by_datatype( Helper::get_string_value( $schema[ $_value['key'] ]['type'] ) ) . "%%'";
1118 + $values[] = $_value['value'];
1119 + break;
1120 +
1121 + case 'IN':
1122 + case 'NOT IN':
1123 + // A scalar is a caller bug, not an empty set, and it must
1124 + // surface. 'NOT IN' with value 5 -- a plausible typo for
1125 + // [ 5 ] -- would otherwise drop the condition and exclude
1126 + // nobody, with no error and a green test suite, while the
1127 + // same typo on 'IN' fails closed. On a primitive whose only
1128 + // job is scoping data, that asymmetry is a hazard.
1129 + if ( ! is_array( $_value['value'] ) ) {
1130 + _doing_it_wrong(
1131 + __METHOD__,
1132 + esc_html( "{$compare} requires an array value, received " . gettype( $_value['value'] ) . '.' ),
1133 + '2.12.7'
1134 + );
1135 + break;
1136 + }
1137 +
1138 + // An empty list cannot be interpolated: "col IN ()" is a syntax
1139 + // error that fails the whole query, listing and COUNT alike.
1140 + // An empty IN matches nothing, so '1 = 0' says that in any
1141 + // relation. An empty NOT IN excludes nothing, but a literal
1142 + // would be '1 = 1', and that makes an enclosing OR group
1143 + // unconditionally true. Dropping the condition means the same
1144 + // thing under AND and stays fail-closed under OR.
1145 + if ( [] === $_value['value'] ) {
1146 + if ( 'IN' === $compare ) {
1147 + $clause_parts[] = '1 = 0';
1148 + }
1149 + break;
1150 + }
1151 +
1152 + // Based on the number of values and datatype, it will create WHERE clause for $wpdb::prepare method. Eg: for ID with three values column: ID IN (%d, %d, %d).
1153 + $datatype = $this->get_format_by_datatype( Helper::get_string_value( $schema[ $_value['key'] ]['type'] ) );
1154 + $clause_parts[] = $_value['key'] . ' ' . $compare . ' (' . implode( ', ', array_fill( 0, count( $_value['value'] ), $datatype ) ) . ')';
1155 + $values = array_merge( $values, $_value['value'] );
1156 + break;
1157 +
1158 + default:
1159 + $clause_parts[] = $_value['key'] . ' ' . $compare . ' ' . $this->get_format_by_datatype( Helper::get_string_value( $schema[ $_value['key'] ]['type'] ) );
1160 + $values[] = $_value['value'];
1161 + break;
1162 + }
739 1163 }
740 1164 }
1165 +
1166 + if ( ! empty( $clause_parts ) ) {
1167 + $groups[] = '(' . implode( ' ' . $relation . ' ', $clause_parts ) . ')';
1168 + }
741 1169 continue;
742 1170 }
743 1171
744 1172 if ( ! isset( $schema[ $key ] ) ) {
@@ -745,18 +1173,26 @@
745 1173 // Skip strictly if current key is not in our schema.
746 1174 continue;
747 1175 }
748 1176
749 - $where .= ' ' . $key . ' = ' . $this->get_format_by_datatype( Helper::get_string_value( $schema[ $key ]['type'] ) ) . ' ' . $relation;
1177 + $groups[] = '(' . $key . ' = ' . $this->get_format_by_datatype( Helper::get_string_value( $schema[ $key ]['type'] ) ) . ')';
750 1178 $values[] = $value;
751 1179 }
752 1180
753 - if ( ! $where ) {
1181 + if ( empty( $groups ) ) {
754 1182 return '';
755 1183 }
756 1184
757 - $where = ' WHERE ' . trim( trim( $where, $relation ) );
1185 + $where = ' WHERE ' . implode( ' AND ', $groups );
758 1186
1187 + if ( [] === $values ) {
1188 + // Every branch that builds a placeholder also pushes a value, so an
1189 + // empty list here means the only conditions were constant ones. There
1190 + // is nothing for prepare() to fill, and calling it with no placeholder
1191 + // trips _doing_it_wrong.
1192 + return $where;
1193 + }
1194 +
759 1195 // Prepare the query with placeholders.
760 1196 // @phpstan-ignore-next-line -- We are already assigning non-literal string above using "get_format_by_datatype" methods.
761 1197 return $wpdb->prepare( $where, ...$values ); // phpcs:ignore -- We are returning prepared sql query here. We are already using necessary placeholders in $where variable.
762 1198 }
@@ -768,9 +1204,9 @@
768 1204 * Prepare and format data based on the schema.
769 1205 *
770 1206 * @param array<mixed> $data An associative array of data where the key is the column name and the value is the data to process.
771 1207 * Missing values will be replaced with default values specified in the schema.
772 - * @param boolean $skip_defaults Whether or not to skip the defaults values. Pass true if updating the data.
1208 + * @param bool $skip_defaults Whether or not to skip the defaults values. Pass true if updating the data.
773 1209 * @since 0.0.10
774 1210 * @return array<array<mixed>> An associative array containing:
775 1211 * - 'data': Prepared data with values encoded according to their data types.
776 1212 * - 'format': An array of format specifiers corresponding to the data values.
@@ -852,8 +1288,9 @@
852 1288 * - 'string': Encoded as a string.
853 1289 * - 'number': Encoded as an integer.
854 1290 * - 'boolean': Encoded as a boolean.
855 1291 * - 'array': Encoded as a JSON string.
1292 + * @since 1.8.0 - 'datetime': Returns the value as it is, assuming it is already in SQL DATETIME format.
856 1293 */
857 1294 protected function encode_by_datatype( $value, $type ) {
858 1295 switch ( $type ) {
859 1296 case 'string':
@@ -867,7 +1304,11 @@
867 1304
868 1305 case 'array':
869 1306 // Lets json_encode array values instead of serializing it.
870 1307 return Helper::encode_json( Helper::get_array_value( $value ) );
1308 +
1309 + case 'datetime':
1310 + // For datetime, we will return the value as it is because we are using sql DATETIME format.
1311 + return $value;
871 1312 }
872 1313 }
873 1314 }