PluginProbe
seQura / 4.3.0
seQura v4.3.0
4.3.4 4.3.3 4.3.2 4.3.1 trunk 2.0.0 2.0.10 2.0.11 2.0.12 2.0.5 2.0.6 2.0.7 2.0.8 2.0.9 3.0.0 3.0.2 3.0.5 3.0.6 3.0.7 3.1.0 3.1.1 3.2.0 3.2.1 3.2.2 4.0.0 All 30 releases
← All changes | src/Repositories/class-repository.php +504 -43 3.0.54.3.0 View file →
@@ -7,8 +7,9 @@
7 7 */
8 8
9 9 namespace SeQura\WC\Repositories;
10 10
11 +use Exception;
11 12 use SeQura\Core\Infrastructure\ORM\Entity;
12 13 use SeQura\Core\Infrastructure\ORM\Exceptions\QueryFilterInvalidParamException;
13 14 use SeQura\Core\Infrastructure\ORM\Interfaces\RepositoryInterface;
14 15 use SeQura\Core\Infrastructure\ORM\QueryFilter\QueryCondition;
@@ -14,14 +15,16 @@
14 15 use SeQura\Core\Infrastructure\ORM\QueryFilter\QueryCondition;
15 16 use SeQura\Core\Infrastructure\ORM\QueryFilter\QueryFilter;
16 17 use SeQura\Core\Infrastructure\ORM\Utility\IndexHelper;
17 18 use SeQura\Core\Infrastructure\ServiceRegister;
19 +use SeQura\WC\Dto\Table_Index;
20 +use SeQura\WC\Dto\Table_Index_Column;
18 21 use wpdb;
19 22
20 23 /**
21 24 * Shared repository functionality.
22 25 */
23 -abstract class Repository implements RepositoryInterface, Interface_Deletable_Repository {
26 +abstract class Repository implements RepositoryInterface, Interface_Deletable_Repository, Interface_Table_Migration_Repository {
24 27
25 28 /**
26 29 * Entity class FQN.
27 30 *
@@ -29,8 +32,33 @@
29 32 */
30 33 protected $entity_class;
31 34
32 35 /**
36 + * Whether caching is enabled. Evaluated once per request via the 'sequra_cache_enabled' filter.
37 + *
38 + * Public to allow test suites to reset the static state between tests
39 + * without requiring Reflection.
40 + *
41 + * @var bool|null
42 + */
43 + public static $cache_enabled = null;
44 +
45 + /**
46 + * Cache group for table existence checks.
47 + */
48 + public const TABLE_EXISTS_CACHE_GROUP = 'sequra_table_exists';
49 +
50 + /**
51 + * Cache group for data query results and version counters.
52 + */
53 + public const DATA_CACHE_GROUP = 'sequra_data';
54 +
55 + /**
56 + * TTL for cache entries in seconds.
57 + */
58 + private const CACHE_TTL = 300;
59 +
60 + /**
33 61 * Database session object.
34 62 *
35 63 * @var \wpdb
36 64 */
@@ -36,8 +64,15 @@
36 64 */
37 65 protected $db;
38 66
39 67 /**
68 + * Cache repository.
69 + *
70 + * @var Interface_Cache_Repository
71 + */
72 + protected $cache;
73 +
74 + /**
40 75 * Returns unprefixed table name.
41 76 */
42 77 abstract protected function get_unprefixed_table_name(): string;
43 78
@@ -43,13 +78,22 @@
43 78
44 79 /**
45 80 * Returns full table name.
46 81 */
47 - protected function get_table_name(): string {
82 + public function get_table_name(): string {
48 83 return $this->db->prefix . $this->get_unprefixed_table_name();
49 84 }
50 85
51 86 /**
87 + * Get the name that is set to the original table during the migration.
88 + *
89 + * @return string The name of the old table.
90 + */
91 + public function get_legacy_table_name() {
92 + return $this->get_table_name() . '_legacy';
93 + }
94 +
95 + /**
52 96 * Constructor.
53 97 *
54 98 * @throws \RuntimeException If database service not found.
55 99 */
@@ -57,12 +101,34 @@
57 101 $db = ServiceRegister::getService( \wpdb::class );
58 102 if ( ! $db instanceof \wpdb ) {
59 103 throw new \RuntimeException( 'Database service not found.' );
60 104 }
61 - $this->db = $db;
105 + $this->db = $db;
106 + $this->cache = ServiceRegister::getService( Interface_Cache_Repository::class );
62 107 }
63 108
64 109 /**
110 + * Check if caching is enabled.
111 + * Result is evaluated once per request and cached statically.
112 + *
113 + * Disable all repository caching by adding to functions.php or an mu-plugin:
114 + * add_filter( 'sequra_cache_enabled', '__return_false' );
115 + */
116 + private static function is_cache_enabled(): bool {
117 + if ( null === self::$cache_enabled ) {
118 + /**
119 + * Whether repository caching is enabled.
120 + * Set to false to disable all repository caching and fall back to direct database queries.
121 + *
122 + * @since 4.2.0
123 + * @param bool $enabled Whether caching is enabled. Default true.
124 + */
125 + self::$cache_enabled = (bool) \apply_filters( 'sequra_cache_enabled', true );
126 + }
127 + return self::$cache_enabled;
128 + }
129 +
130 + /**
65 131 * Returns full class name.
66 132 *
67 133 * @return string Full class name.
68 134 */
@@ -77,9 +143,9 @@
77 143 *
78 144 * @param string $entity_class Entity class.
79 145 * @return void
80 146 */
81 - public function setEntityClass( $entity_class ) {
147 + public function setEntityClass( $entity_class ): void {
82 148 $this->entity_class = $entity_class;
83 149 }
84 150
85 151 /**
@@ -84,18 +150,14 @@
84 150
85 151 /**
86 152 * Executes select query.
87 153 *
88 - * @param QueryFilter $filter Filter for query.
154 + * @param QueryFilter|null $filter Filter for query.
89 155 *
90 156 * @return Entity[] A list of found entities ot empty array.
91 157 * @throws QueryFilterInvalidParamException If filter condition is invalid.
92 158 */
93 - public function select( QueryFilter $filter = null ) {
94 - if ( ! $this->table_exists() ) {
95 - return array();
96 - }
97 -
159 + public function select( ?QueryFilter $filter = null ) {
98 160 /**
99 161 * Entity object.
100 162 *
101 163 * @var Entity $entity
@@ -107,25 +169,55 @@
107 169 if ( $filter ) {
108 170 $query .= $this->apply_query_filter( $filter, IndexHelper::mapFieldsToIndexes( $entity ) );
109 171 }
110 172
111 - $raw_results = $this->db->get_results( $query, ARRAY_A );
112 - if ( ! is_array( $raw_results ) ) {
113 - return array();
173 + // Only cache bounded queries (with LIMIT) to avoid exceeding the 1 MB cache entry size limit.
174 + // Unbounded selects (e.g. deleteAllOrders) can return arbitrarily large result sets.
175 + $is_cacheable = self::is_cache_enabled() && null !== $filter && $filter->getLimit() > 0;
176 +
177 + if ( $is_cacheable ) {
178 + $found = false;
179 + $cached = $this->cache->get( $this->build_data_cache_key( $query ), self::DATA_CACHE_GROUP, $found );
180 + if ( $found ) {
181 + return $cached;
182 + }
114 183 }
115 184
116 - return $this->translateToEntities( $raw_results );
185 + $raw_results = array();
186 + if ( $this->table_exists() ) {
187 + $raw_results = $this->db->get_results( $query, ARRAY_A );
188 + if ( ! is_array( $raw_results ) ) {
189 + $raw_results = array();
190 + }
191 + }
192 + if ( $this->table_exists( true ) ) {
193 + // If the legacy table exists the data may be there.
194 + $legacy_query = str_replace( $this->get_table_name(), $this->get_legacy_table_name(), $query );
195 + $legacy_raw_results = $this->db->get_results( $legacy_query, ARRAY_A );
196 + if ( ! is_array( $legacy_raw_results ) ) {
197 + $legacy_raw_results = array();
198 + }
199 + $raw_results = array_merge( $raw_results, $legacy_raw_results );
200 + }
201 +
202 + $entities = $this->translateToEntities( $raw_results );
203 +
204 + if ( $is_cacheable ) {
205 + $this->cache->set( $this->build_data_cache_key( $query ), $entities, self::DATA_CACHE_GROUP, self::CACHE_TTL );
206 + }
207 +
208 + return $entities;
117 209 }
118 210
119 211 /**
120 212 * Executes select query and returns first result.
121 213 *
122 - * @param QueryFilter $filter Filter for query.
214 + * @param QueryFilter|null $filter Filter for query.
123 215 *
124 216 * @return Entity|null First found entity or NULL.
125 217 * @throws QueryFilterInvalidParamException If filter condition is invalid.
126 218 */
127 - public function selectOne( QueryFilter $filter = null ) {
219 + public function selectOne( ?QueryFilter $filter = null ) {
128 220 if ( ! $filter ) {
129 221 $filter = new QueryFilter();
130 222 }
131 223
@@ -152,9 +244,12 @@
152 244
153 245 return $entity->getId();
154 246 }
155 247
156 - return $this->save_entity_to_storage( $entity );
248 + $id = $this->save_entity_to_storage( $entity );
249 + $this->bump_data_version();
250 +
251 + return $id;
157 252 }
158 253
159 254 /**
160 255 * Executes update query and returns success flag.
@@ -166,13 +261,41 @@
166 261 public function update( Entity $entity ) {
167 262 if ( ! $this->table_exists() ) {
168 263 return false;
169 264 }
170 -
171 - $item = $this->prepare_entity_for_storage( $entity );
172 -
265 + $item = $this->prepare_entity_for_storage( $entity );
266 + $where = array( 'id' => $entity->getId() );
267 +
268 + // Check if entity wasn't already migrated and migrate it including the new data.
269 + if ( $this->table_exists( true ) && $this->entity_exists( $entity->getId(), true ) ) {
270 + if ( 1 !== $this->db->update( $this->get_legacy_table_name(), $item, $where ) ) {
271 + return false;
272 + }
273 + // Read from the legacy table.
274 + $raw_results = $this->db->get_results( "SELECT * FROM {$this->get_legacy_table_name()} WHERE id = {$entity->getId()} LIMIT 1;", ARRAY_A );
275 + if ( empty( $raw_results ) ) {
276 + return false;
277 + }
278 + $entity = $this->translateToEntities( $raw_results )[0] ?? null;
279 + if ( ! $entity ) {
280 + return false;
281 + }
282 + // Insert into the new table.
283 + $item = $this->prepare_entity_for_storage( $entity );
284 + if ( false !== $this->db->insert( $this->get_table_name(), $item ) ) {
285 + return false;
286 + }
287 + // Delete the row from the legacy table.
288 + $this->db->delete( $this->get_legacy_table_name(), $where );
289 + $this->bump_data_version();
290 + return true;
291 + }
173 292 // Only one record should be updated.
174 - return 1 === $this->db->update( $this->get_table_name(), $item, array( 'id' => $entity->getId() ) );
293 + $updated = 1 === $this->db->update( $this->get_table_name(), $item, $where );
294 + if ( $updated ) {
295 + $this->bump_data_version();
296 + }
297 + return $updated;
175 298 }
176 299
177 300 /**
178 301 * Executes delete query and returns success flag.
@@ -181,26 +304,34 @@
181 304 *
182 305 * @return bool TRUE if operation succeeded; otherwise, FALSE.
183 306 */
184 307 public function delete( Entity $entity ) {
185 - if ( ! $this->table_exists() ) {
186 - return false;
308 + $where = array( 'id' => $entity->getId() );
309 + $deleted = false;
310 + if ( $this->table_exists() ) {
311 + $result = $this->db->delete( $this->get_table_name(), $where );
312 + $deleted = ! empty( $result );
187 313 }
188 - return false !== $this->db->delete( $this->get_table_name(), array( 'id' => $entity->getId() ) );
314 + if ( $this->table_exists( true ) ) {
315 + // Delete from legacy table.
316 + $result = $this->db->delete( $this->get_legacy_table_name(), $where );
317 + $deleted = $deleted || ! empty( $result );
318 + }
319 + if ( $deleted ) {
320 + $this->bump_data_version();
321 + }
322 + return $deleted;
189 323 }
190 324
191 325 /**
192 326 * Counts records that match filter criteria.
193 327 *
194 - * @param QueryFilter $filter Filter for query.
328 + * @param QueryFilter|null $filter Filter for query.
195 329 *
196 330 * @return int Number of records that match filter criteria.
197 331 * @throws QueryFilterInvalidParamException If filter condition is invalid.
198 332 */
199 - public function count( QueryFilter $filter = null ) {
200 - if ( ! $this->table_exists() ) {
201 - return 0;
202 - }
333 + public function count( ?QueryFilter $filter = null ) {
203 334 /**
204 335 * Entity object.
205 336 *
206 337 * @var Entity $entity
@@ -212,11 +343,37 @@
212 343 if ( $filter ) {
213 344 $query .= $this->apply_query_filter( $filter, IndexHelper::mapFieldsToIndexes( $entity ) );
214 345 }
215 346
216 - $result = $this->db->get_results( $query, ARRAY_A );
347 + // count() always returns a single integer — safe to cache regardless of result set size.
348 + $is_cacheable = self::is_cache_enabled();
349 + $cache_key = $this->build_data_cache_key( 'count:' . $query );
217 350
218 - return empty( $result[0]['total'] ) ? 0 : intval( $result[0]['total'] );
351 + if ( $is_cacheable ) {
352 + $found = false;
353 + $cached = $this->cache->get( $cache_key, self::DATA_CACHE_GROUP, $found );
354 + if ( $found && is_numeric( $cached ) ) {
355 + return (int) $cached;
356 + }
357 + }
358 +
359 + $count = 0;
360 + if ( $this->table_exists() ) {
361 + $result = $this->db->get_results( $query, ARRAY_A );
362 + $count += empty( $result[0]['total'] ) || ! is_numeric( $result[0]['total'] ) ? 0 : (int) $result[0]['total'];
363 + }
364 + if ( $this->table_exists( true ) ) {
365 + // If the legacy table exists, count the data there too.
366 + $legacy_query = str_replace( $this->get_table_name(), $this->get_legacy_table_name(), $query );
367 + $result = $this->db->get_results( $legacy_query, ARRAY_A );
368 + $count += empty( $result[0]['total'] ) || ! is_numeric( $result[0]['total'] ) ? 0 : (int) $result[0]['total'];
369 + }
370 +
371 + if ( $is_cacheable ) {
372 + $this->cache->set( $cache_key, $count, self::DATA_CACHE_GROUP, self::CACHE_TTL );
373 + }
374 +
375 + return $count;
219 376 }
220 377
221 378 /**
222 379 * Escapes provided value.
@@ -225,9 +382,9 @@
225 382 *
226 383 * @return string Escaped value.
227 384 */
228 385 protected function escape( $value ) {
229 - return addslashes( strval( $value ) );
386 + return addslashes( \strval( $value ) );
230 387 }
231 388
232 389 /**
233 390 * Checks if value exists and escapes it if it's not.
@@ -285,9 +442,9 @@
285 442 */
286 443 $values = $condition->getValue();
287 444 $escaped_values = array();
288 445 foreach ( $values as $value ) {
289 - $escaped_values[] = is_string( $value ) ? $this->escape_value( $value ) : $value;
446 + $escaped_values[] = \is_string( $value ) ? $this->escape_value( $value ) : $value;
290 447 }
291 448
292 449 $value = '(' . implode( ', ', $escaped_values ) . ')';
293 450 break;
@@ -363,9 +520,9 @@
363 520 */
364 521 if ( ! isset( $item['data'] ) || ! isset( $item['id'] ) ) {
365 522 continue;
366 523 }
367 - $data = (array) json_decode( strval( $item['data'] ), true );
524 + $data = (array) json_decode( \strval( $item['data'] ), true );
368 525 /**
369 526 * Entity object.
370 527 *
371 528 * @var Entity $entity
@@ -371,9 +528,11 @@
371 528 * @var Entity $entity
372 529 */
373 530 $entity = isset( $data['class_name'] ) ? new $data['class_name']() : new $this->entity_class();
374 531 $entity->inflate( $data );
375 - $entity->setId( $item['id'] );
532 + if ( is_numeric( $item['id'] ) ) {
533 + $entity->setId( (int) $item['id'] );
534 + }
376 535
377 536 $entities[] = $entity;
378 537 }
379 538
@@ -418,11 +577,15 @@
418 577 'index_4' => null,
419 578 'index_5' => null,
420 579 'index_6' => null,
421 580 'index_7' => null,
422 - 'data' => wp_json_encode( $entity->toArray() ),
581 + 'data' => \wp_json_encode( $entity->toArray() ),
423 582 );
424 583
584 + if ( $entity->getId() ) {
585 + $storage_item['id'] = $entity->getId();
586 + }
587 +
425 588 foreach ( $indexes as $index => $value ) {
426 589 $storage_item[ 'index_' . $index ] = $value;
427 590 }
428 591
@@ -439,9 +602,9 @@
439 602 *
440 603 * @return void
441 604 */
442 605 protected function validate_index_column( $column, array $index_map ) {
443 - if ( 'id' !== $column && ! array_key_exists( $column, $index_map ) ) {
606 + if ( 'id' !== $column && ! \array_key_exists( $column, $index_map ) ) {
444 607 throw new QueryFilterInvalidParamException( esc_html__( 'Column is not id or index.', 'sequra' ) );
445 608 }
446 609 }
447 610
@@ -446,20 +609,318 @@
446 609 }
447 610
448 611 /**
449 612 * Delete all the entities.
613 + *
614 + * @param string|null $store_id Delete entities from this store. Passing null will delete all entities.
450 615 */
451 - public function delete_all(): bool {
616 + public function delete_all( $store_id = null ): bool {
617 + $deleted = false;
618 + $sql = 'DELETE FROM ' . \sanitize_text_field( $this->get_table_name() );
619 + if ( $store_id ) {
620 + $column = $this->get_store_id_index_column();
621 + if ( ! $column ) {
622 + return false;
623 + }
624 + $sql .= ' WHERE ' . \sanitize_text_field( $column ) . ' = ' . \sanitize_text_field( $store_id );
625 + }
626 + if ( $this->table_exists() ) {
627 + $result = $this->db->query( $sql );
628 + $deleted = ! empty( $result );
629 + }
630 + if ( $this->table_exists( true ) ) {
631 + $result = $this->db->query( str_replace( $this->get_table_name(), $this->get_legacy_table_name(), $sql ) );
632 + $deleted = $deleted || ! empty( $result );
633 + }
634 + if ( $deleted ) {
635 + $this->bump_data_version();
636 + }
637 + return $deleted;
638 + }
639 +
640 + /**
641 + * Get the index column name that stores the store ID.
642 + *
643 + * @return string Index column name or empty string if not applicable.
644 + */
645 + protected function get_store_id_index_column(): string {
646 + return 'index_1';
647 + }
648 +
649 + /**
650 + * Check if table exists in the database.
651 + *
652 + * @param boolean $legacy If true, check for legacy table.
653 + */
654 + public function table_exists( $legacy = false ): bool {
655 + $table_name = \sanitize_text_field( ! $legacy ? $this->get_table_name() : $this->get_legacy_table_name() );
656 +
657 + if ( self::is_cache_enabled() ) {
658 + $found = false;
659 + $cached = $this->cache->get( $table_name, self::TABLE_EXISTS_CACHE_GROUP, $found );
660 + if ( $found ) {
661 + return (bool) $cached;
662 + }
663 + }
664 +
665 + $result = $this->db->get_var( "SHOW TABLES LIKE '{$table_name}'" ) === $table_name;
666 +
667 + if ( self::is_cache_enabled() ) {
668 + $this->cache->set( $table_name, $result, self::TABLE_EXISTS_CACHE_GROUP, self::CACHE_TTL );
669 + }
670 +
671 + return $result;
672 + }
673 +
674 + /**
675 + * Invalidate the table existence cache for a specific table.
676 + *
677 + * @param string $table_name The table name to invalidate.
678 + */
679 + private function invalidate_table_exists_cache( $table_name ): void {
680 + if ( self::is_cache_enabled() ) {
681 + $this->cache->delete( $table_name, self::TABLE_EXISTS_CACHE_GROUP );
682 + }
683 + }
684 +
685 + /**
686 + * Build a versioned cache key for a data query.
687 + * The version is bumped on every write, making previous keys stale.
688 + *
689 + * @param string $query The SQL query string used as the cache discriminator.
690 + */
691 + private function build_data_cache_key( $query ): string {
692 + return $this->entity_class . ':' . md5( $query ) . ':v' . $this->get_data_version();
693 + }
694 +
695 + /**
696 + * Get the current data version for this entity class.
697 + */
698 + private function get_data_version(): int {
699 + $found = false;
700 + $version = $this->cache->get( $this->get_data_version_key(), self::DATA_CACHE_GROUP, $found );
701 + return $found && is_numeric( $version ) ? (int) $version : 0;
702 + }
703 +
704 + /**
705 + * Get the cache key that stores the data version for this entity class.
706 + */
707 + private function get_data_version_key(): string {
708 + return 'version:' . $this->entity_class . ':' . $this->get_table_name();
709 + }
710 +
711 + /**
712 + * Bump the data version for this entity class, invalidating all cached reads.
713 + * Uses an atomic increment to avoid a read-then-write race on concurrent requests.
714 + */
715 + protected function bump_data_version(): void {
716 + $this->cache->increment( $this->get_data_version_key(), self::DATA_CACHE_GROUP, self::CACHE_TTL );
717 + }
718 +
719 + /**
720 + * Remove entities that are older than a certain date or that are invalid.
721 + * This performs a cleanup of the repository data.
722 + */
723 + public function delete_old_and_invalid() {
724 + // Do nothing by default. Implement in child class if needed.
725 + }
726 +
727 + /**
728 + * Check if the index exists.
729 + *
730 + * @param Table_Index $index The index to check.
731 + * @return bool True if the index exists, false otherwise.
732 + */
733 + public function index_exists( $index ) {
734 + $index_name = \sanitize_key( $index->name );
735 + return ! empty( $this->db->get_col( "SHOW INDEX FROM `{$this->get_table_name()}` WHERE Key_name = '{$index_name}'" ) );
736 + }
737 +
738 + /**
739 + * Add an index to the table.
740 + *
741 + * @param Table_Index $index The index.
742 + * @return bool True if the index was added or already exists, false otherwise.
743 + */
744 + public function add_index( $index ) {
745 + if ( $this->index_exists( $index ) ) {
746 + return true;
747 + }
748 + $index_name = \sanitize_key( $index->name );
749 + $columns = array();
750 + foreach ( $index->columns as $column ) {
751 + $columns[] = '`' . \sanitize_key( $column->name ) . '`' . ( null !== $column->char_limit ? "({$column->char_limit})" : '' );
752 + }
753 + $columns = implode( ',', $columns );
754 + return false !== $this->db->query( "ALTER TABLE `{$this->get_table_name()}` ADD INDEX `{$index_name}` ({$columns})" );
755 + }
756 +
757 + /**
758 + * Execute the migration process one by one.
759 + * This implementation is intended for migrations that don't change the table structure.
760 + */
761 + public function migrate_next_row() {
762 + if ( ! $this->table_exists() || ! $this->table_exists( true ) ) {
763 + return;
764 + }
765 + $raw_results = $this->db->get_results( "SELECT * FROM {$this->get_legacy_table_name()} LIMIT 1;", ARRAY_A );
766 + if ( ! \is_array( $raw_results ) ) {
767 + return;
768 + }
769 +
770 + $entity = $this->translateToEntities( $raw_results )[0] ?? null;
771 + if ( ! $entity ) {
772 + return;
773 + }
774 + // Check if entity already exists in the table.
775 + if ( $this->entity_exists( $entity->getId() ) ) {
776 + return;
777 + }
778 +
779 + $storage_item = $this->prepare_entity_for_storage( $entity );
780 + $result = $this->db->insert( $this->get_table_name(), $storage_item );
781 + if ( false !== $result ) {
782 + // Delete the row from the legacy table.
783 + $this->db->delete( $this->get_legacy_table_name(), array( 'id' => $entity->getId() ) );
784 + $this->bump_data_version();
785 + }
786 + }
787 +
788 + /**
789 + * Check if the migration process is complete.
790 + *
791 + * @return bool True if the migration process is complete, false otherwise.
792 + */
793 + public function is_migration_complete() {
794 + // Check if the legacy table exists.
795 + if ( $this->table_exists( true ) ) {
796 + return false;
797 + }
798 + // Check if the indexes exist.
799 + $indexes = $this->get_required_indexes();
800 + foreach ( $indexes as $index ) {
801 + if ( ! $this->index_exists( $index ) ) {
802 + return false;
803 + }
804 + }
805 +
806 + return true;
807 + }
808 +
809 + /**
810 + * Get the SQL statement to create the table without the indexes definition.
811 + * Resulting string should include an additional %s placeholder for the indexes.
812 + *
813 + * @return string The SQL statement to create the table.
814 + */
815 + protected function get_create_table_sql() {
816 + $charset_collate = $this->db->get_charset_collate();
817 + return "CREATE TABLE {$this->get_table_name()} (
818 + `id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
819 + `type` VARCHAR(255),
820 + `index_1` VARCHAR(127),
821 + `index_2` VARCHAR(127),
822 + `index_3` VARCHAR(127),
823 + `index_4` VARCHAR(127),
824 + `index_5` VARCHAR(127),
825 + `index_6` VARCHAR(127),
826 + `index_7` VARCHAR(127),
827 + `data` LONGTEXT,
828 + PRIMARY KEY (id) %s) $charset_collate;";
829 + }
830 +
831 + /**
832 + * Create the table if it doesn't exist.
833 + *
834 + * @throws Exception If the table creation fails.
835 + */
836 + public function create_table() {
837 + $indexes = array();
838 + foreach ( $this->get_required_indexes() as $index ) {
839 + $indexes[] = $index->to_sql();
840 + }
841 + $indexes = implode( ', ', $indexes );
842 + if ( ! empty( $indexes ) ) {
843 + $indexes = ', ' . $indexes;
844 + }
845 +
846 + $sql = \sprintf( $this->get_create_table_sql(), $indexes );
847 + require_once ABSPATH . 'wp-admin/includes/upgrade.php';
848 + $result = \dbDelta( $sql );
849 + $this->invalidate_table_exists_cache( $this->get_table_name() );
452 850 if ( ! $this->table_exists() ) {
851 + throw new Exception( \esc_html( "SQL: $sql\nResult: " . implode( '. ', $result ) ) );
852 + }
853 + }
854 +
855 + /**
856 + * Make sure that the required tables for the migration are created.
857 + *
858 + * @throws Exception If cannot prepare tables for migration.
859 + */
860 + public function prepare_tables_for_migration() {
861 + // Rename the table to legacy table if it doesn't exist.
862 + if ( ! $this->table_exists( true ) && false === $this->db->query( "RENAME TABLE {$this->get_table_name()} TO {$this->get_legacy_table_name()};" ) ) {
863 + throw new Exception( \esc_html( "Could not rename table {$this->get_table_name()} to {$this->get_legacy_table_name()}" ) );
864 + }
865 + $this->invalidate_table_exists_cache( $this->get_table_name() );
866 + $this->invalidate_table_exists_cache( $this->get_legacy_table_name() );
867 +
868 + if ( ! $this->table_exists() ) {
869 + // Create the table if not exists.
870 + $this->create_table();
871 +
872 + // Add the auto-increment next value to the new table.
873 + $raw_id = $this->db->get_var( "SELECT MAX(id) FROM {$this->get_legacy_table_name()};" );
874 + $auto_increment = null !== $raw_id && is_numeric( $raw_id ) ? (int) $raw_id + 1 : 1;
875 + if ( false === $this->db->query( "ALTER TABLE {$this->get_table_name()} AUTO_INCREMENT = {$auto_increment};" ) ) {
876 + throw new Exception( \esc_html( "Could not set auto-increment value for table {$this->get_table_name()} to {$auto_increment}" ) );
877 + }
878 + }
879 + }
880 +
881 + /**
882 + * Evaluates if the legacy table should be removed and if so, removes it.
883 + *
884 + * @return bool True if the legacy table was removed or did not exist, false otherwise.
885 + */
886 + public function maybe_remove_legacy_table() {
887 + if ( ! $this->table_exists( true ) ) {
888 + return true;
889 + }
890 + $raw_results = $this->db->get_results( "SELECT 1 FROM `{$this->get_legacy_table_name()}` LIMIT 1;", ARRAY_A );
891 + if ( ! empty( $raw_results ) ) {
892 + // Legacy table is not empty, do not remove it.
453 893 return false;
454 894 }
455 - return false !== $this->db->query( 'DELETE FROM ' . sanitize_text_field( $this->get_table_name() ) );
895 + $dropped = false !== $this->db->query( "DROP TABLE IF EXISTS `{$this->get_legacy_table_name()}`;" );
896 + if ( $dropped ) {
897 + $this->invalidate_table_exists_cache( $this->get_legacy_table_name() );
898 + $this->bump_data_version();
899 + }
900 + return $dropped;
456 901 }
457 902
458 903 /**
459 - * Check if table exists in the database.
904 + * Get a list of indexes that are required for the table.
905 + *
906 + * @return Table_Index[] The list of indexes.
460 907 */
461 - protected function table_exists(): bool {
462 - $table_name = sanitize_text_field( $this->get_table_name() );
463 - return $this->db->get_var( "SHOW TABLES LIKE '{$table_name}'" ) === $table_name;
908 + public function get_required_indexes() {
909 + return array(
910 + new Table_Index( $this->get_table_name() . '_type', array( new Table_Index_Column( 'type', 64 ) ) ),
911 + );
912 + }
913 +
914 + /**
915 + * Check if entity exists in the database.
916 + *
917 + * @param int $id Entity ID.
918 + * @param bool $legacy If true, check for legacy table.
919 + * @return bool True if entity exists, false otherwise.
920 + */
921 + protected function entity_exists( $id, $legacy = false ): bool {
922 + $table_name = $legacy ? $this->get_legacy_table_name() : $this->get_table_name();
923 + $raw_results = $this->db->get_results( "SELECT 1 FROM `$table_name` WHERE id = {$id} LIMIT 1;", ARRAY_A );
924 + return ! empty( $raw_results );
464 925 }
465 926 }