PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / trunk
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management vtrunk
1.5.1 1.5.0 1.4.0 1.3.0 trunk 0.0.1 1.0.0 1.1.0 1.1.1 1.1.2 1.2.0
suredonation / inc / database / base.php

base.php in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management trunk, at inc/database/base.php

628 lines 15.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * SureDonation Database Tables Base Class.
4 *
5 * @package SureDonation
6 */
7
8 namespace SureDonation\Inc\Database;
9
10 use SureDonation\Inc\Helper;
11
12 // Exit if accessed directly.
13 defined( 'ABSPATH' ) || exit;
14
15 /**
16 * SureDonation Database Tables Base Class
17 *
18 * Provides basic database operations using $wpdb methods.
19 * All queries in child classes use $wpdb->prepare() with explicit placeholders.
20 *
21 * @since 0.0.1
22 */
23 abstract class Base {
24 /**
25 * Option key for database table versions within consolidated options.
26 *
27 * @since 0.0.1
28 */
29 public const VERSION_OPTION_KEY = 'database_table_versions';
30
31 /**
32 * WordPress Database class instance.
33 *
34 * @var \wpdb
35 * @since 0.0.1
36 */
37 protected $wpdb;
38
39 /**
40 * Current database table prefix mixed with 'suredonation_' as ending.
41 *
42 * @var string
43 * @since 0.0.1
44 */
45 protected $table_prefix;
46
47 /**
48 * Custom table suffix without any prefix. This needs to be overridden from child class.
49 *
50 * @var string
51 * @since 0.0.1
52 */
53 protected $table_suffix;
54
55 /**
56 * Version for current custom table.
57 *
58 * @var int
59 * @since 0.0.1
60 */
61 protected $table_version = 1;
62
63 /**
64 * Full table name mixed with table prefix and table suffix.
65 *
66 * @var string
67 * @since 0.0.1
68 */
69 private $table_name;
70
71 /**
72 * Whether or not the current database table is upgradable.
73 *
74 * @var bool
75 * @since 0.0.1
76 */
77 protected $db_upgradable;
78
79 /**
80 * Previously stored version of this table before the current upgrade
81 * (0 when the table had no recorded version yet). Exposed so child classes
82 * can gate one-time data migrations in run_data_migrations().
83 *
84 * @var int
85 * @since 1.3.0
86 */
87 protected $prev_version = 0;
88
89 /**
90 * Current table database result caches.
91 *
92 * @var array<mixed>
93 * @since 0.0.1
94 */
95 private $caches = [];
96
97 /**
98 * Init class.
99 *
100 * @return void
101 * @since 0.0.1
102 */
103 public function __construct() {
104 global $wpdb;
105
106 $this->wpdb = $wpdb;
107 $this->table_prefix = $this->wpdb->prefix . 'suredonation_';
108 $this->table_name = $this->table_prefix . $this->table_suffix;
109 }
110
111 /**
112 * Actions to initialize during object unload.
113 *
114 * @return void
115 * @since 0.0.1
116 */
117 public function __destruct() {
118 $this->stop_db_upgrade();
119 }
120
121 /**
122 * Returns the current table schema.
123 *
124 * @return array<string,array<mixed>>
125 * @since 0.0.1
126 */
127 abstract public function get_schema();
128
129 /**
130 * Current table columns definition to create table.
131 *
132 * @return array<string>
133 * @since 0.0.1
134 */
135 abstract public function get_columns_definition();
136
137 /**
138 * Columns to add if the table already exists. Override in child class.
139 * Each entry is a bare SQL fragment: "column_name TYPE [constraints] [AFTER other_col]"
140 * or "INDEX index_name (column)" for indexes.
141 *
142 * @return array<string>
143 * @since 1.0.0
144 */
145 public function get_new_columns_definition() {
146 return [];
147 }
148
149 /**
150 * Run one-time data migrations for this table after its columns are in
151 * place. Called only while the table is upgradable (see register.php).
152 * No-op by default; override in a child class and gate on $this->prev_version.
153 *
154 * @return void
155 * @since 1.3.0
156 */
157 public function run_data_migrations() {}
158
159 /**
160 * Start the database upgrade process.
161 *
162 * @return void
163 * @since 0.0.1
164 */
165 public function start_db_upgrade() {
166 $versions = Helper::get_suredonation_option( self::VERSION_OPTION_KEY, [] );
167 $versions = is_array( $versions ) ? $versions : [];
168 $prev_version = ! empty( $versions[ $this->table_suffix ] ) ? absint( $versions[ $this->table_suffix ] ) : false;
169
170 $this->prev_version = $prev_version ? (int) $prev_version : 0;
171
172 if ( ! $prev_version ) {
173 $this->db_upgradable = true;
174 return;
175 }
176
177 $this->db_upgradable = $this->table_version > $prev_version;
178 }
179
180 /**
181 * Stop the database upgrade process.
182 *
183 * @return bool Returns true on success.
184 * @since 0.0.1
185 */
186 public function stop_db_upgrade() {
187 if ( ! $this->db_upgradable ) {
188 return false;
189 }
190
191 $versions = Helper::get_suredonation_option( self::VERSION_OPTION_KEY, [] );
192 $versions = is_array( $versions ) ? $versions : [];
193
194 $versions[ $this->table_suffix ] = $this->table_version;
195
196 Helper::update_suredonation_option( self::VERSION_OPTION_KEY, $versions );
197
198 return true;
199 }
200
201 /**
202 * Check if current table's DB is upgradable or not.
203 *
204 * @return bool True or false depending if DB is upgradable or not.
205 * @since 0.0.1
206 */
207 public function is_db_upgradable() {
208 return $this->db_upgradable;
209 }
210
211 /**
212 * Returns full table name.
213 *
214 * @return string
215 * @since 0.0.1
216 */
217 public function get_tablename() {
218 return $this->table_name;
219 }
220
221 /**
222 * Conditionally returns current database charset or collate.
223 *
224 * @return string
225 * @since 0.0.1
226 */
227 public function get_charset_collate() {
228 $charset_collate = '';
229
230 if ( $this->wpdb->has_cap( 'collation' ) ) {
231 if ( ! empty( $this->wpdb->charset ) ) {
232 $charset_collate = "DEFAULT CHARACTER SET {$this->wpdb->charset}";
233 }
234 if ( ! empty( $this->wpdb->collate ) ) {
235 $charset_collate .= " COLLATE {$this->wpdb->collate}";
236 }
237 }
238
239 return $charset_collate;
240 }
241
242 /**
243 * Create table.
244 *
245 * @param array<string> $columns Array of columns.
246 * @return int|bool
247 * @since 0.0.1
248 */
249 public function create( $columns = [] ) {
250 if ( ! $this->db_upgradable ) {
251 return false;
252 }
253
254 if ( empty( $columns ) ) {
255 return false;
256 }
257
258 $columns_list = implode( ', ', $columns );
259 $wpdb = $this->wpdb;
260
261 // `$wpdb->prepare()` cannot be used here: it escapes single quotes in
262 // the interpolated column-definition string, turning literal `DEFAULT ''`
263 // into `DEFAULT \'\'`, which MySQL rejects with a syntax error. The
264 // table name, column list, and charset are all hardcoded DDL (not user
265 // input), so direct concatenation is safe.
266 //
267 // Column definitions must quote string literals with single quotes.
268 // wpdb strips the composite `ANSI` sql_mode on connect but not a
269 // standalone `ANSI_QUOTES`, under which `DEFAULT ""` parses as an empty
270 // identifier and fails the statement permanently on every retry.
271 $query = sprintf(
272 'CREATE TABLE IF NOT EXISTS `%s` ( %s ) %s',
273 esc_sql( $this->get_tablename() ),
274 $columns_list,
275 $this->get_charset_collate()
276 );
277
278 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.NotPrepared
279 $result = $wpdb->query( $query );
280
281 if ( false === $result ) {
282 $this->db_upgradable = false;
283 }
284
285 return $result;
286 }
287
288 /**
289 * Add new columns to an existing table conditionally.
290 *
291 * Checks existing columns/indexes and only adds missing ones via ALTER TABLE.
292 *
293 * @param array<string> $new_columns Column definitions to add.
294 * @return int|bool Result of ALTER query, or false.
295 * @since 1.0.0
296 */
297 public function maybe_add_new_columns( $new_columns = [] ) {
298 if ( ! $new_columns ) {
299 return false;
300 }
301
302 if ( ! $this->db_upgradable ) {
303 return false;
304 }
305
306 $existing_columns = $this->get_columns();
307
308 if ( ! $existing_columns ) {
309 // Table does not exist or is new.
310 return false;
311 }
312
313 $existing_indexes = $this->get_indexes();
314 $alter_queries = [];
315 $wpdb = $this->wpdb;
316
317 foreach ( $new_columns as $column_definition ) {
318 // Check if this is an INDEX definition.
319 preg_match( '/INDEX\s+(.*?)\s+\(/', $column_definition, $index_matches );
320
321 if ( ! empty( $index_matches[1] ) ) {
322 if ( isset( $existing_indexes[ $index_matches[1] ] ) ) {
323 continue; // Index already exists.
324 }
325 // Index definitions come from get_new_columns() — hardcoded DDL, not user input.
326 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Hardcoded DDL fragment from get_new_columns().
327 $alter_queries[] = 'ADD ' . $column_definition;
328 continue;
329 }
330
331 // Extract column name from definition.
332 preg_match( '/(\w+)\s/', $column_definition, $column_matches );
333 $column_name = $column_matches[1] ?? '';
334
335 if ( ! isset( $existing_columns[ $column_name ] ) ) {
336 // Column definitions come from get_new_columns() — hardcoded DDL, not user input.
337 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Hardcoded DDL fragment from get_new_columns().
338 $alter_queries[] = 'ADD COLUMN ' . $column_definition;
339 }
340 }
341
342 if ( ! $alter_queries ) {
343 return false;
344 }
345
346 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.NotPrepared -- ALTER TABLE with prepared table name and hardcoded DDL fragments.
347 $result = $wpdb->query( $wpdb->prepare( 'ALTER TABLE %i ', $this->get_tablename() ) . implode( ', ', $alter_queries ) . ';' );
348
349 if ( false === $result ) {
350 $this->db_upgradable = false;
351 }
352
353 return $result;
354 }
355
356 /**
357 * Returns an array columns of current table.
358 *
359 * @return array<string,array<string,mixed>>
360 * @since 0.0.1
361 */
362 public function get_columns() {
363 $wpdb = $this->wpdb;
364
365 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
366 $columns = $wpdb->get_results( $wpdb->prepare( 'SHOW COLUMNS FROM %i', $this->get_tablename() ), ARRAY_A );
367
368 if ( empty( $columns ) ) {
369 return [];
370 }
371
372 $_columns = [];
373 if ( is_array( $columns ) ) {
374 foreach ( $columns as $column ) {
375 if ( ! is_string( $column['Field'] ) ) {
376 continue;
377 }
378
379 $_columns[ $column['Field'] ] = $column;
380 }
381 }
382 return $_columns;
383 }
384
385 /**
386 * Returns an array indexes of current table.
387 *
388 * @return array<mixed>
389 * @since 0.0.1
390 */
391 public function get_indexes() {
392 $wpdb = $this->wpdb;
393
394 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
395 $indexes = $wpdb->get_results( $wpdb->prepare( 'SHOW INDEX FROM %i', $this->get_tablename() ), ARRAY_A );
396
397 if ( empty( $indexes ) ) {
398 return [];
399 }
400
401 $_indexes = [];
402 if ( is_array( $indexes ) ) {
403 foreach ( $indexes as $index ) {
404 $_indexes[ $index['Key_name'] ] = $index; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
405 }
406 }
407 return $_indexes;
408 }
409
410 /**
411 * Insert data. Wrapper method for wpdb::insert.
412 *
413 * @param array<mixed> $data Data to insert.
414 * @param array<string>|string|null $format Optional format specifiers.
415 * @return int|false The id of the inserted entry, or false on error.
416 * @since 0.0.1
417 */
418 public function use_insert( $data, $format = null ) {
419 $prepared_data = $this->prepare_data( $data );
420
421 if ( is_null( $format ) ) {
422 $format = $prepared_data['format'];
423 }
424
425 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
426 $result = $this->wpdb->insert( $this->get_tablename(), $prepared_data['data'], $format ); // @phpstan-ignore argument.type
427 return $result ? $this->wpdb->insert_id : false;
428 }
429
430 /**
431 * Update a row data of current table. Wrapper method for wpdb::update.
432 *
433 * @param array<string,mixed> $data Data to update.
434 * @param array<string,mixed> $where WHERE clauses.
435 * @return int|false The number of rows updated, or false on error.
436 * @since 0.0.1
437 */
438 public function use_update( $data, $where ) {
439 $prepared_data = $this->prepare_data( $data, true );
440 $format = $prepared_data['format'];
441
442 $this->cache_reset();
443
444 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
445 return $this->wpdb->update(
446 $this->get_tablename(),
447 $prepared_data['data'],
448 $where,
449 $format // @phpstan-ignore argument.type
450 );
451 }
452
453 /**
454 * Delete a row data of current table. Wrapper method for wpdb::delete.
455 *
456 * @param array<string,mixed> $where WHERE clauses.
457 * @param array<string>|string $where_format Optional format specifiers.
458 * @return int|false The number of rows deleted, or false on error.
459 * @since 0.0.1
460 */
461 public function use_delete( $where, $where_format = null ) {
462 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
463 return $this->wpdb->delete( $this->get_tablename(), $where, $where_format );
464 }
465
466 /**
467 * Retrieve a cached value by its key.
468 *
469 * @param string $key The cache key.
470 * @return mixed|null The cached value or null.
471 * @since 0.0.1
472 */
473 protected function cache_get( $key ) {
474 $key = md5( $key );
475 if ( ! isset( $this->caches[ $key ] ) ) {
476 return null;
477 }
478 return $this->caches[ $key ];
479 }
480
481 /**
482 * Store a value in the cache.
483 *
484 * @param string $key The cache key.
485 * @param mixed $value The value to store.
486 * @return mixed The stored value.
487 * @since 0.0.1
488 */
489 protected function cache_set( $key, $value ) {
490 $key = md5( $key );
491 $this->caches[ $key ] = $value;
492 return $value;
493 }
494
495 /**
496 * Reset the cache.
497 *
498 * @return void
499 * @since 0.0.1
500 */
501 protected function cache_reset() {
502 $this->caches = [];
503 }
504
505 /**
506 * Prepare and format data based on the schema.
507 *
508 * @param array<mixed> $data Data to prepare.
509 * @param bool $skip_defaults Whether to skip defaults.
510 * @return array<array<mixed>> Prepared data with format specifiers.
511 * @since 0.0.1
512 */
513 protected function prepare_data( $data, $skip_defaults = false ) {
514 $_data = [];
515 $format = [];
516
517 foreach ( $this->get_schema() as $key => $value ) {
518 if ( ! isset( $data[ $key ] ) ) {
519 if ( $skip_defaults || ! isset( $value['default'] ) ) {
520 continue;
521 }
522 $data[ $key ] = $value['default'];
523 }
524
525 $value_type = isset( $value['type'] ) && is_string( $value['type'] ) ? $value['type'] : 'string';
526 $format[] = $this->get_format_by_datatype( $value_type );
527 $_data[ $key ] = $this->encode_by_datatype( $data[ $key ], $value_type );
528 }
529
530 return [
531 'data' => $_data,
532 'format' => $format,
533 ];
534 }
535
536 /**
537 * Get the SQL format specifier based on the provided data type.
538 *
539 * @param string $type The data type.
540 * @return string The SQL format specifier.
541 * @since 0.0.1
542 */
543 protected function get_format_by_datatype( $type ) {
544 $format = '%s';
545
546 switch ( $type ) {
547 case 'string':
548 case 'array':
549 case 'datetime':
550 $format = '%s';
551 break;
552
553 case 'number':
554 case 'boolean':
555 $format = '%d';
556 break;
557
558 case 'decimal':
559 $format = '%f';
560 break;
561 }
562
563 return $format;
564 }
565
566 /**
567 * Decode data based on the schema data types.
568 *
569 * @param array<mixed> $data Data to decode.
570 * @return array<mixed> Decoded data.
571 * @since 0.0.1
572 */
573 protected function decode_by_datatype( $data ) {
574 $_data = [];
575
576 foreach ( $this->get_schema() as $key => $schema ) {
577 if ( ! array_key_exists( $key, $data ) ) {
578 continue;
579 }
580
581 $value = $data[ $key ];
582 if ( isset( $schema['type'] ) && 'array' === $schema['type'] ) {
583 $json_string = is_scalar( $value ) ? (string) $value : '';
584 $_data[ $key ] = json_decode( $json_string, true );
585 if ( ! is_array( $_data[ $key ] ) ) {
586 $_data[ $key ] = [];
587 }
588 } else {
589 $_data[ $key ] = $value;
590 }
591 }
592
593 return $_data;
594 }
595
596 /**
597 * Encode a value based on the specified data type.
598 *
599 * @param mixed $value The value to encode.
600 * @param string $type The data type.
601 * @return mixed The encoded value.
602 * @since 0.0.1
603 */
604 protected function encode_by_datatype( $value, $type ) {
605 switch ( $type ) {
606 case 'string':
607 return is_scalar( $value ) ? (string) $value : '';
608
609 case 'number':
610 return is_numeric( $value ) ? (int) $value : 0;
611
612 case 'boolean':
613 return (bool) $value;
614
615 case 'array':
616 return wp_json_encode( is_array( $value ) ? $value : [] );
617
618 case 'datetime':
619 return $value;
620
621 case 'decimal':
622 return is_numeric( $value ) ? (float) $value : 0.0;
623 }
624
625 return $value;
626 }
627 }
628