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 +199 -26 3.2.14.3.0 View file →
@@ -32,8 +32,33 @@
32 32 */
33 33 protected $entity_class;
34 34
35 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 + /**
36 61 * Database session object.
37 62 *
38 63 * @var \wpdb
39 64 */
@@ -39,8 +64,15 @@
39 64 */
40 65 protected $db;
41 66
42 67 /**
68 + * Cache repository.
69 + *
70 + * @var Interface_Cache_Repository
71 + */
72 + protected $cache;
73 +
74 + /**
43 75 * Returns unprefixed table name.
44 76 */
45 77 abstract protected function get_unprefixed_table_name(): string;
46 78
@@ -69,12 +101,34 @@
69 101 $db = ServiceRegister::getService( \wpdb::class );
70 102 if ( ! $db instanceof \wpdb ) {
71 103 throw new \RuntimeException( 'Database service not found.' );
72 104 }
73 - $this->db = $db;
105 + $this->db = $db;
106 + $this->cache = ServiceRegister::getService( Interface_Cache_Repository::class );
74 107 }
75 108
76 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 + /**
77 131 * Returns full class name.
78 132 *
79 133 * @return string Full class name.
80 134 */
@@ -96,14 +150,14 @@
96 150
97 151 /**
98 152 * Executes select query.
99 153 *
100 - * @param QueryFilter $filter Filter for query.
154 + * @param QueryFilter|null $filter Filter for query.
101 155 *
102 156 * @return Entity[] A list of found entities ot empty array.
103 157 * @throws QueryFilterInvalidParamException If filter condition is invalid.
104 158 */
105 - public function select( QueryFilter $filter = null ) {
159 + public function select( ?QueryFilter $filter = null ) {
106 160 /**
107 161 * Entity object.
108 162 *
109 163 * @var Entity $entity
@@ -109,14 +163,26 @@
109 163 * @var Entity $entity
110 164 */
111 165 $entity = new $this->entity_class();
112 166 $type = $entity->getConfig()->getType();
113 -
167 +
114 168 $query = "SELECT * FROM {$this->get_table_name()} WHERE type = '$type' ";
115 169 if ( $filter ) {
116 170 $query .= $this->apply_query_filter( $filter, IndexHelper::mapFieldsToIndexes( $entity ) );
117 171 }
118 -
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 +
119 185 $raw_results = array();
120 186 if ( $this->table_exists() ) {
121 187 $raw_results = $this->db->get_results( $query, ARRAY_A );
122 188 if ( ! is_array( $raw_results ) ) {
@@ -124,10 +190,10 @@
124 190 }
125 191 }
126 192 if ( $this->table_exists( true ) ) {
127 193 // If the legacy table exists the data may be there.
128 - $query = str_replace( $this->get_table_name(), $this->get_legacy_table_name(), $query );
129 - $legacy_raw_results = $this->db->get_results( $query, ARRAY_A );
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 );
130 196 if ( ! is_array( $legacy_raw_results ) ) {
131 197 $legacy_raw_results = array();
132 198 }
133 199 $raw_results = array_merge( $raw_results, $legacy_raw_results );
@@ -132,20 +198,26 @@
132 198 }
133 199 $raw_results = array_merge( $raw_results, $legacy_raw_results );
134 200 }
135 201
136 - return $this->translateToEntities( $raw_results );
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;
137 209 }
138 210
139 211 /**
140 212 * Executes select query and returns first result.
141 213 *
142 - * @param QueryFilter $filter Filter for query.
214 + * @param QueryFilter|null $filter Filter for query.
143 215 *
144 216 * @return Entity|null First found entity or NULL.
145 217 * @throws QueryFilterInvalidParamException If filter condition is invalid.
146 218 */
147 - public function selectOne( QueryFilter $filter = null ) {
219 + public function selectOne( ?QueryFilter $filter = null ) {
148 220 if ( ! $filter ) {
149 221 $filter = new QueryFilter();
150 222 }
151 223
@@ -172,9 +244,12 @@
172 244
173 245 return $entity->getId();
174 246 }
175 247
176 - return $this->save_entity_to_storage( $entity );
248 + $id = $this->save_entity_to_storage( $entity );
249 + $this->bump_data_version();
250 +
251 + return $id;
177 252 }
178 253
179 254 /**
180 255 * Executes update query and returns success flag.
@@ -210,12 +285,17 @@
210 285 return false;
211 286 }
212 287 // Delete the row from the legacy table.
213 288 $this->db->delete( $this->get_legacy_table_name(), $where );
289 + $this->bump_data_version();
214 290 return true;
215 291 }
216 292 // Only one record should be updated.
217 - return 1 === $this->db->update( $this->get_table_name(), $item, $where );
293 + $updated = 1 === $this->db->update( $this->get_table_name(), $item, $where );
294 + if ( $updated ) {
295 + $this->bump_data_version();
296 + }
297 + return $updated;
218 298 }
219 299
220 300 /**
221 301 * Executes delete query and returns success flag.
@@ -235,8 +315,11 @@
235 315 // Delete from legacy table.
236 316 $result = $this->db->delete( $this->get_legacy_table_name(), $where );
237 317 $deleted = $deleted || ! empty( $result );
238 318 }
319 + if ( $deleted ) {
320 + $this->bump_data_version();
321 + }
239 322 return $deleted;
240 323 }
241 324
242 325 /**
@@ -241,14 +324,14 @@
241 324
242 325 /**
243 326 * Counts records that match filter criteria.
244 327 *
245 - * @param QueryFilter $filter Filter for query.
328 + * @param QueryFilter|null $filter Filter for query.
246 329 *
247 330 * @return int Number of records that match filter criteria.
248 331 * @throws QueryFilterInvalidParamException If filter condition is invalid.
249 332 */
250 - public function count( QueryFilter $filter = null ) {
333 + public function count( ?QueryFilter $filter = null ) {
251 334 /**
252 335 * Entity object.
253 336 *
254 337 * @var Entity $entity
@@ -259,8 +342,21 @@
259 342 $query = "SELECT COUNT(*) as `total` FROM {$this->get_table_name()} WHERE type = '$type' ";
260 343 if ( $filter ) {
261 344 $query .= $this->apply_query_filter( $filter, IndexHelper::mapFieldsToIndexes( $entity ) );
262 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 +
263 359 $count = 0;
264 360 if ( $this->table_exists() ) {
265 361 $result = $this->db->get_results( $query, ARRAY_A );
266 362 $count += empty( $result[0]['total'] ) || ! is_numeric( $result[0]['total'] ) ? 0 : (int) $result[0]['total'];
@@ -266,12 +362,17 @@
266 362 $count += empty( $result[0]['total'] ) || ! is_numeric( $result[0]['total'] ) ? 0 : (int) $result[0]['total'];
267 363 }
268 364 if ( $this->table_exists( true ) ) {
269 365 // If the legacy table exists, count the data there too.
270 - $query = str_replace( $this->get_table_name(), $this->get_legacy_table_name(), $query );
271 - $result = $this->db->get_results( $query, ARRAY_A );
272 - $count += empty( $result[0]['total'] ) || ! is_numeric( $result[0]['total'] ) ? 0 : (int) $result[0]['total'];
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'];
273 369 }
370 +
371 + if ( $is_cacheable ) {
372 + $this->cache->set( $cache_key, $count, self::DATA_CACHE_GROUP, self::CACHE_TTL );
373 + }
374 +
274 375 return $count;
275 376 }
276 377
277 378 /**
@@ -281,9 +382,9 @@
281 382 *
282 383 * @return string Escaped value.
283 384 */
284 385 protected function escape( $value ) {
285 - return addslashes( strval( $value ) );
386 + return addslashes( \strval( $value ) );
286 387 }
287 388
288 389 /**
289 390 * Checks if value exists and escapes it if it's not.
@@ -341,9 +442,9 @@
341 442 */
342 443 $values = $condition->getValue();
343 444 $escaped_values = array();
344 445 foreach ( $values as $value ) {
345 - $escaped_values[] = is_string( $value ) ? $this->escape_value( $value ) : $value;
446 + $escaped_values[] = \is_string( $value ) ? $this->escape_value( $value ) : $value;
346 447 }
347 448
348 449 $value = '(' . implode( ', ', $escaped_values ) . ')';
349 450 break;
@@ -419,9 +520,9 @@
419 520 */
420 521 if ( ! isset( $item['data'] ) || ! isset( $item['id'] ) ) {
421 522 continue;
422 523 }
423 - $data = (array) json_decode( strval( $item['data'] ), true );
524 + $data = (array) json_decode( \strval( $item['data'] ), true );
424 525 /**
425 526 * Entity object.
426 527 *
427 528 * @var Entity $entity
@@ -501,9 +602,9 @@
501 602 *
502 603 * @return void
503 604 */
504 605 protected function validate_index_column( $column, array $index_map ) {
505 - if ( 'id' !== $column && ! array_key_exists( $column, $index_map ) ) {
606 + if ( 'id' !== $column && ! \array_key_exists( $column, $index_map ) ) {
506 607 throw new QueryFilterInvalidParamException( esc_html__( 'Column is not id or index.', 'sequra' ) );
507 608 }
508 609 }
509 610
@@ -529,8 +630,11 @@
529 630 if ( $this->table_exists( true ) ) {
530 631 $result = $this->db->query( str_replace( $this->get_table_name(), $this->get_legacy_table_name(), $sql ) );
531 632 $deleted = $deleted || ! empty( $result );
532 633 }
634 + if ( $deleted ) {
635 + $this->bump_data_version();
636 + }
533 637 return $deleted;
534 638 }
535 639
536 640 /**
@@ -548,12 +652,72 @@
548 652 * @param boolean $legacy If true, check for legacy table.
549 653 */
550 654 public function table_exists( $legacy = false ): bool {
551 655 $table_name = \sanitize_text_field( ! $legacy ? $this->get_table_name() : $this->get_legacy_table_name() );
552 - return $this->db->get_var( "SHOW TABLES LIKE '{$table_name}'" ) === $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;
553 672 }
554 673
555 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 + /**
556 720 * Remove entities that are older than a certain date or that are invalid.
557 721 * This performs a cleanup of the repository data.
558 722 */
559 723 public function delete_old_and_invalid() {
@@ -598,9 +762,9 @@
598 762 if ( ! $this->table_exists() || ! $this->table_exists( true ) ) {
599 763 return;
600 764 }
601 765 $raw_results = $this->db->get_results( "SELECT * FROM {$this->get_legacy_table_name()} LIMIT 1;", ARRAY_A );
602 - if ( ! is_array( $raw_results ) ) {
766 + if ( ! \is_array( $raw_results ) ) {
603 767 return;
604 768 }
605 769
606 770 $entity = $this->translateToEntities( $raw_results )[0] ?? null;
@@ -616,8 +780,9 @@
616 780 $result = $this->db->insert( $this->get_table_name(), $storage_item );
617 781 if ( false !== $result ) {
618 782 // Delete the row from the legacy table.
619 783 $this->db->delete( $this->get_legacy_table_name(), array( 'id' => $entity->getId() ) );
784 + $this->bump_data_version();
620 785 }
621 786 }
622 787
623 788 /**
@@ -677,11 +842,12 @@
677 842 if ( ! empty( $indexes ) ) {
678 843 $indexes = ', ' . $indexes;
679 844 }
680 845
681 - $sql = sprintf( $this->get_create_table_sql(), $indexes );
846 + $sql = \sprintf( $this->get_create_table_sql(), $indexes );
682 847 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
683 848 $result = \dbDelta( $sql );
849 + $this->invalidate_table_exists_cache( $this->get_table_name() );
684 850 if ( ! $this->table_exists() ) {
685 851 throw new Exception( \esc_html( "SQL: $sql\nResult: " . implode( '. ', $result ) ) );
686 852 }
687 853 }
@@ -695,10 +861,12 @@
695 861 // Rename the table to legacy table if it doesn't exist.
696 862 if ( ! $this->table_exists( true ) && false === $this->db->query( "RENAME TABLE {$this->get_table_name()} TO {$this->get_legacy_table_name()};" ) ) {
697 863 throw new Exception( \esc_html( "Could not rename table {$this->get_table_name()} to {$this->get_legacy_table_name()}" ) );
698 864 }
865 + $this->invalidate_table_exists_cache( $this->get_table_name() );
866 + $this->invalidate_table_exists_cache( $this->get_legacy_table_name() );
699 867
700 - if ( ! $this->table_exists() ) {
868 + if ( ! $this->table_exists() ) {
701 869 // Create the table if not exists.
702 870 $this->create_table();
703 871
704 872 // Add the auto-increment next value to the new table.
@@ -723,9 +891,14 @@
723 891 if ( ! empty( $raw_results ) ) {
724 892 // Legacy table is not empty, do not remove it.
725 893 return false;
726 894 }
727 - return false !== $this->db->query( "DROP TABLE IF EXISTS `{$this->get_legacy_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;
728 901 }
729 902
730 903 /**
731 904 * Get a list of indexes that are required for the table.