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 +980 -111 0.0.10 → 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
@@ -48,8 +47,19 @@
48 47 */
49 48 protected $table_suffix;
50 49
51 50 /**
51 + * Version for current custom table. Default is 1.
52 + * Unlike semantic versioning [eg: 1.0.0, 1.0.1] we use natural integer like 1, 2, 3... and so on.
53 + * Update the table version from child class when any DB upgrade or alteration related changes are made.
54 + *
55 + * @var int
56 + * @since 0.0.13
57 + * @override
58 + */
59 + protected $table_version = 1;
60 +
61 + /**
52 62 * Full table name mixed with table prefix and table suffix.
53 63 *
54 64 * @var string
55 65 * @since 0.0.10
@@ -56,8 +66,17 @@
56 66 */
57 67 private $table_name;
58 68
59 69 /**
70 + * Whether or not the current database table is upgradable.
71 + * Determines on the basis of the table version.
72 + *
73 + * @var bool
74 + * @since 0.0.13
75 + */
76 + private $db_upgradable;
77 +
78 + /**
60 79 * Current table database result caches.
61 80 *
62 81 * @var array<mixed>
63 82 * @since 0.0.10
@@ -64,8 +83,16 @@
64 83 */
65 84 private $caches = [];
66 85
67 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 + /**
68 95 * Init class.
69 96 *
70 97 * @since 0.0.10
71 98 * @return void
@@ -78,8 +105,22 @@
78 105 $this->table_name = $this->table_prefix . $this->table_suffix;
79 106 }
80 107
81 108 /**
109 + * Actions to initialize during object unload.
110 + *
111 + * @since 0.0.13
112 + * @return void
113 + */
114 + public function __destruct() {
115 + /**
116 + * Just incase if any developer forgets to stop the db upgrade after starting.
117 + * This fallback handling will take care of such scenarios.
118 + */
119 + $this->stop_db_upgrade();
120 + }
121 +
122 + /**
82 123 * Returns the current table schema.
83 124 *
84 125 * @since 0.0.10
85 126 * @return array<string,array<mixed>>
@@ -86,8 +127,99 @@
86 127 */
87 128 abstract public function get_schema();
88 129
89 130 /**
131 + * Current table columns definition to create table. These definitions will be used by the create() method.
132 + *
133 + * @since 0.0.13
134 + * @return array<string>
135 + */
136 + abstract public function get_columns_definition();
137 +
138 + /**
139 + * Any columns that needs to be added if the current table already exists. These definitions will be used by maybe_add_new_columns() method.
140 + * Override this from child class if needed.
141 + *
142 + * @since 0.0.13
143 + * @return array<string>
144 + * @override
145 + */
146 + public function get_new_columns_definition() {
147 + return [];
148 + }
149 +
150 + /**
151 + * Array of columns that needs to be renamed to new column name. It will be used by maybe_rename_columns() method.
152 + * Format:
153 + * [
154 + * [
155 + * 'from' => 'old_column_name',
156 + * 'to' => 'new_column_name',
157 + * 'type' => 'column type definition eg: LONGTEXT', // Optional.
158 + * ],
159 + * ]
160 + *
161 + * @since 0.0.13
162 + * @return array<array<string,string>>
163 + */
164 + public function get_columns_to_rename() {
165 + return [];
166 + }
167 +
168 + /**
169 + * Start the database upgrade process.
170 + *
171 + * @since 0.0.13
172 + * @return void
173 + */
174 + public function start_db_upgrade() {
175 + $versions = Helper::get_array_value( get_option( 'srfm_database_table_versions', [] ) );
176 + $prev_version = ! empty( $versions[ $this->table_suffix ] ) ? absint( $versions[ $this->table_suffix ] ) : false;
177 +
178 + if ( ! $prev_version ) {
179 + /**
180 + * If we are here then there is the chance that
181 + * this site is the new site or fresh setup.
182 + */
183 + $this->db_upgradable = true;
184 + return;
185 + }
186 +
187 + $this->db_upgradable = $this->table_version > $prev_version;
188 + }
189 +
190 + /**
191 + * Stop the database upgrade process.
192 + *
193 + * @since 0.0.13
194 + * @return bool Returns true on success.
195 + */
196 + public function stop_db_upgrade() {
197 + if ( ! $this->db_upgradable ) {
198 + // Only upgrade when it is needed.
199 + return false;
200 + }
201 +
202 + $versions = Helper::get_array_value( get_option( 'srfm_database_table_versions', [] ) );
203 +
204 + $versions[ $this->table_suffix ] = $this->table_version;
205 +
206 + update_option( 'srfm_database_table_versions', $versions );
207 +
208 + return true;
209 + }
210 +
211 + /**
212 + * Check if current table's DB is upgradable or not.
213 + *
214 + * @since 0.0.13
215 + * @return bool True or false depending if DB is upgradable or not.
216 + */
217 + public function is_db_upgradable() {
218 + return $this->db_upgradable;
219 + }
220 +
221 + /**
90 222 * Returns full table name.
91 223 *
92 224 * @since 0.0.10
93 225 * @return string
@@ -96,47 +228,194 @@
96 228 return $this->table_name;
97 229 }
98 230
99 231 /**
100 - * Retrieve a cached value by its key.
232 + * Whether this table currently exists in the database.
101 233 *
102 - * @param string $key The cache key.
103 - * @since 0.0.10
104 - * @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.
105 249 */
106 - protected function cache_get( $key ) {
107 - $key = md5( $key );
108 - if ( ! isset( $this->caches[ $key ] ) ) {
109 - 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;
110 259 }
111 - return $this->caches[ $key ];
260 +
261 + return $found === $table;
112 262 }
113 263
114 264 /**
115 - * 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.
116 266 *
117 - * @param string $key The cache key.
118 - * @param mixed $value The value to store in the cache.
119 - * @since 0.0.10
120 - * @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.
121 283 */
122 - protected function cache_set( $key, $value ) {
123 - $key = md5( $key );
124 - $this->caches[ $key ] = $value;
125 - 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] : '';
126 321 }
127 322
128 323 /**
129 - * Reset the cache by clearing all stored values.
324 + * Rename a differently-prefixed table into this table's expected name.
130 325 *
131 - * @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
132 371 * @return void
133 372 */
134 - protected function cache_reset() {
135 - $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.
136 384 }
137 385
138 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 + /**
139 418 * Conditionally returns current database charset or collate.
140 419 *
141 420 * @since 0.0.10
142 421 * @return string
@@ -163,8 +442,13 @@
163 442 * @since 0.0.10
164 443 * @return int|bool
165 444 */
166 445 public function create( $columns = [] ) {
446 + if ( ! $this->db_upgradable ) {
447 + // Only upgrade when it is needed.
448 + return false;
449 + }
450 +
167 451 if ( empty( $columns ) ) {
168 452 return false; // It's better to return a boolean for failure.
169 453 }
170 454
@@ -173,109 +457,366 @@
173 457 ', ',
174 458 $columns
175 459 );
176 460
461 + $wpdb = $this->wpdb;
462 +
177 463 // Execute the query.
178 - // phpcs:ignore
179 - return $this->wpdb->query( "CREATE TABLE IF NOT EXISTS {$this->get_tablename()} ( {$columns_list} ) {$this->get_charset_collate()}" );
464 + $query = $wpdb->prepare( 'CREATE TABLE IF NOT EXISTS %1s ( %2s ) %3s', $this->get_tablename(), $columns_list, $this->get_charset_collate() ); // phpcs:ignore -- It is okay to use complex placeholder here for the table name, column list and character set because we don't want to quote these variables.
465 +
466 + if ( ! $query ) {
467 + // If we are here, then we probably have bad query to work with and prepare method has returned null-ish value.
468 + return false;
469 + }
470 +
471 + $result = $wpdb->query( $query ); // phpcs:ignore -- We are already using prepare above.
472 +
473 + if ( false === $result ) {
474 + // Stop DB alteration if we have any error.
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() );
491 + }
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 +
499 + return $result;
180 500 }
181 501
182 502 /**
183 - * Drop or delete current table.
503 + * Rename the column of the current table conditionally.
184 504 *
185 - * @since 0.0.10
186 - * @return int|bool
505 + * @param array<array<string,string>> $rename_columns Array of columns to rename.
506 + * @since 0.0.13
507 + * @return int|bool Boolean true for CREATE, ALTER, TRUNCATE and DROP queries. Number of rows affected/selected for all other queries. Boolean false on error.
187 508 */
188 - public function drop() {
509 + public function maybe_rename_columns( $rename_columns = [] ) {
510 + if ( ! $rename_columns ) {
511 + return false;
512 + }
513 +
514 + if ( ! $this->db_upgradable ) {
515 + // Only upgrade when it is needed.
516 + return false;
517 + }
518 +
519 + $existing_columns = $this->get_columns();
520 +
521 + if ( ! $existing_columns ) {
522 + // Table does not exists or is new table.
523 + return false;
524 + }
525 +
189 526 $wpdb = $this->wpdb;
190 527
191 - // Escape table name.
192 - $table_name = $wpdb->esc_like( $this->get_tablename() );
528 + $query_parts = [];
529 + foreach ( $rename_columns as $column ) {
530 + if ( empty( $existing_columns[ $column['from'] ] ) ) {
531 + // Bail if column is already renamed or does not exists.
532 + continue;
533 + }
193 534
194 - // Prepare the SQL query.
195 - $query = $wpdb->prepare(
196 - 'DROP TABLE IF EXISTS %s',
197 - $table_name
198 - );
535 + $query_part = $wpdb->prepare(
536 + 'CHANGE %1s %2s %3s', // phpcs:ignore -- It is okay to use complex placeholders as we don't want values to be quoted.
537 + $column['from'],
538 + $column['to'],
539 + ! empty( $column['type'] ) ? $column['type'] : $existing_columns[ $column['from'] ]['Type'] // This is column type i.e LONGTEXT, BIGINT etc.
540 + );
199 541
200 - if ( ! $query ) {
542 + if ( is_string( $query_part ) && $query_part ) {
543 + $query_parts[] = trim( $query_part );
544 + }
545 + }
546 +
547 + if ( empty( $query_parts ) ) {
548 + // No renaming required.
201 549 return false;
202 550 }
203 551
204 - // Execute the query.
205 - // phpcs:ignore
206 - return $wpdb->query( $query );
552 + $result = $wpdb->query( $wpdb->prepare( 'ALTER TABLE %1s ', $this->get_tablename() ) . implode( ', ', $query_parts ) . ';' ); // phpcs:ignore -- It is okay to use query directly here.
553 +
554 + if ( false === $result ) {
555 + // Stop DB alteration if we have any error.
556 + $this->db_upgradable = false;
557 + }
558 +
559 + return $result;
207 560 }
208 561
209 562 /**
210 - * Check if current table exists.
563 + * Adds the new columns to the current table conditionally.
211 564 *
212 - * @since 0.0.10
213 - * @return boolean
565 + * @param array<string> $new_columns The array of new columns to add. Same as the create method.
566 + * @since 0.0.13
567 + * @return int|bool Boolean true for CREATE, ALTER, TRUNCATE and DROP queries. Number of rows affected/selected for all other queries. Boolean false on error.
214 568 */
215 - public function exists() {
216 - global $wpdb;
569 + public function maybe_add_new_columns( $new_columns = [] ) {
570 + if ( ! $new_columns ) {
571 + return false;
572 + }
217 573
218 - // Escape table name.
219 - $table_name = $wpdb->esc_like( $this->get_tablename() );
574 + if ( ! $this->db_upgradable ) {
575 + // Only upgrade when it is needed.
576 + return false;
577 + }
220 578
221 - // Prepare the SQL query to check if the table exists.
222 - $query = $wpdb->prepare(
223 - 'SHOW TABLES LIKE %s',
224 - $table_name
225 - );
579 + $existing_columns = $this->get_columns();
226 580
227 - // Check if the table exists.
228 - // phpcs:ignore
229 - if ( $wpdb->get_var( $query ) === $table_name ) {
230 - return true;
581 + if ( ! $existing_columns ) {
582 + // Table does not exists or is new table.
583 + return false;
231 584 }
232 585
586 + $existing_indexes = $this->get_indexes();
587 +
588 + $alter_queries = [];
589 +
590 + $wpdb = $this->wpdb;
591 +
592 + // Check and add each column if it does not exist.
593 + foreach ( $new_columns as $column_definition ) {
594 + preg_match( '/INDEX\s+(.*?)\s+\(/', $column_definition, $index_matches );
595 +
596 + if ( ! empty( $index_matches[1] ) ) {
597 + if ( isset( $existing_indexes[ $index_matches[1] ] ) ) {
598 + // Move to next element if current index already exists.
599 + continue;
600 + }
601 + // Stack and move to next if we are indexing.
602 + $alter_queries[] = $wpdb->prepare( 'ADD %1s', $column_definition ); // phpcs:ignore -- We don't need quote here.
603 + continue;
604 + }
605 +
606 + preg_match( '/(\w+)\s/', $column_definition, $column_matches );
607 + $column_name = $column_matches[1] ?? '';
608 +
609 + // If the column does not exist, add it.
610 + if ( ! isset( $existing_columns[ $column_name ] ) ) {
611 + $alter_queries[] = $wpdb->prepare( 'ADD COLUMN %1s', $column_definition ); // phpcs:ignore -- We don't need quote here.
612 + }
613 + }
614 +
615 + if ( $alter_queries ) {
616 + $query = $wpdb->prepare(
617 + 'ALTER TABLE %1s %2s', // phpcs:ignore -- We don't want to quote the value strings for the query.
618 + $this->get_tablename(),
619 + implode( ', ', $alter_queries )
620 + );
621 +
622 + if ( ! $query ) {
623 + // If we are here then we probably have bad query and prepare method has returned null.
624 + return false;
625 + }
626 +
627 + // Execute the query.
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.
629 +
630 + if ( false === $result ) {
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).
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() );
645 + }
646 +
647 + return $result;
648 + }
649 +
233 650 return false;
234 651 }
235 652
236 653 /**
654 + * Returns an array columns of current table.
655 + *
656 + * @since 0.0.13
657 + * @return array<string,array<string,mixed>>
658 + */
659 + public function get_columns() {
660 + $wpdb = $this->wpdb;
661 +
662 + $columns = $wpdb->get_results( $wpdb->prepare( 'SHOW COLUMNS FROM %1s', $this->get_tablename() ), ARRAY_A ); // phpcs:ignore -- It is okay to use query db directly here.
663 +
664 + if ( empty( $columns ) ) {
665 + return [];
666 + }
667 +
668 + $_columns = [];
669 + if ( is_array( $columns ) ) {
670 + foreach ( $columns as $column ) {
671 + if ( ! is_string( $column['Field'] ) ) {
672 + continue;
673 + }
674 +
675 + $_columns[ $column['Field'] ] = $column;
676 + }
677 + }
678 + return $_columns;
679 + }
680 +
681 + /**
682 + * Returns an array indexes of current table.
683 + *
684 + * @since 0.0.13
685 + * @return array<mixed>
686 + */
687 + public function get_indexes() {
688 + $wpdb = $this->wpdb;
689 +
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.
691 +
692 + if ( empty( $indexes ) ) {
693 + return [];
694 + }
695 +
696 + $_indexes = [];
697 + if ( is_array( $indexes ) ) {
698 + foreach ( $indexes as $index ) {
699 + $_indexes[ $index['Key_name'] ] = $index; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
700 + }
701 + }
702 + return $_indexes;
703 + }
704 +
705 + /**
237 706 * Insert data. Basically, a wrapper method for wpdb::insert.
238 707 *
239 - * @param array<mixed> $data Data to insert (in column => value pairs).
240 - * Both `$data` columns and `$data` values should be "raw" (neither should be SQL escaped).
241 - * Sending a null value will cause the column to be set to NULL - the corresponding
242 - * format is ignored in this case.
243 - * @param string[]|string $format Optional. An array of formats to be mapped to each of the value in `$data`.
708 + * @param array<mixed> $data Data to insert (in column => value pairs).
709 + * Both `$data` columns and `$data` values should be "raw" (neither should be SQL escaped).
710 + * Sending a null value will cause the column to be set to NULL - the corresponding
711 + * format is ignored in this case.
712 + * @param array<string>|string|null $format Optional. An array of formats to be mapped to each of the value in `$data`.
244 713 * If string, that format will be used for all of the values in `$data`.
245 714 * A format is one of '%d', '%f', '%s' (integer, float, string).
246 715 * If omitted, all values in `$data` will be treated as strings unless otherwise
247 716 * specified in wpdb::$field_types. Default null.
248 717 * @since 0.0.10
249 - * @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.
250 719 */
251 - public function insert( $data, $format = null ) {
720 + public function use_insert( $data, $format = null ) {
252 721 $prepared_data = $this->prepare_data( $data );
253 722
254 723 if ( is_null( $format ) ) {
724 + /**
725 + * Use formats from schema if not provided explicitly.
726 + *
727 + * @var array<string>|string|null $format Format specifier for the data.
728 + */
255 729 $format = $prepared_data['format'];
256 730 }
257 731
258 - // @phpstan-ignore-next-line
259 - 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;
260 738 }
261 739
262 740 /**
741 + * Update a row data of current table. Basically, a wrapper method for wpdb::update.
742 + *
743 + * @param array<string,mixed> $data Data to update (in column => value pairs).
744 + * Both $data columns and $data values should be "raw" (neither should be SQL escaped).
745 + * Sending a null value will cause the column to be set to NULL - the corresponding
746 + * format is ignored in this case.
747 + * @param array<string,mixed> $where A named array of WHERE clauses (in column => value pairs).
748 + * Multiple clauses will be joined with ANDs.
749 + * Both $where columns and $where values should be "raw".
750 + * Sending a null value will create an IS NULL comparison - the corresponding
751 + * format will be ignored in this case.
752 + * @since 0.0.13
753 + * @return int|false The number of rows updated, or false on error.
754 + */
755 + public function use_update( $data, $where ) { // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore -- It is okay. This is our wrapper method.
756 + $prepared_data = $this->prepare_data( $data, true );
757 +
758 + /**
759 + * Data format specifier.
760 + *
761 + * @var array<string>|string|null $format Format specifier for the data.
762 + */
763 + $format = $prepared_data['format'];
764 +
765 + // Reset the cache on update.
766 + $this->cache_reset();
767 +
768 + return $this->wpdb->update(
769 + $this->get_tablename(),
770 + $prepared_data['data'],
771 + $where,
772 + $format
773 + );
774 + }
775 +
776 + /**
777 + * Delete a row data of current table. Basically, a wrapper method for wpdb::delete.
778 + *
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.
789 + * @since 0.0.13
790 + * @return int|false The number of rows deleted, or false on error.
791 + */
792 + public function use_delete( $where, $where_format = null ) {
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;
799 + }
800 +
801 + /**
263 802 * Retrieve results from the database based on the given WHERE clauses and selected columns.
264 803 *
265 804 * This method builds a SQL SELECT query with optional WHERE clauses and retrieves the results
266 805 * from the database. The results are cached to improve performance on subsequent requests.
267 806 *
268 - * @param array<mixed> $where_clauses Optional. An associative array of WHERE clauses for the SQL query.
269 - * Each key represents a column name, and each value is the value
270 - * to match. If the value is an array, it will be used in an IN clause.
271 - * Example: ['column1' => 'value1', 'column2' => ['value2', 'value3']].
272 - * Default is an empty array.
273 - * @param string $columns Optional. A string specifying which columns to select. Defaults to '*' (all columns).
807 + * @param array<mixed> $where_clauses Optional. An associative array of WHERE clauses for the SQL query.
808 + * Each key represents a column name, and each value is the value
809 + * to match. If the value is an array, it will be used in an IN clause.
810 + * Example: ['column1' => 'value1', 'column2' => ['value2', 'value3']].
811 + * Default is an empty array.
812 + * @param string $columns Optional. A string specifying which columns to select. Defaults to '*' (all columns).
813 + * @param array<string> $extra_queries Optional. Array of extra queries to append at the end of main query.
814 + * @param bool $decode Optional. Whether to decode the results by datatype. Default is true.
274 815 * @since 0.0.10
275 816 * @return array<mixed> An associative array of results where each element represents a row, or an empty array if no results are found.
276 817 */
277 - public function get_results( $where_clauses = [], $columns = '*' ) {
818 + public function get_results( $where_clauses = [], $columns = '*', $extra_queries = [], $decode = true ) {
278 819 $wpdb = $this->wpdb;
279 820
280 821 $table_name = $this->get_tablename();
281 822
@@ -282,45 +823,22 @@
282 823 // Start building the query.
283 824 $query = "SELECT {$columns} FROM {$table_name}";
284 825
285 826 // If there are WHERE clauses, prepare and append them to the query.
286 - if ( is_array( $where_clauses ) && ! empty( $where_clauses ) ) {
287 - // Start constructing WHERE clause.
288 - $where_clause = [];
289 - $values = [];
827 + $query .= $this->prepare_where_clauses( $where_clauses );
290 828
291 - // Current table schema.
292 - $schema = $this->get_schema();
293 -
294 - foreach ( $where_clauses as $key => $value ) {
295 - if ( ! isset( $schema[ $key ] ) ) {
296 - // Skip strictly if current key is not in our schema.
297 - continue;
298 - }
299 -
300 - // @phpstan-ignore-next-line
301 - $where_clause[] = $key . ' = ' . $this->get_format_by_datatype( $schema[ $key ]['type'] );
302 - $values[] = $value;
303 - }
304 -
305 - if ( ! empty( $where_clause ) ) {
306 - // Combine the WHERE clauses into a single string.
307 - $query .= ' WHERE ' . implode( ' AND ', $where_clause );
308 - }
309 -
310 - // Prepare the query with placeholders.
311 - // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
312 - // @phpstan-ignore-next-line
313 - $query = $wpdb->prepare( $query, ...$values );
314 - // phpcs:enable
829 + if ( ! empty( $extra_queries ) ) {
830 + $query .= ' ' . implode( ' ', array_map( 'trim', $extra_queries ) );
315 831 }
316 832
317 - // Add a semicolon (optional, not necessary in practice).
318 - $query .= ';';
833 + // Add a semicolon at the end of the query.
834 + $query = rtrim( trim( $query ), ';' ) . ';';
319 835
320 836 $cached_results = $this->cache_get( $query );
321 - if ( $cached_results ) {
322 - // 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.
323 841 return Helper::get_array_value( $cached_results );
324 842 }
325 843
326 844 // phpcs:ignore
@@ -325,9 +843,9 @@
325 843
326 844 // phpcs:ignore
327 845 $results = $wpdb->get_results( $query, ARRAY_A );
328 846
329 - if ( ! empty( $results ) && is_array( $results ) ) {
847 + if ( $decode && ! empty( $results ) && is_array( $results ) ) {
330 848 foreach ( $results as &$result ) {
331 849 $result = $this->decode_by_datatype( $result );
332 850 }
333 851 }
@@ -336,24 +854,371 @@
336 854 return Helper::get_array_value( $this->cache_set( $query, $results ) );
337 855 }
338 856
339 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 + /**
907 + * Get the total number of rows in the table.
908 + *
909 + * @param array<mixed> $where_clauses Optional. An associative array of WHERE clauses for the SQL query.
910 + * @since 0.0.13
911 + * @return int The total number of rows in the table.
912 + */
913 + public function get_total_count( $where_clauses = [] ) {
914 + $wpdb = $this->wpdb;
915 +
916 + $table_name = $this->get_tablename();
917 +
918 + // Start building the query.
919 + $query = "SELECT COUNT(*) FROM {$table_name}";
920 +
921 + // If there are WHERE clauses, prepare and append them to the query.
922 + $query .= $this->prepare_where_clauses( $where_clauses );
923 +
924 + // Add a semicolon at the end of the query.
925 + $query = rtrim( trim( $query ), ';' ) . ';';
926 +
927 + $cached_results = $this->cache_get( $query );
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.
932 + return Helper::get_integer_value( $cached_results );
933 + }
934 +
935 + // phpcs:ignore
936 + $results = Helper::get_integer_value( $wpdb->get_var( $query ) );
937 +
938 + // Execute the query and return the integer count.
939 + return Helper::get_integer_value( $this->cache_set( $query, $results ) );
940 + }
941 +
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 + /**
1044 + * Prepares WHERE clauses for a SQL query based on the provided conditions.
1045 + *
1046 + * This method constructs a WHERE statement by iterating through the
1047 + * specified conditions, appending them with the appropriate SQL syntax.
1048 + * It supports both single key-value pairs and arrays of conditions.
1049 + *
1050 + * @param array<mixed> $where_clauses {
1051 + * An associative array of conditions to include in the WHERE clause.
1052 + *
1053 + * @type string|array $key The column name or an array of conditions.
1054 + * @type array $value {
1055 + * An associative array of comparison data.
1056 + *
1057 + * @type string $key The column name for comparison.
1058 + * @type string $compare The comparison operator (e.g., '=', 'LIKE').
1059 + * @type mixed $value The value to compare against.
1060 + * @type string $RELATION Optional. The logical relation ('AND' or 'OR').
1061 + * }
1062 + * }
1063 + *
1064 + * @since 2.12.7 -- Added support for "NOT IN" compare.
1065 + * @since 1.1.1 -- Added support for "IN" compare.
1066 + * @since 0.0.13
1067 + * @return string The prepared SQL WHERE clause with placeholders, or an empty string if no clauses were provided.
1068 + */
1069 + protected function prepare_where_clauses( $where_clauses = [] ) {
1070 + if ( empty( $where_clauses ) ) {
1071 + return '';
1072 + }
1073 +
1074 + $wpdb = $this->wpdb;
1075 +
1076 + // If there are WHERE clauses, prepare and append them to the query.
1077 + if ( is_array( $where_clauses ) ) {
1078 + $groups = [];
1079 + $values = [];
1080 + $schema = $this->get_schema();
1081 +
1082 + foreach ( $where_clauses as $key => $value ) {
1083 +
1084 + $relation = ! empty( $value['RELATION'] ) ? trim( $value['RELATION'] ) : 'AND';
1085 + $relation = in_array( strtoupper( $relation ), [ 'AND', 'OR' ], true ) ? strtoupper( $relation ) : 'AND';
1086 +
1087 + if ( is_int( $key ) ) {
1088 + $clause_parts = [];
1089 + foreach ( $value as $_key => $_value ) {
1090 + if ( is_int( $_key ) ) {
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;
1103 + }
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 + }
1163 + }
1164 + }
1165 +
1166 + if ( ! empty( $clause_parts ) ) {
1167 + $groups[] = '(' . implode( ' ' . $relation . ' ', $clause_parts ) . ')';
1168 + }
1169 + continue;
1170 + }
1171 +
1172 + if ( ! isset( $schema[ $key ] ) ) {
1173 + // Skip strictly if current key is not in our schema.
1174 + continue;
1175 + }
1176 +
1177 + $groups[] = '(' . $key . ' = ' . $this->get_format_by_datatype( Helper::get_string_value( $schema[ $key ]['type'] ) ) . ')';
1178 + $values[] = $value;
1179 + }
1180 +
1181 + if ( empty( $groups ) ) {
1182 + return '';
1183 + }
1184 +
1185 + $where = ' WHERE ' . implode( ' AND ', $groups );
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 +
1195 + // Prepare the query with placeholders.
1196 + // @phpstan-ignore-next-line -- We are already assigning non-literal string above using "get_format_by_datatype" methods.
1197 + return $wpdb->prepare( $where, ...$values ); // phpcs:ignore -- We are returning prepared sql query here. We are already using necessary placeholders in $where variable.
1198 + }
1199 +
1200 + return '';
1201 + }
1202 +
1203 + /**
340 1204 * Prepare and format data based on the schema.
341 1205 *
342 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.
343 1207 * Missing values will be replaced with default values specified in the schema.
1208 + * @param bool $skip_defaults Whether or not to skip the defaults values. Pass true if updating the data.
344 1209 * @since 0.0.10
345 1210 * @return array<array<mixed>> An associative array containing:
346 1211 * - 'data': Prepared data with values encoded according to their data types.
347 1212 * - 'format': An array of format specifiers corresponding to the data values.
348 1213 */
349 - protected function prepare_data( $data ) {
1214 + protected function prepare_data( $data, $skip_defaults = false ) {
350 1215 $_data = [];
351 1216 $format = [];
352 1217 foreach ( $this->get_schema() as $key => $value ) {
353 1218 // Process defaults.
354 1219 if ( ! isset( $data[ $key ] ) ) {
355 - if ( ! isset( $value['default'] ) ) {
1220 + if ( $skip_defaults || ! isset( $value['default'] ) ) {
356 1221 continue;
357 1222 }
358 1223 $data[ $key ] = $value['default'];
359 1224 }
@@ -402,15 +1267,14 @@
402 1267 */
403 1268 protected function decode_by_datatype( $data ) {
404 1269 $_data = [];
405 1270 foreach ( $this->get_schema() as $key => $schema ) {
406 - // Process defaults.
407 - if ( ! isset( $data[ $key ] ) ) {
1271 + if ( ! array_key_exists( $key, $data ) ) {
408 1272 continue;
409 1273 }
410 1274
411 1275 // Lets decode from JSON to Array for the results.
412 - $_data[ $key ] = 'array' === $schema['type'] ? json_decode( Helper::get_string_value( $data[ $key ] ), true ) : $data[ $key ];
1276 + $_data[ $key ] = 'array' === $schema['type'] ? Helper::get_array_value( json_decode( Helper::get_string_value( $data[ $key ] ), true ) ) : $data[ $key ];
413 1277 }
414 1278 return $_data;
415 1279 }
416 1280
@@ -424,8 +1288,9 @@
424 1288 * - 'string': Encoded as a string.
425 1289 * - 'number': Encoded as an integer.
426 1290 * - 'boolean': Encoded as a boolean.
427 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.
428 1293 */
429 1294 protected function encode_by_datatype( $value, $type ) {
430 1295 switch ( $type ) {
431 1296 case 'string':
@@ -438,8 +1303,12 @@
438 1303 return boolval( $value );
439 1304
440 1305 case 'array':
441 1306 // Lets json_encode array values instead of serializing it.
442 - return wp_json_encode( Helper::get_array_value( $value ) );
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;
443 1312 }
444 1313 }
445 1314 }