PluginProbe
seQura / trunk
seQura vtrunk
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
sequra / src / Repositories / class-repository.php

class-repository.php in seQura trunk, at src/Repositories/class-repository.php

927 lines 26.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Shared repository functionality.
4 *
5 * @package SeQura/WC
6 * @subpackage SeQura/WC/Repositories
7 */
8
9 namespace SeQura\WC\Repositories;
10
11 use Exception;
12 use SeQura\Core\Infrastructure\ORM\Entity;
13 use SeQura\Core\Infrastructure\ORM\Exceptions\QueryFilterInvalidParamException;
14 use SeQura\Core\Infrastructure\ORM\Interfaces\RepositoryInterface;
15 use SeQura\Core\Infrastructure\ORM\QueryFilter\QueryCondition;
16 use SeQura\Core\Infrastructure\ORM\QueryFilter\QueryFilter;
17 use SeQura\Core\Infrastructure\ORM\Utility\IndexHelper;
18 use SeQura\Core\Infrastructure\ServiceRegister;
19 use SeQura\WC\Dto\Table_Index;
20 use SeQura\WC\Dto\Table_Index_Column;
21 use wpdb;
22
23 /**
24 * Shared repository functionality.
25 */
26 abstract class Repository implements RepositoryInterface, Interface_Deletable_Repository, Interface_Table_Migration_Repository {
27
28 /**
29 * Entity class FQN.
30 *
31 * @var string
32 */
33 protected $entity_class;
34
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 /**
61 * Database session object.
62 *
63 * @var \wpdb
64 */
65 protected $db;
66
67 /**
68 * Cache repository.
69 *
70 * @var Interface_Cache_Repository
71 */
72 protected $cache;
73
74 /**
75 * Returns unprefixed table name.
76 */
77 abstract protected function get_unprefixed_table_name(): string;
78
79 /**
80 * Returns full table name.
81 */
82 public function get_table_name(): string {
83 return $this->db->prefix . $this->get_unprefixed_table_name();
84 }
85
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 /**
96 * Constructor.
97 *
98 * @throws \RuntimeException If database service not found.
99 */
100 public function __construct() {
101 $db = ServiceRegister::getService( \wpdb::class );
102 if ( ! $db instanceof \wpdb ) {
103 throw new \RuntimeException( 'Database service not found.' );
104 }
105 $this->db = $db;
106 $this->cache = ServiceRegister::getService( Interface_Cache_Repository::class );
107 }
108
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 /**
131 * Returns full class name.
132 *
133 * @return string Full class name.
134 */
135 public static function getClassName() {
136 return __CLASS__;
137 }
138
139 /**
140 * Sets repository entity
141 *
142 * @noinspection PhpDocMissingThrowsInspection
143 *
144 * @param string $entity_class Entity class.
145 * @return void
146 */
147 public function setEntityClass( $entity_class ): void {
148 $this->entity_class = $entity_class;
149 }
150
151 /**
152 * Executes select query.
153 *
154 * @param QueryFilter|null $filter Filter for query.
155 *
156 * @return Entity[] A list of found entities ot empty array.
157 * @throws QueryFilterInvalidParamException If filter condition is invalid.
158 */
159 public function select( ?QueryFilter $filter = null ) {
160 /**
161 * Entity object.
162 *
163 * @var Entity $entity
164 */
165 $entity = new $this->entity_class();
166 $type = $entity->getConfig()->getType();
167
168 $query = "SELECT * FROM {$this->get_table_name()} WHERE type = '$type' ";
169 if ( $filter ) {
170 $query .= $this->apply_query_filter( $filter, IndexHelper::mapFieldsToIndexes( $entity ) );
171 }
172
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 }
183 }
184
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;
209 }
210
211 /**
212 * Executes select query and returns first result.
213 *
214 * @param QueryFilter|null $filter Filter for query.
215 *
216 * @return Entity|null First found entity or NULL.
217 * @throws QueryFilterInvalidParamException If filter condition is invalid.
218 */
219 public function selectOne( ?QueryFilter $filter = null ) {
220 if ( ! $filter ) {
221 $filter = new QueryFilter();
222 }
223
224 $filter->setLimit( 1 );
225 $results = $this->select( $filter );
226
227 return ! empty( $results ) ? $results[0] : null;
228 }
229
230 /**
231 * Executes insert query and returns ID of created entity. Entity will be updated with new ID.
232 *
233 * @param Entity $entity Entity to be saved.
234 *
235 * @return int Identifier of saved entity.
236 */
237 public function save( Entity $entity ) {
238 if ( ! $this->table_exists() ) {
239 return -1;
240 }
241
242 if ( $entity->getId() ) {
243 $this->update( $entity );
244
245 return $entity->getId();
246 }
247
248 $id = $this->save_entity_to_storage( $entity );
249 $this->bump_data_version();
250
251 return $id;
252 }
253
254 /**
255 * Executes update query and returns success flag.
256 *
257 * @param Entity $entity Entity to be updated.
258 *
259 * @return bool TRUE if operation succeeded; otherwise, FALSE.
260 */
261 public function update( Entity $entity ) {
262 if ( ! $this->table_exists() ) {
263 return false;
264 }
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 }
292 // Only one record should be updated.
293 $updated = 1 === $this->db->update( $this->get_table_name(), $item, $where );
294 if ( $updated ) {
295 $this->bump_data_version();
296 }
297 return $updated;
298 }
299
300 /**
301 * Executes delete query and returns success flag.
302 *
303 * @param Entity $entity Entity to be deleted.
304 *
305 * @return bool TRUE if operation succeeded; otherwise, FALSE.
306 */
307 public function delete( Entity $entity ) {
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 );
313 }
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;
323 }
324
325 /**
326 * Counts records that match filter criteria.
327 *
328 * @param QueryFilter|null $filter Filter for query.
329 *
330 * @return int Number of records that match filter criteria.
331 * @throws QueryFilterInvalidParamException If filter condition is invalid.
332 */
333 public function count( ?QueryFilter $filter = null ) {
334 /**
335 * Entity object.
336 *
337 * @var Entity $entity
338 */
339 $entity = new $this->entity_class();
340 $type = $entity->getConfig()->getType();
341
342 $query = "SELECT COUNT(*) as `total` FROM {$this->get_table_name()} WHERE type = '$type' ";
343 if ( $filter ) {
344 $query .= $this->apply_query_filter( $filter, IndexHelper::mapFieldsToIndexes( $entity ) );
345 }
346
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 );
350
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;
376 }
377
378 /**
379 * Escapes provided value.
380 *
381 * @param mixed $value Value to be escaped.
382 *
383 * @return string Escaped value.
384 */
385 protected function escape( $value ) {
386 return addslashes( \strval( $value ) );
387 }
388
389 /**
390 * Checks if value exists and escapes it if it's not.
391 *
392 * @param mixed $value Value to be escaped.
393 *
394 * @return string Escaped value.
395 */
396 protected function escape_value( $value ) {
397 return null === $value ? 'NULL' : "'" . $this->escape( $value ) . "'";
398 }
399
400 /**
401 * Builds WHERE part of select query.
402 *
403 * @param mixed[]$filter_by Filter conditions in query.
404 *
405 * @return string Where condition.
406 */
407 protected function build_condition( $filter_by ) {
408 if ( empty( $filter_by ) ) {
409 return '';
410 }
411
412 $where = array();
413 foreach ( $filter_by as $key => $value ) {
414 if ( null === $value ) {
415 $where[] = "`$key` IS NULL";
416 } else {
417 $where[] = "`$key` = '" . $this->escape( $value ) . "'";
418 }
419 }
420
421 return ' WHERE ' . implode( ' AND ', $where );
422 }
423
424 /**
425 * Converts filter value to index string representation.
426 *
427 * @param QueryCondition $condition Query condition.
428 *
429 * @return string|null Converted value.
430 */
431 protected function convert_value( QueryCondition $condition ) {
432 $value = IndexHelper::castFieldValue( $condition->getValue(), $condition->getValueType() );
433 switch ( $condition->getValueType() ) {
434 case 'string':
435 $value = $this->escape_value( $condition->getValue() );
436 break;
437 case 'array':
438 /**
439 * Values
440 *
441 * @var mixed[] $values
442 */
443 $values = $condition->getValue();
444 $escaped_values = array();
445 foreach ( $values as $value ) {
446 $escaped_values[] = \is_string( $value ) ? $this->escape_value( $value ) : $value;
447 }
448
449 $value = '(' . implode( ', ', $escaped_values ) . ')';
450 break;
451 default:
452 // 'integer', 'dateTime','boolean','double'
453 $value = $this->escape_value( $value );
454 break;
455 }
456
457 return $value;
458 }
459
460 /**
461 * Builds query filter part of the query.
462 *
463 * @param QueryFilter $filter Query filter object.
464 * @param mixed[] $field_index_map Property to index number map.
465 *
466 * @return string Query filter addendum.
467 * @throws QueryFilterInvalidParamException If filter condition is invalid.
468 */
469 protected function apply_query_filter( QueryFilter $filter, array $field_index_map = array() ) {
470 $query = '';
471 $conditions = $filter->getConditions();
472 if ( ! empty( $conditions ) ) {
473 $query .= ' AND (';
474 $first = true;
475 foreach ( $conditions as $condition ) {
476 $this->validate_index_column( $condition->getColumn(), $field_index_map );
477 $chain_op = $first ? '' : $condition->getChainOperator();
478 $first = false;
479 $column = 'id' === $condition->getColumn() ? 'id' : 'index_' . $field_index_map[ $condition->getColumn() ];
480 $operator = $condition->getOperator();
481 $query .= " $chain_op $column $operator " . $this->convert_value( $condition );
482 }
483
484 $query .= ')';
485 }
486
487 if ( $filter->getOrderByColumn() ) {
488 $this->validate_index_column( $filter->getOrderByColumn(), $field_index_map );
489 $order_index = 'id' === $filter->getOrderByColumn() ? 'id' : 'index_' . $field_index_map[ $filter->getOrderByColumn() ];
490 $query .= " ORDER BY {$order_index} {$filter->getOrderDirection()}";
491 }
492
493 if ( $filter->getLimit() ) {
494 $offset = (int) $filter->getOffset();
495 $query .= " LIMIT {$offset}, {$filter->getLimit()}";
496 }
497
498 return $query;
499 }
500
501 /**
502 * Transforms raw database query rows to entities.
503 *
504 * @param mixed[]$result Raw database query result.
505 *
506 * @return Entity[] Array of transformed entities.
507 */
508 protected function translateToEntities( array $result ) {
509 /**
510 * Array of decoded entities.
511 *
512 * @var Entity[] $entities
513 */
514 $entities = array();
515 foreach ( $result as $item ) {
516 /**
517 * Raw data.
518 *
519 * @var mixed[] $item
520 */
521 if ( ! isset( $item['data'] ) || ! isset( $item['id'] ) ) {
522 continue;
523 }
524 $data = (array) json_decode( \strval( $item['data'] ), true );
525 /**
526 * Entity object.
527 *
528 * @var Entity $entity
529 */
530 $entity = isset( $data['class_name'] ) ? new $data['class_name']() : new $this->entity_class();
531 $entity->inflate( $data );
532 if ( is_numeric( $item['id'] ) ) {
533 $entity->setId( (int) $item['id'] );
534 }
535
536 $entities[] = $entity;
537 }
538
539 return $entities;
540 }
541
542 /**
543 * Saves entity to system storage.
544 *
545 * @param Entity $entity Entity to be stored.
546 *
547 * @return int Inserted entity identifier.
548 */
549 protected function save_entity_to_storage( Entity $entity ) {
550 if ( ! $this->table_exists() ) {
551 return -1;
552 }
553 $storage_item = $this->prepare_entity_for_storage( $entity );
554
555 $this->db->insert( $this->get_table_name(), $storage_item );
556
557 $insert_id = (int) $this->db->insert_id;
558 $entity->setId( $insert_id );
559
560 return $insert_id;
561 }
562
563 /**
564 * Prepares entity in format for storage.
565 *
566 * @param Entity $entity Entity to be stored.
567 *
568 * @return mixed[] Item prepared for storage.
569 */
570 protected function prepare_entity_for_storage( Entity $entity ) {
571 $indexes = IndexHelper::transformFieldsToIndexes( $entity );
572 $storage_item = array(
573 'type' => $entity->getConfig()->getType(),
574 'index_1' => null,
575 'index_2' => null,
576 'index_3' => null,
577 'index_4' => null,
578 'index_5' => null,
579 'index_6' => null,
580 'index_7' => null,
581 'data' => \wp_json_encode( $entity->toArray() ),
582 );
583
584 if ( $entity->getId() ) {
585 $storage_item['id'] = $entity->getId();
586 }
587
588 foreach ( $indexes as $index => $value ) {
589 $storage_item[ 'index_' . $index ] = $value;
590 }
591
592 return $storage_item;
593 }
594
595 /**
596 * Validates if column can be filtered or sorted by.
597 *
598 * @param string $column Column name.
599 * @param mixed[] $index_map Index map.
600 *
601 * @throws QueryFilterInvalidParamException If filter condition is invalid.
602 *
603 * @return void
604 */
605 protected function validate_index_column( $column, array $index_map ) {
606 if ( 'id' !== $column && ! \array_key_exists( $column, $index_map ) ) {
607 throw new QueryFilterInvalidParamException( esc_html__( 'Column is not id or index.', 'sequra' ) );
608 }
609 }
610
611 /**
612 * Delete all the entities.
613 *
614 * @param string|null $store_id Delete entities from this store. Passing null will delete all entities.
615 */
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() );
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.
893 return false;
894 }
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;
901 }
902
903 /**
904 * Get a list of indexes that are required for the table.
905 *
906 * @return Table_Index[] The list of indexes.
907 */
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 );
925 }
926 }
927