diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7e1fe39 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +/.github export-ignore +/.vscode export-ignore +/.editorconfig export-ignore +/.gitignore export-ignore +/.gitattributes export-ignore +/docker-compose.yml export-ignore +/package-lock.json export-ignore +/phpstan.neon export-ignore +/phpunit.xml export-ignore +/pint.json export-ignore +/tests export-ignore diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4eabe3e..29dd64a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -63,5 +63,105 @@ jobs: - name: Install dependencies run: composer update --prefer-dist --no-interaction --no-progress + - name: Run Pint + run: composer lint + - name: Run Larastan run: vendor/bin/phpstan analyse --memory-limit=512M --error-format=github + + database: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + include: + - label: MySQL + driver: mysql + image: mysql:8.4 + port: 3306 + container_port: 3306 + username: root + password: normcache + options: >- + --health-cmd "mysqladmin ping --password=normcache" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + + - label: PostgreSQL + driver: pgsql + image: postgres:17 + port: 5432 + container_port: 5432 + username: postgres + password: normcache + options: >- + --health-cmd "pg_isready -U postgres -d normcache" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + + - label: MariaDB + driver: mariadb + image: mariadb:11.4 + port: 3307 + container_port: 3306 + username: root + password: normcache + options: >- + --health-cmd "healthcheck.sh --connect --innodb_initialized" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + + name: Database / ${{ matrix.label }} + + services: + redis: + image: redis:7 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 3s + --health-retries 5 + + database: + image: ${{ matrix.image }} + env: + MYSQL_DATABASE: normcache + MYSQL_ROOT_PASSWORD: normcache + POSTGRES_DB: normcache + POSTGRES_PASSWORD: normcache + MARIADB_DATABASE: normcache + MARIADB_ROOT_PASSWORD: normcache + ports: + - ${{ matrix.port }}:${{ matrix.container_port }} + options: ${{ matrix.options }} + + steps: + - uses: actions/checkout@v6 + + - uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + extensions: redis, pdo_mysql, pdo_pgsql + coverage: none + + - name: Install dependencies + run: composer update --prefer-dist --no-interaction --no-progress + + - name: Run database contract and cascade tests + env: + TEST_DB_DRIVER: ${{ matrix.driver }} + TEST_DB_HOST: 127.0.0.1 + TEST_DB_PORT: ${{ matrix.port }} + TEST_DB_DATABASE: normcache + TEST_DB_USERNAME: ${{ matrix.username }} + TEST_DB_PASSWORD: ${{ matrix.password }} + run: >- + vendor/bin/phpunit + tests/Integration/Contract + tests/Integration/Cache/DeleteInvalidationTest.php diff --git a/.gitignore b/.gitignore index 5601783..e2b0f71 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,5 @@ composer.lock .phpunit.result.cache .phpunit.cache/ tests/Benchmark/ -tests/Concerns/ docs .worktrees/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c685eb..90c774f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [4.0.0] — 2026-08-04 + +### Added + +- **Membership revalidation:** a cached membership survives a version bump when every version in the gap was a precise `UPDATE` whose columns are disjoint from the query's predicate and order columns. Configure with `revalidation`, and declare `protected array $volatileColumns` for columns the database writes without the statement naming them. +- **Automatic result overlays:** eligible canonical queries store a complete result payload for faster warm reads and rebuild it from canonical rows when the overlay is missing. +- **Unified query controls:** `dependsOn()` accepts models and table names, `tag()` groups related queries, `NormCache::invalidate()` accepts model or table targets, and `NormCache::withoutCache()` runs a whole callback, eager loads included, against the database. +- **Runtime cache switch:** `normcache:disable` and `normcache:enable`, plus their facade equivalents, pause and safely resume caching across application nodes. +- **Database source scopes:** `database.connections..normcache_scope` isolates shards or tenants and allows aliases for the same source to share cache state deliberately. +- **Serializer selection:** choose `auto`, `php`, or `igbinary` payload serialization. + +### Changed + +- **BREAKING cache layout:** cache spaces were removed and Redis placement is now derived from physical tables and query groups. Cache keys changed, so v3 and v4 must not serve traffic together. Run `normcache:disable`, deploy every web and worker node, then run `normcache:enable`. +- **BREAKING API consolidation:** replace `dependsOnTables()` with `dependsOn()`, legacy invalidation methods with `NormCache::invalidate()`, and model-scoped tag flushing with `flushTag('name')`. +- **BREAKING flush behavior:** `normcache:flush` now always performs a global invalidation; `--model` and `--space` were removed. +- Model attribute lifetime is configured with `row_ttl` / `NORMCACHE_ROW_TTL` rather than `ttl` / `NORMCACHE_TTL`, and the key-prefix variable is now `NORMCACHE_KEY_PREFIX` rather than `NORMCACHE_PREFIX`. + +### Fixed + +- Improved cache isolation for connection aliases, shards, tenants, and runtime database switching. +- Manual and automatic invalidation now handle connection-aware table names, unknown write targets, and writes that throw after reaching the database safely. +- Improved build-lock and Redis reconnect behavior when responses are lost or operations are retried. + +### Removed + +- Cache spaces, `$normCacheSpaces`, `space()`, the `spaces` configuration, and space-targeted flushing. +- Legacy `cooldown`, `fallback`, and `fire_retrieved` options, `Builder::explain()`, and the cache-manager facade accessors (`modelCache()`, `resultCache()`, `versionStore()`, and friends). +- The `ModelCacheHit`, `ModelCacheMiss`, and `CacheMetricRecorded` events. +- The `stampede_wake_tokens` option and `NORMCACHE_STAMPEDE_WAKE_TOKENS`; the wake-token count is now fixed. +- Environment overrides for build-lock tuning: set `building_lock_ttl` and `stampede_wait_ms` in the published config file, not `NORMCACHE_BUILDING_LOCK_TTL` / `NORMCACHE_STAMPEDE_WAIT_MS`. +- The relation-specific cache classes; relation and pivot reads are cached through the same query path as everything else. + +--- + ## [3.1.0] — 2026-07-23 ### Added diff --git a/README.md b/README.md index c16c794..cb649a9 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,15 @@ -# Laravel Normcache +# Laravel NormCache -**Normalized, self-invalidating Redis caching for Laravel Eloquent.** +**Redis-backed normalized query caching for Laravel Eloquent.** [![Tests](https://github.com/kai-init/laravel-normcache/actions/workflows/tests.yml/badge.svg)](https://github.com/kai-init/laravel-normcache/actions/workflows/tests.yml) [![PHPStan](https://img.shields.io/badge/PHPStan-level%205-brightgreen.svg)](phpstan.neon) [![Latest Version on Packagist](https://img.shields.io/packagist/v/kai-init/laravel-normcache.svg)](https://packagist.org/packages/kai-init/laravel-normcache) [![License](https://img.shields.io/github/license/kai-init/laravel-normcache.svg)](LICENSE) -Normcache caches model-query results as ID lists and stores model attributes in versioned model keys. When a model changes, Normcache bumps a version key instead of scanning and deleting every query that may have returned that model. +NormCache stores complete model rows once and lets many cached queries share them. Invalidation is `O(1)`: a write bumps Redis counters instead of scanning and deleting every query that might contain a changed row. -**Requirements:** PHP 8.2+, Laravel 12/13, Redis 6.0+ - -## Table of Contents - -- [Installation](#installation) -- [What's new in v3](#whats-new-in-v3) -- [Usage](#usage) -- [Invalidation](#invalidation) -- [Cache spaces](#cache-spaces) -- [Configuration](#configuration) -- [Bypasses and limitations](#bypasses-and-limitations) -- [Observability](#observability) -- [License](#license) +Requirements: PHP 8.2+, Laravel 12/13, Redis 6.0+. ## Installation @@ -29,10 +17,15 @@ Normcache caches model-query results as ID lists and stores model attributes in composer require kai-init/laravel-normcache ``` -Add `Cacheable` to models you want Normcache to manage: +Publish the configuration: + +```bash +php artisan vendor:publish --tag=normcache-config +``` + +Add `Cacheable` to Eloquent models whose writes and reads NormCache should observe: ```php -use Illuminate\Database\Eloquent\Model; use NormCache\Traits\Cacheable; class Post extends Model @@ -41,209 +34,273 @@ class Post extends Model } ``` -## What's new in v3 - -Redis Cluster sharding is now fully atomic within each cache space. Normcache keeps the keys for a cached operation and its valid dependencies in one hash slot, so cache reads, rebuilds, and invalidation coordination remain atomic. - -- **Cache spaces:** declare `$normCacheSpaces` on a model and select a declared space with `->space()` when needed. -- **Space-targeted flushing:** use `NormCache::flushAll('space')` or `php artisan normcache:flush --space=...`. -- **Named table dependencies:** `dependsOnTables()` works in named spaces and is invalidated with `invalidateTableVersion()`. -- **Upgrade from 2.4:** run `php artisan normcache:flush` before deploying v3 to clear legacy cache keys. - ## Usage -Normal Eloquent reads are cached automatically for cacheable models: +Ordinary reads need no cache-specific call: ```php -Post::where('active', true)->get(); +Post::where('published', true)->get(); Post::find(1); -Post::paginate(20); ``` -Use `withoutCache()` or `ttl()` per query: +Cache controls are available on the model's query builder, including after `toBase()`: ```php -Post::withoutCache()->get(); -Post::where('active', true)->ttl(600)->get(); +Post::query()->withoutCache()->get(); +Post::query()->where('published', true)->ttl(600)->get(); +Post::query()->where('published', true)->tag('homepage')->get(); +Post::query()->cacheContext('tenant:' . $tenantId)->get(); ``` -### Cross-table queries +Use the facade callback when an entire operation, including separately executed eager loads, must read directly from the database: + +```php +$posts = NormCache::withoutCache( + fn() => Post::query()->with('comments')->get(), +); +``` + +Writes inside the callback continue to invalidate NormCache normally. + +## Canonical & Normalized Row Caching + +Unlike traditional query caching, which stores a full copy of every result set, NormCache stores each row once and caches queries as references to it: + +- **Rows stored once**: each database row lives under a single canonical key (`table:r:g:`). +- **Queries store only IDs**: a normalized query stores its ordered primary keys in the `m` field of one query hash (`table:q::`), not as copied model attributes. +- **No `KEYS` or `SCAN`**: updating a model deletes just that row key and bumps the table version counter (`table:ver`). Invalidation cost does not grow with the number of cached queries. +- **One update**: because all queries share the same row key, updating Post #42 refreshes it everywhere on the next read — no per-query cleanup. + +## Automatic Result & Projection Overlay + +For small result sets, NormCache stores the assembled result in the `r` field of the same query hash, so a warm read is a single Redis fetch instead of a membership lookup plus row assembly: + +- **Automatic promotion**: a canonical query is promoted when it returns at most `auto_overlay_max_rows + 1` rows (default `1000`, so up to 1001) and the encoded payload is at most 128 KiB — a fixed cap that keeps wide rows out. +- **Self-healing**: writes to the query's tables invalidate the overlay along with the canonical rows. If the overlay is missing or expired, the read falls back to canonical row assembly and re-promotes. -Simple `whereHas` / `whereDoesntHave` constraints on cacheable relations and plain string joins with an explicit root-table projection are inferred automatically: +## Tags and selective flushing + +Use `tag()` to group related cached queries under a named invalidation namespace: ```php -Author::whereHas('posts', fn($q) => $q->where('published', true))->get(); +$posts = Post::query() + ->where('published', true) + ->tag('homepage') + ->get(); ``` -For other cross-table reads, declare dependencies explicitly: +Any number of different queries can share the same tag, and flushing that tag invalidates all of their query-shaped payloads without affecting untagged queries or queries using another tag: ```php -Author::query()->dependsOn([Post::class])->get(); +NormCache::flushTag('homepage'); +``` + +`flushTag()` advances a Redis version counter; it does not scan for or delete matching keys. The affected queries miss and rebuild on their next read, while old payloads expire naturally. Tags are an additional manual invalidation boundary and do not replace automatic dependency invalidation when an underlying table changes. -Author::join('legacy_stats', 'legacy_stats.author_id', '=', 'authors.id') - ->select('authors.*') - ->dependsOnTables(['legacy_stats']) +## Database security contexts + +Queries whose results depend on implicit database state must declare a stable cache context. This includes PostgreSQL row-level security, SQL Server security policies, active database roles, and tenant-aware session variables that change row visibility without changing SQL or bindings: + +```php +$posts = Post::query() + ->cacheContext('tenant:' . $tenantId) + ->where('published', true) ->get(); ``` -`dependsOnTables()` declares a read dependency only. If that table is changed outside Eloquent, call `NormCache::invalidateTableVersion($connection, $table)` after the write. +The context is hashed into the cache identity and is never written verbatim to Redis. Context-bearing queries use isolated full-result storage rather than shared canonical rows, because the same physical primary key may represent a different visible row in each database security context. + +Apply `cacheContext()` to every cached query affected by the implicit policy, including separately executed eager-load queries. If a stable context is unavailable, use `withoutCache()` instead. Authorization or tenancy already represented in SQL bindings, the database source scope, or physical table identity does not need an additional cache context. + +## Dependencies -### Aggregates and relationships +NormCache infers identifiable tables from ordinary joins, unions, subqueries, and relationship queries. Complex or rejected raw SQL can still be cached with explicit physical dependencies: -`count`, `exists`, `value`, `pluck`, `sum`, `avg`, `min`, `max`, pagination totals, and `withCount` / `withSum` / `withAvg` / `withMin` / `withMax` / `withExists` are cached when their dependencies are safe. +```php +Author::query() + ->whereRaw( + 'exists (select 1 from (select author_id from legacy_stats) as recent where recent.author_id = authors.id)' + ) + ->dependsOn(['legacy_stats']) + ->get(); +``` -Eager-loaded `BelongsTo`, `BelongsToMany`, `MorphTo`, `MorphToMany`, `MorphedByMany`, `HasManyThrough`, and `HasOneThrough` relations are cached. `attach`, `detach`, `sync`, and `updateExistingPivot` invalidate the relevant pivot cache. +`dependsOn()` accepts Eloquent model classes and table names. It authorizes an otherwise opaque query only when NormCache can resolve all declared dependencies. ## Invalidation -Eloquent writes on cacheable models invalidate automatically. For manual invalidation: +Writes issued through a `Cacheable` model invalidate automatically: -```php -use NormCache\Facades\NormCache; +- inside a transaction, invalidation is applied only after the outer transaction commits; +- if a write's target cannot be resolved safely, NormCache advances the global epoch rather than leave reachable stale data; +- if a write throws a database exception, its outcome is uncertain, so its tables are invalidated broadly before the original exception is rethrown. + +Use the facade after writes performed elsewhere: -NormCache::flushModel(Post::class); +```php +NormCache::invalidate([Post::class, Comment::class], connection: 'mysql'); +NormCache::invalidate(['posts', 'comments'], connection: 'mysql'); +NormCache::flushTag('homepage'); NormCache::flushAll(); -NormCache::flushAll('content'); ``` +The global CLI flush takes no options: + ```bash -php artisan normcache:flush --model="App\Models\Post" php artisan normcache:flush -php artisan normcache:flush --space=content ``` -If you mutate cacheable tables outside Eloquent, flush the affected model or table version yourself: +`flushAll()` and the command advance a global epoch. Old payloads expire naturally; NormCache does not scan Redis keys. -```php -DB::table('posts')->update(['published' => true]); -NormCache::flushModel(Post::class); +## Redis invalidation outages + +NormCache fails open when Redis is unavailable: the database write succeeds and that request bypasses the cache. If only the writer loses Redis while other nodes can still read it, those nodes can serve stale data until the entry expires or a later invalidation succeeds. The condition is logged at `critical` with the affected table and invalidation mode. + +After Redis connectivity is restored, run the global flush to advance the epoch and make any payloads from the outage unreachable: + +```bash +php artisan normcache:flush ``` -Tags can group query entries for manual flushing: +## Temporarily disabling the cache -```php -Author::whereHas('posts') - ->dependsOn([Post::class]) - ->tag('homepage') - ->get(); +Use the runtime commands when NormCache needs to be paused across all application nodes without changing configuration or redeploying: -NormCache::flushTag(Author::class, 'homepage'); -NormCache::flushTagAcrossModels('homepage'); +```bash +php artisan normcache:disable +php artisan normcache:enable ``` -## Cache spaces +While disabled, reads bypass NormCache and go directly to the database, and writes do not perform cache invalidation. The switch is stored in Redis and is observed by new requests and jobs across every node. + +`normcache:enable` atomically advances the global epoch before clearing the disabled flag. This prevents payloads cached before the pause from being served after writes occurred while invalidation was disabled. + +Use this deployment sequence: + +```bash +php artisan normcache:disable +# Deploy the new package version to every web and worker node. +php artisan normcache:enable +``` -Cache spaces are Normcache's Redis Cluster sharding boundary. Each space has a Redis hash tag, so a cached operation stays within one Cluster slot. +While disabled, reads bypass Redis and writes intentionally perform no cache invalidation. After every node runs the new code, `normcache:enable` advances the global epoch before clearing the disabled flag, making all payloads from before or during the transition unreachable. -Models without a declaration use the default space (`{nc}`). Declare named spaces with `$normCacheSpaces`: +## Configuration ```php -use Illuminate\Database\Eloquent\Model; -use NormCache\Traits\Cacheable; +return [ + 'enabled' => true, + 'connection' => 'cache', + 'key_prefix' => '', + 'serializer' => 'auto', // auto, php, or igbinary -class Post extends Model -{ - use Cacheable; + 'row_ttl' => 604800, + 'query_ttl' => 3600, + // Set to 0 to disable automatic result overlays. + 'auto_overlay_max_rows' => 1000, - protected static array $normCacheSpaces = ['content']; -} + 'revalidation' => true, + + 'building_lock_ttl' => 5, + 'stampede_wait_ms' => 200, + + 'events' => false, + 'debugbar' => false, +]; ``` -How space resolution works: +`row_ttl` applies to shared canonical rows. `query_ttl` applies to memberships, result payloads, and the change records revalidation reads. Per-query `ttl()` changes only query-shaped payloads; raising it above `query_ttl` buys reach, not revalidation, because the records covering the version gap expire first. -- If no `space()` is selected, a model uses its first declared space as its home space. -- `Post::query()->space('content')` explicitly selects a space. -- `space()` must select a space declared by the model, otherwise Normcache throws an `InvalidArgumentException`. -- A model may declare multiple spaces, up to `spaces.max_per_model`. -- Writes bump the model version in every declared space. +Primary-key metadata comes from the model — `getKeyName()` and `getKeyType()` — not from the database, so a model with a non-default key needs no configuration. Precise invalidation is restricted to integer keys; string keys invalidate the whole table version, because PHP token equality does not prove row identity under a case-insensitive collation. -Dependencies must belong to the active space: +### Volatile columns + +A cached query automatically survives an `UPDATE` whose columns are disjoint from the query's filters and ordering. This assumes the `UPDATE` named every column the write changed. When the database writes one it did not name — a generated column, MySQL `ON UPDATE CURRENT_TIMESTAMP`, SQL Server `rowversion`, a same-row trigger — declare it on the model: ```php -class Author extends Model +class Post extends Model { use Cacheable; - protected static array $normCacheSpaces = ['content']; + protected array $volatileColumns = ['updated_at', 'search_vector']; } - -Post::query() - ->space('content') - ->dependsOn([Author::class]) - ->get(); ``` -If a model dependency is not valid in the active space, Normcache bypasses the cache by default. Set `spaces.cross_space_behavior` to `throw` to fail loudly during development. Raw table dependencies from `dependsOnTables()` can be used in any active space and are invalidated with `invalidateTableVersion()`. +Only queries filtering or ordering on that column pay for the declaration. Setting `revalidation` to `false` turns the behavior off everywhere, which is the lever to pull if you suspect an undeclared column is serving stale rows. + +### Database source scopes -Configure placement when you need to control Redis Cluster hash tags: +Table cache identity includes a logical database source scope. By default NormCache uses the Laravel connection name, which keeps connections such as `shard-a` and `shard-b` isolated even when both servers use the same database and table names. + +Set `normcache_scope` inside a Laravel database connection when aliases intentionally represent the same physical data source: ```php -'spaces' => [ - 'max_per_model' => 16, - 'cross_space_behavior' => env('NORMCACHE_CROSS_SPACE_BEHAVIOR', 'bypass'), - 'placement' => [ - 'catalog' => ['hash_tag' => 'nc:catalog'], +'connections' => [ + 'mysql-primary' => [ + 'driver' => 'mysql', + 'normcache_scope' => 'commerce-primary', + // ... + ], + + 'mysql-read' => [ + 'driver' => 'mysql', + 'normcache_scope' => 'commerce-primary', + // ... ], ], ``` -## Configuration +When an application mutates a connection in place for tenant or shard switching, update `normcache_scope` together with the database endpoint. Connections with neither a name nor an explicit scope bypass caching because a stable source identity cannot be established. -Publish `config/normcache.php` if you need to customize runtime behavior: +## Bypasses and limitations -```bash -php artisan vendor:publish --tag=normcache-config -``` +NormCache bypasses reads when correctness cannot be established, including: -Common options: - -| Option | Purpose | -| ---------------------- | --------------------------------------------------------------- | -| `connection` | Redis connection name. Default: `cache`. | -| `enabled` | Master on/off switch. | -| `ttl` | Model attribute key lifetime. | -| `query_ttl` | Query/result/pivot/through key lifetime. | -| `key_prefix` | Prefix for all Normcache Redis keys. | -| `cooldown` | Debounce version bumps for write-heavy models. | -| `building_lock_ttl` | Cache rebuild lock lifetime. | -| `stampede_wait_ms` | How long waiters block for a rebuild wake signal. | -| `stampede_wake_tokens` | Number of waiters to wake after a rebuild. | -| `fallback` | Fail open to the database on Redis errors when `true`. | -| `events` | Dispatch hit/miss/bypass, metric, and invalidation events. | -| `fire_retrieved` | Fire Eloquent `retrieved` for cached models when `true`. | -| `debugbar` | Enable Laravel Debugbar integration when installed. | -| `spaces.*` | Cache-space limits, cross-space policy, and hash-tag placement. | +- open database transactions; +- `lockForUpdate()` and `sharedLock()`; +- `useWritePdo()`; +- custom fetch modes, `pretend()`, cursors, and `explain()`; +- explicit `withoutCache()`; +- raw or opaque dependencies not fully authorized with `dependsOn()`; +- volatile SQL expressions. -## Bypasses and limitations +Other limits: -Normcache bypasses caching for unsafe reads rather than risking stale or incorrect data. +- canonical storage requires the model's single-column primary key; +- views are not detected, and no write invalidates them — declare their physical tables with `dependsOn()`; +- deletes follow `CASCADE`, `SET NULL`, and `SET DEFAULT` foreign keys, including multi-level cascades; an unresolvable graph advances the global epoch instead; +- `DB::table()`, raw SQL, models without the trait, external services — need a `NormCache::invalidate(...)` call, as do trigger side effects and `ON UPDATE` referential actions; +- a table whose triggers modify rows the statement did not name must not use `Cacheable`: those rows stay in the row store until `row_ttl`, so `find()` keeps serving the stale copy. -Always bypassed: +## Redis Cluster -- pessimistic locks (`lockForUpdate`, `sharedLock`) -- reads forced to the write connection with `useWritePdo()` -- reads inside a database transaction -- `DB::table(...)`, `DB::select()`, and raw SQL +All keys for one physical table share a Redis hash slot. Query-group entries use their own query hash slot. Global epoch, dependency versions, and tag versions are read separately and validated against payload state; Predis Cluster batches cross-slot state groups in one pipeline. -Usually require `dependsOn()` or `dependsOnTables()`: +## Recommended Redis Configuration -- manual `whereExists` -- raw predicates -- nested relation constraints -- expression joins -- `GROUP BY`, `DISTINCT`, and calculated columns +Broad invalidation (generation bumps, tag flushes, epoch advances) retires entries by bumping a counter rather than deleting keys, so orphaned payloads stay in Redis until `row_ttl` / `query_ttl` expires them. Give Redis a memory ceiling and a `volatile-*` eviction policy: -Other limitations: +```ini +maxmemory 4gb +maxmemory-policy volatile-lru +``` -- Models should use standard single-column primary keys. -- Writes outside Eloquent are invisible unless you manually flush or invalidate. -- Packages that replace Eloquent builders, relation classes, or hydration behavior may bypass parts of Normcache. -- Normcache caches model connection/table metadata. Call `NormCache\Support\CacheKeyBuilder::reset()` after switching tenants dynamically. +`volatile-*` evicts only keys that carry a TTL. NormCache's payloads do; its version counters do not, so they survive eviction — which matters, because losing a counter would make already-retired payloads readable again. ## Observability -When events are enabled, Normcache dispatches cache hit, miss, bypass, metric, and invalidation events. When `fruitcake/laravel-debugbar` is installed and `normcache.debugbar` is enabled, cache hits, misses, bypasses, and model fetches appear in Debugbar. +When `events` is enabled, NormCache dispatches cache hit, miss, bypass, repair, and invalidation events. When `fruitcake/laravel-debugbar` is installed and `debugbar` is enabled, cache activity appears in Laravel Debugbar. + +## Serializer configuration + +Choose the payload serializer with `serializer` / `NORMCACHE_SERIALIZER`: + +- `auto` preserves automatic extension detection; +- `php` always uses PHP serialization and is the safest choice for heterogeneous fleets; +- `igbinary` requires `ext-igbinary` on the node and fails application boot when it is unavailable. + +Payloads carry a one-byte serializer marker, so an igbinary-enabled node can read both PHP and igbinary payloads during a rolling deployment. A node without igbinary treats an igbinary payload as a cache miss instead of attempting the wrong decoder. ## License -MIT +**MIT** diff --git a/composer.json b/composer.json index bab5d27..0bc9bb0 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "kai-init/laravel-normcache", - "description": "Normalized caching for Laravel Eloquent. Self-invalidating, Redis-backed. Caches query IDs and model entities separately with versioned invalidation.", + "description": "Redis-backed normalized query caching for Laravel Eloquent.", "type": "library", "license": "MIT", "authors": [ @@ -16,8 +16,8 @@ "redis", "normcache", "normalized-cache", - "model-cache", - "query-cache" + "query-cache", + "redis-cluster" ], "require": { "php": "^8.2", @@ -31,9 +31,8 @@ "predis/predis": "^3.4.0 <3.5" }, "suggest": { - "laravel/octane": "Install to enable the NormCache Octane reset logic (^2.0)", "fruitcake/laravel-debugbar": "Install to enable the NormCache Debugbar panel (^4.0)", - "ext-igbinary": "Faster, smaller serialization for cached model payloads — detected automatically when available" + "ext-igbinary": "Faster, smaller serialization when normcache.serializer is auto or igbinary" }, "autoload": { "psr-4": { @@ -48,7 +47,6 @@ "scripts": { "test": "vendor/bin/phpunit", "test:cluster": "REDIS_CLUSTER=true REDIS_PORT=7010 vendor/bin/phpunit", - "bench": "vendor/bin/phpunit tests/Benchmark", "lint": "vendor/bin/pint --test", "format": "vendor/bin/pint", "analyse": "vendor/bin/phpstan analyse --memory-limit=512M" diff --git a/config/normcache.php b/config/normcache.php index c402775..dfba2c4 100644 --- a/config/normcache.php +++ b/config/normcache.php @@ -1,55 +1,22 @@ env('NORMCACHE_CONNECTION', 'cache'), - - // Boot-time master switch; false bypasses the cache. 'enabled' => env('NORMCACHE_ENABLED', true), + 'connection' => env('NORMCACHE_CONNECTION', 'cache'), + 'key_prefix' => env('NORMCACHE_KEY_PREFIX', ''), + 'serializer' => env('NORMCACHE_SERIALIZER', 'auto'), - // Model attribute payload TTL (seconds). - 'ttl' => (int) env('NORMCACHE_TTL', 604800), - - // Query, result, pivot, and through-cache TTL (seconds). + 'row_ttl' => (int) env('NORMCACHE_ROW_TTL', 604800), 'query_ttl' => (int) env('NORMCACHE_QUERY_TTL', 3600), - // Prefix every NormCache key. Useful when sharing a Redis database. - 'key_prefix' => env('NORMCACHE_PREFIX', ''), - - // Debounce automatic version bumps on write-heavy models; 0 bumps immediately (seconds). - 'cooldown' => (int) env('NORMCACHE_COOLDOWN', 0), - - // Build-lock expiry (seconds). - 'building_lock_ttl' => (int) env('NORMCACHE_BUILDING_LOCK_TTL', 5), + // Zero disables automatic result overlays. + 'auto_overlay_max_rows' => 1000, - // Max wait for another request's build wake signal (milliseconds). - 'stampede_wait_ms' => (int) env('NORMCACHE_STAMPEDE_WAIT_MS', 200), + 'revalidation' => (bool) env('NORMCACHE_REVALIDATION', true), - // Wake tokens pushed when a cache build releases. Raise for high same-key concurrency. - 'stampede_wake_tokens' => (int) env('NORMCACHE_STAMPEDE_WAKE_TOKENS', 64), + 'building_lock_ttl' => 5, + 'stampede_wait_ms' => 200, - // Dispatch cache hit, miss, and bypass events. Enable only if something consumes them. 'events' => (bool) env('NORMCACHE_EVENTS', false), - - // true fails open to DB on Redis errors; false re-throws them. - 'fallback' => (bool) env('NORMCACHE_FALLBACK', true), - - // Fire retrieved for cached models if observers depend on that Eloquent event. - 'fire_retrieved' => (bool) env('NORMCACHE_FIRE_RETRIEVED', false), - - // Register the Laravel Debugbar collector for local cache inspection. - 'debugbar' => env('NORMCACHE_DEBUGBAR', false), - // Redis Cluster sharding via model-declared cache spaces. - 'spaces' => [ - // Max spaces per model. Writes bump one version key per space. - 'max_per_model' => 16, - - // Cross-space dependency handling: 'bypass' or 'throw'. - 'cross_space_behavior' => env('NORMCACHE_CROSS_SPACE_BEHAVIOR', 'bypass'), - - // Optional space => hash-tag override. Default: content => {nc:content}. - 'placement' => [ - // 'catalog' => ['hash_tag' => 'nc:catalog'], - ], - ], + 'debugbar' => (bool) env('NORMCACHE_DEBUGBAR', false), ]; diff --git a/docker-compose.yml b/docker-compose.yml index 49fbf7e..67fce1c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,3 @@ -# Local Redis for the test suite. -# redis — single node on 6379 (default `composer test`) -# redis-cluster — 6-node cluster (3 masters + 3 replicas) on 7010-7015, -# matching REDIS_CLUSTER_NODES in phpunit.xml (`composer test:cluster`) services: redis: image: redis:7-alpine diff --git a/phpstan.neon b/phpstan.neon index 2669e8d..1f18ede 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -9,21 +9,6 @@ parameters: bootstrapFiles: - stubs/DebugBar.stub.php ignoreErrors: - - - identifier: arguments.count - path: src/Support/RedisStore.php - - - identifier: argument.type - path: src/Support/RedisStore.php - - - identifier: argument.type - path: src/Support/RedisScanner.php - identifier: trait.unused path: src/Traits/Cacheable.php - - - identifier: trait.unused - path: src/Relations/* - - - message: '#class_exists\(\) with .+ will always evaluate to false#' - path: src/CacheServiceProvider.php diff --git a/phpunit.xml b/phpunit.xml index e88c69b..2d9ec24 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -16,9 +16,8 @@ - - + + - diff --git a/pint.json b/pint.json index 1f2576f..9bffdef 100644 --- a/pint.json +++ b/pint.json @@ -1,15 +1,14 @@ { "preset": "laravel", - "exclude": [ - "docs", - "tests/Benchmark", - "tests/Concerns", - "tests/Fixtures/database" - ], + "exclude": [], "rules": { "not_operator_with_successor_space": false, "phpdoc_single_line_var_spacing": false, - "concat_space": {"spacing": "one"}, - "function_declaration": {"closure_fn_spacing": "none"} + "concat_space": { + "spacing": "one" + }, + "function_declaration": { + "closure_fn_spacing": "none" + } } } diff --git a/src/Cache/BuildLeaseCoordinator.php b/src/Cache/BuildLeaseCoordinator.php new file mode 100644 index 0000000..0742785 --- /dev/null +++ b/src/Cache/BuildLeaseCoordinator.php @@ -0,0 +1,119 @@ +isDirectPrimaryKey() && !$plan->isQueryGroup() => $this->keys->queryBuild( + $plan->root, + $state->version, + $namespace, + $queryHash, + ), + $plan->isDirectPrimaryKey() => $this->keys->rowBuild( + $plan->root, + $state->generation, + (string) $plan->primaryKeyToken, + ), + default => $this->keys->queryGroupBuild($queryHash), + }; + + return $this->acquire( + $buildingKey, + fn(string $token): string => $this->wakeKey($plan, $queryHash, $token), + ); + } + + public function claimRepair( + TableIdentity $root, + string $generation, + string $batchHash, + ): BuildLease { + return $this->acquire( + $this->keys->repairBuild($root, $generation, $batchHash), + fn(string $token): string => $this->keys->repairWake( + $root, + $generation, + $batchHash, + $token, + ), + ); + } + + public function release(BuildLease $lease, bool $wakeWaiters = true): void + { + if (!$lease->owner || $lease->token === null) { + return; + } + + try { + $this->store->releaseBuilding( + $lease->buildingKey, + $wakeWaiters ? (string) $lease->wakeKey : '', + $lease->token, + $this->config->wakeTtl(), + ); + } catch (\Throwable $exception) { + $this->runtime->fail($exception); + } + } + + /** @param callable(string): string $wakeKey */ + private function acquire(string $buildingKey, callable $wakeKey): BuildLease + { + $token = bin2hex(random_bytes(16)); + [$owner, $ownerToken] = $this->store->claimBuild( + $buildingKey, + $token, + $this->config->buildingLockTtl, + ); + + return new BuildLease( + $owner, + $buildingKey, + $ownerToken === null ? null : $wakeKey($ownerToken), + $ownerToken, + ); + } + + private function wakeKey(QueryPlan $plan, string $queryHash, string $token): string + { + return match (true) { + !$plan->isDirectPrimaryKey() && !$plan->isQueryGroup() => $this->keys->wake( + $plan->root, + 'q', + $queryHash, + $token, + ), + $plan->isDirectPrimaryKey() => $this->keys->wake( + $plan->root, + 'r', + (string) $plan->primaryKeyToken, + $token, + ), + default => $this->keys->queryGroupWake($queryHash, $token), + }; + } +} diff --git a/src/Cache/CacheRuntime.php b/src/Cache/CacheRuntime.php new file mode 100644 index 0000000..016a8e9 --- /dev/null +++ b/src/Cache/CacheRuntime.php @@ -0,0 +1,152 @@ +readBypassDepth > 0 || !$this->config->enabled || !$this->available) { + return false; + } + + try { + return !$this->resolveState()[1]; + } catch (\Throwable $exception) { + $this->fail($exception); + + return false; + } + } + + public function invalidating(): bool + { + if (!$this->config->enabled) { + return false; + } + + if (!$this->available) { + return true; + } + + try { + return !$this->resolveDisabled(); + } catch (\Throwable) { + return true; + } + } + + public function epoch(): string + { + return $this->epoch !== null && !$this->epochExpired() + ? $this->epoch + : $this->resolveState()[0]; + } + + public function knownEpoch(): ?string + { + return $this->epoch; + } + + public function rememberEpoch(string $epoch): void + { + if ($this->epoch === null) { + $this->epoch = $epoch; + $this->epochReadAt = microtime(true); + } + } + + public function forgetEpoch(): void + { + $this->epoch = null; + $this->epochReadAt = null; + $this->runtimeDisabled = null; + } + + private function epochExpired(): bool + { + return $this->epochReadAt !== null + && (microtime(true) - $this->epochReadAt) >= self::EPOCH_REFRESH_SECONDS; + } + + public function withoutCache(callable $callback): mixed + { + $this->readBypassDepth++; + + try { + return $callback(); + } finally { + $this->readBypassDepth--; + } + } + + public function available(): bool + { + return $this->available; + } + + public function disable(): void + { + $this->available = false; + } + + public function fail(\Throwable $exception): void + { + $this->disable(); + $this->failures->cacheUnavailable($exception); + } + + /** @return array{0: string, 1: bool} */ + private function resolveState(): array + { + if ($this->epoch === null || $this->epochExpired()) { + $epochKey = $this->keys->epoch(); + $disabledKey = $this->keys->disabled(); + $values = $this->store->mget([$epochKey, $disabledKey]); + $this->epoch = $values[$epochKey] ?? '0'; + $this->epochReadAt = microtime(true); + // Refreshes expose remote kill switches to an active scope. + $this->runtimeDisabled = ($values[$disabledKey] ?? null) !== null; + } + + return [$this->epoch, $this->runtimeDisabled ?? false]; + } + + private function resolveDisabled(): bool + { + if ($this->runtimeDisabled === true) { + return $this->runtimeDisabled = $this->readFlag(); + } + + return $this->runtimeDisabled ??= $this->readFlag(); + } + + private function readFlag(): bool + { + return $this->store->getRaw($this->keys->disabled()) !== null; + } +} diff --git a/src/Cache/CacheStateResolver.php b/src/Cache/CacheStateResolver.php new file mode 100644 index 0000000..d6c0c74 --- /dev/null +++ b/src/Cache/CacheStateResolver.php @@ -0,0 +1,284 @@ +pendingKeys($plan, $namespace)['all']; + } + + private function pendingKeys( + QueryPlan $plan, + string $namespace, + ?string $knownVersion = null, + ?string $knownGeneration = null, + ?bool $usesGeneration = null, + ): array { + $versionKeys = []; + + foreach ($plan->dependencies as $dependency) { + $versionKeys[$dependency->hash] = $this->keys->version($dependency); + } + + $rootVersionKey = $versionKeys[$plan->root->hash] ?? null; + + if ($knownVersion !== null && $rootVersionKey !== null) { + unset($versionKeys[$plan->root->hash]); + } + + $generationKey = $knownGeneration === null && ($usesGeneration ?? $plan->usesGeneration()) + ? $this->keys->generation($plan->root) + : null; + $tagKey = $this->tagKey($namespace); + $epochKey = $this->unknownEpochKey(); + + return [ + 'versions' => $versionKeys, + 'root' => $rootVersionKey, + 'generation' => $generationKey, + 'tag' => $tagKey, + 'epoch' => $epochKey, + 'all' => array_values(array_unique(array_filter([ + $epochKey, + ...array_values($versionKeys), + $generationKey, + $tagKey, + ]))), + ]; + } + + /** @param ?array $prefetched values for pendingStateKeys() */ + public function resolve( + QueryPlan $plan, + string $namespace, + string $queryHash, + ?string $knownVersion = null, + ?string $knownGeneration = null, + ?bool $usesGeneration = null, + ?array $prefetched = null, + ): CacheState { + $pending = $this->pendingKeys( + $plan, + $namespace, + $knownVersion, + $knownGeneration, + $usesGeneration, + ); + $versionKeys = $pending['versions']; + $rootVersionKey = $pending['root']; + $generationKey = $pending['generation']; + $tagKey = $pending['tag']; + $epochKey = $pending['epoch']; + $values = $prefetched ?? $this->store->mget($pending['all']); + $this->rememberEpochFrom($epochKey, $values); + + if ( + $knownVersion !== null + && $rootVersionKey !== null + && !array_key_exists($rootVersionKey, $values) + ) { + $versionKeys[$plan->root->hash] = $rootVersionKey; + $values[$rootVersionKey] = $knownVersion; + } + + $allVersions = []; + + foreach ($versionKeys as $hash => $key) { + $allVersions[$hash] = $values[$key] ?? '0'; + } + + ksort($allVersions, SORT_STRING); + $rootVersion = $knownVersion ?? $allVersions[$plan->root->hash] ?? '0'; + $generation = $knownGeneration + ?? ($generationKey !== null ? ($values[$generationKey] ?? '0') : '0'); + $tag = $tagKey !== null ? ($values[$tagKey] ?? '0') : null; + $versions = $allVersions; + + if (!$plan->isQueryGroup()) { + unset($versions[$plan->root->hash]); + } + + $key = match (true) { + $plan->isDirectPrimaryKey() => $this->keys->row( + $plan->root, + $generation, + (string) $plan->primaryKeyToken, + ), + $plan->isQueryGroup() => $this->keys->queryGroupEntry($queryHash, $namespace), + default => $this->keys->queryEntry( + $plan->root, + $namespace, + $queryHash, + ), + }; + + return new CacheState( + key: $key, + epoch: $this->runtime->epoch(), + version: $rootVersion, + generation: $generation, + versions: $versions, + tag: $tag, + tagKey: $tagKey, + ); + } + + /** + * @param list $rowKeys + * @return array{0: CacheState, 1: array} + */ + public function resolveCanonical( + QueryPlan $plan, + string $namespace, + string $queryHash, + array $rowKeys, + ): array { + $keys = $this->stateKeys( + $plan, + $this->tagKey($namespace), + $this->unknownEpochKey(), + usesGeneration: true, + ); + $values = $this->store->mget(array_values(array_unique([ + ...$rowKeys, + ...$keys['all'], + ]))); + $this->rememberEpochFrom($keys['epoch'], $values); + + $versions = []; + + foreach ($keys['dependencies'] as $hash => $key) { + $versions[$hash] = $values[$key] ?? '0'; + } + + ksort($versions, SORT_STRING); + $version = $values[$keys['version']] ?? '0'; + + return [ + new CacheState( + key: $this->keys->queryEntry($plan->root, $namespace, $queryHash), + epoch: $this->runtime->epoch(), + version: $version, + generation: $keys['generation'] !== null + ? ($values[$keys['generation']] ?? '0') + : '0', + versions: $versions, + tag: $keys['tag'] !== null ? ($values[$keys['tag']] ?? '0') : null, + tagKey: $keys['tag'], + ), + $values, + ]; + } + + /** @phpstan-impure */ + public function isCurrent( + QueryPlan $plan, + CacheState $expected, + ?bool $usesGeneration = null, + ): bool { + $keys = $this->stateKeys( + $plan, + $expected->tagKey, + $this->keys->epoch(), + $usesGeneration, + ); + $values = $this->store->mget($keys['all']); + $current = static fn(string $key): string => $values[$key] ?? '0'; + + if ( + $current((string) $keys['epoch']) !== $expected->epoch + || $current($keys['version']) !== $expected->version + || $keys['generation'] !== null + && $current($keys['generation']) !== $expected->generation + ) { + return false; + } + + foreach ($keys['dependencies'] as $hash => $key) { + if ($current($key) !== ($expected->versions[$hash] ?? null)) { + return false; + } + } + + return $expected->tag === null + || $keys['tag'] !== null && $current($keys['tag']) === $expected->tag; + } + + /** + * @return array{ + * epoch: ?string, + * version: string, + * generation: string|null, + * dependencies: array, + * tag: ?string, + * all: list + * } + */ + private function stateKeys( + QueryPlan $plan, + ?string $tagKey, + ?string $epochKey, + ?bool $usesGeneration = null, + ): array { + $dependencies = []; + + foreach ($plan->dependencies as $dependency) { + if ($dependency->hash !== $plan->root->hash) { + $dependencies[$dependency->hash] = $this->keys->version($dependency); + } + } + + $versionKey = $this->keys->version($plan->root); + $generationKey = ($usesGeneration ?? $plan->usesGeneration()) + ? $this->keys->generation($plan->root) + : null; + + return [ + 'epoch' => $epochKey, + 'version' => $versionKey, + 'generation' => $generationKey, + 'dependencies' => $dependencies, + 'tag' => $tagKey, + 'all' => array_values(array_filter([ + $versionKey, + $generationKey, + $epochKey, + ...array_values($dependencies), + $tagKey, + ])), + ]; + } + + private function tagKey(string $namespace): ?string + { + return str_starts_with($namespace, 'g') + ? $this->keys->tagVersion(substr($namespace, 1, 32)) + : null; + } + + private function unknownEpochKey(): ?string + { + return $this->runtime->knownEpoch() === null ? $this->keys->epoch() : null; + } + + /** @param array $values */ + private function rememberEpochFrom(?string $epochKey, array $values): void + { + if ($epochKey !== null) { + $this->runtime->rememberEpoch($values[$epochKey] ?? '0'); + } + } +} diff --git a/src/Cache/CanonicalRowRepository.php b/src/Cache/CanonicalRowRepository.php new file mode 100644 index 0000000..f266588 --- /dev/null +++ b/src/Cache/CanonicalRowRepository.php @@ -0,0 +1,134 @@ +store->fetchRow( + $this->keys->generation($plan->root), + $this->keys->tablePrefix($plan->root), + (string) $plan->primaryKeyToken, + ); + $generation = RedisProtocol::version($result, 0); + $raw = RedisProtocol::value($result, 1); + + if (!is_string($raw)) { + return new CachedRow($generation); + } + + $payload = $this->codec->decodeRow( + $raw, + $plan->primaryKey, + $plan->primaryKeyToken, + ); + + if (!$payload->valid) { + return new CachedRow($generation, reason: 'corrupt_payload'); + } + + $epoch = $this->runtime->epoch(); + + if ($payload->epoch !== $epoch) { + return new CachedRow($generation); + } + + return new CachedRow($generation, $epoch, $payload->rows[0]); + } + + public function state(QueryPlan $plan, string $generation, string $epoch): CacheState + { + return new CacheState( + key: $this->keys->row($plan->root, $generation, (string) $plan->primaryKeyToken), + epoch: $epoch, + version: '0', + generation: $generation, + versions: [], + tag: null, + tagKey: null, + ); + } + + /** @return list<\stdClass>|null null on missing deleted-at column; empty array when filtered by visibility. */ + public function visibleRows(QueryPlan $plan, \stdClass $row): ?array + { + if ($plan->softDeleteMode === null || $plan->deletedAtColumn === null) { + return [$row]; + } + + if (!property_exists($row, $plan->deletedAtColumn)) { + return null; + } + + $deleted = $row->{$plan->deletedAtColumn} !== null; + + if ( + $plan->softDeleteMode === 'default' && $deleted + || $plan->softDeleteMode === 'only' && !$deleted + ) { + return []; + } + + return [$row]; + } + + /** @param array $rows */ + public function publish( + QueryPlan $plan, + CacheState $state, + array $rows, + BuildLease $lease, + ): void { + if ( + count($rows) !== 1 + || !$rows[0] instanceof \stdClass + || !property_exists($rows[0], $plan->primaryKey->column) + || $plan->primaryKey->token($rows[0]->{$plan->primaryKey->column}) + !== $plan->primaryKeyToken + ) { + $this->leases->release($lease); + + return; + } + + $encoded = $this->codec->encodeRow($rows[0], $state->epoch); + + $this->store->publishVersionedEntries( + entryKeys: [$state->key], + entryPayloads: [$encoded], + ttl: $this->config->rowTtl, + versionKeys: [ + $this->keys->version($plan->root), + $this->keys->generation($plan->root), + ], + expectedVersions: [ + $state->version, + $state->generation, + ], + buildingKey: $lease->buildingKey, + wakeKey: $lease->wakeKey, + token: $lease->token, + wakeTtl: $this->config->wakeTtl(), + ); + } +} diff --git a/src/Cache/Engine.php b/src/Cache/Engine.php new file mode 100644 index 0000000..9ac242c --- /dev/null +++ b/src/Cache/Engine.php @@ -0,0 +1,894 @@ +isInternal()) { + return $database(); + } + + if (!$this->runtime->readable()) { + return $database(); + } + + $this->observer->begin(); + + $connection = $query->getConnection(); + $analysis = $this->dependencies->analyze($connection, $query); + $table = $analysis->root; + + if ($analysis->bypassReason !== null || $table === null) { + return $this->bypass( + $query, + $analysis->bypassReason ?? 'unidentifiable_dependency', + $statement, + $database, + ); + } + + $dependencies = $analysis->tables; + $plan = $this->planner->plan( + $query, + $table, + $dependencies, + $analysis->queryScoped, + $operation, + ); + + if ($analysis->volatile) { + return $this->bypass( + $query, + 'volatile_expression', + $statement, + $database, + $plan, + ); + } + + $namespace = $this->identity->namespace( + $query->configuredTag(), + $query->configuredCacheContext(), + ); + $context = new ReadContext($query, $plan, $namespace); + $canonicalQueryHash = null; + $dependencyHashes = array_map( + static fn(TableIdentity $dependency): string => $dependency->hash, + $dependencies, + ); + $hash = $this->queryHashResolver( + $context, + $connection, + $dependencyHashes, + $operation, + $statement, + ); + + try { + $canonicalQueryHash = $this->resultOverlayCanonicalHash( + $context, + $connection, + $dependencyHashes, + $statement, + ); + $cached = $this->readCache( + $context, + $hash, + $canonicalQueryHash, + ); + + if ($cached->served()) { + if ($this->observer->observing()) { + $this->reportRead( + $context, + $hash->value(), + $statement, + $cached, + ); + } + + return $cached->rows; + } + } catch (\InvalidArgumentException) { + return $this->bypass( + $query, + 'unsupported_query_shape', + $statement, + $database, + $plan, + ); + } catch (\Throwable $exception) { + $this->runtime->fail($exception); + + return $database(); + } + + try { + $queryHash = $hash->value(); + $revalidated = $this->revalidate($context, $cached, $queryHash, $statement); + + if ($revalidated !== null) { + return $revalidated; + } + + $lease = $this->leases->claim($context->plan, $cached->state, $context->namespace, $queryHash); + } catch (\Throwable $exception) { + $this->runtime->fail($exception); + + return $database(); + } + + $this->observer->miss( + $context->query, + $context->plan, + $queryHash, + $statement, + $cached->reason, + ); + + if (!$lease->owner) { + if ($lease->wakeKey !== null) { + try { + $this->store->brpop( + $lease->wakeKey, + $this->config->stampedeWaitMs / 1000, + ); + // Direct-PK waiters retry through the row-key branch. + $retry = $this->readCache( + $context, + $hash, + $canonicalQueryHash, + ); + + if ($retry->served()) { + $this->reportRead( + $context, + $queryHash, + $statement, + $retry, + ); + + return $retry->rows; + } + } catch (\Throwable $exception) { + $this->runtime->fail($exception); + + return $database(); + } + } + + return $primaryDatabase(); + } + + try { + $rows = $primaryDatabase(); + } catch (\Throwable $exception) { + $this->leases->release($lease); + + throw $exception; + } + + try { + // Publish scripts validate state while holding the lease. + $this->publish($context, $cached->state, $rows, $lease); + } catch (\Throwable $exception) { + $this->leases->release($lease); + $this->runtime->fail($exception); + } + + return $rows; + } + + /** + * @param callable(): array $database + */ + private function bypass( + QueryBuilder $query, + string $reason, + QueryStatement $statement, + callable $database, + ?QueryPlan $plan = null, + ): array { + $this->observer->bypass($query, $reason, $statement, $plan); + + return $database(); + } + + /** @param list $dependencyHashes */ + private function queryHashResolver( + ReadContext $context, + Connection $connection, + array $dependencyHashes, + string $operation, + QueryStatement $statement, + ): QueryHashResolver { + if ($context->plan->isCanonical()) { + return new QueryHashResolver(fn(): string => $this->canonicalQueryHash( + $context, + $connection, + $dependencyHashes, + $statement, + )); + } + + return new QueryHashResolver(fn(): string => $this->identity->hash( + route: $context->plan->route, + rootHash: $context->plan->root->hash, + dependencyHashes: $dependencyHashes, + sql: $statement->sql(), + bindings: $statement->preparedBindings($connection), + namespace: $context->namespace, + operation: $operation, + )); + } + + /** @param list $dependencyHashes */ + private function resultOverlayCanonicalHash( + ReadContext $context, + Connection $connection, + array $dependencyHashes, + QueryStatement $statement, + ): ?string { + if (!$context->plan->supportsCanonicalProjectionFallback()) { + return null; + } + + return $this->canonicalQueryHash($context, $connection, $dependencyHashes, $statement); + } + + private function readCache( + ReadContext $context, + QueryHashResolver $hash, + ?string $canonicalQueryHash, + ): CacheRead { + if ($context->plan->isDirectPrimaryKey()) { + return $this->readDirect($context, $hash); + } + + return $this->read( + $context, + $hash->value(), + $canonicalQueryHash, + ); + } + + private function reportRead( + ReadContext $context, + string $queryHash, + QueryStatement $statement, + CacheRead $read, + ): void { + if ($read->outcome === ReadOutcome::REPAIRED) { + $this->observer->repaired( + $context->query, + $context->plan, + $queryHash, + $statement, + $read->reason, + ); + + return; + } + + $this->observer->hit( + $context->query, + $context->plan, + $queryHash, + $statement, + $read->reason, + ); + } + + private function read( + ReadContext $context, + string $queryHash, + ?string $canonicalQueryHash = null, + ): CacheRead { + if ($context->plan->isCanonical()) { + return $this->config->maxAutoOverlayRows > 0 + ? $this->readCanonicalWithResultOverlay( + $context, + $queryHash, + ) + : $this->readCanonical($context, $queryHash); + } + + if ($context->plan->isQueryGroup()) { + $entryKey = $this->keys->queryGroupEntry($queryHash, $context->namespace); + [$raw, $values] = $this->store->readHashFieldWithValues( + $entryKey, + 'r', + $this->states->pendingStateKeys($context->plan, $context->namespace), + ); + $state = $this->states->resolve( + $context->plan, + $context->namespace, + $queryHash, + prefetched: $values, + ); + + return $this->entries->readResult($state, $raw); + } + + if ($canonicalQueryHash !== null && $context->plan->supportsCanonicalProjectionFallback()) { + return $this->readResultOrCanonicalProjection( + $context, + $queryHash, + $canonicalQueryHash, + ); + } + + $entry = $this->store->fetchResult( + $this->keys->version($context->plan->root), + $this->keys->tablePrefix($context->plan->root), + $context->namespace, + $queryHash, + ); + $version = RedisProtocol::version($entry, 0); + $raw = RedisProtocol::value($entry, 1); + + if (!is_string($raw) && $context->plan->supportsRowFallback()) { + $fallback = $this->readResultRowFallback($context->plan); + + if ($fallback !== null) { + return $fallback; + } + } + + $state = $this->states->resolve($context->plan, $context->namespace, $queryHash, $version); + + return $this->entries->readResult($state, $raw); + } + + private function readCanonicalWithResultOverlay( + ReadContext $context, + string $queryHash, + ): CacheRead { + $head = $this->store->fetchResultOrCanonical( + versionKey: $this->keys->version($context->plan->root), + generationKey: $this->keys->generation($context->plan->root), + tablePrefix: $this->keys->tablePrefix($context->plan->root), + namespace: $context->namespace, + resultQueryHash: $queryHash, + canonicalQueryHash: $queryHash, + ); + $status = RedisProtocol::status($head); + $version = RedisProtocol::version($head); + + if ($status === RedisProtocol::RESULT) { + $state = $this->states->resolve( + $context->plan, + $context->namespace, + $queryHash, + $version, + usesGeneration: false, + ); + $result = $this->entries->readResult($state, RedisProtocol::resultPayload($head)); + + if ($result->served()) { + return $result->withReason('result_overlay'); + } + + $overlayReason = $result->reason; + $canonicalResult = $this->readCanonicalHead( + $context, + $queryHash, + $this->canonicalHeadFrom($head, $version), + true, + ); + + if ($canonicalResult->promotable()) { + $promoted = $this->entries->promoteResult( + $context->query, + $context->plan, + $canonicalResult->state, + $context->namespace, + $queryHash, + $canonicalResult->rows, + ); + + if ($overlayReason === 'corrupt_payload') { + $canonicalResult = $this->entries->rebuiltResultOutcome( + $canonicalResult, + $promoted, + ); + } + } + + return $canonicalResult; + } + + $generation = RedisProtocol::version($head, 2); + $canonicalHead = $status === RedisProtocol::MEMBERSHIP + ? [ + RedisProtocol::HIT, + $version, + $generation, + RedisProtocol::canonicalPayload($head), + ] + : [$status, $version, $generation]; + $result = $this->readCanonicalHead( + $context, + $queryHash, + $canonicalHead, + true, + ); + + if ($result->promotable()) { + $this->entries->promoteResult( + $context->query, + $context->plan, + $result->state, + $context->namespace, + $queryHash, + $result->rows, + ); + } + + return $result; + } + + private function readResultOrCanonicalProjection( + ReadContext $context, + string $queryHash, + string $canonicalQueryHash, + ): CacheRead { + $head = $this->store->fetchResultOrCanonical( + versionKey: $this->keys->version($context->plan->root), + generationKey: $this->keys->generation($context->plan->root), + tablePrefix: $this->keys->tablePrefix($context->plan->root), + namespace: $context->namespace, + resultQueryHash: $queryHash, + canonicalQueryHash: $canonicalQueryHash, + ); + $status = RedisProtocol::status($head); + $version = RedisProtocol::version($head); + + if ($status !== RedisProtocol::RESULT) { + return $this->readCanonicalProjectionFallback( + $context, + $queryHash, + $canonicalQueryHash, + $head, + null, + ); + } + + $state = $this->states->resolve($context->plan, $context->namespace, $queryHash, $version); + $result = $this->entries->readResult($state, RedisProtocol::resultPayload($head)); + + if ($result->served()) { + return $result->withReason('result_overlay'); + } + + return $this->readCanonicalProjectionFallback( + $context, + $queryHash, + $canonicalQueryHash, + $this->canonicalHeadFrom($head, $version, RedisProtocol::MEMBERSHIP), + $result->reason, + ); + } + + /** + * @param array $head + * @return array + */ + private function canonicalHeadFrom( + array $head, + string $version, + string $status = RedisProtocol::HIT, + ): array { + $generation = RedisProtocol::resultGeneration($head); + $membership = RedisProtocol::resultMembership($head); + + return is_string($membership) + ? [$status, $version, $generation, $membership] + : [RedisProtocol::MISS, $version, $generation]; + } + + private function readCanonicalProjectionFallback( + ReadContext $context, + string $queryHash, + string $canonicalQueryHash, + array $head, + ?string $fallbackReason, + ): CacheRead { + $status = RedisProtocol::status($head); + $version = RedisProtocol::version($head); + + if ($status === RedisProtocol::MEMBERSHIP || $status === RedisProtocol::HIT) { + $generation = RedisProtocol::version($head, 2); + $canonicalHead = [ + RedisProtocol::HIT, + $version, + $generation, + RedisProtocol::canonicalPayload($head), + ]; + $result = $this->readCanonicalHead( + $context, + $canonicalQueryHash, + $canonicalHead, + false, + ); + + if (!$result->served()) { + $result = $this->revalidateProjectionSource( + $context, + $canonicalQueryHash, + $canonicalHead, + $result, + ); + } + + if ($result->served()) { + $projected = $this->projectRows( + $result->rows, + (array) $context->plan->projectedColumns, + ); + + if ($projected !== null) { + $result = $result->withRows($projected); + $promoted = $this->entries->promoteResult( + $context->query, + $context->plan, + $result->state, + $context->namespace, + $queryHash, + $projected, + ); + + return $fallbackReason === 'corrupt_payload' + ? $this->entries->rebuiltResultOutcome($result, $promoted) + : $result->withReason('canonical_projection_fallback'); + } + } + + $fallbackReason = $result->reason ?? $fallbackReason; + } + + $state = $this->states->resolve($context->plan, $context->namespace, $queryHash, $version); + + return new CacheRead($state, ReadOutcome::MISS, [], $fallbackReason); + } + + /** @param array $canonicalHead */ + private function revalidateProjectionSource( + ReadContext $context, + string $canonicalQueryHash, + array $canonicalHead, + CacheRead $stale, + ): CacheRead { + if ( + !$this->config->revalidation + || !$this->revalidator->revalidate($context, $stale) + ) { + return $stale; + } + + $revalidated = $this->readCanonicalHead( + $context, + $canonicalQueryHash, + $canonicalHead, + repairMissing: true, + rootVersionApproved: true, + ); + + if (!$revalidated->served()) { + return $stale; + } + + $this->restamp($context, $revalidated->state, $revalidated->rows); + + return $revalidated; + } + + /** @param list $rows + * @param list $columns + * @return list<\stdClass>|null + */ + private function projectRows(array $rows, array $columns): ?array + { + $projectedRows = []; + + foreach ($rows as $row) { + $projected = new \stdClass; + + foreach ($columns as $column) { + if (!property_exists($row, $column)) { + return null; + } + + $projected->{$column} = $row->{$column}; + } + + $projectedRows[] = $projected; + } + + return $projectedRows; + } + + private function readDirect(ReadContext $context, QueryHashResolver $hash): CacheRead + { + $cached = $this->rows->read($context->plan); + + $resolve = fn(): CacheState => $this->states->resolve( + $context->plan, + $context->namespace, + $hash->value(), + knownGeneration: $cached->generation, + ); + + if ($cached->row === null) { + return new CacheRead($resolve(), ReadOutcome::MISS, [], $cached->reason); + } + + $rows = $this->rows->visibleRows($context->plan, $cached->row); + + if ($rows === null) { + return new CacheRead($resolve(), ReadOutcome::MISS, [], 'corrupt_payload'); + } + + return new CacheRead( + $this->rows->state($context->plan, $cached->generation, (string) $cached->epoch), + ReadOutcome::HIT, + $rows, + ); + } + + private function readResultRowFallback(QueryPlan $plan): ?CacheRead + { + $cached = $this->rows->read($plan); + + if ($cached->row === null) { + return null; + } + + $rows = $this->rows->visibleRows($plan, $cached->row); + + if ($rows === null) { + return null; + } + + $rows = $this->projectRows($rows, (array) $plan->projectedColumns); + + if ($rows === null) { + return null; + } + + return new CacheRead( + $this->rows->state($plan, $cached->generation, (string) $cached->epoch), + ReadOutcome::HIT, + $rows, + 'row_cache_fallback', + ); + } + + private function readCanonical( + ReadContext $context, + string $queryHash, + ): CacheRead { + $head = $this->store->fetchCanonical( + versionKey: $this->keys->version($context->plan->root), + generationKey: $this->keys->generation($context->plan->root), + tablePrefix: $this->keys->tablePrefix($context->plan->root), + namespace: $context->namespace, + queryHash: $queryHash, + ); + + return $this->readCanonicalHead( + $context, + $queryHash, + $head, + true, + ); + } + + private function readCanonicalHead( + ReadContext $context, + string $queryHash, + array $head, + bool $repairMissing, + bool $rootVersionApproved = false, + ): CacheRead { + return $this->entries->readCanonical( + $context->plan, + $context->namespace, + $queryHash, + $head, + $repairMissing, + fn(CacheState $state, array $tokens): ?RowRepair => $this->repairer->repair( + $context->query, + $context->plan, + $state, + $tokens, + ), + $rootVersionApproved, + ); + } + + private function revalidate( + ReadContext $context, + CacheRead $cached, + string $queryHash, + QueryStatement $statement, + ): ?array { + if ( + !$this->config->revalidation + || !$this->revalidator->revalidate($context, $cached) + ) { + return null; + } + + $revalidated = $this->readCanonicalHead( + $context, + $queryHash, + [ + RedisProtocol::HIT, + $cached->state->version, + $cached->state->generation, + $cached->staleMembershipRaw, + ], + repairMissing: true, + rootVersionApproved: true, + ); + + if (!$revalidated->served()) { + return null; + } + + // Re-stamp against the state that validated the rows. + $this->restamp($context, $revalidated->state, $revalidated->rows); + $this->reportRead($context, $queryHash, $statement, $revalidated); + + return $revalidated->rows; + } + + /** @param array $rows */ + private function restamp(ReadContext $context, CacheState $state, array $rows): void + { + try { + $this->entries->restampCanonical( + $context->query, + $context->plan, + $state, + $rows, + $this->entries->inlineResult($state, $rows), + ); + } catch (\Throwable $exception) { + $this->runtime->fail($exception); + } + } + + /** @param array $rows */ + private function publish( + ReadContext $context, + CacheState $state, + array $rows, + BuildLease $lease, + ): void { + match (true) { + $context->plan->isCanonical() => $this->publishCanonical( + $context, + $state, + $rows, + $lease, + ), + $context->plan->isDirectPrimaryKey() => $this->rows->publish($context->plan, $state, $rows, $lease), + default => $this->entries->publishResult( + $context->query, + $context->plan, + $state, + $rows, + $lease, + $this->config->wakeTtl(), + ), + }; + } + + private function publishCanonical( + ReadContext $context, + CacheState $state, + array $rows, + BuildLease $lease, + ): void { + $overlay = $this->entries->inlineResult($state, $rows); + + if (!$this->entries->publishCanonical( + $context->query, + $context->plan, + $state, + $rows, + $lease, + $this->config->wakeTtl(), + $overlay->payload, + $overlay->rejected, + )) { + $this->leases->release($lease); + } + } + + /** @param list $dependencyHashes */ + private function canonicalQueryHash( + ReadContext $context, + Connection $connection, + array $dependencyHashes, + QueryStatement $statement, + ): string { + $query = $context->query; + $preparedBindings = $statement->preparedBindings($connection); + + if ($query->columns === null || $query->columns === ['*']) { + return $this->identity->hash( + route: QueryPlan::CANONICAL, + rootHash: $context->plan->root->hash, + dependencyHashes: $dependencyHashes, + sql: $statement->sql(), + bindings: $preparedBindings, + namespace: $context->namespace, + operation: 'select', + ); + } + + $canonical = $query->clone()->select('*'); + + return $this->identity->hash( + route: QueryPlan::CANONICAL, + rootHash: $context->plan->root->hash, + dependencyHashes: $dependencyHashes, + sql: $canonical->toSql(), + bindings: $query->bindings['select'] === [] + ? $preparedBindings + : $connection->prepareBindings($canonical->getBindings()), + namespace: $context->namespace, + operation: 'select', + ); + } +} diff --git a/src/Cache/Invalidator.php b/src/Cache/Invalidator.php deleted file mode 100644 index 809389b..0000000 --- a/src/Cache/Invalidator.php +++ /dev/null @@ -1,522 +0,0 @@ -> */ - private array $flushQueue = []; - - /** @var array>> */ - private array $versionQueue = []; - - /** @var array, true>> */ - private array $modelVersionQueue = []; - - /** @var array> */ - private array $modelEvictionQueue = []; - - /** @var array> */ - private array $tableVersionQueue = []; - - public function __construct( - private readonly RedisStore $store, - private readonly CacheKeyBuilder $keys, - private readonly CacheConfig $config, - private readonly CacheSpaceRegistry $spaceRegistry, - private readonly VersionStore $versions, - ) {} - - public function beginModelSave(Model $model, bool $observeBeforeWrite = true): ModelWriteState - { - $preInvalidated = $observeBeforeWrite - && $model->exists - && $model->isDirty() - && $model->getConnection()->transactionLevel() === 0 - && !$this->isPendingRestoreSave($model); - - if ($preInvalidated) { - $this->invalidateVersion($model); - } - - return new ModelWriteState($model->exists); - } - - // Always bump post-write too: a pre-write-only bump can race a concurrent read. - public function completeModelSave(Model $model, ModelWriteState $state, bool $succeeded): void - { - if (!$succeeded || !$this->modelWriteChanged($model, $state->existed)) { - return; - } - - $this->invalidateVersion($model, isInsert: !$state->existed); - } - - public function recordBuilderWrite(Model $model, WriteOperation $operation, bool $changed): void - { - if (!$changed) { - return; - } - - if ($operation->isInsert()) { - $this->invalidateVersion($model, isInsert: true); - - return; - } - - if ($model->exists) { - $this->invalidateVersion($model); - - return; - } - - $this->flushModel($model); - } - - public function recordPivotWrite( - string $connection, - string $table, - array $modelClasses, - bool $changed, - ): void { - if (!$changed) { - return; - } - - $this->invalidatePivotTableVersion($connection, $table, $modelClasses); - } - - // isInsert: a freshly-inserted row has no prior cache entry, so eviction is skipped. - public function invalidateVersion(Model $model, bool $isInsert = false): void - { - if (!$this->config->enabled) { - return; - } - - $connection = $model->getConnection()->getName(); - - $this->queueOrRun( - $connection, - function () use ($connection, $model, $isInsert): void { - $this->modelVersionQueue[$connection][$model::class] = true; - if (!$isInsert) { - $this->modelEvictionQueue[$connection][] = $model; - } - }, - function () use ($model, $connection, $isInsert): void { - if (!$isInsert) { - $this->evictModelKey($model, $connection); - } - $this->invalidateModelNow($model::class, $connection); - }, - ); - } - - // $freshTableSpaces must match whatever the paired version bump uses — otherwise a space - // registered between the two reads can get its version bumped but not its key evicted. - private function evictModelKey(Model $model, string $connection, bool $freshTableSpaces = false): void - { - $id = $model->getKey(); - if ($id === null) { - return; - } - - $classKey = $this->keys->classKey($model::class, $connection); - $keys = []; - foreach ($this->modelSpaces($model::class, $connection, $freshTableSpaces) as $space) { - $version = $this->versions->currentVersion($model::class, $space, $connection); - $keys[] = $this->keys->modelPrefix($classKey, $version, $space) . $id; - } - - if ($keys !== []) { - $this->store->delete($keys); - } - } - - public function flushModel(Model|string $model): void - { - if (!$this->config->enabled) { - return; - } - - $modelClass = is_string($model) ? $model : $model::class; - $connection = is_string($model) - ? ($this->keys->prototype($modelClass)->getConnectionName() ?? DB::getDefaultConnection()) - : $model->getConnection()->getName(); - - $this->queueOrRun( - $connection, - fn() => $this->flushQueue[$connection][$modelClass] = true, - fn() => $this->forceFlushModel($modelClass, $connection), - ); - } - - public function invalidateTableVersion(string $connection, string $table): void - { - if (!$this->config->enabled) { - return; - } - - $classKey = $this->keys->tableKey($connection, $table); - $this->queueOrRun( - $connection, - fn() => $this->tableVersionQueue[$connection][$classKey] = true, - fn() => $this->invalidateTableNow($classKey), - ); - } - - public function invalidatePivotTableVersion(string $connection, string $table, array $modelClasses): void - { - if (!$this->config->enabled) { - return; - } - - $classKey = $this->keys->tableKey($connection, $table); - $spaces = []; - foreach ($modelClasses as $modelClass) { - foreach ($this->modelSpaces($modelClass, freshTableSpaces: true) as $space) { - $spaces[$space->name] = $space; - } - } - $spaces = array_values($spaces); - - $this->queueOrRun( - $connection, - fn() => $this->queueVersionFlush($connection, $classKey, $spaces), - function () use ($classKey, $spaces): void { - foreach ($spaces as $space) { - $this->versions->bump($classKey, $this->config, $space); - } - $this->reportBumps('pivot_table', $classKey, $spaces); - }, - ); - } - - public function invalidateMultipleVersions(array $modelClasses, ?string $connection = null): void - { - if (!$this->config->enabled || $modelClasses === []) { - return; - } - - $groups = []; - foreach ($modelClasses as $modelClass) { - $resolved = $connection - ?? ($this->keys->prototype($modelClass)->getConnectionName() ?? DB::getDefaultConnection()); - $groups[$resolved][] = $modelClass; - } - - foreach ($groups as $resolved => $classes) { - $this->queueOrRun( - $resolved, - function () use ($resolved, $classes): void { - foreach ($classes as $modelClass) { - $this->modelVersionQueue[$resolved][$modelClass] = true; - } - }, - function () use ($resolved, $classes): void { - foreach ($classes as $modelClass) { - $this->invalidateModelNow($modelClass, $resolved); - } - }, - ); - } - } - - public function forceFlushModel(string $modelClass, ?string $connection = null): void - { - $classKey = $this->keys->classKey($modelClass, $connection); - $spaces = $this->modelSpaces($modelClass, $connection, true); - foreach ($spaces as $space) { - $this->versions->forceBump($classKey, $this->versions->versionTtl($this->config), $space); - } - $this->reportBumps('model_flush', $modelClass, $spaces); - } - - public function flushAll(CacheSpace|string|null $space = null): int - { - $patterns = [ - CacheKeyBuilder::K_QUERY . ':*', CacheKeyBuilder::K_MODEL . ':*', - CacheKeyBuilder::K_VER . ':*', CacheKeyBuilder::K_COUNT . ':*', - CacheKeyBuilder::K_SCALAR . ':*', CacheKeyBuilder::K_PIVOT . ':*', - CacheKeyBuilder::K_THROUGH . ':*', CacheKeyBuilder::K_SCHEDULED . ':*', - CacheKeyBuilder::K_BUILDING . ':*', CacheKeyBuilder::K_WAKE . ':*', - CacheKeyBuilder::K_RESULT . ':*', - ]; - - if (!$this->store->isCluster() && $space === null) { - $count = $this->store->flushByPatterns($this->prefixedForAnySpace($patterns)); - CacheReporter::invalidation('flush_all', '*', $count); - - return $count; - } - - $spaces = $space === null - ? $this->spaceRegistry->knownSpaces() - : [is_string($space) ? $this->spaceRegistry->space($space) : $space]; - - $count = $this->store->flushByPatterns($this->prefixedForSpaces($patterns, $spaces)); - CacheReporter::invalidation('flush_all', '*', $count, array_column($spaces, 'name')); - - return $count; - } - - public function flushTag(string $modelClass, string $tag): int - { - CacheKeyBuilder::assertValidTag($tag); - $classKey = $this->keys->classKey($modelClass); - - $spaces = $this->modelSpaces($modelClass, freshTableSpaces: true); - $count = $this->store->flushByPatterns($this->prefixedForSpaces([ - CacheKeyBuilder::K_RESULT . ':' . $classKey . ':' . $tag . ':*', - CacheKeyBuilder::K_QUERY . ':' . $classKey . ':' . $tag . ':*', - CacheKeyBuilder::K_COUNT . ':' . $classKey . ':' . $tag . ':*', - CacheKeyBuilder::K_SCALAR . ':' . $classKey . ':' . $tag . ':*', - CacheKeyBuilder::K_THROUGH . ':' . $classKey . ':' . $tag . ':*', - ], $spaces)); - CacheReporter::invalidation('tag', $modelClass . ':' . $tag, $count, array_column($spaces, 'name')); - - return $count; - } - - public function flushTagAcrossModels(string $tag): int - { - CacheKeyBuilder::assertValidTag($tag); - $patterns = [ - CacheKeyBuilder::K_RESULT . ':*:' . $tag . ':*', CacheKeyBuilder::K_QUERY . ':*:' . $tag . ':*', - CacheKeyBuilder::K_COUNT . ':*:' . $tag . ':*', CacheKeyBuilder::K_SCALAR . ':*:' . $tag . ':*', - CacheKeyBuilder::K_THROUGH . ':*:' . $tag . ':*', - ]; - - $spaces = $this->store->isCluster() ? $this->spaceRegistry->knownSpaces() : []; - $count = $this->store->flushByPatterns( - !$this->store->isCluster() - ? $this->prefixedForAnySpace($patterns) - : $this->prefixedForSpaces($patterns, $spaces), - ); - CacheReporter::invalidation('tag', '*:' . $tag, $count, array_column($spaces, 'name')); - - return $count; - } - - public function commitPending(string $connection): void - { - $flushes = array_keys($this->flushQueue[$connection] ?? []); - $models = array_keys($this->modelVersionQueue[$connection] ?? []); - $evictions = $this->modelEvictionQueue[$connection] ?? []; - $versions = $this->versionQueue[$connection] ?? []; - $tables = array_keys($this->tableVersionQueue[$connection] ?? []); - $this->discardPending($connection); - - if (($flushes === [] && $models === [] && $versions === [] && $tables === []) || !$this->config->enabled) { - return; - } - - CacheFallback::attempt($this->config, function () use ($connection, $flushes, $models, $evictions, $versions, $tables): void { - foreach ($evictions as $model) { - $this->evictModelKey($model, $connection, freshTableSpaces: true); - } - - /** @var array> $invalidated */ - $invalidated = []; - /** @var array> $pending */ - $pending = []; - /** @var array> $pendingTypes */ - $pendingTypes = []; - $queue = function ( - string $classKey, - array $spaces, - string $dependencyType, - string $target, - ) use (&$pending, &$pendingTypes, &$invalidated): void { - foreach ($spaces as $space) { - if (!isset($invalidated[$classKey][$space->name])) { - $pending[$classKey][$space->name] = $space; - } - } - $pendingTypes[$classKey][$dependencyType] = $target; - }; - - foreach ($flushes as $modelClass) { - $classKey = $this->keys->classKey($modelClass, $connection); - $spaces = $this->modelSpaces($modelClass, $connection, true); - $this->forceFlushModel($modelClass, $connection); - foreach ($spaces as $space) { - $invalidated[$classKey][$space->name] = true; - } - } - foreach ($models as $modelClass) { - $queue( - $this->keys->classKey($modelClass, $connection), - $this->modelSpaces($modelClass, $connection, true), - 'model', - $modelClass, - ); - } - foreach ($versions as $classKey => $spaces) { - $queue($classKey, $spaces, 'pivot_table', $classKey); - } - foreach ($tables as $classKey) { - $queue($classKey, $this->spaceRegistry->freshSpacesForTable($classKey), 'table', $classKey); - } - foreach ($pending as $classKey => $spaces) { - foreach ($spaces as $space) { - $this->versions->bump($classKey, $this->config, $space); - } - foreach ($pendingTypes[$classKey] ?? [] as $dependencyType => $target) { - $this->reportBumps($dependencyType, $target, array_values($spaces)); - } - } - }); - } - - public function discardPending(string $connection): void - { - unset( - $this->flushQueue[$connection], - $this->modelVersionQueue[$connection], - $this->modelEvictionQueue[$connection], - $this->versionQueue[$connection], - $this->tableVersionQueue[$connection], - ); - } - - public function discardAllPending(): void - { - $this->flushQueue = $this->modelVersionQueue = $this->modelEvictionQueue - = $this->versionQueue = $this->tableVersionQueue = []; - } - - /** @return list */ - public function modelSpaces(string $modelClass, ?string $connection = null, bool $freshTableSpaces = false): array - { - $connection ??= $this->keys->declaredConnection($modelClass); - $tableKey = $this->keys->classKey($modelClass, $connection); - $tableSpaces = $freshTableSpaces - ? $this->spaceRegistry->freshSpacesForTable($tableKey) - : $this->spaceRegistry->spacesForTable($tableKey); - $spaces = []; - foreach ([...$this->spaceRegistry->spacesForModel($modelClass), ...$tableSpaces] as $space) { - $spaces[$space->name] = $space; - } - - return array_values($spaces); - } - - private function invalidateModelNow(string $modelClass, ?string $connection = null): void - { - $classKey = $this->keys->classKey($modelClass, $connection); - $spaces = $this->modelSpaces($modelClass, $connection); - foreach ($spaces as $space) { - $this->versions->bump($classKey, $this->config, $space); - } - $this->reportBumps('model', $modelClass, $spaces); - } - - private function invalidateTableNow(string $classKey): void - { - $spaces = $this->spaceRegistry->freshSpacesForTable($classKey); - foreach ($spaces as $space) { - $this->versions->bump($classKey, $this->config, $space); - } - $this->reportBumps('table', $classKey, $spaces); - } - - private function queueOrRun(?string $connection, callable $queue, callable $immediate): void - { - if ($connection !== null && DB::connection($connection)->transactionLevel() > 0) { - $queue(); - - return; - } - CacheFallback::attempt($this->config, $immediate); - } - - private function queueVersionFlush(string $connection, string $classKey, array $spaces): void - { - $queued = []; - foreach ($this->versionQueue[$connection][$classKey] ?? [] as $space) { - $queued[$space->name] = $space; - } - foreach ($spaces as $space) { - $queued[$space->name] = $space; - } - $this->versionQueue[$connection][$classKey] = array_values($queued); - } - - private function reportBumps(string $dependencyType, string $target, array $spaces): void - { - CacheReporter::invalidation( - $dependencyType, - $target, - count($spaces), - array_column($spaces, 'name'), - ); - } - - private function modelWriteChanged(Model $model, bool $existed): bool - { - if (!$existed) { - return $model->wasRecentlyCreated; - } - - if ($this->isCompletedRestoreSave($model)) { - return true; - } - - return $model->wasChanged(); - } - - private function isPendingRestoreSave(Model $model): bool - { - if (!method_exists($model, 'getDeletedAtColumn')) { - return false; - } - - $column = $model->getDeletedAtColumn(); - - return $model->isDirty($column) && $model->getAttribute($column) === null; - } - - private function isCompletedRestoreSave(Model $model): bool - { - if (!method_exists($model, 'getDeletedAtColumn')) { - return false; - } - - $column = $model->getDeletedAtColumn(); - - return $model->wasChanged($column) && $model->getAttribute($column) === null; - } - - private function prefixedForSpaces(array $patterns, array $spaces): array - { - $prefixed = []; - foreach ($spaces as $space) { - foreach ($patterns as $pattern) { - $prefixed[] = $this->keys->prefixed($pattern, $space); - } - } - - return $prefixed; - } - - private function prefixedForAnySpace(array $patterns): array - { - return array_map( - fn(string $pattern) => preg_replace('/^\{[^}]+\}:/', '{*}:', $this->keys->prefixed($pattern)), - $patterns, - ); - } -} diff --git a/src/Cache/MembershipRevalidator.php b/src/Cache/MembershipRevalidator.php new file mode 100644 index 0000000..53bfaf6 --- /dev/null +++ b/src/Cache/MembershipRevalidator.php @@ -0,0 +1,92 @@ +staleMembership; + $predicate = $context->plan->predicateColumns; + + // Projections rebuild from repaired canonical rows. + if ( + $stale === null + || $predicate === null + || !($context->plan->isCanonical() || $context->plan->supportsCanonicalProjectionFallback()) + ) { + return false; + } + + $from = $stale->rootVersion; + $to = $read->state->version; + + if ($from === null || !ctype_digit($from) || !ctype_digit($to)) { + return false; + } + + $from = (int) $from; + $to = (int) $to; + + if ($to <= $from || ($to - $from) > self::MAX_VERSION_GAP) { + return false; + } + + // Change records describe only root-version gaps. + if ( + $stale->epoch !== $read->state->epoch + || $stale->generation !== $read->state->generation + || $stale->versions !== $read->state->versions + || $stale->tagVersion !== $read->state->tag + ) { + return false; + } + + $keys = []; + + for ($version = $from + 1; $version <= $to; $version++) { + $keys[] = $this->keys->changeRecord($context->plan->root, (string) $version); + } + + $records = $this->store->mget($keys); + $guarded = array_flip($predicate); + + foreach ($records as $payload) { + if (!is_string($payload)) { + return false; + } + + $record = $this->changes->decode($payload); + + if ( + !$record->valid + || !$record->precise + || $record->mutation !== MutationType::UPDATE->value + ) { + return false; + } + + foreach ($record->columns as $column) { + if (isset($guarded[$column])) { + return false; + } + } + } + + return true; + } +} diff --git a/src/Cache/ModelCache.php b/src/Cache/ModelCache.php deleted file mode 100644 index a5db29c..0000000 --- a/src/Cache/ModelCache.php +++ /dev/null @@ -1,543 +0,0 @@ -getModel() ?? CacheKeyBuilder::prototype($modelClass); - $connection = $connectionModel->getConnection()->getName() - ?? $connectionModel->getConnectionName() - ?? ''; - $classKey = $this->keys->classKey($modelClass, $connection); - $projection = $columns !== null ? AttributeProjector::normalizeProjection($columns) : null; - - if ($raw === null) { - [$versionKey, $scheduledKey] = $this->keys->versionKeyPair($classKey); - [$modelVersion, $raw] = $this->store->getManyForCurrentVersion( - $versionKey, - $scheduledKey, - $this->keys->modelVersionPrefix($classKey), - $ids, - ); - } else { - $modelVersion = $resolvedVersion ?? 0; - } - - ['hits' => $hits, 'missed' => $missed, 'ordered' => $orderedHits] = $this->hydrateModelPayload( - $ids, - $modelClass, - $raw, - $projection, - $prototype, - ); - $repairCount = count($missed); - - if ($missed === []) { - if ($reporting) { - CacheReporter::modelHitActive($modelClass, $ids, $startedAt, [ - ...CacheReporter::cacheMeta(CacheKind::Model, CacheStatus::Hit, space: $this->keys->activeSpace()), - ]); - } - - return $orderedHits; - } - - $context = new ModelFetchContext( - modelClass: $modelClass, - classKey: $classKey, - projection: $projection, - prototype: $prototype, - missedQuery: $missedQuery, - preserveQueryShape: $preserveQueryShape, - modelVersion: $modelVersion, - ); - $context->hits = $hits; - - if ($context->hits !== [] && $reporting) { - CacheReporter::modelHitActive($modelClass, array_keys($context->hits), $startedAt, [ - ...CacheReporter::cacheMeta(CacheKind::Model, CacheStatus::Hit, space: $this->keys->activeSpace()), - ]); - } - - if ($versionDeferred) { - $context->modelVersion = $this->versions->currentVersion($modelClass, $this->keys->activeSpace(), $connection); - } - - if ($reporting) { - CacheReporter::modelMissActive($modelClass, $missed, $startedAt, [ - 'hits' => array_keys($context->hits), - 'partial' => $context->hits !== [], - 'repair_count' => $repairCount, - ...CacheReporter::cacheMeta(CacheKind::Model, CacheStatus::Miss, space: $this->keys->activeSpace()), - ]); - } - - [$context->lockKey, $context->wakeKey, $context->token] = $this->buildLockTriple($classKey, $context->modelVersion, $missed); - [$status, $missed] = $this->fetchMissedStatus($missed, $context); - - if ($status === LuaStatus::Building && $missed !== []) { - $this->store->brpop($context->wakeKey, $this->stampedeWaitMs / 1000.0); - [$status, $missed] = $this->fetchMissedStatus($missed, $context); - } - - if ($status === LuaStatus::Miss) { - $this->fetchAndMergeWithLockRelease($missed, $context); - } elseif ($status === LuaStatus::Hit && $missed !== []) { - if ($this->store->setNxEx($context->lockKey, $context->token, $this->buildingLockTtl)) { - $this->fetchAndMergeWithLockRelease($missed, $context); - } else { - $this->fetchAndMerge($missed, $context, false); - } - } elseif ($status === LuaStatus::Building && $missed !== []) { - $this->fetchAndMerge($missed, $context, false); - } - - $ordered = []; - foreach ($ids as $id) { - if (isset($context->hits[$id])) { - $ordered[] = $context->hits[$id]; - } - } - - if ($reporting) { - CacheReporter::metric( - 'model_entry_repairs', - $repairCount, - CacheKind::Model, - CacheStatus::Miss, - $modelClass, - space: $this->keys->activeSpace(), - ); - } - - return $ordered; - } - - public function rawForVersion( - string $modelClass, - array $ids, - int $version, - ?string $connection = null, - ): array { - if ($ids === []) { - return []; - } - - $classKey = $this->keys->classKey($modelClass, $connection); - $prefix = $this->keys->modelPrefix($classKey, $version); - - return $this->store->getMany(array_map( - static fn(mixed $id): string => $prefix . $id, - $ids, - )); - } - - public function store( - string $modelClass, - array $modelAttrs, - ?CacheSpace $space = null, - ?string $connection = null, - ): void { - $space ??= $this->keys->activeSpace(); - $version = $this->versions->currentVersion($modelClass, $space, $connection); - $this->storeForVersion($modelClass, $modelAttrs, $version, $space, $connection); - } - - public function storeForBuild( - string $modelClass, - array $modelAttrs, - BuildHandle $build, - ?CacheSpace $space = null, - ?string $connection = null, - ): void { - $classKey = $this->keys->classKey($modelClass, $connection); - $index = array_search($this->keys->verKey($classKey, $space), $build->versionKeys, true); - - if ($index === false || !isset($build->expectedVersions[$index])) { - return; - } - - $this->storeForVersion( - $modelClass, - $modelAttrs, - (int) $build->expectedVersions[$index], - $space, - $connection, - ); - } - - public function storeForVersion( - string $modelClass, - array $modelAttrs, - int $expectedVersion, - ?CacheSpace $space = null, - ?string $connection = null, - ): void { - if ($modelAttrs === []) { - return; - } - - $classKey = $this->keys->classKey($modelClass, $connection); - $attrsByKey = []; - - foreach ($modelAttrs as $id => $attrs) { - $attrsByKey[$this->keys->modelPrefix($classKey, $expectedVersion, $space) . $id] = $attrs; - } - - $this->store->setManyIfVersion( - $attrsByKey, - $this->config->ttl, - $this->keys->verKey($classKey, $space), - $expectedVersion, - ); - } - - public function hydrateResult(array $payload, Model $model, bool $cached = true): array - { - $modelClass = $model::class; - $prototype = $model; - $hydrate = RawAttributes::hydrateClosure(); - $models = []; - - foreach ($payload as $attrs) { - $instance = clone $prototype; - $hydrate($instance, $attrs, $this->fireRetrieved); - $models[] = $instance; - } - - if ($cached && $models !== [] && CacheReporter::active()) { - $keys = []; - foreach ($models as $instance) { - if ($instance->getKey() !== null) { - $keys[] = $instance->getKey(); - } - } - CacheReporter::modelHit($modelClass, $keys, null, [ - ...CacheReporter::cacheMeta(CacheKind::Model, CacheStatus::Hit, space: $this->keys->activeSpace()), - ]); - } - - return $models; - } - - public static function reset(): void - { - self::$overridesNewFromBuilder = []; - } - - private function buildLockTriple(string $classKey, int $modelVersion, array $ids): array - { - $sorted = $ids; - sort($sorted); - $segment = 'model:v' . $modelVersion; - $lockSuffix = $this->keys->resultBuildIdentityHash($segment, null, implode(',', $sorted)); - - return [ - $this->keys->resultBuildingKey($classKey, $segment, $lockSuffix), - $this->keys->wakeKey($classKey, $lockSuffix), - $this->versions->buildLockToken(), - ]; - } - - private function modelKeysFor(string $classKey, int $modelVersion, array $ids): array - { - $prefix = $this->keys->modelPrefix($classKey, $modelVersion); - - return array_map(static fn(mixed $id): string => $prefix . $id, $ids); - } - - private function fetchMissedStatus(array $idsToFetch, ModelFetchContext $context): array - { - $result = $this->store->fetchBatchBuildStatus( - $this->modelKeysFor($context->classKey, $context->modelVersion, $idsToFetch), - $context->lockKey, - $context->wakeKey, - $context->token, - $this->buildingLockTtl, - ); - $raw = $this->store->unserializeMany($result[3] ?? []); - ['hits' => $newHits, 'missed' => $stillMissed] = $this->hydrateModelPayload( - $idsToFetch, - $context->modelClass, - $raw, - $context->projection, - $context->prototype, - ); - - foreach ($newHits as $id => $hit) { - $context->hits[$id] = $hit; - } - - if ($stillMissed === []) { - return [LuaStatus::Hit, []]; - } - - return [LuaStatus::fromLua($result[0] ?? null), $stillMissed]; - } - - private function fetchAndMergeWithLockRelease(array $missed, ModelFetchContext $context): void - { - try { - $this->fetchAndMerge($missed, $context, true); - } catch (\Throwable $e) { - $this->store->releaseBuilding($context->lockKey, $context->wakeKey, $context->token); - - throw $e; - } - } - - private function fetchAndMerge(array $missed, ModelFetchContext $context, bool $writeCache): void - { - if ($missed === []) { - return; - } - - foreach ($this->fetchFromDatabaseAndCache($missed, $context, $writeCache) as $id => $model) { - $context->hits[$id] = $model; - } - } - - private function hydrateModelPayload( - array $ids, - string $modelClass, - array $raw, - ?array $projection, - ?Model $prototype = null, - ): array { - $prototype ??= CacheKeyBuilder::prototype($modelClass); - $hydrate = RawAttributes::hydrateClosure(); - $hits = []; - $missed = []; - $ordered = []; - $seen = []; - - foreach ($ids as $index => $id) { - if (isset($seen[$id])) { - continue; - } - $seen[$id] = true; - $attrs = $raw[$index] ?? null; - - if (!is_array($attrs)) { - $missed[] = $id; - - continue; - } - - if ($projection !== null) { - $attrs = AttributeProjector::projectAttributes($attrs, $projection); - } - - $instance = clone $prototype; - $hydrate($instance, $attrs, $this->fireRetrieved); - $hits[$id] = $instance; - $ordered[] = $instance; - } - - return ['hits' => $hits, 'missed' => $missed, 'ordered' => $ordered]; - } - - private function fetchFromDatabaseAndCache(array $missed, ModelFetchContext $context, bool $writeCache): array - { - $query = $this->prepareMissedQuery( - $context->modelClass, - $context->missedQuery, - $context->preserveQueryShape, - ); - - return $this->overridesNewFromBuilder($query->getModel()) - ? $this->fetchAndCacheUsingEloquent($missed, $query, $context, $writeCache) - : $this->fetchAndCacheUsingClosure($missed, $query, $context, $writeCache); - } - - private function fetchAndCacheUsingEloquent( - array $missed, - EloquentBuilder $query, - ModelFetchContext $context, - bool $writeCache, - ): array { - $prototype = CacheKeyBuilder::prototype($context->modelClass); - $primaryKey = $prototype->getKeyName(); - $loaded = $query->whereIn($prototype->getQualifiedKeyName(), $missed) - ->get([$prototype->getTable() . '.*']); - $hydrate = $context->projection !== null ? RawAttributes::hydrateClosure() : null; - $models = $this->cacheAndCollect($loaded, $context, $writeCache, function ($model) use ($primaryKey, $hydrate, $context) { - $attrs = $model->getRawOriginal(); - - if ($hydrate !== null) { - $hydrate($model, AttributeProjector::projectAttributes($attrs, $context->projection), false); - } - - return array_key_exists($primaryKey, $attrs) ? [$attrs[$primaryKey], $attrs, $model] : null; - }); - - return $models; - } - - private function fetchAndCacheUsingClosure( - array $missed, - EloquentBuilder $query, - ModelFetchContext $context, - bool $writeCache, - ): array { - $prototype = $query->getModel(); - $primaryKey = $prototype->getKeyName(); - $hydrate = RawAttributes::hydrateClosure(); - $connectionName = $prototype->getConnectionName(); - $rows = $query->whereIn($prototype->getQualifiedKeyName(), $missed) - ->toBase() - ->get([$prototype->getTable() . '.*']); - $models = $this->cacheAndCollect($rows, $context, $writeCache, function ($row) use ($primaryKey, $prototype, $hydrate, $connectionName, $context) { - $attrs = (array) $row; - - if (!array_key_exists($primaryKey, $attrs)) { - return null; - } - - $returnAttrs = $context->projection !== null - ? AttributeProjector::projectAttributes($attrs, $context->projection) - : $attrs; - $instance = clone $prototype; - $instance->setConnection($connectionName); - $hydrate($instance, $returnAttrs, true); - - return [$attrs[$primaryKey], $attrs, $instance]; - }); - - return $models; - } - - private function cacheAndCollect( - iterable $rows, - ModelFetchContext $context, - bool $writeCache, - callable $each, - ): array { - $deletedAtColumn = CacheKeyBuilder::deletedAtColumn($context->modelClass); - $prefix = $this->keys->modelPrefix($context->classKey, $context->modelVersion); - $attrsByKey = []; - $models = []; - - foreach ($rows as $row) { - $result = $each($row); - - if ($result === null) { - continue; - } - - [$id, $attrs, $model] = $result; - - if ($writeCache && !($deletedAtColumn && isset($attrs[$deletedAtColumn]))) { - $attrsByKey[$prefix . $id] = $attrs; - } - - $models[$id] = $model; - } - - $this->storeModelAttrs($attrsByKey, $context, $writeCache); - - return $models; - } - - private function storeModelAttrs(array $attrsByKey, ModelFetchContext $context, bool $writeCache): void - { - $this->store->setManyIfVersion( - $attrsByKey, - $this->config->ttl, - $this->keys->verKey($context->classKey), - $context->modelVersion, - $writeCache ? $context->lockKey : null, - $writeCache ? $context->wakeKey : null, - $writeCache ? $context->token : null, - ); - } - - private function overridesNewFromBuilder(Model $model): bool - { - $class = $model::class; - - return self::$overridesNewFromBuilder[$class] ??= - (new \ReflectionMethod($model, 'newFromBuilder'))->getDeclaringClass()->getName() !== Model::class; - } - - private function prepareMissedQuery( - string $modelClass, - ?CacheableBuilder $missedQuery, - bool $preserveQueryShape, - ): EloquentBuilder { - if ($missedQuery === null || !$preserveQueryShape || !$this->canPreserveQueryShape($missedQuery->getQuery())) { - /** @var CacheableBuilder $builder */ - $builder = $missedQuery !== null - ? $missedQuery->getModel()->newQuery() - : $modelClass::query(); - - if ($missedQuery !== null) { - $builder->withoutGlobalScopes($missedQuery->removedScopes()); - } - - return $builder->withoutCache(); - } - - $base = $missedQuery->getQuery() - ->cloneWithout(['columns', 'orders', 'limit', 'offset', 'groups', 'havings']) - ->cloneWithoutBindings(['select', 'order', 'groupBy', 'having']); - $builder = (new CacheableBuilder($base)) - ->setModel($missedQuery->getModel()) - ->withoutCache(); - $builder->withoutGlobalScopes($missedQuery->removedScopes()); - - return $builder; - } - - private function canPreserveQueryShape(QueryBuilder $base): bool - { - return $base->unions === null && is_string($base->from); - } -} diff --git a/src/Cache/ModelIndexCache.php b/src/Cache/ModelIndexCache.php deleted file mode 100644 index a3c150b..0000000 --- a/src/Cache/ModelIndexCache.php +++ /dev/null @@ -1,114 +0,0 @@ -finalizeModels($this->models->getModels( - $primaryKeys, - $model, - $selectedColumns, - null, - $prepared->builder, - false, - $prototype, - )); - } - - public function get( - PreparedQuery $prepared, - CachePlan $plan, - string $model, - ?array $selectedColumns, - ?string $cacheTag, - ?int $queryTtl, - ?float $debugbarStart, - Model $prototype, - ): Collection { - $builder = $prepared->builder; - $hash = QueryHasher::forModelIndexQuery($builder, $prepared->base); - $connection = $prototype->getConnection()->getName() - ?? $prototype->getConnectionName() - ?? ''; - - $outcome = $this->store->getOrBuild( - adapter: $this->adapter, - build: fn() => $this->buildIds($prepared->base, $prototype), - modelClass: $model, - hash: $hash, - tag: $cacheTag, - depClasses: $plan->dependencies->depClassesFor($model), - depTableKeys: $plan->dependencies->tables, - kind: CacheKind::ModelIndex, - ttl: $queryTtl, - connection: $connection, - ); - - $ids = $outcome->payload; - $cached = $outcome->status === CacheStatus::Hit || $outcome->status === CacheStatus::Empty; - - if ($cached) { - CacheReporter::queryHit($model, $outcome->key, $debugbarStart, [ - ...CacheReporter::cacheMeta(CacheKind::ModelIndex, $outcome->status, space: $plan->space), - 'payload_shape' => 'ids + models', - ]); - } else { - CacheReporter::queryMiss($model, $outcome->key, $debugbarStart, [ - ...CacheReporter::cacheMeta(CacheKind::ModelIndex, $outcome->status, space: $plan->space), - 'payload_shape' => 'ids', - ]); - } - - $resolvedVersion = isset($outcome->build->expectedVersions[0]) - ? (int) $outcome->build->expectedVersions[0] - : null; - $raw = $resolvedVersion !== null - ? $this->models->rawForVersion($model, $ids, $resolvedVersion, $connection) - : null; - - return $prepared->finalizeModels($this->models->getModels( - $ids, - $model, - $selectedColumns, - $raw, - $builder, - true, - $prototype, - $resolvedVersion, - )); - } - - private function buildIds(QueryBuilder $query, Model $prototype): array - { - return $query - ->cloneWithout(['columns']) - ->cloneWithoutBindings(['select']) - ->select($prototype->getKeyName()) - ->pluck($prototype->getKeyName()) - ->all(); - } -} diff --git a/src/Cache/QueryEntryRepository.php b/src/Cache/QueryEntryRepository.php new file mode 100644 index 0000000..57912ae --- /dev/null +++ b/src/Cache/QueryEntryRepository.php @@ -0,0 +1,537 @@ +): ?RowRepair $repair + */ + public function readCanonical( + QueryPlan $plan, + string $namespace, + string $queryHash, + array $head, + bool $repairMissing, + \Closure $repair, + bool $rootVersionApproved = false, + ): CacheRead { + $status = RedisProtocol::status($head); + $version = RedisProtocol::version($head); + $generation = RedisProtocol::version($head, 2); + $rawMembership = $status === RedisProtocol::HIT + ? RedisProtocol::canonicalPayload($head) + : null; + $miss = fn(?string $reason = null): CacheRead => new CacheRead( + $this->states->resolve($plan, $namespace, $queryHash, $version, $generation), + ReadOutcome::MISS, + [], + $reason, + ); + + if (!is_string($rawMembership)) { + return $miss(); + } + + $membership = $this->memberships->decode($rawMembership); + + if (!$membership->valid) { + return $miss('corrupt_payload'); + } + + $rowPrefix = $this->keys->rowPrefix($plan->root, $generation); + $unique = []; + + // Membership tokens are not unique. + foreach ($membership->ids as $token) { + $unique[$rowPrefix . $token] = true; + } + + [$state, $fetched] = $this->states->resolveCanonical( + $plan, + $namespace, + $queryHash, + array_keys($unique), + ); + + if ( + $state->version !== $version + || $state->generation !== $generation + || $membership->epoch !== $state->epoch + || $membership->generation !== $state->generation + || $membership->versions !== $state->versions + || $membership->tagVersion !== $state->tag + || (!$rootVersionApproved && $membership->rootVersion !== $state->version) + ) { + return new CacheRead( + $state, + ReadOutcome::MISS, + staleMembership: $membership, + staleMembershipRaw: $rawMembership, + ); + } + + if ($membership->ids === []) { + return new CacheRead( + $state, + ReadOutcome::HIT, + overlayRejected: $membership->overlayRejected, + ); + } + + $rows = []; + $missingAt = []; + $corrupt = false; + + foreach ($membership->ids as $index => $token) { + $rowKey = $rowPrefix . $token; + $rawRow = $fetched[$rowKey] ?? null; + + if ($rawRow === null) { + $missingAt[$index] = $token; + + continue; + } + + $row = $this->codec->decodeRowObject($rawRow, $state->epoch, $plan->primaryKey, $token); + + if ($row === null) { + $corrupt = true; + $missingAt[$index] = $token; + + continue; + } + + $rows[$index] = $row; + } + + $outcome = ReadOutcome::HIT; + + if ($missingAt !== []) { + if (!$repairMissing) { + return new CacheRead($state, ReadOutcome::MISS); + } + + $repairResult = $repair($state, array_values($missingAt)); + + if ($repairResult === null) { + return new CacheRead($state, ReadOutcome::MISS); + } + + $repaired = $repairResult->rows; + $outcome = $repairResult->outcome; + + foreach ($missingAt as $index => $token) { + if (!isset($repaired[$token])) { + return new CacheRead($state, ReadOutcome::MISS); + } + + $rows[$index] = $repaired[$token]; + } + + ksort($rows); + $rows = array_values($rows); + } + + return new CacheRead( + $state, + $outcome, + $rows, + $corrupt + ? 'corrupt_payload' + : ($outcome === ReadOutcome::REPAIRED ? 'row_repair' : null), + $membership->overlayRejected, + ); + } + + /** + * @param array $rows + */ + public function publishCanonical( + QueryBuilder $query, + QueryPlan $plan, + CacheState $state, + array $rows, + BuildLease $lease, + int $wakeTtl, + ?string $resultOverlay = null, + bool $overlayRejected = false, + ): bool { + $ids = []; + $rowKeys = []; + $rowPayloads = []; + $positions = []; + $rowPrefix = $this->keys->rowPrefix($plan->root, $state->generation); + + foreach ($rows as $row) { + if (!$row instanceof \stdClass || !property_exists($row, $plan->primaryKey->column)) { + return false; + } + + $token = $plan->primaryKey->token($row->{$plan->primaryKey->column}); + + if ($token === null) { + return false; + } + + $encoded = $this->codec->encodeRow($row, $state->epoch); + $ids[] = $token; + + if (array_key_exists($token, $positions)) { + if ($rowPayloads[$positions[$token]] !== $encoded) { + return false; + } + + continue; + } + + $positions[$token] = count($rowKeys); + $rowKeys[] = $rowPrefix . $token; + $rowPayloads[] = $encoded; + } + + $membership = $this->memberships->encode( + epoch: $state->epoch, + generation: $state->generation, + ids: $ids, + versions: $state->versions, + tagVersion: $state->tag, + overlayRejected: $overlayRejected, + rootVersion: $state->version, + ); + + // Rows publish in guarded slices; membership follows once they are durable. + if (!$this->store->publishRows( + versionKey: $this->keys->version($plan->root), + generationKey: $this->keys->generation($plan->root), + buildingKey: $lease->buildingKey, + rowKeys: $rowKeys, + rowPayloads: $rowPayloads, + expectedVersion: $state->version, + expectedGeneration: $state->generation, + rowTtl: $this->config->rowTtl, + token: (string) $lease->token, + leaseTtl: $this->config->buildingLockTtl, + )) { + return false; + } + + return $this->store->publishCanonical( + versionKey: $this->keys->version($plan->root), + generationKey: $this->keys->generation($plan->root), + membershipKey: $state->key, + rowKeys: [], + rowPayloads: [], + expectedVersion: $state->version, + expectedGeneration: $state->generation, + membershipPayload: $membership, + membershipTtl: $query->configuredTtl() ?? $this->config->queryTtl, + rowTtl: $this->config->rowTtl, + buildingKey: $lease->buildingKey, + wakeKey: (string) $lease->wakeKey, + token: (string) $lease->token, + wakeTtl: $wakeTtl, + resultPayload: $resultOverlay, + ); + } + + /** @param array $rows */ + public function restampCanonical( + QueryBuilder $query, + QueryPlan $plan, + CacheState $state, + array $rows, + OverlayAdmission $overlay, + ): void { + $ids = []; + + foreach ($rows as $row) { + if (!$row instanceof \stdClass || !property_exists($row, $plan->primaryKey->column)) { + return; + } + + $token = $plan->primaryKey->token($row->{$plan->primaryKey->column}); + + if ($token === null) { + return; + } + + $ids[] = $token; + } + + $membership = $this->memberships->encode( + epoch: $state->epoch, + generation: $state->generation, + ids: $ids, + versions: $state->versions, + tagVersion: $state->tag, + overlayRejected: $overlay->rejected, + rootVersion: $state->version, + ); + $ttl = $query->configuredTtl() ?? $this->config->queryTtl; + + if ($overlay->payload !== null) { + $this->store->publishVersionedEntries( + entryKeys: [$state->key, $state->key], + entryPayloads: [$membership, $overlay->payload], + ttl: $ttl, + versionKeys: [$this->keys->version($plan->root)], + expectedVersions: [$state->version], + entryFields: ['m', 'r'], + ); + + return; + } + + // Drop only after the guarded write accepts this publisher. + if ($this->store->publishVersionedEntries( + entryKeys: [$state->key], + entryPayloads: [$membership], + ttl: $ttl, + versionKeys: [$this->keys->version($plan->root)], + expectedVersions: [$state->version], + entryFields: ['m'], + )) { + $this->store->deleteHashField($state->key, 'r'); + } + } + + public function readResult(CacheState $state, mixed $raw): CacheRead + { + if (!is_string($raw)) { + return new CacheRead($state, ReadOutcome::MISS); + } + + $payload = $this->codec->decode($raw); + + if (!$payload->valid) { + return new CacheRead($state, ReadOutcome::MISS, [], 'corrupt_payload'); + } + + return $payload->epoch === $state->epoch + && $payload->versions === $state->versions + && $payload->tagVersion === $state->tag + && $payload->rootVersion === $state->version + ? new CacheRead($state, ReadOutcome::HIT, $payload->rows) + : new CacheRead($state, ReadOutcome::MISS); + } + + /** @param array $rows */ + public function publishResult( + QueryBuilder $query, + QueryPlan $plan, + CacheState $state, + array $rows, + BuildLease $lease, + int $wakeTtl, + ): bool { + $encoded = $this->codec->encode( + $rows, + $state->epoch, + $state->version, + $state->versions, + $state->tag, + ); + + $versionKeys = $plan->isResult() + ? [$this->keys->version($plan->root)] + : []; + $expected = $plan->isResult() + ? [$state->version] + : []; + + return $this->store->publishVersionedEntries( + entryKeys: [$state->key], + entryPayloads: [$encoded], + ttl: $query->configuredTtl() ?? $this->config->queryTtl, + versionKeys: $versionKeys, + expectedVersions: $expected, + buildingKey: $lease->buildingKey, + wakeKey: $lease->wakeKey, + token: $lease->token, + wakeTtl: $wakeTtl, + entryFields: ['r'], + ); + } + + /** @param array $rows */ + public function promoteResult( + QueryBuilder $query, + QueryPlan $plan, + CacheState $sourceState, + string $namespace, + string $queryHash, + array $rows, + ): bool { + $lease = null; + + try { + $encoded = $this->encodeResultWithinLimits($rows, $sourceState); + + if ($encoded === null) { + return false; + } + + $ttl = $query->configuredTtl() ?? $this->config->queryTtl; + $resultState = new CacheState( + key: $this->keys->queryEntry( + $plan->root, + $namespace, + $queryHash, + ), + epoch: $sourceState->epoch, + version: $sourceState->version, + generation: $plan->usesGeneration() ? $sourceState->generation : '0', + versions: $sourceState->versions, + tag: $sourceState->tag, + tagKey: $sourceState->tagKey, + ); + $lease = $this->leases->claim($plan, $resultState, $namespace, $queryHash); + + if (!$lease->owner) { + return false; + } + + $current = $this->states->resolve($plan, $namespace, $queryHash); + + if (!$current->equals($resultState)) { + $this->leases->release($lease); + + return false; + } + + return $this->store->publishVersionedEntries( + entryKeys: [$resultState->key], + entryPayloads: [$encoded], + ttl: $ttl, + versionKeys: [$this->keys->version($plan->root)], + expectedVersions: [$resultState->version], + buildingKey: $lease->buildingKey, + wakeKey: $lease->wakeKey, + token: $lease->token, + wakeTtl: $this->config->wakeTtl(), + entryFields: ['r'], + ); + } catch (\Throwable $exception) { + if ($lease !== null && $lease->owner) { + $this->leases->release($lease); + } + + $this->runtime->fail($exception); + + return false; + } + } + + /** + * @param array $rows + */ + public function inlineResult(CacheState $state, array $rows): OverlayAdmission + { + if ($this->config->maxAutoOverlayRows === 0) { + return OverlayAdmission::notAttempted(); + } + + try { + $encoded = $this->encodeResultWithinLimits($rows, $state); + } catch (\Throwable $exception) { + $this->runtime->fail($exception); + + return OverlayAdmission::notAttempted(); + } + + return $encoded === null + ? OverlayAdmission::rejected() + : OverlayAdmission::accepted($encoded); + } + + public function rebuiltResultOutcome(CacheRead $read, bool $promoted): CacheRead + { + return $promoted + ? $read->asRepaired('result_overlay_rebuilt') + : $read->withReason('corrupt_result_overlay_fallback'); + } + + /** @param array $rows */ + private function encodeResultWithinLimits(array $rows, CacheState $state): ?string + { + $count = count($rows); + + if ( + $this->config->maxAutoOverlayRows === 0 + || $count > $this->config->maxAutoOverlayRows + self::PAGINATION_LOOKAHEAD_ROWS + ) { + return null; + } + + if ( + $count > self::ESTIMATE_PROBE_MIN_ROWS + && $this->resultExceedsEstimate($rows, $state, $count) + ) { + return null; + } + + $encoded = $this->codec->encode( + $rows, + $state->epoch, + $state->version, + $state->versions, + $state->tag, + ); + + return strlen($encoded) > self::MAX_AUTO_OVERLAY_BYTES ? null : $encoded; + } + + /** @param array $rows */ + private function resultExceedsEstimate(array $rows, CacheState $state, int $count): bool + { + $first = $this->encodedResultLength($rows, $state, 1); + $marginal = $count < 2 ? 0 : $this->encodedResultLength($rows, $state, 2) - $first; + + return $first + $marginal * ($count - 1) > self::MAX_AUTO_OVERLAY_BYTES; + } + + /** @param array $rows */ + private function encodedResultLength(array $rows, CacheState $state, int $take): int + { + return strlen($this->codec->encode( + array_slice($rows, 0, $take), + $state->epoch, + $state->version, + $state->versions, + $state->tag, + )); + } +} diff --git a/src/Cache/QueryHashResolver.php b/src/Cache/QueryHashResolver.php new file mode 100644 index 0000000..c802546 --- /dev/null +++ b/src/Cache/QueryHashResolver.php @@ -0,0 +1,22 @@ +resolve = \Closure::fromCallable($resolve); + } + + public function value(): string + { + return $this->value ??= ($this->resolve)(); + } +} diff --git a/src/Cache/ReadContext.php b/src/Cache/ReadContext.php new file mode 100644 index 0000000..863364e --- /dev/null +++ b/src/Cache/ReadContext.php @@ -0,0 +1,15 @@ +payloads->getOrBuild( - adapter: $this->throughAdapter, - build: $build, - modelClass: $modelClass, - hash: $hash, - tag: $tag, - depClasses: $depClasses, - depTableKeys: $depTableKeys, - kind: CacheKind::RelationIndex, - ttl: $ttl, - connection: $connection, - ); - } - - public function runPivot( - string $parentClass, - string $relatedClass, - string $relation, - array $parentIds, - string $constraintHash, - string $pivotTableKey, - callable $onBuild, - callable $onMiss, - callable $onStore, - callable $onHit, - ?string $connection = null, - ): Collection { - $result = $this->fetchPivot( - $parentClass, - $relatedClass, - $relation, - $parentIds, - $constraintHash, - $pivotTableKey, - $connection, - ); - - if ($result->status === CacheStatus::Building) { - $result = $this->waitForPivotBuild( - $parentClass, - $relatedClass, - $relation, - $parentIds, - $constraintHash, - $pivotTableKey, - $connection, - ); - - if ($result === null) { - CacheReporter::metric( - 'build_budget_exhausted', - 1, - CacheKind::RelationIndex, - CacheStatus::Building, - $relatedClass, - ResultKind::Collection, - $this->keys->activeSpace(), - ['relation' => $relation, 'parents' => count($parentIds)], - ); - - return $onBuild(); - } - } - - if ($result->missedIds() === []) { - return $onHit($result); - } - - [$models, $cacheModels] = $onMiss($result); - $onStore($cacheModels, $result); - - return $models; - } - - public function fetchPivot( - string $parentClass, - string $relatedClass, - string $relation, - array $parentIds, - string $constraintHash, - string $pivotTableKey, - ?string $connection = null, - ): PivotCacheResult { - $parentKey = $this->keys->classKey($parentClass); - $relatedKey = $this->keys->classKey($relatedClass, $connection); - [$versionKeys, $scheduledKeys] = $this->keys->depKeyPairs( - $relatedKey, - [], - [$pivotTableKey], - ); - $segment = $this->store->fetchVersionedPivotSegment($versionKeys, $scheduledKeys); - $pivotKeys = []; - foreach ($parentIds as $id) { - $pivotKeys[] = $this->keys->pivotKey( - $parentKey, - $relatedKey, - $relation, - $constraintHash, - $segment, - $id, - ); - } - - [$payloads, $corruptCount] = $this->decodePivotPayloads( - $pivotKeys, - $this->store->getRawMany($pivotKeys), - ); - $data = array_combine($parentIds, $payloads); - $expectedVersions = $this->keys->versionsFromSegment($segment); - $this->reportPivotCorruption($relatedClass, $relation, $corruptCount); - $missed = array_keys(array_filter($data, fn($payload) => !is_array($payload))); - - if ($missed === []) { - return new PivotCacheResult( - $segment, - $data, - new BuildHandle(versionKeys: $versionKeys, expectedVersions: $expectedVersions), - CacheStatus::Hit, - ); - } - - [$lockKey, $wakeKey] = $this->pivotLockKeys( - $relatedKey, - $relation, - $constraintHash, - $parentIds, - $segment, - ); - $token = $this->versions->buildLockToken(); - $missedKeys = []; - foreach ($missed as $id) { - $missedKeys[] = $this->keys->pivotKey( - $parentKey, - $relatedKey, - $relation, - $constraintHash, - $segment, - $id, - ); - } - - $result = $this->store->fetchBatchBuildStatus( - $missedKeys, - $lockKey, - $wakeKey, - $token, - $this->buildingLockTtl, - ); - [$retryPayloads, $retryCorrupt] = $this->decodePivotPayloads( - $missedKeys, - $result[3] ?? [], - ); - $this->reportPivotCorruption($relatedClass, $relation, $retryCorrupt); - foreach ($missed as $index => $id) { - if (isset($retryPayloads[$index]) && is_array($retryPayloads[$index])) { - $data[$id] = $retryPayloads[$index]; - } - } - if (array_filter($data, fn($payload) => !is_array($payload)) === []) { - return new PivotCacheResult( - $segment, - $data, - new BuildHandle(versionKeys: $versionKeys, expectedVersions: $expectedVersions), - CacheStatus::Hit, - ); - } - - if (LuaStatus::fromLua($result[0] ?? null) === LuaStatus::Miss) { - return new PivotCacheResult( - $segment, - $data, - new BuildHandle($lockKey, $token, $wakeKey, $versionKeys, $expectedVersions), - CacheStatus::Miss, - ); - } - - return new PivotCacheResult( - $segment, - $data, - new BuildHandle(versionKeys: $versionKeys, expectedVersions: $expectedVersions), - CacheStatus::Building, - ); - } - - public function waitForPivotBuild( - string $parentClass, - string $relatedClass, - string $relation, - array $parentIds, - string $constraintHash, - string $pivotTableKey, - ?string $connection = null, - ): ?PivotCacheResult { - $relatedKey = $this->keys->classKey($relatedClass, $connection); - [, $wakeKey] = $this->pivotLockKeys( - $relatedKey, - $relation, - $constraintHash, - $parentIds, - null, - ); - $this->store->brpop($wakeKey, $this->stampedeWaitMs / 1000.0); - $result = $this->fetchPivot( - $parentClass, - $relatedClass, - $relation, - $parentIds, - $constraintHash, - $pivotTableKey, - $connection, - ); - - if ($result->status === CacheStatus::Building) { - return null; - } - - return $result; - } - - public function storePivotEntries( - array $entries, - ?int $ttl, - BuildHandle $build, - string $modelClass, - ): bool { - $encoded = array_map(fn($payload) => $this->pivotAdapter->encode($payload), $entries); - $stored = $this->store->storeVersionedPayload( - $encoded, - $ttl ?? $this->queryTtl, - $build->versionKeys, - $build->expectedVersions, - $build->buildingKey, - $build->wakeKey, - $build->buildingToken, - ); - - CacheReporter::metric( - 'pivot_payload_store', - count($entries), - CacheKind::RelationIndex, - CacheStatus::Miss, - $modelClass, - ResultKind::Collection, - $this->keys->activeSpace(), - ); - - return $stored; - } - - private function decodePivotPayloads(array $keys, array $rawPayloads): array - { - $payloads = []; - $corruptCount = 0; - - foreach ($keys as $index => $key) { - $raw = $rawPayloads[$index] ?? null; - if (!is_string($raw)) { - $payloads[] = null; - - continue; - } - - $decoded = $this->pivotAdapter->decode($raw); - if ($decoded->valid) { - $payloads[] = $decoded->payload; - - continue; - } - - $corruptCount++; - $payloads[] = null; - $this->store->delete($key); - } - - return [$payloads, $corruptCount]; - } - - private function reportPivotCorruption( - string $relatedClass, - string $relation, - int $corruptCount, - ): void { - if ($corruptCount === 0) { - return; - } - - CacheReporter::metric( - 'corrupt_payloads', - $corruptCount, - CacheKind::RelationIndex, - CacheStatus::Miss, - $relatedClass, - ResultKind::Collection, - $this->keys->activeSpace(), - ['relation' => $relation], - ); - } - - private function pivotLockKeys( - string $relatedKey, - string $relation, - string $constraintHash, - array $parentIds, - ?string $segment, - ): array { - $sortedIds = $parentIds; - sort($sortedIds); - $lockSuffix = $this->keys->resultBuildIdentityHash( - 'pivot', - $relation, - $constraintHash . ':' . implode(',', $sortedIds), - ); - - return [ - $segment !== null - ? $this->keys->resultBuildingKey($relatedKey, $segment, $lockSuffix) - : null, - $this->keys->wakeKey($relatedKey, $lockSuffix), - ]; - } -} diff --git a/src/Cache/ResultCache.php b/src/Cache/ResultCache.php deleted file mode 100644 index 112117f..0000000 --- a/src/Cache/ResultCache.php +++ /dev/null @@ -1,129 +0,0 @@ -builder; - $model = $builder->getModel(); - $modelClass = $model::class; - $connection = $model->getConnection()->getName() - ?? $model->getConnectionName() - ?? ''; - $tag = $builder->getCacheTag(); - $ttl = $builder->getQueryTtl(); - $debugbarStart = CacheReporter::beginMeasure(); - $namespace = $this->keys->namespaceFor(CacheKind::Result, $kind); - $hash = $this->resolveHash($prepared, $kind, $columns); - $depClasses = $plan->dependencies->depClassesFor($modelClass); - $depTableKeys = $plan->dependencies->tables; - $structuredPayload = $kind === ResultKind::Collection; - $lockSuffix = $this->keys->resultBuildIdentityHash($namespace, $tag, $hash); - - $execution = CacheFallback::rescue( - $this->config, - function () use ( - $structuredPayload, - $compute, - $modelClass, - $hash, - $tag, - $depClasses, - $depTableKeys, - $ttl, - $connection, - $lockSuffix, - $debugbarStart, - $kind, - $plan, - ) { - $resolve = fn() => $this->store->getOrBuild( - adapter: $this->adapter, - build: fn() => $structuredPayload ? $compute() : [$compute()], - modelClass: $modelClass, - hash: $hash, - tag: $tag, - depClasses: $depClasses, - depTableKeys: $depTableKeys, - kind: CacheKind::Result, - resultKind: $kind, - ttl: $ttl, - connection: $connection, - lockSuffix: $lockSuffix, - ); - - $outcome = $resolve(); - - if (!$structuredPayload - && ($outcome->status === CacheStatus::Hit || $outcome->status === CacheStatus::Empty) - && !array_key_exists(0, $outcome->payload)) { - $this->store->delete($outcome->key); - $outcome = $resolve(); - } - - $cached = $outcome->status === CacheStatus::Hit || $outcome->status === CacheStatus::Empty; - - if ($cached) { - CacheReporter::queryHit($modelClass, $outcome->key, $debugbarStart, [ - ...CacheReporter::cacheMeta(CacheKind::Result, $outcome->status, $kind, $plan->space), - 'payload_shape' => $kind->value, - ]); - } else { - CacheReporter::queryMiss($modelClass, $outcome->key, $debugbarStart, [ - ...CacheReporter::cacheMeta(CacheKind::Result, $outcome->status, $kind, $plan->space), - 'payload_shape' => $kind->value, - ]); - } - - return [ - 'value' => $structuredPayload ? $outcome->payload : $outcome->payload[0], - 'cached' => $cached, - ]; - }, - fn() => ['value' => $compute(), 'cached' => false], - ); - - return [$execution['value'], $execution['cached']]; - } - - private function resolveHash(PreparedQuery $prepared, ResultKind $kind, array $columns): string - { - $query = $prepared->base; - - if ($kind === ResultKind::Collection) { - return QueryHasher::forResultQuery($prepared->builder, $prepared->baseWithColumns($columns)); - } - - return match ($kind) { - ResultKind::PaginationCount => QueryHasher::forPaginationCountQuery($prepared->builder, $query), - default => QueryHasher::forScalarQuery($prepared->builder, $query, $kind->value, $columns), - }; - } -} diff --git a/src/Cache/RowRepairer.php b/src/Cache/RowRepairer.php new file mode 100644 index 0000000..73e3c63 --- /dev/null +++ b/src/Cache/RowRepairer.php @@ -0,0 +1,239 @@ + $tokens */ + public function repair( + QueryBuilder $query, + QueryPlan $plan, + CacheState $state, + array $tokens, + ): ?RowRepair { + $tokens = array_values(array_unique($tokens)); + sort($tokens, SORT_STRING); + + $batchHash = hash('xxh128', TableIdentity::encodeFields($tokens)); + $lease = $this->leases->claimRepair($plan->root, $state->generation, $batchHash); + + if (!$lease->owner) { + if ($lease->wakeKey !== null) { + $this->store->brpop( + $lease->wakeKey, + $this->config->stampedeWaitMs / 1000, + ); + } + + $rows = $this->readRepaired($plan, $state, $tokens); + + return $rows !== null + && $this->states->isCurrent($plan, $state, usesGeneration: true) + ? new RowRepair($rows, ReadOutcome::HIT) + : null; + } + + $repaired = $this->readRepaired($plan, $state, $tokens); + + if ($repaired !== null) { + $this->leases->release($lease); + + return $this->states->isCurrent($plan, $state, usesGeneration: true) + ? new RowRepair($repaired, ReadOutcome::HIT) + : null; + } + + try { + $rows = $this->build($query, $plan, $state, $tokens, $lease); + } catch (\Throwable $exception) { + $this->leases->release($lease); + + throw $exception; + } + + if ($rows === null) { + $this->leases->release($lease); + } + + return $rows === null + ? null + : new RowRepair($rows, ReadOutcome::REPAIRED); + } + + /** + * @param list $tokens + * @return array|null + */ + private function build( + QueryBuilder $query, + QueryPlan $plan, + CacheState $state, + array $tokens, + BuildLease $lease, + ): ?array { + $connection = $query->getConnection(); + $values = []; + + foreach ($tokens as $token) { + $value = $plan->primaryKey->valueFromToken($token); + + if ($value === null) { + return null; + } + + $values[] = $value; + } + + $rowsByToken = []; + + try { + foreach (array_chunk($values, self::REPAIR_BATCH_SIZE) as $batch) { + $rows = $connection + ->query() + ->from($plan->root->qualifiedTable()) + ->whereIn($plan->primaryKey->column, $batch) + ->useWritePdo() + ->get(); + + foreach ($rows as $row) { + if (!property_exists($row, $plan->primaryKey->column)) { + return null; + } + + $token = $plan->primaryKey->token($row->{$plan->primaryKey->column}); + + if ($token === null) { + return null; + } + + $rowsByToken[$token] = $row; + } + } + } catch (\Throwable $exception) { + // A database fault is not a cache fault. + $this->failures->repairUnreachable($exception, $plan->root, count($tokens)); + + return null; + } + + if (!$this->states->isCurrent($plan, $state, usesGeneration: true)) { + return null; + } + + $rowPrefix = $this->keys->rowPrefix($plan->root, $state->generation); + $rowKeys = []; + $rowPayloads = []; + + foreach ($tokens as $token) { + if (!isset($rowsByToken[$token])) { + return null; + } + + $rowKeys[] = $rowPrefix . $token; + $rowPayloads[] = $this->codec->encodeRow($rowsByToken[$token], $state->epoch); + } + + if (!$this->store->publishRows( + versionKey: $this->keys->version($plan->root), + generationKey: $this->keys->generation($plan->root), + buildingKey: $lease->buildingKey, + rowKeys: $rowKeys, + rowPayloads: $rowPayloads, + expectedVersion: $state->version, + expectedGeneration: $state->generation, + rowTtl: $this->config->rowTtl, + token: (string) $lease->token, + leaseTtl: $this->config->buildingLockTtl, + )) { + return null; + } + + // Waiters only wake once every repaired row is durable. + if (!$this->store->publishVersionedEntries( + entryKeys: [], + entryPayloads: [], + ttl: $this->config->rowTtl, + versionKeys: [ + $this->keys->version($plan->root), + $this->keys->generation($plan->root), + ], + expectedVersions: [ + $state->version, + $state->generation, + ], + buildingKey: $lease->buildingKey, + wakeKey: $lease->wakeKey, + token: $lease->token, + wakeTtl: $this->config->wakeTtl(), + )) { + return null; + } + + return $this->states->isCurrent($plan, $state, usesGeneration: true) ? $rowsByToken : null; + } + + /** + * @param list $tokens + * @return array|null + */ + private function readRepaired( + QueryPlan $plan, + CacheState $state, + array $tokens, + ): ?array { + $rowKeys = []; + + foreach ($tokens as $token) { + $rowKeys[$token] = $this->keys->row($plan->root, $state->generation, $token); + } + + $raw = $this->store->mget(array_values($rowKeys)); + $rows = []; + + foreach ($rowKeys as $token => $rowKey) { + $payload = $raw[$rowKey] ?? null; + $row = $payload === null + ? null + : $this->codec->decodeRowObject( + $payload, + $state->epoch, + $plan->primaryKey, + $token, + ); + + if ($row === null) { + return null; + } + + $rows[$token] = $row; + } + + return $rows; + } +} diff --git a/src/Cache/VersionStore.php b/src/Cache/VersionStore.php deleted file mode 100644 index 5b42c0f..0000000 --- a/src/Cache/VersionStore.php +++ /dev/null @@ -1,91 +0,0 @@ -normalizeVersion( - $this->fetchVersionWithCooldown($this->keys->classKey($modelClass, $connection), $space) - ); - } - - public function currentTableVersion(string $connectionName, string $table, ?CacheSpace $space = null): int - { - return $this->normalizeVersion( - $this->fetchVersionWithCooldown($this->keys->tableKey($connectionName, $table), $space) - ); - } - - public function bump( - string $classKey, - CacheConfig $config, - ?CacheSpace $space = null, - ): void { - if ($config->cooldown <= 0) { - $this->forceBump($classKey, $this->versionTtl($config), $space); - - return; - } - - [$versionKey, $scheduledKey] = $this->keys->versionKeyPair($classKey, $space); - $this->store->fetchVersionWithCooldown($versionKey, $scheduledKey); - - $dueAtMs = (int) floor(microtime(true) * 1000) + ($config->cooldown * 1000); - $this->store->setNxEx( - $scheduledKey, - (string) $dueAtMs, - $config->cooldown + $this->versionTtl($config), - ); - } - - public function forceBump( - string $classKey, - int $ttl, - ?CacheSpace $space = null, - ): int { - return $this->store->incrementAndExpire( - $this->keys->verKey($classKey, $space), - $ttl, - ); - } - - public function versionTtl(CacheConfig $config): int - { - return max($config->ttl, $config->queryTtl) * 2; - } - - public function buildLockToken(): string - { - return bin2hex(random_bytes(16)); - } - - public function normalizeVersion(mixed $value = null): int - { - return $value !== null ? (int) $value : 0; - } - - private function fetchVersionWithCooldown(string $classKey, ?CacheSpace $space = null): mixed - { - [$versionKey, $scheduledKey] = $this->keys->versionKeyPair($classKey, $space); - - return $this->store->fetchVersionWithCooldown( - $versionKey, - $scheduledKey, - ); - } -} diff --git a/src/Cache/VersionedPayloadStore.php b/src/Cache/VersionedPayloadStore.php deleted file mode 100644 index 477be82..0000000 --- a/src/Cache/VersionedPayloadStore.php +++ /dev/null @@ -1,206 +0,0 @@ -keys->classKey($modelClass, $connection); - $namespace = $this->keys->namespaceFor($kind, $resultKind); - $lockSuffix ??= $hash; - [$versionKeys, $scheduledKeys] = $this->keys->depKeyPairs( - $classKey, - $depClasses, - $depTableKeys, - ); - $prefix = $this->keys->namespacedPrefix($namespace, $classKey, $tag); - $wakeKey = $this->keys->wakeKey($classKey, $lockSuffix); - $token = $this->versions->buildLockToken(); - - $fetched = $this->fetch( - $adapter, - $versionKeys, - $scheduledKeys, - $prefix, - $classKey, - $hash, - $lockSuffix, - $wakeKey, - $token, - ); - - if ($this->isResolved($fetched)) { - return $fetched; - } - - if ($fetched->status === CacheStatus::Building) { - $this->store->brpop($wakeKey, $this->stampedeWaitMs / 1000.0); - $retried = $this->fetch( - $adapter, - $versionKeys, - $scheduledKeys, - $prefix, - $classKey, - $hash, - $lockSuffix, - $wakeKey, - $token, - ); - - if ($this->isResolved($retried)) { - return $retried; - } - - if ($retried->status === CacheStatus::Building) { - $payload = $build(); - - return new VersionedPayloadOutcome( - payload: $payload, - status: CacheStatus::Building, - key: $retried->key, - build: new BuildHandle, - ); - } - - $fetched = $retried; - } - - try { - $payload = $build(); - $encoded = $adapter->encode($payload); - $this->store->storeVersionedPayload( - [$fetched->key => $encoded], - $ttl ?? $this->queryTtl, - $fetched->build->versionKeys, - $fetched->build->expectedVersions, - $fetched->build->buildingKey, - $fetched->build->wakeKey, - $fetched->build->buildingToken, - ); - } catch (\Throwable $e) { - $this->store->releaseBuilding( - $fetched->build->buildingKey ?? '', - $fetched->build->wakeKey ?? '', - $fetched->build->buildingToken, - ); - - throw $e; - } - - return new VersionedPayloadOutcome( - payload: $payload, - status: CacheStatus::Miss, - key: $fetched->key, - build: $fetched->build, - ); - } - - public function delete(string $key): void - { - $this->store->delete($key); - } - - private function isResolved(VersionedPayloadOutcome $outcome): bool - { - return $outcome->status === CacheStatus::Hit || $outcome->status === CacheStatus::Empty; - } - - private function fetch( - PayloadAdapter $adapter, - array $versionKeys, - array $scheduledKeys, - string $prefix, - string $classKey, - string $hash, - string $lockSuffix, - string $wakeKey, - string $token, - ): VersionedPayloadOutcome { - $result = $this->store->fetchVersionedPayload( - $versionKeys, - $scheduledKeys, - $prefix, - $this->keys->buildingPrefix($classKey), - $this->keys->wakePrefix($classKey), - $hash, - $lockSuffix, - $token, - $this->buildingLockTtl, - $this->config->cooldownEnabled(), - ); - $status = LuaStatus::fromLua($result[0] ?? null); - $segment = (string) ($result[1] ?? ''); - $key = $prefix . $segment . ':' . $hash; - $buildingKey = $this->keys->resultBuildingKey($classKey, $segment, $lockSuffix); - $expectedVersions = $this->keys->versionsFromSegment($segment); - $build = new BuildHandle( - $buildingKey, - (string) (($status === LuaStatus::Miss ? $result[2] : null) ?? $token), - $wakeKey, - $versionKeys, - $expectedVersions, - ); - - if ($status !== LuaStatus::Hit) { - return match ($status) { - LuaStatus::Miss => new VersionedPayloadOutcome(null, CacheStatus::Miss, $key, $build), - default => new VersionedPayloadOutcome(null, CacheStatus::Building, $key, new BuildHandle), - }; - } - - $decoded = $adapter->decode($result[2] ?? null); - - if ($decoded->valid) { - return new VersionedPayloadOutcome( - $decoded->payload, - $decoded->empty ? CacheStatus::Empty : CacheStatus::Hit, - $key, - new BuildHandle(versionKeys: $versionKeys, expectedVersions: $expectedVersions), - ); - } - - $this->store->delete($key); - $claimed = $this->store->setNxEx($buildingKey, $token, $this->buildingLockTtl); - if ($claimed) { - $this->store->delete($wakeKey); - } - - return $claimed - ? new VersionedPayloadOutcome(null, CacheStatus::Miss, $key, $build) - : new VersionedPayloadOutcome(null, CacheStatus::Building, $key, new BuildHandle); - } -} diff --git a/src/CacheManager.php b/src/CacheManager.php index c28589c..a60e4fe 100644 --- a/src/CacheManager.php +++ b/src/CacheManager.php @@ -2,136 +2,181 @@ namespace NormCache; -use NormCache\Cache\Invalidator; -use NormCache\Cache\ModelCache; -use NormCache\Cache\ModelIndexCache; -use NormCache\Cache\RelationIndexCache; -use NormCache\Cache\ResultCache; -use NormCache\Cache\VersionStore; -use NormCache\Spaces\CacheSpaceResolver; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Arr; +use Illuminate\Support\Facades\DB; +use NormCache\Cache\CacheRuntime; +use NormCache\Planning\DeleteDependencyResolver; +use NormCache\Planning\TableIdentityResolver; use NormCache\Support\CacheKeyBuilder; +use NormCache\Support\QueryIdentity; use NormCache\Support\RedisStore; -use NormCache\Traits\HandlesInvalidation; use NormCache\Values\CacheConfig; -use NormCache\Values\CacheSpace; +use NormCache\Values\TableIdentity; -class CacheManager +final readonly class CacheManager { - use HandlesInvalidation; - public function __construct( - private readonly ModelIndexCache $modelIndexes, - private readonly ResultCache $resultCache, - private readonly RelationIndexCache $relationIndexes, - private readonly ModelCache $modelCache, - private readonly VersionStore $versions, - private readonly Invalidator $invalidation, - private readonly RedisStore $store, - private readonly CacheKeyBuilder $keys, - private readonly CacheConfig $config, - private readonly CacheSpaceResolver $spaceResolver, + private CacheConfig $config, + private CacheRuntime $runtime, + private RedisStore $store, + private CacheKeyBuilder $keys, + private Invalidator $invalidator, + private TableIdentityResolver $tables, + private DeleteDependencyResolver $deleteDependencies, + private QueryIdentity $identity, ) {} - public function modelIndexes(): ModelIndexCache + /** + * @param Model|class-string|string|list|string> $targets + */ + public function invalidate(Model|string|array $targets, ?string $connection = null): bool { - return $this->modelIndexes; - } + $identities = []; + $success = true; - public function resultCache(): ResultCache - { - return $this->resultCache; - } + foreach (Arr::wrap($targets) as $target) { + $identity = $this->invalidationIdentity($target, $connection); - public function relationIndexes(): RelationIndexCache - { - return $this->relationIndexes; - } + if ($identity === null) { + $success = false; - public function modelCache(): ModelCache - { - return $this->modelCache; - } + continue; + } - public function versionStore(): VersionStore - { - return $this->versions; - } + $identities[$identity->encoded] = $identity; + } - public function invalidator(): Invalidator - { - return $this->invalidation; + return $this->invalidator->invalidateTables(array_values($identities)) && $success; } - public function config(): CacheConfig + public function invalidateTable(string $connection, string $table): bool { - return $this->config; + return $this->invalidate($table, $connection); } - public function isEnabled(): bool + /** @param list $tables */ + public function invalidateTables(string $connection, array $tables): bool { - return $this->config->enabled; + return $this->invalidate($tables, $connection); } - public function isFallbackEnabled(): bool + private function invalidationIdentity(mixed $target, ?string $connection): ?TableIdentity { - return $this->config->fallbackEnabled; - } + if ($target instanceof Model) { + return $this->modelInvalidationIdentity($target, $connection); + } - public function isEventsEnabled(): bool - { - return $this->config->dispatchEvents; - } + if (!is_string($target)) { + throw new \InvalidArgumentException( + 'invalidate() expects Eloquent models, model class names, or table names.', + ); + } - public function enable(): void - { - $this->config->enabled = true; + if (is_a($target, Model::class, true)) { + return $this->modelInvalidationIdentity(new $target, $connection); + } + + return $this->tables->resolve(DB::connection($connection), $target); } - public function disable(): void + private function modelInvalidationIdentity(Model $model, ?string $connection): ?TableIdentity { - $this->config->enabled = false; + if ($connection !== null) { + $model = clone $model; + $model->setConnection($connection); + } + + return $this->tables->resolve($model->getConnection(), $model->getTable()); } - public function store(): RedisStore + public function flushTag(string $tag): bool { - return $this->store; + $hash = $this->identity->tagHash($tag); + + return $this->increment($this->keys->tagVersion($hash)); } - public function keys(): CacheKeyBuilder + public function flushAll(): bool { - return $this->keys; + $this->deleteDependencies->clear(); + $this->runtime->forgetEpoch(); + + return $this->increment($this->keys->epoch(), force: true); } - public function spaceFor(string $modelClass, ?string $explicitSpace = null): CacheSpace + public function withoutCache(callable $callback): mixed { - return $this->spaceResolver->resolve($modelClass, $explicitSpace); + return $this->runtime->withoutCache($callback); } - public function withSpace(?CacheSpace $space, callable $callback): mixed + public function disableCache(): bool { - if ($space === null) { - return $this->keys->withSpace(null, $callback); + if (!$this->config->enabled) { + return false; } - $active = $this->keys->activeSpace(); + try { + $this->store->setRawForever($this->keys->disabled(), '1'); + $this->runtime->forgetEpoch(); + + return true; + } catch (\Throwable $exception) { + $this->runtime->fail($exception); - return $active !== null && $active->name === $space->name - ? $callback() - : $this->keys->withSpace($space, $callback); + return false; + } } - public function withSpaceForModel(string $modelClass, ?string $explicitSpace, callable $callback): mixed + public function enableCache(): ?int { - return $this->withSpace($this->spaceFor($modelClass, $explicitSpace), $callback); + if (!$this->config->enabled) { + return null; + } + + $this->runtime->forgetEpoch(); + + try { + return $this->store->enableCache( + $this->keys->epoch(), + $this->keys->disabled(), + ); + } catch (\Throwable $exception) { + $this->runtime->fail($exception); + + return null; + } } - public function currentVersion(string $modelClass, ?string $connection = null): int + public function cacheDisabled(): bool { - return $this->versions->currentVersion($modelClass, $this->modelSpaces($modelClass)[0], $connection); + if (!$this->config->enabled) { + return false; + } + + try { + return $this->store->getRaw($this->keys->disabled()) !== null; + } catch (\Throwable $exception) { + $this->runtime->fail($exception); + + return false; + } } - public function currentTableVersion(string $connectionName, string $table): int + private function increment(string $key, bool $force = false): bool { - return $this->versions->currentTableVersion($connectionName, $table); + if (!$force && !$this->config->enabled) { + return false; + } + + try { + $this->store->increment($key); + + return true; + } catch (\Throwable $exception) { + $this->runtime->fail($exception); + + return false; + } } } diff --git a/src/CacheManagerFactory.php b/src/CacheManagerFactory.php deleted file mode 100644 index 18ce507..0000000 --- a/src/CacheManagerFactory.php +++ /dev/null @@ -1,120 +0,0 @@ - $overrides */ - public function make(array $overrides = []): CacheManager - { - $connection = (string) $this->value($overrides, 'connection', 'normcache.connection'); - $ttl = (int) $this->value($overrides, 'ttl', 'normcache.ttl'); - $queryTtl = (int) $this->value($overrides, 'query_ttl', 'normcache.query_ttl'); - $keyPrefix = (string) $this->value($overrides, 'key_prefix', 'normcache.key_prefix', ''); - $cooldown = (int) $this->value($overrides, 'cooldown', 'normcache.cooldown', 0); - $enabled = (bool) $this->value($overrides, 'enabled', 'normcache.enabled', true); - $events = (bool) $this->value($overrides, 'events', 'normcache.events', false); - $fallback = (bool) $this->value($overrides, 'fallback', 'normcache.fallback', true); - $fireRetrieved = (bool) $this->value($overrides, 'fire_retrieved', 'normcache.fire_retrieved', false); - $buildingLockTtl = (int) $this->value($overrides, 'building_lock_ttl', 'normcache.building_lock_ttl', 5); - $stampedeWaitMs = (int) $this->value($overrides, 'stampede_wait_ms', 'normcache.stampede_wait_ms', 200); - $stampedeWakeTokens = (int) $this->value($overrides, 'stampede_wake_tokens', 'normcache.stampede_wake_tokens', 64); - - $keys = new CacheKeyBuilder('{nc}:', $keyPrefix); - $store = new RedisStore($connection, $stampedeWakeTokens); - $versions = new VersionStore($store, $keys); - $config = new CacheConfig( - ttl: $ttl, - queryTtl: $queryTtl, - cooldown: $cooldown, - enabled: $enabled, - fallbackEnabled: $fallback, - dispatchEvents: $events, - stampedeWakeTokens: $stampedeWakeTokens, - ); - $modelCache = new ModelCache( - $store, - $keys, - $versions, - $config, - $fireRetrieved, - $buildingLockTtl, - $stampedeWaitMs, - ); - $payloads = new VersionedPayloadStore( - $store, - $keys, - $versions, - $config, - $queryTtl, - $buildingLockTtl, - $stampedeWaitMs, - ); - $serializedArrays = new SerializedArrayAdapter($store); - $invalidator = new Invalidator($store, $keys, $config, $this->spaceRegistry, $versions); - $modelIndexes = new ModelIndexCache( - $payloads, - new ModelIndexAdapter, - $modelCache, - ); - $resultCache = new ResultCache( - $payloads, - $serializedArrays, - $config, - $keys, - ); - $relationIndexes = new RelationIndexCache( - $payloads, - new ThroughIndexAdapter, - $serializedArrays, - $store, - $keys, - $versions, - $queryTtl, - $buildingLockTtl, - $stampedeWaitMs, - ); - - return new CacheManager( - modelIndexes: $modelIndexes, - resultCache: $resultCache, - relationIndexes: $relationIndexes, - modelCache: $modelCache, - versions: $versions, - invalidation: $invalidator, - store: $store, - keys: $keys, - config: $config, - spaceResolver: $this->spaceResolver, - ); - } - - /** @param array $overrides */ - private function value(array $overrides, string $override, string $configKey, mixed $default = null): mixed - { - return array_key_exists($override, $overrides) - ? $overrides[$override] - : config($configKey, $default); - } -} diff --git a/src/CacheServiceProvider.php b/src/CacheServiceProvider.php index 7cb6122..6b63a4a 100644 --- a/src/CacheServiceProvider.php +++ b/src/CacheServiceProvider.php @@ -2,146 +2,139 @@ namespace NormCache; +use DebugBar\DataCollector\TimeDataCollector; +use Illuminate\Database\Events\MigrationsEnded; +use Illuminate\Database\Events\TransactionBeginning; use Illuminate\Database\Events\TransactionCommitted; use Illuminate\Database\Events\TransactionRolledBack; -use Illuminate\Queue\Events\JobProcessed; -use Illuminate\Queue\Events\Looping; use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; +use NormCache\Cache\BuildLeaseCoordinator; +use NormCache\Cache\CacheRuntime; +use NormCache\Cache\CacheStateResolver; +use NormCache\Cache\CanonicalRowRepository; +use NormCache\Cache\Engine; +use NormCache\Cache\MembershipRevalidator; +use NormCache\Cache\QueryEntryRepository; +use NormCache\Cache\RowRepairer; +use NormCache\Console\DisableCommand; +use NormCache\Console\EnableCommand; use NormCache\Console\FlushCommand; -use NormCache\Debug\NormCacheCollector; -use NormCache\Debug\NormCacheDebugBarCollector; -use NormCache\Planning\CachePlanner; -use NormCache\Planning\CachePlanSpaceValidator; -use NormCache\Planning\QueryEligibility; -use NormCache\Spaces\CacheSpaceRegistry; -use NormCache\Spaces\CacheSpaceResolver; +use NormCache\Debug\DebugBarCollector; +use NormCache\Payload\ChangeRecordCodec; +use NormCache\Payload\MembershipCodec; +use NormCache\Payload\RawResultCodec; +use NormCache\Planning\DeleteDependencyResolver; +use NormCache\Planning\DependencyAnalyzer; +use NormCache\Planning\MutationKeyExtractor; +use NormCache\Planning\QueryPlanner; +use NormCache\Planning\TableIdentityResolver; use NormCache\Support\CacheKeyBuilder; -use NormCache\Support\CacheReporter; +use NormCache\Support\CacheSerializer; +use NormCache\Support\FailureReporter; +use NormCache\Support\QueryIdentity; +use NormCache\Support\QueryObserver; use NormCache\Support\RedisStore; +use NormCache\Values\CacheConfig; -class CacheServiceProvider extends ServiceProvider +final class CacheServiceProvider extends ServiceProvider { public function register(): void { $this->mergeConfigFrom(__DIR__ . '/../config/normcache.php', 'normcache'); - $this->app->singleton(CacheSpaceRegistry::class, function () { - $metadataStore = new RedisStore((string) config('normcache.connection'), (int) config('normcache.stampede_wake_tokens', 64)); - - return new CacheSpaceRegistry( - maxPerModel: (int) config('normcache.spaces.max_per_model', 16), - placement: (array) config('normcache.spaces.placement', []), - metadataStore: $metadataStore, - metadataKeyPrefix: (string) config('normcache.key_prefix', ''), - ); - }); - - $this->app->singleton(CacheSpaceResolver::class, function ($app) { - return new CacheSpaceResolver($app->make(CacheSpaceRegistry::class)); - }); - - $this->app->singleton(QueryEligibility::class); - - $this->app->singleton(CachePlanSpaceValidator::class, function ($app) { - return new CachePlanSpaceValidator( - registry: $app->make(CacheSpaceRegistry::class), - resolver: $app->make(CacheSpaceResolver::class), - crossSpaceBehavior: (string) config('normcache.spaces.cross_space_behavior', 'bypass'), - debug: (bool) config('app.debug', false), - logger: $app->make('log'), - eligibility: $app->make(QueryEligibility::class), - ); - }); - - $this->app->scoped(CachePlanner::class, function ($app) { - return new CachePlanner( - eligibility: $app->make(QueryEligibility::class), - spaceValidator: $app->make(CachePlanSpaceValidator::class), - config: $app->make(CacheManager::class)->config(), - ); - }); + $this->app->singleton(CacheConfig::class, fn() => CacheConfig::fromArray( + (array) config('normcache', []), + )); + $this->app->singleton(CacheKeyBuilder::class, fn($app) => new CacheKeyBuilder( + $app->make(CacheConfig::class)->keyPrefix, + )); + $this->app->singleton(RedisStore::class, fn($app) => new RedisStore( + $app->make(CacheConfig::class)->connection, + )); + $this->app->singleton(CacheSerializer::class, fn($app) => new CacheSerializer( + $app->make(CacheConfig::class)->serializer, + )); + $this->app->singleton(RawResultCodec::class); + $this->app->singleton(ChangeRecordCodec::class); + $this->app->singleton(MembershipCodec::class); + $this->app->singleton(QueryIdentity::class); + $this->app->singleton(QueryPlanner::class); + $this->app->singleton(MutationKeyExtractor::class); + $this->app->scoped(QueryObserver::class, function ($app): QueryObserver { + $config = $app->make(CacheConfig::class); + $collector = null; + + if ($config->debugbar && $app->bound('debugbar') && class_exists(TimeDataCollector::class)) { + $debugbar = $app->make('debugbar'); + + if (!method_exists($debugbar, 'isEnabled') || $debugbar->isEnabled()) { + $collector = new DebugBarCollector; + $debugbar->addCollector($collector); + } + } - $this->app->singleton(CacheManagerFactory::class, function ($app) { - return new CacheManagerFactory( - $app->make(CacheSpaceRegistry::class), - $app->make(CacheSpaceResolver::class), - ); + return new QueryObserver($config, $collector, $app->make(FailureReporter::class)); }); - $this->app->scoped(CacheManager::class, fn($app) => $app->make(CacheManagerFactory::class)->make()); - + $this->app->singleton(TableIdentityResolver::class); + $this->app->singleton(DeleteDependencyResolver::class); + $this->app->singleton(DependencyAnalyzer::class); + + $this->app->scoped(FailureReporter::class); + $this->app->scoped(CacheRuntime::class); + $this->app->scoped(CacheStateResolver::class); + $this->app->scoped(BuildLeaseCoordinator::class); + $this->app->scoped(RowRepairer::class); + $this->app->scoped(CanonicalRowRepository::class); + $this->app->scoped(QueryEntryRepository::class); + $this->app->singleton(MembershipRevalidator::class); + $this->app->scoped(Invalidator::class); + $this->app->scoped(Engine::class); + $this->app->scoped(CacheManager::class); $this->app->alias(CacheManager::class, 'normcache'); - - CacheReporter::configureEvents( - fn(): bool => $this->app->make(CacheManager::class)->config()->dispatchEvents, - ); } public function boot(): void { - if (config('normcache.enabled', true)) { - Event::listen(TransactionCommitted::class, function (TransactionCommitted $event) { - if ($event->connection->transactionLevel() === 0) { - $this->app->make(CacheManager::class)->commitPending($event->connection->getName()); - } - }); - - Event::listen(TransactionRolledBack::class, function (TransactionRolledBack $event) { - if ($event->connection->transactionLevel() === 0) { - $this->app->make(CacheManager::class)->discardPending($event->connection->getName()); - } - }); - - $resetManager = function () { - CacheKeyBuilder::reset(); - $this->app->make(CacheSpaceRegistry::class)->resetMetadataMemo(); - - $manager = $this->app->make(CacheManager::class); - $manager->discardAllPending(); - $manager->enable(); - }; + Event::listen(TransactionBeginning::class, function (TransactionBeginning $event): void { + $name = (string) $event->connection->getName(); - Event::listen(JobProcessed::class, $resetManager); - Event::listen(Looping::class, $resetManager); - - // Reset request-scoped runtime state between Octane requests and tasks. - foreach (['RequestReceived', 'TaskReceived'] as $event) { - $octaneEvent = "Laravel\\Octane\\Events\\$event"; - if (class_exists($octaneEvent)) { - Event::listen($octaneEvent, $resetManager); - } + // A thrown commit emits no completion event and may have reached the database. + if ($event->connection->transactionLevel() === 1) { + $this->app->make(Invalidator::class)->commit($name); } - if (config('normcache.debugbar', false) && $this->debugbarIsEnabled()) { - $this->registerDebugbarCollector(); + // Registering per level keeps invalidation ahead of any afterCommit + // callback the application adds at that same nesting level. + try { + $event->connection->afterCommit(function () use ($name): void { + $this->app->make(Invalidator::class)->commit($name); + }); + } catch (\Throwable $exception) { + $this->app->make(FailureReporter::class)->cacheUnavailable($exception); } - } + }); + Event::listen(TransactionCommitted::class, function (TransactionCommitted $event): void { + if ($event->connection->transactionLevel() === 0) { + $this->app->make(Invalidator::class)->commit((string) $event->connection->getName()); + } + }); + Event::listen(TransactionRolledBack::class, function (TransactionRolledBack $event): void { + if ($event->connection->transactionLevel() === 0) { + $this->app->make(Invalidator::class)->rollback((string) $event->connection->getName()); + } + }); + Event::listen(MigrationsEnded::class, function (): void { + $this->app->make(CacheManager::class)->flushAll(); + }); if ($this->app->runningInConsole()) { $this->publishes([ __DIR__ . '/../config/normcache.php' => config_path('normcache.php'), ], 'normcache-config'); - $this->commands([FlushCommand::class]); + $this->commands([FlushCommand::class, DisableCommand::class, EnableCommand::class]); } } - - private function registerDebugbarCollector(): void - { - $collector = new NormCacheDebugBarCollector; - NormCacheCollector::register($collector); - $this->app->make('debugbar')->addCollector($collector); - } - - private function debugbarIsEnabled(): bool - { - if (!$this->app->bound('debugbar')) { - return false; - } - - $debugbar = $this->app->make('debugbar'); - - return !method_exists($debugbar, 'isEnabled') || $debugbar->isEnabled(); - } } diff --git a/src/CacheableBuilder.php b/src/CacheableBuilder.php deleted file mode 100644 index 273ef32..0000000 --- a/src/CacheableBuilder.php +++ /dev/null @@ -1,601 +0,0 @@ -skipCache = true; - - return $this; - } - - public function isCacheSkipped(): bool - { - return $this->skipCache; - } - - public function ttl(int $ttl): static - { - if ($ttl <= 0) { - throw new \InvalidArgumentException('NormCache TTL must be greater than zero.'); - } - - $this->queryTtl = $ttl; - - return $this; - } - - public function tag(string $tag): static - { - CacheKeyBuilder::assertValidTag($tag); - - $this->cacheTag = $tag; - - return $this; - } - - public function space(string $name): static - { - $this->cacheSpace = $name; - - return $this; - } - - public function getSpace(): ?string - { - return $this->cacheSpace; - } - - public function dependsOn(array $modelClasses): static - { - if (empty($modelClasses)) { - throw new \InvalidArgumentException('dependsOn() requires at least one model class.'); - } - - $existing = $this->dependsOn ?? []; - - foreach ($modelClasses as $class) { - if (!is_string($class)) { - throw new \InvalidArgumentException('dependsOn() expects model class names, not model instances.'); - } - - if (!isset(self::$validatedModelClasses[$class])) { - if (!is_a($class, Model::class, true)) { - throw new \InvalidArgumentException("dependsOn() class [{$class}] must be an Eloquent model."); - } - - if (!in_array(Cacheable::class, class_uses_recursive($class), true)) { - throw new \InvalidArgumentException( - "dependsOn() class [{$class}] must use the NormCache\\Cacheable trait." - ); - } - - self::$validatedModelClasses[$class] = true; - } - - if (!in_array($class, $existing, true)) { - $existing[] = $class; - } - } - - $this->dependsOn = $existing; - - return $this; - } - - public function dependsOnTables(array $tables): static - { - if (empty($tables)) { - throw new \InvalidArgumentException('dependsOnTables() requires at least one table name.'); - } - - foreach ($tables as $table) { - if (!is_string($table) || $table === '') { - throw new \InvalidArgumentException('dependsOnTables() expects non-empty table name strings.'); - } - - if (preg_match('/[:{}\s*]/', $table)) { - throw new \InvalidArgumentException( - 'dependsOnTables() table name must not contain reserved characters (: { } * or whitespace).' - ); - } - } - - $conn = $this->model->getConnection()->getName(); - $this->dependsOnTables = array_values(array_unique(array_merge( - $this->dependsOnTables, - array_map(fn($table) => NormCache::keys()->tableKey($conn, $table), $tables), - ))); - - return $this; - } - - public function explicitDependencies(): ?array - { - return $this->dependsOn; - } - - public function explicitTableDependencies(): array - { - return $this->dependsOnTables; - } - - public function hasExplicitDependencies(): bool - { - return $this->dependsOn !== null || $this->dependsOnTables !== []; - } - - public function capturedDependencies(): DependencySet - { - return $this->capturedDependencies ?? DependencySet::empty(); - } - - public function capturedContextReasons(): array - { - return $this->capturedContextReasons; - } - - public function capturedOpaqueJoins(): int - { - return $this->capturedOpaqueJoins; - } - - public function acknowledgeOpaqueJoins(int $count): void - { - $this->capturedOpaqueJoins = $count; - } - - // toSql() forces ofMany()'s lazily-registered self-join to materialize before counting it. - public function acknowledgeOfManySelfJoin(): void - { - $base = $this->getQuery(); - $base->toSql(); - $this->acknowledgeOpaqueJoins(count($base->joins ?? [])); - } - - public function hasCapturedOpaqueFrom(): bool - { - return $this->capturedOpaqueFrom; - } - - public function capturedOpaqueWhereSubqueries(): int - { - return $this->capturedOpaqueWhereSubqueries; - } - - public function addCapturedContextReason(string $category, string $reason): void - { - $this->capturedContextReasons[$category] = array_values(array_unique([ - ...($this->capturedContextReasons[$category] ?? []), - $reason, - ])); - } - - /** - * @param Closure(CacheableBuilder): mixed|array|string|Expression $column - */ - public function where($column, $operator = null, $value = null, $boolean = 'and'): static - { - if (!$column instanceof Closure || $operator !== null) { - return parent::where($column, $operator, $value, $boolean); - } - - $nested = $this->model->newQueryWithoutRelationships(); - - if (!$nested instanceof self) { - return parent::where($column, $operator, $value, $boolean); - } - - $column($nested); - - $this->mergeCapturedBuilderState($nested); - $this->eagerLoad = array_merge($this->eagerLoad, $nested->getEagerLoads()); - $this->withoutGlobalScopes($nested->removedScopes()); - $this->query->addNestedWhereQuery($nested->getQuery(), $boolean); - - return $this; - } - - public function selectSub($query, $as): static - { - $this->captureSubqueryDependencies($query); - $this->query->selectSub($query, $as); - - return $this; - } - - public function joinSub($query, $as, $first, $operator = null, $second = null, $type = 'inner', $where = false): static - { - $this->captureSubqueryDependencies($query); - $this->capturedOpaqueJoins++; - $this->query->joinSub($query, $as, $first, $operator, $second, $type, $where); - - return $this; - } - - public function fromSub($query, $as): static - { - $this->captureSubqueryDependencies($query); - $this->capturedOpaqueFrom = true; - $this->query->fromSub($query, $as); - - return $this; - } - - public function whereIn($column, $values, $boolean = 'and', $not = false): static - { - if ($values instanceof Closure) { - $callback = $values; - $callback($values = $this->query->newQuery()); - } - - if ($values instanceof Builder || $values instanceof QueryBuilder || $values instanceof EloquentRelation) { - $this->captureSubqueryDependencies($values); - $this->capturedOpaqueWhereSubqueries++; - } - - $this->query->whereIn($column, $values, $boolean, $not); - - return $this; - } - - public function whereNotIn($column, $values, $boolean = 'and'): static - { - return $this->whereIn($column, $values, $boolean, true); - } - - public function orWhereIn($column, $values): static - { - return $this->whereIn($column, $values, 'or'); - } - - public function orWhereNotIn($column, $values): static - { - return $this->whereIn($column, $values, 'or', true); - } - - private function captureSubqueryDependencies(mixed $query): void - { - $base = match (true) { - $query instanceof self => $query->toBase(), - $query instanceof Builder => $query->toBase(), - $query instanceof QueryBuilder => $query, - $query instanceof EloquentRelation => $query->getQuery()->toBase(), - default => null, - }; - - if ($base === null) { - $this->addCapturedContextReason('dependency', 'subquery dependency could not be inferred'); - - return; - } - - $connection = $this->model->getConnection()->getName(); - $analyzer = $this->planner()->analyzer(); - $dependencies = $analyzer->inferQueryDependencies($base, $connection); - $this->capturedDependencies = ($this->capturedDependencies ?? DependencySet::empty())->merge($dependencies); - - $table = is_string($base->from) ? CacheKeyBuilder::stripTableAlias($base->from) : $this->model->getTable(); - $reasons = BypassReasons::fromInspection(new QueryInspection( - flags: $analyzer->flags($base, $table, $base->columns), - )); - - foreach ($reasons as $category => $items) { - foreach ($items as $reason) { - $this->addCapturedContextReason($category, $reason); - } - } - } - - protected function mergeCapturedBuilderState(self $builder): void - { - $this->capturedDependencies = ($this->capturedDependencies ?? DependencySet::empty()) - ->merge($builder->capturedDependencies()); - - foreach ($builder->capturedContextReasons() as $category => $reasons) { - foreach ($reasons as $reason) { - $this->addCapturedContextReason($category, $reason); - } - } - - $this->capturedOpaqueJoins += $builder->capturedOpaqueJoins(); - $this->capturedOpaqueFrom = $this->capturedOpaqueFrom || $builder->hasCapturedOpaqueFrom(); - $this->capturedOpaqueWhereSubqueries += $builder->capturedOpaqueWhereSubqueries(); - } - - public function getQueryTtl(): ?int - { - return $this->queryTtl; - } - - public function getCacheTag(): ?string - { - return $this->cacheTag; - } - - // ------------------------------------------------------------------------- - // Execution - // ------------------------------------------------------------------------- - - public function explain(): string - { - $prepared = $this->prepareCacheExecution(); - $plan = $this->planPrepared($prepared, fn() => CachePlanContext::models( - ProjectionClassifier::resolve($prepared->base, ['*']), - selectAll: true, - ), PlanningMode::Explain); - - $space = ($plan->space !== null && $plan->space->name !== 'default') - ? ' [space: ' . $plan->space->name . ']' - : ''; - - return match ($plan->strategy) { - CacheStrategy::DirectModels => 'cached: direct (primary key)' . $space, - CacheStrategy::ModelIndex => 'cached' . $space, - CacheStrategy::Result => $this->explainResultStrategy() . $space, - CacheStrategy::LiveQuery => $this->explainBypassStrategy($plan), - }; - } - - private function explainResultStrategy(): string - { - $hasExplicit = $this->dependsOn !== null || $this->dependsOnTables !== []; - - return $hasExplicit ? 'cached: result (dependsOn())' : 'cached: result'; - } - - private function explainBypassStrategy(CachePlan $plan): string - { - $labels = BypassReasons::labels(); - $parts = []; - foreach ($plan->bypassReasons as $category => $reasons) { - $parts[] = ($labels[$category] ?? $category) . ': ' . implode(', ', $reasons); - } - - return 'not cached — ' . implode(' | ', $parts); - } - - public function get($columns = ['*']): Collection - { - if ($this->skipCache || !NormCache::isEnabled()) { - return parent::get($columns); - } - - $debugbarStart = CacheReporter::beginMeasure(); - - $columns = Arr::wrap($columns); - $prepared = $this->prepareCacheExecution(); - $model = $this->model::class; - $base = $prepared->base; - - $plan = $this->planPrepared($prepared, fn() => CachePlanContext::models( - ProjectionClassifier::resolve($base, $columns), - selectAll: $columns === ['*'], - )); - - return NormCache::withSpace($plan->space, fn() => match ($plan->strategy) { - CacheStrategy::DirectModels => CacheFallback::rescue( - NormCache::config(), - fn() => NormCache::modelIndexes()->getDirect($prepared, $plan->primaryKeys, $model, $plan->columns, $this->model), - fn() => $prepared->collect($columns), - ), - CacheStrategy::ModelIndex => CacheFallback::rescue( - NormCache::config(), - fn() => NormCache::modelIndexes()->get($prepared, $plan, $model, $plan->columns, $this->cacheTag, $this->queryTtl, $debugbarStart, $this->model), - fn() => $prepared->collect($columns) - ), - CacheStrategy::Result => $this->executeResultQuery($prepared, $plan, $columns), - CacheStrategy::LiveQuery => $this->bypassAndReturn($model, $plan->bypassReasons, $debugbarStart, $prepared, $columns), - }); - } - - private function executeResultQuery( - PreparedQuery $prepared, - CachePlan $plan, - array $columns, - ): Collection { - $model = $this->model::class; - - [$payload, $cached] = NormCache::resultCache()->execute( - $prepared, - $plan, - ResultKind::Collection, - $columns, - function () use ($prepared, $columns) { - if ($this->hasAggregateColumns()) { - return $this->resultPayloadFromEloquentModels($prepared->collect($columns, false)); - } - - return $prepared->baseWithColumns($columns)->get()->map(fn($row) => (array) $row)->all(); - } - ); - - return $prepared->finalizeModels(NormCache::modelCache()->hydrateResult($payload, $this->model, $cached)); - } - - private function bypassAndReturn( - string $model, - array $bypassReasons, - ?float $debugbarStart, - PreparedQuery $prepared, - array $columns, - ): Collection { - CacheReporter::queryBypassed($model, $bypassReasons, $debugbarStart); - - return $prepared->collect($columns); - } - - public function paginate($perPage = null, $columns = ['*'], $pageName = 'page', $page = null, $total = null): LengthAwarePaginator - { - if ($total !== null || $this->skipCache || !NormCache::isEnabled()) { - return parent::paginate($perPage, $columns, $pageName, $page, $total); - } - - $debugbarStart = CacheReporter::beginMeasure(); - - $prepared = $this->prepareCacheExecution(); - $plan = $this->planPrepared($prepared, fn() => CachePlanContext::paginationCount()); - - if ($plan->strategy === CacheStrategy::LiveQuery) { - CacheReporter::queryBypassed($this->model::class, $plan->bypassReasons, $debugbarStart); - - return parent::paginate($perPage, $columns, $pageName, $page); - } - - try { - $cachedTotal = NormCache::withSpace($plan->space, fn() => $this->rememberPaginationTotal($prepared, $plan)); - } catch (\Throwable $e) { - CacheFallback::fallback(NormCache::config(), $e); - - $cachedTotal = null; - } - - return parent::paginate($perPage, $columns, $pageName, $page, $cachedTotal); - } - - public function eagerLoadRelations(array $models): array - { - if (!$this->skipCache) { - return parent::eagerLoadRelations($models); - } - - $original = $this->eagerLoad; - - try { - foreach ($this->eagerLoad as $name => $constraint) { - $this->eagerLoad[$name] = function ($query) use ($constraint) { - $constraint($query); - $builder = $query instanceof EloquentRelation ? $query->getQuery() : $query; - if ($builder instanceof self) { - $builder->withoutCache(); - } - }; - } - - return parent::eagerLoadRelations($models); - } finally { - $this->eagerLoad = $original; - } - } - - // ------------------------------------------------------------------------- - // Infrastructure - // ------------------------------------------------------------------------- - - public function prepareCacheExecution(): PreparedQuery - { - return $this->prepareScopedQuery()->applyBeforeCallbacks(); - } - - public function prepareScopedQuery(): PreparedQuery - { - /** @var self $builder */ - $builder = $this->applyScopes(); - - return new PreparedQuery($builder, $builder->getQuery()); - } - - // ------------------------------------------------------------------------- - // Internal - // ------------------------------------------------------------------------- - - public function cachePlan( - QueryBuilder $base, - CachePlanContext $context, - PlanningMode $planningMode = PlanningMode::Hot, - ): CachePlan { - return $this->planner()->plan($this, $base, $context, $planningMode); - } - - /** @param Closure(): CachePlanContext $context */ - public function planPrepared( - PreparedQuery $prepared, - Closure $context, - PlanningMode $mode = PlanningMode::Hot, - ): CachePlan { - $builder = $prepared->builder; - $base = $prepared->base; - - return $builder->cachePlan($base, $context(), $mode); - } - - public function planner(): CachePlanner - { - return app(CachePlanner::class); - } - - private function rememberPaginationTotal(PreparedQuery $prepared, CachePlan $plan): int - { - [$value] = NormCache::resultCache()->execute( - $prepared, - $plan, - ResultKind::PaginationCount, - [], - fn() => $prepared->base->getCountForPagination() - ); - - return (int) $value; - } -} diff --git a/src/Console/DisableCommand.php b/src/Console/DisableCommand.php new file mode 100644 index 0000000..7a46337 --- /dev/null +++ b/src/Console/DisableCommand.php @@ -0,0 +1,33 @@ +enabled) { + $this->warn('NormCache is already disabled by configuration. Nothing changed.'); + + return self::SUCCESS; + } + + if (!NormCache::disableCache()) { + $this->error('NormCache could not be disabled. The cache is still active.'); + + return self::FAILURE; + } + + $this->info('NormCache disabled. Reads bypass to the database.'); + + return self::SUCCESS; + } +} diff --git a/src/Console/EnableCommand.php b/src/Console/EnableCommand.php new file mode 100644 index 0000000..850305e --- /dev/null +++ b/src/Console/EnableCommand.php @@ -0,0 +1,35 @@ +enabled) { + $this->warn('NormCache is disabled by configuration. Set NORMCACHE_ENABLED=true to use this command.'); + + return self::FAILURE; + } + + $epoch = NormCache::enableCache(); + + if ($epoch === null) { + $this->error('NormCache could not be enabled. The cache remains disabled.'); + + return self::FAILURE; + } + + $this->info("NormCache enabled at epoch {$epoch}."); + + return self::SUCCESS; + } +} diff --git a/src/Console/FlushCommand.php b/src/Console/FlushCommand.php index 08aecc8..aab93c8 100644 --- a/src/Console/FlushCommand.php +++ b/src/Console/FlushCommand.php @@ -4,52 +4,23 @@ use Illuminate\Console\Command; use NormCache\Facades\NormCache; -use NormCache\Traits\Cacheable; -class FlushCommand extends Command +final class FlushCommand extends Command { - protected $signature = 'normcache:flush - {--model= : Fully-qualified class name of the model to flush} - {--space= : Cache space to flush}'; + protected $signature = 'normcache:flush'; - protected $description = 'Flush the normcache. Flushes all entries unless --model is specified.'; + protected $description = 'Invalidate every NormCache payload by advancing the global epoch.'; public function handle(): int { - $model = $this->option('model'); + if (!NormCache::flushAll()) { + $this->error('NormCache global invalidation failed.'); - return $model ? $this->flushModel($model) : $this->flushAll(); - } - - private function flushAll(): int - { - $space = $this->option('space'); - $count = NormCache::flushAll($space ?: null); - - $target = $space ? " in space [{$space}]" : ''; - $this->info("Flushed {$count} NormCache key(s){$target}."); - - return Command::SUCCESS; - } - - private function flushModel(string $model): int - { - if (!class_exists($model)) { - $this->error("Class [{$model}] does not exist."); - - return Command::FAILURE; - } - - if (!in_array(Cacheable::class, class_uses_recursive($model), true)) { - $this->error("Class [{$model}] does not use the Cacheable trait."); - - return Command::FAILURE; + return self::FAILURE; } - NormCache::forceFlushModel($model); - - $this->info("Cache flushed for [{$model}]."); + $this->info('NormCache global epoch advanced.'); - return Command::SUCCESS; + return self::SUCCESS; } } diff --git a/src/Database/QueryBuilder.php b/src/Database/QueryBuilder.php new file mode 100644 index 0000000..de48a75 --- /dev/null +++ b/src/Database/QueryBuilder.php @@ -0,0 +1,640 @@ + */ + private array $volatileColumns = []; + + private bool $skipped = false; + + private bool $internal = false; + + private ?int $ttl = null; + + private ?string $tag = null; + + private ?string $cacheContext = null; + + /** @var array */ + private array $dependencies = []; + + /** @var \WeakMap|null */ + private ?\WeakMap $capturedSubqueries = null; + + private int $writeDepth = 0; + + private int $nestedMutationSequence = 0; + + public function __construct( + Connection $connection, + ?Grammar $grammar = null, + ?Processor $processor = null, + ) { + parent::__construct($connection, $grammar, $processor); + } + + public function selectSub($query, $as) + { + $result = parent::selectSub($query, $as); + $subquery = $this->baseSubquery($query); + + if ($subquery instanceof Builder) { + $produced = end($this->columns); + + if ($produced instanceof Expression) { + $this->captureSubquery($produced, $subquery); + } + } + + return $result; + } + + public function joinSub( + $query, + $as, + $first, + $operator = null, + $second = null, + $type = 'inner', + $where = false, + ) { + $result = parent::joinSub( + $query, + $as, + $first, + $operator, + $second, + $type, + $where, + ); + $subquery = $this->baseSubquery($query); + + if ($subquery instanceof Builder) { + $join = end($this->joins); + $produced = $join->table; + + if ($produced instanceof Expression) { + $this->captureSubquery($produced, $subquery); + } + } + + return $result; + } + + private function baseSubquery(mixed $query): ?Builder + { + if ($query instanceof EloquentBuilder || $query instanceof Relation) { + return $query->toBase(); + } + + return $query instanceof Builder ? $query : null; + } + + private function captureSubquery(Expression $expression, Builder $subquery): void + { + $this->capturedSubqueries ??= new \WeakMap; + $this->capturedSubqueries[$expression] = [ + 'builder' => $subquery, + 'sql' => $subquery->getGrammar()->compileSelect($subquery), + ]; + } + + public function capturedSubquery(Expression $expression): ?Builder + { + $captured = $this->capturedSubqueries === null + ? null + : ($this->capturedSubqueries[$expression] ?? null); + + if ($captured !== null && $this->capturedBuilderIsCurrent($captured)) { + return $captured['builder']; + } + + return null; + } + + /** @param array{builder: Builder, sql: string} $captured */ + private function capturedBuilderIsCurrent(array $captured): bool + { + try { + return $captured['builder']->getGrammar()->compileSelect($captured['builder']) + === $captured['sql']; + } catch (\Throwable) { + return false; + } + } + + public function getConnection(): Connection + { + // The constructor narrows the inherited property to Connection. + /** @var Connection */ + return $this->connection; + } + + /** @param class-string $modelClass */ + public function enableCachingForModel( + string $modelClass, + string $keyName, + string $keyType, + ?string $deletedAtColumn = null, + array $volatileColumns = [], + ): static { + $this->modelClass = $modelClass; + $this->primaryKey = new PrimaryKeyMetadata( + $keyName, + $keyType === 'int' || $keyType === 'integer' + ? PrimaryKeyMetadata::INTEGER + : PrimaryKeyMetadata::STRING, + ); + $this->deletedAtColumn = $deletedAtColumn; + $this->volatileColumns = $volatileColumns; + + return $this; + } + + /** @return list */ + public function volatileColumns(): array + { + return $this->volatileColumns; + } + + /** @return class-string|null */ + public function modelClass(): ?string + { + return $this->modelClass; + } + + public function primaryKey(): ?PrimaryKeyMetadata + { + return $this->primaryKey; + } + + public function deletedAtColumn(): ?string + { + return $this->deletedAtColumn; + } + + public function withoutCache(): static + { + $this->skipped = true; + + return $this; + } + + public function internal(): static + { + $this->internal = true; + + return $this; + } + + public function isInternal(): bool + { + return $this->internal; + } + + public function ttl(int $seconds): static + { + if ($seconds < 1) { + throw new \InvalidArgumentException('NormCache TTL must be greater than zero.'); + } + + $this->ttl = $seconds; + + return $this; + } + + public function configuredTtl(): ?int + { + return $this->ttl; + } + + public function tag(string $tag): static + { + (new QueryIdentity)->tagHash($tag); + $this->tag = $tag; + + return $this; + } + + public function configuredTag(): ?string + { + return $this->tag; + } + + public function cacheContext(string $context): static + { + (new QueryIdentity)->contextHash($context); + $this->cacheContext = $context; + + return $this; + } + + public function configuredCacheContext(): ?string + { + return $this->cacheContext; + } + + /** @param array $dependencies */ + public function dependsOn(array $dependencies): static + { + if ($dependencies === []) { + throw new \InvalidArgumentException( + 'dependsOn() requires at least one model class or table name.' + ); + } + + foreach ($dependencies as $dependency) { + $declaration = $this->dependencyDeclaration($dependency); + $this->dependencies[$declaration->key()] = $declaration; + } + + return $this; + } + + private function dependencyDeclaration(mixed $dependency): DependencyDeclaration + { + if (!is_string($dependency)) { + throw new \InvalidArgumentException( + 'dependsOn() expects model class names or table names.' + ); + } + + if (is_a($dependency, Model::class, true)) { + return DependencyDeclaration::model($dependency); + } + + if ($this->isDefinedType($dependency)) { + throw new \InvalidArgumentException( + "dependsOn() class [{$dependency}] must be an Eloquent model." + ); + } + + $table = trim($dependency); + + if (str_contains($table, '\\')) { + throw new \InvalidArgumentException( + "dependsOn() model class [{$dependency}] does not exist." + ); + } + + if ($table === '' || preg_match('/[:{}\s*]/', $table) === 1) { + throw new \InvalidArgumentException( + 'dependsOn() table names must not contain reserved characters (: { } * or whitespace).' + ); + } + + return DependencyDeclaration::table($table); + } + + private function isDefinedType(string $type): bool + { + return class_exists($type) + || interface_exists($type) + || trait_exists($type) + || enum_exists($type); + } + + /** @return list */ + public function dependencies(): array + { + return array_values($this->dependencies); + } + + protected function runSelect() + { + $this->applyBeforeQueryCallbacks(); + $statement = new QueryStatement(fn(): array => [$this->toSql(), $this->getBindings()]); + + [$bypass, $reason] = $this->bypassDecision(); + + if ($bypass) { + if ($reason !== null) { + $observer = app(QueryObserver::class); + $observer->begin(); + $observer->bypass($this, $reason, $statement); + } + + return $this->connection->select( + $statement->sql(), + $statement->bindings(), + !$this->useWritePdo, + $this->fetchUsing, + ); + } + + // The bypass guard rejects write PDOs and custom fetch modes. + $select = fn(bool $useReadPdo): array => $this->connection->select( + $statement->sql(), + $statement->bindings(), + $useReadPdo, + ); + + // Direct primary-key hits never need compiled SQL. + return app(Engine::class)->select( + $this, + $statement, + 'select', + fn() => $select(true), + fn() => $select(false), + ); + } + + public function exists() + { + $this->applyBeforeQueryCallbacks(); + $sql = $this->grammar->compileExists($this); + $bindings = $this->getBindings(); + $statement = new QueryStatement(fn(): array => [$sql, $bindings]); + + [$bypass, $reason] = $this->bypassDecision(); + $results = $bypass + ? $this->runBypassedExists($statement, $reason) + : app(Engine::class)->select( + $this, + $statement, + 'exists', + fn() => $this->connection->select($statement->sql(), $statement->bindings(), true), + fn() => $this->connection->select($statement->sql(), $statement->bindings(), false), + ); + + if (!isset($results[0])) { + return false; + } + + $result = (array) $results[0]; + + return (bool) $result['exists']; + } + + public function insert(array $values): bool + { + return $this->writeWithInvalidation( + mutation: MutationType::INSERT, + mayAffectExistingRows: false, + operation: fn(): bool => parent::insert($values), + invalidate: static fn(bool $result): bool => $values !== [] && $result, + ); + } + + public function insertOrIgnore(array $values): int + { + return $this->writeWithInvalidation( + mutation: MutationType::INSERT, + mayAffectExistingRows: false, + operation: fn(): int => parent::insertOrIgnore($values), + invalidate: static fn(int $result): bool => $result > 0, + ); + } + + public function insertOrIgnoreReturning(array $values, array $returning = ['*'], $uniqueBy = null): mixed + { + return $this->writeWithInvalidation( + mutation: MutationType::INSERT, + mayAffectExistingRows: false, + operation: fn(): mixed => parent::insertOrIgnoreReturning($values, $returning, $uniqueBy), + invalidate: static fn($result): bool => $result->isNotEmpty(), + ); + } + + public function insertGetId(array $values, $sequence = null): int|string + { + return $this->writeWithInvalidation( + mutation: MutationType::INSERT, + mayAffectExistingRows: false, + operation: fn() => parent::insertGetId($values, $sequence), + ); + } + + public function insertUsing(array $columns, $query): int + { + return $this->writeWithInvalidation( + mutation: MutationType::INSERT, + mayAffectExistingRows: false, + operation: fn(): int => parent::insertUsing($columns, $query), + invalidate: static fn(int $result): bool => $result > 0, + ); + } + + public function insertOrIgnoreUsing(array $columns, $query): int + { + return $this->writeWithInvalidation( + mutation: MutationType::INSERT, + mayAffectExistingRows: false, + operation: fn(): int => parent::insertOrIgnoreUsing($columns, $query), + invalidate: static fn(int $result): bool => $result > 0, + ); + } + + public function update(array $values): int + { + return $this->writeWithInvalidation( + mutation: MutationType::UPDATE, + mayAffectExistingRows: true, + operation: fn(): int => parent::update($values), + invalidate: static fn(int $result): bool => $result > 0, + assigned: $values, + ); + } + + public function updateFrom(array $values): int + { + return $this->writeWithInvalidation( + mutation: MutationType::UPDATE, + mayAffectExistingRows: true, + operation: fn(): int => parent::updateFrom($values), + invalidate: static fn(int $result): bool => $result > 0, + assigned: $values, + ); + } + + public function updateOrInsert(array $attributes, $values = []): bool + { + $mutationSequence = $this->nestedMutationSequence; + + return $this->writeWithInvalidation( + mutation: MutationType::UPSERT, + mayAffectExistingRows: true, + operation: fn(): bool => parent::updateOrInsert($attributes, $values), + invalidate: fn(): bool => $this->nestedMutationSequence !== $mutationSequence, + forceBroadInvalidation: true, + ); + } + + public function upsert(array $values, $uniqueBy, $update = null): int + { + return $this->writeWithInvalidation( + mutation: MutationType::UPSERT, + mayAffectExistingRows: true, + operation: fn(): int => parent::upsert($values, $uniqueBy, $update), + invalidate: $values !== [], + forceBroadInvalidation: true, + ); + } + + public function delete($id = null) + { + return $this->writeWithInvalidation( + mutation: MutationType::DELETE, + mayAffectExistingRows: true, + operation: fn() => parent::delete($id), + invalidate: static fn(int $result): bool => $result > 0, + ); + } + + public function truncate(): void + { + $this->writeWithInvalidation( + mutation: MutationType::TRUNCATE, + mayAffectExistingRows: true, + operation: fn() => parent::truncate(), + forceBroadInvalidation: true, + ); + } + + /** @return array{0: bool, 1: ?string} */ + private function bypassDecision(): array + { + if ($this->writeDepth > 0) { + return [true, null]; + } + + $reason = match (true) { + $this->skipped => 'explicit_without_cache', + $this->connectionPretending() => 'connection_pretending', + $this->connection->transactionLevel() > 0 => 'transaction_active', + $this->useWritePdo => 'write_pdo', + $this->fetchUsing !== [] => 'custom_fetch_mode', + $this->hasCustomDefaultFetchMode() => 'custom_fetch_mode', + default => null, + }; + + return [$reason !== null, $reason]; + } + + private function runBypassedExists( + QueryStatement $statement, + ?string $reason, + ): array { + if ($reason !== null) { + $observer = app(QueryObserver::class); + $observer->begin(); + $observer->bypass($this, $reason, $statement); + } + + return $this->connection->select( + $statement->sql(), + $statement->bindings(), + !$this->useWritePdo, + ); + } + + /** + * $invalidate receives the operation's return value; callables deciding from + * builder state instead may declare no parameters. + * + * @param bool|callable(mixed): bool $invalidate + */ + private function writeWithInvalidation( + MutationType $mutation, + bool $mayAffectExistingRows, + callable $operation, + bool|callable $invalidate = true, + bool $forceBroadInvalidation = false, + ?array $assigned = null, + ): mixed { + $owner = $this->writeDepth === 0; + + $this->writeDepth++; + $failure = null; + $result = null; + + try { + $result = $operation(); + } catch (\Throwable $exception) { + $failure = $exception; + } finally { + $this->writeDepth--; + } + + if ($failure !== null) { + if ($owner) { + try { + app(Invalidator::class)->afterWrite( + query: $this, + mutation: $mutation, + mayAffectExistingRows: $mayAffectExistingRows, + forceBroadInvalidation: true, + assigned: $assigned, + ); + } catch (\Throwable $exception) { + app(FailureReporter::class)->cacheUnavailable($exception); + } + } + + throw $failure; + } + + $shouldInvalidate = is_bool($invalidate) ? $invalidate : $invalidate($result); + + if (!$owner) { + if ($shouldInvalidate) { + $this->nestedMutationSequence++; + } + + return $result; + } + + if ($shouldInvalidate) { + app(Invalidator::class)->afterWrite( + query: $this, + mutation: $mutation, + mayAffectExistingRows: $mayAffectExistingRows, + forceBroadInvalidation: $forceBroadInvalidation, + assigned: $assigned, + ); + } + + return $result; + } + + private function connectionPretending(): bool + { + return $this->getConnection()->pretending(); + } + + private function hasCustomDefaultFetchMode(): bool + { + $options = (array) $this->getConnection()->getConfig('options'); + $mode = $options[\PDO::ATTR_DEFAULT_FETCH_MODE] ?? \PDO::FETCH_OBJ; + + return $mode !== \PDO::FETCH_OBJ; + } +} diff --git a/src/Database/QueryStatement.php b/src/Database/QueryStatement.php new file mode 100644 index 0000000..ad00f05 --- /dev/null +++ b/src/Database/QueryStatement.php @@ -0,0 +1,46 @@ +} */ + private \Closure $resolve; + + /** @var array{0: string, 1: list}|null */ + private ?array $resolved = null; + + /** @var list|null */ + private ?array $preparedBindings = null; + + /** @param callable(): array{0: string, 1: list} $resolve */ + public function __construct(callable $resolve) + { + $this->resolve = \Closure::fromCallable($resolve); + } + + public function sql(): string + { + return $this->resolved()[0]; + } + + /** @return list */ + public function bindings(): array + { + return $this->resolved()[1]; + } + + /** @return list */ + public function preparedBindings(Connection $connection): array + { + return $this->preparedBindings ??= $connection->prepareBindings($this->bindings()); + } + + /** @return array{0: string, 1: list} */ + private function resolved(): array + { + return $this->resolved ??= ($this->resolve)(); + } +} diff --git a/src/Debug/DebugBarCollector.php b/src/Debug/DebugBarCollector.php new file mode 100644 index 0000000..557015a --- /dev/null +++ b/src/Debug/DebugBarCollector.php @@ -0,0 +1,82 @@ +reason ?? $record->route ?? $record->invalidationMode; + $label = '[' . $record->outcome . ']'; + + if ($record->modelClass !== null) { + $label .= ' ' . class_basename($record->modelClass); + } + + if ($detail !== null) { + $label .= ($record->modelClass === null ? ' ' : ': ') . $detail; + } + + $parameters = [ + 'route' => $record->route, + 'table_hash' => $record->tableHash, + 'query_hash' => $record->queryHash, + 'reason' => $record->reason, + 'sql' => $record->sql, + 'bindings' => $record->bindings, + 'model_class' => $record->modelClass, + ]; + + if ($record->outcome === 'invalidation') { + $parameters['invalidation_mode'] = $record->invalidationMode; + $parameters['primary_key_tokens'] = $record->primaryKeyTokens; + } + + $this->addMeasure($label, $record->startedAt, $record->endedAt, $parameters); + } + + public function getName(): string + { + return 'normcache'; + } + + public function collect(): array + { + $data = parent::collect(); + $measures = is_array($data['measures'] ?? null) ? $data['measures'] : []; + $elapsed = 0.0; + + foreach ($measures as $measure) { + $elapsed += (float) ($measure['duration'] ?? 0.0); + } + + $data['summary'] = self::summary(count($measures), $elapsed * 1000); + + return $data; + } + + private static function summary(int $operations, float $milliseconds): string + { + return $operations . ' ops / ' . number_format($milliseconds, 1) . ' ms'; + } + + public function getWidgets(): array + { + return [ + 'NormCache' => [ + 'icon' => 'archive', + 'widget' => 'PhpDebugBar.Widgets.TimelineWidget', + 'map' => 'normcache', + 'default' => '{}', + ], + 'NormCache:badge' => [ + 'map' => 'normcache.summary', + // Debugbar injects defaults into JavaScript verbatim. + 'default' => "'0 ops / 0.0 ms'", + ], + ]; + } +} diff --git a/src/Debug/NormCacheCollector.php b/src/Debug/NormCacheCollector.php deleted file mode 100644 index d3ee3da..0000000 --- a/src/Debug/NormCacheCollector.php +++ /dev/null @@ -1,62 +0,0 @@ -addQueryMeasure($type, $modelClass, $key, $startTime, $meta); - } - - public static function recordModel(string $type, string $modelClass, array $ids, ?float $startTime, array $meta = []): void - { - self::$instance?->addModelMeasure($type, $modelClass, $ids, $startTime, $meta); - } - - public static function recordMetric( - string $metric, - int|float $value, - string $modelClass, - array $meta, - ): void { - self::$instance?->addMetricMeasure($metric, $value, $modelClass, $meta); - } - - public static function recordInvalidation( - string $dependencyType, - string $target, - int $count, - array $spaces, - ): void { - self::$instance?->addInvalidationMeasure($dependencyType, $target, $count, $spaces); - } - - public static function recordBypass(string $modelClass, array $groupedReasons, ?float $startTime): void - { - self::$instance?->addBypassMeasure($modelClass, $groupedReasons, $startTime); - } -} diff --git a/src/Debug/NormCacheDebugBarCollector.php b/src/Debug/NormCacheDebugBarCollector.php deleted file mode 100644 index 36ef766..0000000 --- a/src/Debug/NormCacheDebugBarCollector.php +++ /dev/null @@ -1,155 +0,0 @@ - [ - 'icon' => 'archive', - 'widget' => 'PhpDebugBar.Widgets.TimelineWidget', - 'map' => 'normcache', - 'default' => '{}', - ], - 'Normcache:badge' => [ - 'map' => 'normcache.normcache-measures', - 'default' => 0, - ], - ]; - } - - public function addQueryMeasure(string $type, string $modelClass, string $key, ?float $startTime, array $meta): void - { - $details = ['key' => $key]; - $contains = $meta['contains'] ?? $this->queryContains($type); - - if ($contains !== null) { - $details['contains'] = $contains; - } - - foreach ($meta as $field => $value) { - if (!array_key_exists($field, $details)) { - $details[$field] = $value; - } - } - - $this->addMeasure( - '[' . $type . '] ' . class_basename($modelClass) . ': ' . $this->querySummary($type, $meta), - $startTime ?? microtime(true), - microtime(true), - $details - ); - } - - public function addModelMeasure(string $type, string $modelClass, array $ids, ?float $startTime, array $meta): void - { - $count = count($ids); - $suffix = $count === 1 ? '1 id' : "{$count} ids"; - - $this->addMeasure( - '[' . $type . '] ' . class_basename($modelClass) . ": {$suffix}", - $startTime ?? microtime(true), - microtime(true), - ['ids' => $ids, ...$meta] - ); - } - - public function addMetricMeasure( - string $metric, - int|float $value, - string $modelClass, - array $meta, - ): void { - $now = microtime(true); - $this->addMeasure( - '[metric] ' . $metric . ': ' . class_basename($modelClass), - $now, - $now, - ['metric' => $metric, 'value' => $value, ...$meta], - ); - } - - public function addInvalidationMeasure( - string $dependencyType, - string $target, - int $count, - array $spaces, - ): void { - $now = microtime(true); - $this->addMeasure( - '[invalidation] ' . $dependencyType . ': ' . $target, - $now, - $now, - [ - 'cache_kind' => 'version', - 'dependency_type' => $dependencyType, - 'count' => $count, - 'cache_spaces' => $spaces, - ], - ); - } - - public function addBypassMeasure(string $modelClass, array $groupedReasons, ?float $startTime): void - { - $labels = BypassReasons::labels(); - $parts = []; - - foreach ($groupedReasons as $category => $items) { - $parts[] = ($labels[$category] ?? $category) . ': ' . implode(', ', $items); - } - - $this->addMeasure( - '[bypass] ' . class_basename($modelClass), - $startTime ?? microtime(true), - microtime(true), - ['reasons' => implode(' | ', $parts)] - ); - } - - private function querySummary(string $type, array $meta): string - { - $shape = $meta['payload_shape'] ?? $meta['result_kind'] ?? null; - - return match (true) { - is_string($shape) => $shape, - str_starts_with($type, 'pivot ') => 'pivot', - str_starts_with($type, 'through ') => 'through', - isset($meta['cache_kind']) && is_string($meta['cache_kind']) => $meta['cache_kind'], - default => $type, - }; - } - - private function queryContains(string $type): ?string - { - return match (true) { - str_starts_with($type, 'query hit') => 'model payload fetch and deserialize', - default => null, - }; - } -} diff --git a/src/Enums/CacheKind.php b/src/Enums/CacheKind.php deleted file mode 100644 index a2fae0e..0000000 --- a/src/Enums/CacheKind.php +++ /dev/null @@ -1,12 +0,0 @@ - $primaryKeyTokens */ public function __construct( - public string $dependencyType, - public string $target, - public int $count, - public array $spaces = [], + public string $tableHash, + public string $mode, + public array $primaryKeyTokens = [], ) {} } diff --git a/src/Events/CacheMetricRecorded.php b/src/Events/CacheMetricRecorded.php deleted file mode 100644 index 97371fd..0000000 --- a/src/Events/CacheMetricRecorded.php +++ /dev/null @@ -1,21 +0,0 @@ -> $reasons Bypass reasons grouped by category. - * Categories: 'dependency', 'normalization', 'safety', 'space', 'opted_out' - */ + /** @param list $bindings */ public function __construct( - public string $modelClass, - public array $reasons, + public string $reason, + public string $sql, + public array $bindings, + public ?string $modelClass = null, + public ?string $tableHash = null, + public ?string $queryHash = null, + public ?string $route = null, ) {} } diff --git a/src/Events/QueryCacheHit.php b/src/Events/QueryCacheHit.php index e09aecf..07e7d59 100644 --- a/src/Events/QueryCacheHit.php +++ b/src/Events/QueryCacheHit.php @@ -4,9 +4,14 @@ final readonly class QueryCacheHit { + /** @param list $bindings */ public function __construct( - public string $modelClass, - public string $key, - public array $meta = [], + public string $route, + public string $queryHash, + public string $tableHash, + public string $sql, + public array $bindings, + public ?string $modelClass = null, + public ?string $reason = null, ) {} } diff --git a/src/Events/QueryCacheMiss.php b/src/Events/QueryCacheMiss.php index 367c957..9422d8f 100644 --- a/src/Events/QueryCacheMiss.php +++ b/src/Events/QueryCacheMiss.php @@ -4,9 +4,14 @@ final readonly class QueryCacheMiss { + /** @param list $bindings */ public function __construct( - public string $modelClass, - public string $key, - public array $meta = [], + public string $route, + public string $queryHash, + public string $tableHash, + public string $sql, + public array $bindings, + public ?string $modelClass = null, + public ?string $reason = null, ) {} } diff --git a/src/Events/QueryCacheRepaired.php b/src/Events/QueryCacheRepaired.php new file mode 100644 index 0000000..a0f1e8e --- /dev/null +++ b/src/Events/QueryCacheRepaired.php @@ -0,0 +1,17 @@ + $bindings */ + public function __construct( + public string $route, + public string $queryHash, + public string $tableHash, + public string $sql, + public array $bindings, + public ?string $modelClass = null, + public ?string $reason = null, + ) {} +} diff --git a/src/Exceptions/TableInvalidationException.php b/src/Exceptions/TableInvalidationException.php new file mode 100644 index 0000000..4078edf --- /dev/null +++ b/src/Exceptions/TableInvalidationException.php @@ -0,0 +1,11 @@ +, + * columns: list, + * revalidatable: bool + * }>> + */ + private array $pendingInvalidations = []; + + /** @var array */ + private array $pendingGlobalInvalidations = []; + + public function __construct( + private readonly CacheConfig $config, + private readonly CacheRuntime $runtime, + private readonly RedisStore $store, + private readonly CacheKeyBuilder $keys, + private readonly TableIdentityResolver $tables, + private readonly DeleteDependencyResolver $deleteDependencies, + private readonly MutationKeyExtractor $mutationKeys, + private readonly QueryObserver $observer, + private readonly FailureReporter $failures, + private readonly ChangeRecordCodec $changes, + ) {} + + /** @param array|null $assigned */ + public function afterWrite( + QueryBuilder $query, + MutationType $mutation, + bool $mayAffectExistingRows, + bool $forceBroadInvalidation = false, + ?array $assigned = null, + ): void { + if (!$this->runtime->invalidating()) { + return; + } + + $connection = $query->getConnection(); + $connectionName = (string) $connection->getName(); + $from = $query->from; + + if ($from instanceof Expression) { + $from = $from->getValue($query->getGrammar()); + } + + $table = $this->tables->resolve($connection, $from); + + if ($table === null) { + $this->applyOrQueueGlobal($connection, 'opaque_write'); + $this->failures->opaqueWriteGlobalInvalidation($connectionName); + + return; + } + + $truncating = $mutation === MutationType::TRUNCATE; + $affectedByDelete = []; + + if ($truncating || $mutation === MutationType::DELETE) { + $resolved = $truncating + ? $this->deleteDependencies->affectedByTruncate($connection, $table) + : $this->deleteDependencies->affectedByDelete($connection, $table); + + if ($resolved === null) { + $reason = strtolower($mutation->name) . '_dependencies_unavailable'; + $this->applyOrQueueGlobal($connection, $reason); + $this->failures->deleteDependencyGlobalInvalidation($table, $mutation); + + return; + } + + $affectedByDelete = $resolved; + } + + $broad = $forceBroadInvalidation; + $tokens = []; + + if ($mayAffectExistingRows && !$forceBroadInvalidation) { + $primaryKey = $query->primaryKey(); + $extracted = $primaryKey !== null + && $primaryKey->family === PrimaryKeyMetadata::INTEGER + ? $this->mutationKeys->extractMutation($query, $primaryKey, $assigned) + : null; + + if ( + $extracted === null + || count($extracted) > self::MAX_PRECISE_INVALIDATION_KEYS + ) { + $broad = true; + } else { + $tokens = $extracted; + } + } + + $invalidations = [[ + 'table' => $table, + 'broad' => $broad, + 'tokens' => $tokens, + 'columns' => $assigned === null + ? [] + : $this->changedColumns($assigned, $query->volatileColumns()), + 'revalidatable' => $this->config->revalidation + && $mutation === MutationType::UPDATE + && $assigned !== null + && !$broad + && $tokens !== [], + ]]; + + foreach ($affectedByDelete as $affected) { + if ($affected->hash === $table->hash) { + $invalidations[0]['broad'] = true; + + continue; + } + + $invalidations[] = [ + 'table' => $affected, + 'broad' => true, + 'tokens' => [], + 'columns' => [], + 'revalidatable' => false, + ]; + } + + if ($connection->transactionLevel() > 0) { + if ($truncating) { + $this->applyMany($invalidations); + } + + foreach ($invalidations as $invalidation) { + $this->queueInvalidation( + $invalidation['table'], + $invalidation['broad'], + $invalidation['tokens'], + $invalidation['columns'], + $invalidation['revalidatable'], + ); + } + + return; + } + + $this->applyMany($invalidations); + } + + public function commit(string $connection): void + { + if (isset($this->pendingGlobalInvalidations[$connection])) { + $reason = $this->pendingGlobalInvalidations[$connection]; + unset($this->pendingGlobalInvalidations[$connection]); + $this->pullInvalidations($connection); + $this->applyGlobal($reason); + + return; + } + + $invalidations = []; + + foreach ($this->pullInvalidations($connection) as $pending) { + $tokens = array_keys($pending['tokens']); + $invalidations[] = [ + 'table' => $pending['table'], + 'broad' => $pending['broad'] + || count($tokens) > self::MAX_PRECISE_INVALIDATION_KEYS, + 'tokens' => $tokens, + 'columns' => $pending['columns'], + 'revalidatable' => $pending['revalidatable'], + ]; + } + + $this->applyMany($invalidations); + } + + public function rollback(string $connection): void + { + unset( + $this->pendingInvalidations[$connection], + $this->pendingGlobalInvalidations[$connection], + ); + } + + public function invalidateTable(TableIdentity $table): bool + { + return $this->invalidateTables([$table]); + } + + public function invalidateTables(array $tables): bool + { + // A deliberate kill switch is not a failure to report to the caller. + if ($tables === [] || !$this->runtime->invalidating()) { + return true; + } + + $immediate = []; + + foreach ($tables as $table) { + if (DB::connection($table->connection)->transactionLevel() > 0) { + $this->queueInvalidation($table, true, []); + + continue; + } + + $immediate[] = [ + 'table' => $table, + 'broad' => true, + 'tokens' => [], + 'columns' => [], + 'revalidatable' => false, + ]; + } + + return $this->applyMany($immediate); + } + + /** + * @param list $tokens + * @param list $columns + */ + private function queueInvalidation( + TableIdentity $table, + bool $broad, + array $tokens, + array $columns = [], + bool $revalidatable = false, + ): void { + $current = $this->pendingInvalidations[$table->connection][$table->hash] ?? null; + $tokenSet = $current['tokens'] ?? []; + + foreach ($tokens as $token) { + $tokenSet[$token] = true; + } + + $this->pendingInvalidations[$table->connection][$table->hash] = [ + 'table' => $table, + 'broad' => $broad || ($current['broad'] ?? false), + 'tokens' => $tokenSet, + 'columns' => array_values(array_unique([ + ...($current['columns'] ?? []), + ...$columns, + ])), + 'revalidatable' => ($current['revalidatable'] ?? true) && $revalidatable, + ]; + } + + /** + * @return list, + * columns: list, + * revalidatable: bool + * }> + */ + private function pullInvalidations(string $connection): array + { + $pending = array_values($this->pendingInvalidations[$connection] ?? []); + unset($this->pendingInvalidations[$connection]); + + return $pending; + } + + private function applyOrQueueGlobal(Connection $connection, string $reason): void + { + if ($connection->transactionLevel() > 0) { + $connectionName = (string) $connection->getName(); + $this->pendingGlobalInvalidations[$connectionName] = 'transaction_' . $reason; + + return; + } + + $this->applyGlobal($reason); + } + + private function applyGlobal(string $reason): bool + { + $this->runtime->forgetEpoch(); + + try { + $this->store->increment($this->keys->epoch()); + + return true; + } catch (\Throwable $exception) { + $this->runtime->disable(); + $this->failures->globalInvalidationFailed($exception, $reason); + + return false; + } + } + + /** + * @param list $tokens + * @param list $columns + */ + private function apply( + TableIdentity $table, + bool $broad, + array $tokens, + array $columns = [], + bool $revalidatable = false, + ): bool { + $state = $this->storeInvalidation($table, $broad, $tokens, $columns, $revalidatable); + $mode = $state['mode']; + + $this->observer->begin(); + + try { + $this->store->invalidateTableState(...$state); + $this->observer->invalidated($table, $mode, $tokens); + + return true; + } catch (\Throwable $exception) { + $this->runtime->disable(); + $this->failures->invalidationFailed($exception, $table, $mode, $tokens); + + return false; + } + } + + /** + * @param list, + * columns: list, + * revalidatable: bool + * }> $invalidations + */ + private function applyMany(array $invalidations): bool + { + if ($invalidations === []) { + return true; + } + + if (count($invalidations) === 1) { + $invalidation = $invalidations[0]; + + return $this->apply( + $invalidation['table'], + $invalidation['broad'], + $invalidation['tokens'], + $invalidation['columns'], + $invalidation['revalidatable'], + ); + } + + $states = []; + + foreach ($invalidations as $invalidation) { + $states[] = $this->storeInvalidation( + $invalidation['table'], + $invalidation['broad'], + $invalidation['tokens'], + $invalidation['columns'], + $invalidation['revalidatable'], + ); + } + + $this->observer->begin(); + + try { + $this->store->invalidateTableStates($states); + + $observed = []; + + foreach ($invalidations as $index => $invalidation) { + $observed[] = [ + 'table' => $invalidation['table'], + 'mode' => $states[$index]['mode'], + 'tokens' => $invalidation['tokens'], + ]; + } + + $this->observer->invalidatedMany($observed); + + return true; + } catch (\Throwable $exception) { + $failedIndex = $exception instanceof TableInvalidationException + ? $exception->stateIndex + : 0; + $failed = $invalidations[$failedIndex] ?? $invalidations[0]; + $failure = $exception->getPrevious() ?? $exception; + + $this->runtime->disable(); + $this->failures->invalidationFailed( + $failure, + $failed['table'], + $states[$failedIndex]['mode'] ?? $states[0]['mode'], + $failed['tokens'], + ); + + return false; + } + } + + /** @param list $columns */ + private function changeRecord(array $columns, bool $revalidatable, string $mode): string + { + if (!$revalidatable || $mode !== 'precise') { + return ''; + } + + try { + return $this->changes->encode( + mutation: MutationType::UPDATE->value, + columns: $columns, + precise: true, + ); + } catch (\Throwable) { + // A missing record already fails closed on read. + return ''; + } + } + + /** + * @param array $assigned + * @param list $volatile + * @return list + */ + private function changedColumns(array $assigned, array $volatile): array + { + $columns = []; + + foreach ([...array_keys($assigned), ...$volatile] as $column) { + $column = (string) $column; + $arrow = strpos($column, '->'); + + // A data->k assignment changes the data column. + if ($arrow !== false) { + $column = substr($column, 0, $arrow); + } + + $columns[ColumnName::unqualified($column)] = true; + } + + return array_map(strval(...), array_keys($columns)); + } + + private function storeInvalidation( + TableIdentity $table, + bool $broad, + array $tokens, + array $columns = [], + bool $revalidatable = false, + ): array { + $mode = $broad ? 'generation' : ($tokens === [] ? 'version' : 'precise'); + + return [ + 'versionKey' => $this->keys->version($table), + 'generationKey' => $this->keys->generation($table), + 'mode' => $mode, + 'tokens' => $tokens, + 'rowPrefix' => $this->keys->tablePrefix($table) . ':r:g', + 'changePrefix' => $this->keys->changeRecordPrefix($table), + 'changePayload' => $this->changeRecord($columns, $revalidatable, $mode), + 'changeTtl' => $this->config->queryTtl, + ]; + } +} diff --git a/src/Lua/claim_build.lua b/src/Lua/claim_build.lua new file mode 100644 index 0000000..a325377 --- /dev/null +++ b/src/Lua/claim_build.lua @@ -0,0 +1,15 @@ +-- KEYS[1] = build lease key +-- ARGV[1] = claimant token +-- ARGV[2] = lease TTL in seconds + +if redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2], 'NX') then + return {1, ARGV[1]} +end + +local owner = redis.call('GET', KEYS[1]) or '' + +if owner == ARGV[1] then + return {1, owner} +end + +return {0, owner} diff --git a/src/Lua/enable_cache.lua b/src/Lua/enable_cache.lua new file mode 100644 index 0000000..de5c061 --- /dev/null +++ b/src/Lua/enable_cache.lua @@ -0,0 +1,9 @@ +-- KEYS[1] = epoch key +-- KEYS[2] = runtime disabled flag key +-- +-- Advance epoch before clearing the flag so pre-disable payloads stay unreachable. + +local epoch = redis.call('INCR', KEYS[1]) +redis.call('DEL', KEYS[2]) + +return epoch diff --git a/src/Lua/fetch_batch_build_status.lua b/src/Lua/fetch_batch_build_status.lua deleted file mode 100644 index 1d88f17..0000000 --- a/src/Lua/fetch_batch_build_status.lua +++ /dev/null @@ -1,44 +0,0 @@ --- Re-checks still-missing model/pivot keys and atomically claims the build lock if --- anything is still missing. Used for both model attributes and pivot payloads. --- --- KEYS[1..n] = model/pivot keys to re-check --- KEYS[n+1] = building lock key --- KEYS[n+2] = wake key --- ARGV[1] = lock token --- ARGV[2] = lock TTL in seconds --- --- Returns: {status, lockTokenOrFalse, false, rawValues} -local n = #KEYS - 2 -local chunkSize = 500 -local values = {} -local allHit = true - -for start = 1, n, chunkSize do - local stop = math.min(start + chunkSize - 1, n) - local chunk = {} - - for i = start, stop do - chunk[#chunk + 1] = KEYS[i] - end - - local chunkValues = redis.call('MGET', unpack(chunk)) - - for i = 1, #chunkValues do - values[start + i - 1] = chunkValues[i] - - if not chunkValues[i] then - allHit = false - end - end -end - -if allHit then - return {'hit', false, false, values} -end - -if redis.call('SET', KEYS[n + 1], ARGV[1], 'NX', 'EX', tonumber(ARGV[2])) then - redis.call('DEL', KEYS[n + 2]) - return {'miss', ARGV[1], false, values} -end - -return {'building', false, false, values} diff --git a/src/Lua/fetch_canonical.lua b/src/Lua/fetch_canonical.lua new file mode 100644 index 0000000..3327318 --- /dev/null +++ b/src/Lua/fetch_canonical.lua @@ -0,0 +1,20 @@ +-- Rows are fetched by MGET and state is revalidated afterward. +-- +-- KEYS[1] = version key +-- KEYS[2] = generation key +-- KEYS[3] = table key prefix +-- ARGV[1] = query namespace +-- ARGV[2] = query hash +-- +-- Returns {'hit'|'miss', ver, gen, membership?} + +local version = redis.call('GET', KEYS[1]) or '0' +local generation = redis.call('GET', KEYS[2]) or '0' +local query_key = KEYS[3] .. ':q:' .. ARGV[1] .. ':' .. ARGV[2] +local membership = redis.call('HGET', query_key, 'm') + +if not membership then + return {'miss', version, generation} +end + +return {'hit', version, generation, membership} diff --git a/src/Lua/fetch_result.lua b/src/Lua/fetch_result.lua new file mode 100644 index 0000000..a5dd03f --- /dev/null +++ b/src/Lua/fetch_result.lua @@ -0,0 +1,17 @@ +-- Derives the version so hits need no prior state read. +-- +-- KEYS[1] = version key +-- KEYS[2] = table key prefix +-- ARGV[1] = query namespace +-- ARGV[2] = query hash +-- +-- Returns {ver, payload} with payload absent when the key holds nothing. + +local version = redis.call('GET', KEYS[1]) or '0' +local payload = redis.call('HGET', KEYS[2] .. ':q:' .. ARGV[1] .. ':' .. ARGV[2], 'r') + +if not payload then + return {version} +end + +return {version, payload} diff --git a/src/Lua/fetch_result_or_canonical.lua b/src/Lua/fetch_result_or_canonical.lua new file mode 100644 index 0000000..69a6bdb --- /dev/null +++ b/src/Lua/fetch_result_or_canonical.lua @@ -0,0 +1,40 @@ +-- KEYS[1] = version key +-- KEYS[2] = generation key +-- KEYS[3] = table key prefix +-- ARGV[1] = query namespace +-- ARGV[2] = result query hash +-- ARGV[3] = canonical query hash +-- +-- Returns: +-- {'result', ver, payload, gen, membership?} +-- {'membership', ver, gen, membership} +-- {'miss', ver, gen} + +local version = redis.call('GET', KEYS[1]) or '0' +local generation = redis.call('GET', KEYS[2]) or '0' +local result_key = KEYS[3] .. ':q:' .. ARGV[1] .. ':' .. ARGV[2] +local membership_key = KEYS[3] .. ':q:' .. ARGV[1] .. ':' .. ARGV[3] +local result, membership + +if result_key == membership_key then + local fields = redis.call('HMGET', result_key, 'r', 'm') + result = fields[1] + membership = fields[2] +else + result = redis.call('HGET', result_key, 'r') + membership = redis.call('HGET', membership_key, 'm') +end + +if result then + if membership then + return {'result', version, result, generation, membership} + end + + return {'result', version, result, generation} +end + +if not membership then + return {'miss', version, generation} +end + +return {'membership', version, generation, membership} diff --git a/src/Lua/fetch_row.lua b/src/Lua/fetch_row.lua new file mode 100644 index 0000000..9d22c57 --- /dev/null +++ b/src/Lua/fetch_row.lua @@ -0,0 +1,16 @@ +-- Derives the generation so hits need no prior state read. +-- +-- KEYS[1] = generation key +-- KEYS[2] = table key prefix +-- ARGV[1] = primary-key token +-- +-- Returns {gen, row} with row absent when the key holds nothing. + +local generation = redis.call('GET', KEYS[1]) or '0' +local row = redis.call('GET', KEYS[2] .. ':r:g' .. generation .. ':' .. ARGV[1]) + +if not row then + return {generation} +end + +return {generation, row} diff --git a/src/Lua/fetch_version_with_cooldown.lua b/src/Lua/fetch_version_with_cooldown.lua deleted file mode 100644 index caadb64..0000000 --- a/src/Lua/fetch_version_with_cooldown.lua +++ /dev/null @@ -1,48 +0,0 @@ --- Resolve the current version, applying a scheduled invalidation if it is due. --- When given a model prefix and IDs, also fetch those versioned model payloads. --- --- KEYS[1] = ver key (ver:{classKey}:) --- KEYS[2] = scheduled key (scheduled:{classKey}:) --- KEYS[3] = optional model key prefix ending in ":v" --- ARGV[1] = current timestamp in ms --- ARGV[2] = fetch models ("0" or "1") --- ARGV[3..n] = model IDs when fetching models --- --- Returns: version string | {version string, raw model payloads} -local now = tonumber(ARGV[1]) -local due_at = redis.call('GET', KEYS[2]) -local version - -if due_at then - local due = tonumber(due_at) - if due and due <= now then - redis.call('DEL', KEYS[2]) - version = tostring(redis.call('INCR', KEYS[1])) - elseif not due then - redis.call('DEL', KEYS[2]) - end -end - -version = version or redis.call('GET', KEYS[1]) or '0' - -if ARGV[2] == '0' then - return version -end - -local values = {} -local chunk_size = 500 -for start = 3, #ARGV, chunk_size do - local stop = math.min(start + chunk_size - 1, #ARGV) - local keys = {} - - for i = start, stop do - keys[#keys + 1] = KEYS[3] .. version .. ':' .. ARGV[i] - end - - local chunk = redis.call('MGET', unpack(keys)) - for i = 1, #chunk do - values[start + i - 3] = chunk[i] - end -end - -return {version, values} diff --git a/src/Lua/fetch_versioned_payload.lua b/src/Lua/fetch_versioned_payload.lua deleted file mode 100644 index 11db748..0000000 --- a/src/Lua/fetch_versioned_payload.lua +++ /dev/null @@ -1,57 +0,0 @@ --- Resolve versions with cooldown, fetch versioned payload, claim build lock on miss. --- Used for: model-index, through-relation, and result cache entries. --- --- KEYS[1..n] = version keys --- KEYS[n+1..2n] = scheduled keys when cooldown is enabled --- KEYS[p] = payload key prefix --- KEYS[p+1] = building key prefix --- KEYS[p+2] = wake prefix --- ARGV[1] = payload hash --- ARGV[2] = lock suffix (= hash for model index/through; identity hash for result) --- ARGV[3] = current timestamp in ms --- ARGV[4] = building lock TTL in seconds --- ARGV[5] = building lock token --- ARGV[6] = version key count --- ARGV[7] = cooldown enabled (1/0) --- --- Returns: {'hit', seg, payload} | {'miss', seg, token} | {'building', seg} - -local n = tonumber(ARGV[6]) or ((#KEYS - 3) / 2) -local has_scheduled = ARGV[7] ~= '0' -local prefix_index = has_scheduled and (2 * n + 1) or (n + 1) -local now = tonumber(ARGV[3]) - -local vers = {} -for i = 1, n do - local ver = redis.call('GET', KEYS[i]) or '0' - if has_scheduled then - local scheduled_key = KEYS[n + i] - local due_at = redis.call('GET', scheduled_key) - if due_at then - local due_at_num = tonumber(due_at) - if due_at_num and due_at_num <= now then - redis.call('DEL', scheduled_key) - ver = tostring(redis.call('INCR', KEYS[i])) - elseif not due_at_num then - redis.call('DEL', scheduled_key) - end - end - end - vers[i] = ver -end - -local seg = 'v' .. vers[1] -for i = 2, n do seg = seg .. ':v' .. vers[i] end - -local data = redis.call('GET', KEYS[prefix_index] .. seg .. ':' .. ARGV[1]) -if data then - return {'hit', seg, data} -end - -local building_key = KEYS[prefix_index + 1] .. seg .. ':' .. ARGV[2] -if redis.call('SET', building_key, ARGV[5], 'NX', 'EX', tonumber(ARGV[4])) then - redis.call('DEL', KEYS[prefix_index + 2] .. ARGV[2]) - return {'miss', seg, ARGV[5]} -end - -return {'building', seg} diff --git a/src/Lua/fetch_versioned_pivot.lua b/src/Lua/fetch_versioned_pivot.lua deleted file mode 100644 index c74d964..0000000 --- a/src/Lua/fetch_versioned_pivot.lua +++ /dev/null @@ -1,31 +0,0 @@ --- Resolve the version segment for a pivot cache lookup. Doesn't fetch the pivot payloads — --- PHP fetches those separately via a plain MGET, much faster than Lua's bulk reply marshaling. --- --- KEYS[1..n] = version keys (parent, related, ...) --- KEYS[n+1..2n] = scheduled keys (one per version key, same order) --- ARGV[1] = current timestamp in ms --- --- Returns: seg -local n = #KEYS / 2 -local now = tonumber(ARGV[1]) - -local vers = {} -for i = 1, n do - local ver = redis.call('GET', KEYS[i]) or '0' - local due_at = redis.call('GET', KEYS[n + i]) - if due_at then - local due_at_num = tonumber(due_at) - if due_at_num and due_at_num <= now then - redis.call('DEL', KEYS[n + i]) - ver = tostring(redis.call('INCR', KEYS[i])) - elseif not due_at_num then - redis.call('DEL', KEYS[n + i]) - end - end - vers[i] = ver -end - -local seg = 'v' .. vers[1] -for i = 2, n do seg = seg .. ':v' .. vers[i] end - -return seg diff --git a/src/Lua/invalidate_table.lua b/src/Lua/invalidate_table.lua new file mode 100644 index 0000000..cbc40f8 --- /dev/null +++ b/src/Lua/invalidate_table.lua @@ -0,0 +1,40 @@ +-- KEYS[1] = version key +-- KEYS[2] = generation key +-- KEYS[3] = row key prefix ending in ":r:g" +-- KEYS[4] = change record key prefix ending in ":chg:" +-- ARGV[1] = mode: version | precise | generation +-- ARGV[2] = change record payload, empty when the write records nothing +-- ARGV[3] = change record TTL +-- ARGV[4..] = PK tokens for precise mode + +local mode = ARGV[1] +local version = redis.call('INCR', KEYS[1]) + +if ARGV[2] ~= '' then + redis.call('SETEX', KEYS[4] .. version, ARGV[3], ARGV[2]) +end + +if mode == 'generation' then + local generation = redis.call('INCR', KEYS[2]) + return {version, generation} +end + +if mode == 'precise' then + local generation = redis.call('GET', KEYS[2]) or '0' + local batch = {} + + for i = 4, #ARGV do + batch[#batch + 1] = KEYS[3] .. generation .. ':' .. ARGV[i] + + if #batch == 100 then + redis.call('UNLINK', unpack(batch)) + batch = {} + end + end + + if #batch > 0 then + redis.call('UNLINK', unpack(batch)) + end +end + +return {version} diff --git a/src/Lua/invalidate_tables.lua b/src/Lua/invalidate_tables.lua new file mode 100644 index 0000000..2372f23 --- /dev/null +++ b/src/Lua/invalidate_tables.lua @@ -0,0 +1,50 @@ +-- Standalone Redis only; table keys may occupy different cluster slots. +-- +-- KEYS contains four entries per table: +-- version key, generation key, row key prefix ending in ":r:g", +-- change record key prefix ending in ":chg:" +-- ARGV contains, per table: +-- mode, token count, change record payload, change record TTL, +-- followed by the PK tokens + +local argument = 1 +local versions = {} + +for key = 1, #KEYS, 4 do + local mode = ARGV[argument] + local token_count = tonumber(ARGV[argument + 1]) + local record = ARGV[argument + 2] + local record_ttl = ARGV[argument + 3] + argument = argument + 4 + + local version = redis.call('INCR', KEYS[key]) + versions[#versions + 1] = version + + if record ~= '' then + redis.call('SETEX', KEYS[key + 3] .. version, record_ttl, record) + end + + if mode == 'generation' then + redis.call('INCR', KEYS[key + 1]) + elseif mode == 'precise' then + local generation = redis.call('GET', KEYS[key + 1]) or '0' + local batch = {} + + for token = 1, token_count do + batch[#batch + 1] = KEYS[key + 2] .. generation .. ':' .. ARGV[argument + token - 1] + + if #batch == 100 then + redis.call('UNLINK', unpack(batch)) + batch = {} + end + end + + if #batch > 0 then + redis.call('UNLINK', unpack(batch)) + end + end + + argument = argument + token_count +end + +return versions diff --git a/src/Lua/publish_canonical.lua b/src/Lua/publish_canonical.lua new file mode 100644 index 0000000..ca01fb6 --- /dev/null +++ b/src/Lua/publish_canonical.lua @@ -0,0 +1,70 @@ +-- Writes membership last so readers cannot discover partial row publication. +-- +-- KEYS[1] = version key +-- KEYS[2] = generation key +-- KEYS[3] = query entry key +-- KEYS[4..3+n] = row keys +-- KEYS[4+n] = build lease +-- KEYS[5+n] = token-scoped wake list +-- ARGV[1] = row count +-- ARGV[2] = expected version +-- ARGV[3] = expected generation +-- ARGV[4] = entry TTL +-- ARGV[5] = row TTL +-- ARGV[6] = membership payload +-- ARGV[7..6+n] = row payloads +-- ARGV[7+n] = owner token +-- ARGV[8+n] = wake token count +-- ARGV[9+n] = wake TTL +-- ARGV[10+n] = result overlay payload (optional; absent clears the stale overlay) +-- +-- The overlay shares KEYS[3] with the membership, so one EXPIRE covers both fields. +local n = tonumber(ARGV[1]) +local lease_index = 4 + n +local wake_index = 5 + n +local overlay = ARGV[10 + n] +local token = ARGV[7 + n] +local wake_count = tonumber(ARGV[8 + n]) +local wake_tokens = {} + +for i = 1, wake_count do + wake_tokens[i] = '1' +end + +local function wake() + redis.call('LPUSH', KEYS[wake_index], unpack(wake_tokens)) +end + +local function release() + redis.call('DEL', KEYS[lease_index]) + wake() + redis.call('EXPIRE', KEYS[wake_index], tonumber(ARGV[9 + n])) +end + +if redis.call('GET', KEYS[lease_index]) ~= token then + return 0 +end + +local version = redis.call('GET', KEYS[1]) or '0' +local generation = redis.call('GET', KEYS[2]) or '0' +if version ~= ARGV[2] or generation ~= ARGV[3] then + release() + return 0 +end + +for i = 1, n do + redis.call('SETEX', KEYS[3 + i], tonumber(ARGV[5]), ARGV[6 + i]) +end + +redis.call('HSET', KEYS[3], 'm', ARGV[6]) + +if overlay then + redis.call('HSET', KEYS[3], 'r', overlay) +else + redis.call('HDEL', KEYS[3], 'r') +end + +redis.call('EXPIRE', KEYS[3], tonumber(ARGV[4])) + +release() +return 1 diff --git a/src/Lua/publish_rows.lua b/src/Lua/publish_rows.lua new file mode 100644 index 0000000..885dd1f --- /dev/null +++ b/src/Lua/publish_rows.lua @@ -0,0 +1,34 @@ +-- Rows are reachable by primary key and by other memberships, so an unguarded +-- slice could resurrect a row a concurrent write has already deleted. +-- +-- KEYS[1] = version key +-- KEYS[2] = generation key +-- KEYS[3] = build lease key +-- KEYS[4..3+n] = row keys +-- ARGV[1] = expected version +-- ARGV[2] = expected generation +-- ARGV[3] = row TTL +-- ARGV[4] = owner token +-- ARGV[5] = lease TTL +-- ARGV[6..5+n] = row payloads + +if redis.call('GET', KEYS[3]) ~= ARGV[4] then + return 0 +end + +local version = redis.call('GET', KEYS[1]) or '0' +local generation = redis.call('GET', KEYS[2]) or '0' + +if version ~= ARGV[1] or generation ~= ARGV[2] then + return 0 +end + +local ttl = tonumber(ARGV[3]) + +for i = 4, #KEYS do + redis.call('SETEX', KEYS[i], ttl, ARGV[i + 2]) +end + +redis.call('EXPIRE', KEYS[3], tonumber(ARGV[5])) + +return 1 diff --git a/src/Lua/publish_versioned_entries.lua b/src/Lua/publish_versioned_entries.lua new file mode 100644 index 0000000..ac27ca7 --- /dev/null +++ b/src/Lua/publish_versioned_entries.lua @@ -0,0 +1,71 @@ +-- Releases owned leases on every path; successful publication wakes waiters. +-- +-- KEYS[1..n] = version keys +-- KEYS[n+1..n+m] = cache keys to publish +-- KEYS[n+m+1] = build lease key (optional) +-- KEYS[n+m+2] = wake key (optional) +-- ARGV[1] = n (number of validation keys) +-- ARGV[2] = m (number of cache entries) +-- ARGV[3] = entry TTL in seconds +-- ARGV[4..n+3] = expected validation values +-- ARGV[n+4..n+m+3] = entry hash fields; empty fields use string entries +-- ARGV[n+m+4..n+2m+3] = serialized entry payloads +-- ARGV[n+2m+4] = build lease token; an empty token owns nothing, so a lease +-- key present with one publishes nothing and releases nothing +-- ARGV[n+2m+5] = wake token count (optional; defaults to 1) +-- ARGV[n+2m+6] = wake TTL (optional; defaults to 10) + +local n = tonumber(ARGV[1]) +local m = tonumber(ARGV[2]) +local ttl = tonumber(ARGV[3]) +local token = ARGV[n + 2 * m + 4] or '' +local wake_count = tonumber(ARGV[n + 2 * m + 5] or '1') or 1 +local wake_ttl = tonumber(ARGV[n + 2 * m + 6] or '10') or 10 +local has_lease = #KEYS > n + m +local has_wake = #KEYS > n + m + 1 +local wake_tokens = {} + +for i = 1, wake_count do + wake_tokens[i] = '1' +end + +local function wake() + redis.call('LPUSH', KEYS[n + m + 2], unpack(wake_tokens)) +end + +local function release_building() + if not has_lease then return end + if token == '' or redis.call('GET', KEYS[n + m + 1]) ~= token then return end + redis.call('DEL', KEYS[n + m + 1]) + if has_wake then + wake() + redis.call('EXPIRE', KEYS[n + m + 2], wake_ttl) + end +end + +if has_lease and (token == '' or redis.call('GET', KEYS[n + m + 1]) ~= token) then + return 0 +end + +for i = 1, n do + local current = redis.call('GET', KEYS[i]) or '0' + if current ~= ARGV[3 + i] then + release_building() + return 0 + end +end + +for i = 1, m do + local field = ARGV[n + 3 + i] + local payload = ARGV[n + m + 3 + i] + + if field == '' then + redis.call('SETEX', KEYS[n + i], ttl, payload) + else + redis.call('HSET', KEYS[n + i], field, payload) + redis.call('EXPIRE', KEYS[n + i], ttl) + end +end + +release_building() +return 1 diff --git a/src/Lua/release_building.lua b/src/Lua/release_building.lua deleted file mode 100644 index 0464fa0..0000000 --- a/src/Lua/release_building.lua +++ /dev/null @@ -1,22 +0,0 @@ --- Release a build lock only when the caller still owns it. --- --- KEYS[1] = building lock key --- KEYS[2] = wake key (optional; omit when there are no waiters to signal) --- ARGV[1] = building lock token (optional; empty means release unconditionally) --- ARGV[2] = wake token count (optional; defaults to 1) -local token = ARGV[1] or '' -local wake_count = tonumber(ARGV[2] or '1') or 1 - -if token ~= '' and redis.call('GET', KEYS[1]) ~= token then - return 0 -end - -redis.call('DEL', KEYS[1]) -if #KEYS >= 2 then - for i = 1, wake_count do - redis.call('LPUSH', KEYS[2], '1') - end - redis.call('EXPIRE', KEYS[2], 10) -end - -return 1 diff --git a/src/Lua/store_model_attrs.lua b/src/Lua/store_model_attrs.lua deleted file mode 100644 index 6b4fb64..0000000 --- a/src/Lua/store_model_attrs.lua +++ /dev/null @@ -1,50 +0,0 @@ --- Write model attribute entries only if the version key still matches, then release the --- build lock. The lock is always released, even when the write is skipped. --- --- KEYS[1] = version key (ver:{classKey}:) --- KEYS[2..n+1] = model attribute keys to write --- KEYS[n+2] = building lock key (optional; omit to skip release) --- KEYS[n+3] = wake key (optional; omit when there are no waiters) --- ARGV[1] = expected version --- ARGV[2] = TTL in seconds --- ARGV[3] = n (number of model keys) --- ARGV[4] = building lock token (optional; empty means release unconditionally) --- ARGV[5..n+4] = serialized attribute values --- ARGV[n+5] = wake token count (optional; defaults to 1) -local token = ARGV[4] or '' -local n = tonumber(ARGV[3]) -local wake_count = tonumber(ARGV[n + 5] or '1') or 1 -local has_lock = #KEYS > 1 + n -local has_wake = #KEYS > 1 + n + 1 - -local function release_building() - if not has_lock then return end - local lock = KEYS[n + 2] - if token ~= '' and redis.call('GET', lock) ~= token then return end - redis.call('DEL', lock) - if has_wake then - for i = 1, wake_count do - redis.call('LPUSH', KEYS[n + 3], '1') - end - redis.call('EXPIRE', KEYS[n + 3], 10) - end -end - -if has_lock and token ~= '' and redis.call('GET', KEYS[n + 2]) ~= token then - return 0 -end - -local current = redis.call('GET', KEYS[1]) or '0' -if current ~= ARGV[1] then - release_building() - return 0 -end - -local ttl = tonumber(ARGV[2]) - -for i = 1, n do - redis.call('SETEX', KEYS[1 + i], ttl, ARGV[4 + i]) -end - -release_building() -return n diff --git a/src/Lua/store_versioned_payload.lua b/src/Lua/store_versioned_payload.lua deleted file mode 100644 index 3120534..0000000 --- a/src/Lua/store_versioned_payload.lua +++ /dev/null @@ -1,54 +0,0 @@ --- Write multiple payloads only if all version keys still match their expected values. --- Always releases the building lock, even when the write is skipped. --- On success, signals any BRPOP waiters via the wake key. --- --- KEYS[1..n] = version keys --- KEYS[n+1..n+m] = cache keys to write --- KEYS[n+m+1] = building lock key (optional; omit for a plain write) --- KEYS[n+m+2] = wake key (optional; omit when there are no waiters) --- ARGV[1] = n (number of version keys) --- ARGV[2] = m (number of cache keys) --- ARGV[3] = TTL in seconds --- ARGV[4..n+3] = expected version values --- ARGV[n+4..n+m+3] = serialized payloads --- ARGV[n+m+4] = building lock token (optional; empty means release unconditionally) --- ARGV[n+m+5] = wake token count (optional; defaults to 1) - -local n = tonumber(ARGV[1]) -local m = tonumber(ARGV[2]) -local ttl = tonumber(ARGV[3]) -local token = ARGV[n + m + 4] or '' -local wake_count = tonumber(ARGV[n + m + 5] or '1') or 1 -local has_lock = #KEYS > n + m -local has_wake = #KEYS > n + m + 1 - -local function release_building() - if not has_lock then return end - if token ~= '' and redis.call('GET', KEYS[n + m + 1]) ~= token then return end - redis.call('DEL', KEYS[n + m + 1]) - if has_wake then - for i = 1, wake_count do - redis.call('LPUSH', KEYS[n + m + 2], '1') - end - redis.call('EXPIRE', KEYS[n + m + 2], 10) - end -end - -if has_lock and token ~= '' and redis.call('GET', KEYS[n + m + 1]) ~= token then - return 0 -end - -for i = 1, n do - local current = redis.call('GET', KEYS[i]) or '0' - if current ~= ARGV[3 + i] then - release_building() - return 0 - end -end - -for i = 1, m do - redis.call('SETEX', KEYS[n + i], ttl, ARGV[n + 3 + i]) -end - -release_building() -return 1 diff --git a/src/Payload/ChangeRecordCodec.php b/src/Payload/ChangeRecordCodec.php new file mode 100644 index 0000000..46f2cae --- /dev/null +++ b/src/Payload/ChangeRecordCodec.php @@ -0,0 +1,59 @@ + $columns */ + public function encode(string $mutation, array $columns, bool $precise): string + { + sort($columns, SORT_STRING); + + return json_encode([ + 'f' => self::FORMAT, + 'm' => $mutation, + 'c' => $columns, + 'p' => $precise, + ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + } + + public function decode(string $payload): ChangeRecord + { + try { + $envelope = json_decode($payload, true, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return ChangeRecord::corrupt(); + } + + if ( + !is_array($envelope) + || ($envelope['f'] ?? null) !== self::FORMAT + || !is_string($envelope['m'] ?? null) + || !is_array($envelope['c'] ?? null) + || !is_bool($envelope['p'] ?? null) + ) { + return ChangeRecord::corrupt(); + } + + $columns = []; + + foreach ($envelope['c'] as $column) { + if (!is_string($column)) { + return ChangeRecord::corrupt(); + } + + $columns[] = $column; + } + + return new ChangeRecord( + valid: true, + mutation: $envelope['m'], + columns: $columns, + precise: $envelope['p'], + ); + } +} diff --git a/src/Payload/MembershipCodec.php b/src/Payload/MembershipCodec.php new file mode 100644 index 0000000..d6652ca --- /dev/null +++ b/src/Payload/MembershipCodec.php @@ -0,0 +1,92 @@ + $ids + * @param array $versions + */ + public function encode( + string $epoch, + string $generation, + string $rootVersion, + array $ids, + array $versions = [], + ?string $tagVersion = null, + bool $overlayRejected = false, + ): string { + ksort($versions, SORT_STRING); + + $envelope = [ + 'f' => self::FORMAT, + 'ep' => $epoch, + 'g' => $generation, + 'ids' => implode(',', $ids), + 'vec' => $versions, + 'rv' => $rootVersion, + ]; + + if ($tagVersion !== null) { + $envelope['tv'] = $tagVersion; + } + + if ($overlayRejected) { + $envelope['or'] = true; + } + + return json_encode($envelope, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + } + + public function decode(string $payload): MembershipPayload + { + try { + $envelope = json_decode($payload, true, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return MembershipPayload::corrupt(); + } + + if ( + !is_array($envelope) + || ($envelope['f'] ?? null) !== self::FORMAT + || !is_string($envelope['ep'] ?? null) + || !is_string($envelope['g'] ?? null) + || !is_string($envelope['ids'] ?? null) + || !is_array($envelope['vec'] ?? null) + || !is_string($envelope['rv'] ?? null) + || (array_key_exists('tv', $envelope) && !is_string($envelope['tv'])) + || (array_key_exists('or', $envelope) && !is_bool($envelope['or'])) + ) { + return MembershipPayload::corrupt(); + } + + $ids = $envelope['ids'] === '' ? [] : explode(',', $envelope['ids']); + $versions = []; + + foreach ($envelope['vec'] as $key => $value) { + if (!is_string($key) || !is_string($value)) { + return MembershipPayload::corrupt(); + } + + $versions[$key] = $value; + } + + ksort($versions, SORT_STRING); + + return new MembershipPayload( + valid: true, + ids: $ids, + epoch: $envelope['ep'], + generation: $envelope['g'], + versions: $versions, + tagVersion: $envelope['tv'] ?? null, + overlayRejected: $envelope['or'] ?? false, + rootVersion: $envelope['rv'], + ); + } +} diff --git a/src/Payload/ModelIndexAdapter.php b/src/Payload/ModelIndexAdapter.php deleted file mode 100644 index 22f21ee..0000000 --- a/src/Payload/ModelIndexAdapter.php +++ /dev/null @@ -1,22 +0,0 @@ - $rows + * @param array $versions + */ + public function encode( + array $rows, + string $epoch, + string $rootVersion, + array $versions = [], + ?string $tagVersion = null, + ): string { + ksort($versions, SORT_STRING); + + $nativeRows = []; + + foreach ($rows as $row) { + $nativeRows[] = (array) $row; + } + + $envelope = [ + 'f' => self::FORMAT, + 'ep' => $epoch, + 'vec' => $versions, + 'rv' => $rootVersion, + 'rows' => $nativeRows, + ]; + + if ($tagVersion !== null) { + $envelope['tv'] = $tagVersion; + } + + return $this->serializer->encode($envelope); + } + + public function decode(string $payload): RawResultPayload + { + $envelope = $this->serializer->decode($payload); + + if ( + !is_array($envelope) + || ($envelope['f'] ?? null) !== self::FORMAT + || !is_string($envelope['ep'] ?? null) + || !is_array($envelope['vec'] ?? null) + || !is_array($envelope['rows'] ?? null) + || !is_string($envelope['rv'] ?? null) + || (array_key_exists('tv', $envelope) && !is_string($envelope['tv'])) + ) { + return RawResultPayload::corrupt(); + } + + $versions = $this->stringMap($envelope['vec']); + $rows = $this->rowList($envelope['rows']); + + if ($versions === null || $rows === null) { + return RawResultPayload::corrupt(); + } + + return new RawResultPayload( + valid: true, + rows: $rows, + epoch: $envelope['ep'], + versions: $versions, + tagVersion: $envelope['tv'] ?? null, + rootVersion: $envelope['rv'], + ); + } + + public function encodeRow(\stdClass $row, string $epoch): string + { + return $this->serializer->encode([ + 'f' => self::FORMAT, + 'ep' => $epoch, + 'row' => (array) $row, + ]); + } + + public function decodeRow( + string $payload, + ?PrimaryKeyMetadata $primaryKey = null, + ?string $expectedToken = null, + ): RawResultPayload { + $envelope = $this->serializer->decode($payload); + + if ( + !is_array($envelope) + || ($envelope['f'] ?? null) !== self::FORMAT + || !is_string($envelope['ep'] ?? null) + || !is_array($envelope['row'] ?? null) + || !$this->matchesToken($envelope['row'], $primaryKey, $expectedToken) + ) { + return RawResultPayload::corrupt(); + } + + return new RawResultPayload( + valid: true, + rows: [(object) $envelope['row']], + epoch: $envelope['ep'], + ); + } + + public function decodeRowObject( + string $payload, + string $expectedEpoch, + ?PrimaryKeyMetadata $primaryKey = null, + ?string $expectedToken = null, + ): ?\stdClass { + $envelope = $this->serializer->decode($payload); + + if ( + !is_array($envelope) + || ($envelope['f'] ?? null) !== self::FORMAT + || ($envelope['ep'] ?? null) !== $expectedEpoch + || !is_array($envelope['row'] ?? null) + ) { + return null; + } + + if ($primaryKey !== null && $expectedToken !== null) { + $value = $envelope['row'][$primaryKey->column] ?? null; + + if (!$primaryKey->matchesToken($value, $expectedToken)) { + return null; + } + } + + return (object) $envelope['row']; + } + + private function matchesToken( + array $row, + ?PrimaryKeyMetadata $primaryKey, + ?string $expectedToken, + ): bool { + if ($primaryKey === null || $expectedToken === null) { + return true; + } + + if (!array_key_exists($primaryKey->column, $row)) { + return false; + } + + return $primaryKey->matchesToken($row[$primaryKey->column], $expectedToken); + } + + /** @return array|null */ + private function stringMap(array $values): ?array + { + $result = []; + + foreach ($values as $key => $value) { + if (!is_string($key) || !is_string($value)) { + return null; + } + + $result[$key] = $value; + } + + ksort($result, SORT_STRING); + + return $result; + } + + /** @return list<\stdClass>|null */ + private function rowList(array $rows): ?array + { + $result = []; + + foreach ($rows as $row) { + if (!is_array($row)) { + return null; + } + + $result[] = (object) $row; + } + + return $result; + } +} diff --git a/src/Payload/SerializedArrayAdapter.php b/src/Payload/SerializedArrayAdapter.php deleted file mode 100644 index 47bad6b..0000000 --- a/src/Payload/SerializedArrayAdapter.php +++ /dev/null @@ -1,24 +0,0 @@ -store->serialize($payload); - } - - public function decode(mixed $payload): PayloadDecodeResult - { - $decoded = $this->store->unserialize($payload); - - return is_array($decoded) - ? PayloadDecodeResult::valid($decoded, $decoded === []) - : PayloadDecodeResult::corrupt(); - } -} diff --git a/src/Payload/ThroughIndexAdapter.php b/src/Payload/ThroughIndexAdapter.php deleted file mode 100644 index dbe82be..0000000 --- a/src/Payload/ThroughIndexAdapter.php +++ /dev/null @@ -1,33 +0,0 @@ - array_map('strval', $payload['ids']), - 't' => $payload['throughKeys'], - ], JSON_THROW_ON_ERROR); - } - - public function decode(mixed $payload): PayloadDecodeResult - { - $decoded = is_string($payload) ? json_decode($payload, true) : $payload; - - if (!is_array($decoded) - || !is_array($decoded['i'] ?? null) - || !array_is_list($decoded['i']) - || !is_array($decoded['t'] ?? null) - || !array_is_list($decoded['t']) - || count($decoded['i']) !== count($decoded['t'])) { - return PayloadDecodeResult::corrupt(); - } - - return PayloadDecodeResult::valid([ - 'ids' => $decoded['i'], - 'throughKeys' => $decoded['t'], - ], $decoded['i'] === []); - } -} diff --git a/src/Planning/BypassReasons.php b/src/Planning/BypassReasons.php deleted file mode 100644 index 577d67e..0000000 --- a/src/Planning/BypassReasons.php +++ /dev/null @@ -1,112 +0,0 @@ -|null $resolvedColumns null skips the calculated-column check - * @return array> - */ - public static function forQuery(QueryBuilder $base, string $table, ?array $resolvedColumns = null): array - { - static $analyzer; - - return self::fromInspection( - ($analyzer ??= new QueryAnalyzer)->inspect($base, $table, $resolvedColumns), - ); - } - - /** @return array> */ - public static function fromInspection(QueryInspection $inspection): array - { - $dependency = []; - $normalization = []; - $safety = []; - - if ($inspection->has(QueryInspection::RAW_ORDER)) { - $dependency[] = 'raw ORDER expression'; - } - - if ($inspection->has(QueryInspection::RAW_WHERE)) { - $dependency[] = 'raw WHERE expression'; - } - - if ($inspection->has(QueryInspection::NON_CANONICAL_FROM)) { - $normalization[] = 'non-standard FROM (subquery or raw expression)'; - } - - if ($inspection->has(QueryInspection::JOIN)) { - $normalization[] = 'JOIN clauses'; - } - - if ($inspection->has(QueryInspection::GROUP)) { - $normalization[] = 'GROUP BY'; - } - - if ($inspection->has(QueryInspection::HAVING)) { - $normalization[] = 'HAVING'; - } - - if ($inspection->has(QueryInspection::UNION)) { - $normalization[] = 'UNION'; - } - - if ($inspection->has(QueryInspection::AGGREGATE)) { - $normalization[] = 'aggregate function (count/sum/etc.)'; - } - - if ($inspection->has(QueryInspection::DISTINCT)) { - $normalization[] = 'DISTINCT'; - } - - if ($inspection->has(QueryInspection::LOCK)) { - $safety[] = 'query lock (SELECT FOR UPDATE)'; - } - - if ($inspection->has(QueryInspection::CALCULATED_COLUMNS)) { - $normalization[] = 'calculated or raw SELECT expressions'; - } - - return self::merge([ - 'dependency' => $dependency, - 'normalization' => $normalization, - 'safety' => $safety, - ], $inspection->contextReasons); - } - - public static function labels(): array - { - return [ - 'dependency' => "can't infer cache dependency", - 'normalization' => "result can't be normalized into model keys", - 'safety' => 'bypassed for query correctness', - 'space' => 'cross-space dependencies', - 'opted_out' => 'explicitly disabled', - ]; - } - - public static function merge(array ...$groups): array - { - $merged = []; - - foreach ($groups as $group) { - foreach ($group as $category => $reasons) { - $merged[$category] = array_values(array_unique([ - ...($merged[$category] ?? []), - ...$reasons, - ])); - } - } - - return array_filter($merged); - } -} diff --git a/src/Planning/CachePlanSpaceValidator.php b/src/Planning/CachePlanSpaceValidator.php deleted file mode 100644 index 10b7556..0000000 --- a/src/Planning/CachePlanSpaceValidator.php +++ /dev/null @@ -1,83 +0,0 @@ -isCacheable()) { - return $plan; - } - - $space = $this->resolver->resolve($model::class, $builder->getSpace()); - - if ($this->registry->dependenciesAreOnlyModel( - $model::class, - $plan->dependencies->models, - $plan->dependencies->tables, - )) { - return $plan->withSpace($space); - } - - $validation = $this->registry->validateDependencies( - $space, - $plan->dependencies->models, - $plan->dependencies->tables, - includeDependenciesBySpace: $explain, - ); - - if ($this->eligibility->fitsCacheSpace($validation)) { - if (!$explain && !$this->registry->registerTableDependencies($space, $plan->dependencies->tables)) { - return CachePlan::bypass( - operation: $plan->operation, - dependencies: $plan->dependencies, - bypassReasons: ['space' => ['failed to register table-space dependencies']], - )->withSpace($space); - } - - return $plan->withSpace($space); - } - - $offending = implode(', ', $validation->invalidModels); - $reason = 'cross-space dependencies for space [' . $space->name . ']: ' . $offending; - - if (!$explain && $this->debug) { - $modelClass = $model::class; - $this->logger?->warning( - "NormCache: query for [{$modelClass}] in space [{$space->name}] depends on [{$offending}] " - . 'which are not in that space; the query will not cache. Add them to the space or drop the dependency.' - ); - } - - if (!$explain && $this->crossSpaceBehavior === 'throw') { - throw new \RuntimeException('NormCache: ' . $reason); - } - - return CachePlan::bypass( - operation: $plan->operation, - dependencies: $plan->dependencies, - bypassReasons: ['space' => [$reason]], - )->withSpace($space); - } -} diff --git a/src/Planning/CachePlanner.php b/src/Planning/CachePlanner.php deleted file mode 100644 index bcb8c0c..0000000 --- a/src/Planning/CachePlanner.php +++ /dev/null @@ -1,446 +0,0 @@ -analyzer; - } - - public function plan( - CacheableBuilder $builder, - QueryBuilder $base, - CachePlanContext $context, - PlanningMode $planningMode = PlanningMode::Hot, - ): CachePlan { - $model = $builder->getModel(); - $cacheSkipped = $builder->isCacheSkipped(); - $cacheDisabled = !$this->cacheEnabled(); - $explain = $planningMode === PlanningMode::Explain; - - if ($cacheSkipped || $cacheDisabled || isset($context->contextReasons['opted_out'])) { - return $this->globalBypass($builder, $model, $context, $cacheSkipped, $cacheDisabled); - } - - if (QueryEligibility::usesWritePdo($base)) { - return CachePlan::bypass( - operation: $context->operation, - dependencies: $this->dependencies->resolveBase($builder, $model, $context), - bypassReasons: ['safety' => ['explicit write PDO read']], - ); - } - - $connection = $model->getConnection(); - $insideTransaction = $connection->transactionLevel() > 0; - - if ($insideTransaction && !$explain) { - return $this->transactionBypass($builder, $model, $context); - } - - $inspection = $this->analyze($builder, $model, $base, $context, $explain, $connection); - - $plan = isset($inspection->contextReasons['opted_out']) - ? CachePlan::bypass( - operation: $context->operation, - dependencies: $this->dependencies->resolveBase($builder, $model, $context), - bypassReasons: ['opted_out' => $inspection->contextReasons['opted_out']], - ) - : match ($context->operation) { - CacheOperation::Scalar => $this->planInspectedResult($builder, $base, $context, $inspection, $insideTransaction, $explain, self::SIMPLE_RESULT_FAST_PATH_BLOCKERS), - CacheOperation::PaginationCount => $this->planInspectedResult($builder, $base, $context, $inspection, $insideTransaction, $explain, self::SIMPLE_PAGINATION_FAST_PATH_BLOCKERS), - CacheOperation::Pivot, - CacheOperation::Through => $this->planInspectedResult($builder, $base, $context, $inspection, $insideTransaction, $explain, strictRelation: true), - CacheOperation::Models => $this->planModels($builder, $model, $base, $context, $inspection, $insideTransaction, $explain), - }; - - return $this->spaceValidator->validate($plan, $builder, $model, $explain); - } - - public function applySpaceValidation( - CachePlan $plan, - CacheableBuilder $builder, - Model $model, - bool $explain = false, - ): CachePlan { - return $this->spaceValidator->validate($plan, $builder, $model, $explain); - } - - private function cacheEnabled(): bool - { - return $this->config->enabled; - } - - private function globalBypass( - CacheableBuilder $builder, - Model $model, - CachePlanContext $context, - bool $cacheSkipped, - bool $cacheDisabled, - ): CachePlan { - $reasons = $this->resolveContextReasons( - $context->contextReasons, - cacheSkipped: $cacheSkipped, - cacheDisabled: $cacheDisabled, - insideTransaction: false, - )['opted_out'] ?? []; - - return CachePlan::bypass( - operation: $context->operation, - dependencies: $this->dependencies->resolveBase($builder, $model, $context), - bypassReasons: ['opted_out' => $reasons], - ); - } - - private function transactionBypass( - CacheableBuilder $builder, - Model $model, - CachePlanContext $context, - ): CachePlan { - $reasons = ['inside a database transaction']; - - return CachePlan::bypass( - operation: $context->operation, - dependencies: $this->dependencies->resolveBase($builder, $model, $context), - bypassReasons: ['safety' => $reasons], - ); - } - - private function planModels( - CacheableBuilder $builder, - Model $model, - QueryBuilder $base, - CachePlanContext $context, - QueryInspection $inspection, - bool $insideTransaction, - bool $explain, - ): CachePlan { - $modelClass = $model::class; - $modelTable = $model->getTable(); - $inferred = $inspection->dependencies; - $explicitModels = $builder->explicitDependencies(); - $explicitTables = $builder->explicitTableDependencies(); - $hasExplicit = $builder->hasExplicitDependencies(); - - if ($this->eligibility->qualifiesForDirectModels($explain, $insideTransaction, $hasExplicit, $inspection)) { - return CachePlan::direct( - operation: $context->operation, - dependencies: DependencySet::singleModel($modelClass), - primaryKeys: $inspection->primaryKeys, - columns: $context->columns, - ); - } - - $dependencies = $this->eligibility->dependsOnPrimaryModelOnly($hasExplicit, $inspection) - ? DependencySet::singleModel($modelClass) - : $this->dependencies->resolve( - $modelClass, - $context, - $inspection, - $explicitModels, - $explicitTables, - $hasExplicit, - ); - - if ($this->eligibility->hasSafetyBypass($inspection, $insideTransaction)) { - return $this->safetyBypass($context, $inspection, $dependencies, $insideTransaction); - } - - if ($this->eligibility->canUseModelIndex($inspection, $dependencies)) { - return CachePlan::modelIndex( - operation: $context->operation, - dependencies: $dependencies, - columns: $context->columns, - primaryKeys: $inspection->primaryKeys, - ); - } - - if (!$this->eligibility->canUseResult($inspection, $dependencies, $hasExplicit)) { - return $this->bypassPlan($context, $inspection, $dependencies); - } - - if ($this->eligibility->requiresExplicitSelectForJoinResult($builder, $base, $context)) { - return CachePlan::bypass( - operation: $context->operation, - dependencies: $dependencies, - bypassReasons: ['normalization' => ['join_result_requires_explicit_select']], - ); - } - - return $this->resultPlan($context, $inspection, $dependencies); - } - - private function planInspectedResult( - CacheableBuilder $builder, - QueryBuilder $base, - CachePlanContext $context, - QueryInspection $inspection, - bool $insideTransaction, - bool $explain, - ?int $simpleBypassFlags = null, - bool $strictRelation = false, - ): CachePlan { - $model = $builder->getModel(); - $modelClass = $model::class; - $modelTable = $model->getTable(); - $inferred = $inspection->dependencies; - $explicitModels = $builder->explicitDependencies(); - $explicitTables = $builder->explicitTableDependencies(); - $hasExplicit = $builder->hasExplicitDependencies(); - - if ($simpleBypassFlags !== null - && !$explain - && !$insideTransaction - && !$hasExplicit - && $inspection->contextReasons === [] - && ($plan = $this->trySimpleResultPlan($model, $context, $inspection, $simpleBypassFlags)) !== null) { - return $plan; - } - - $dependencies = $this->dependencies->resolve( - $modelClass, - $context, - $inspection, - $explicitModels, - $explicitTables, - $hasExplicit, - ); - - if ($bypass = $this->safetyBypass($context, $inspection, $dependencies, $insideTransaction)) { - return $bypass; - } - - if ($simpleBypassFlags !== null - && !$hasExplicit - && $inferred->hasNoDependencies() - && $dependencies->safe - && (!empty($base->joins) - || !empty($base->unions) - || !is_string($base->from) - || $base->from !== $modelTable) - ) { - $dependencies = DependencySet::unsafe(['complex_query_requires_depends_on']); - } - - $normalizationFlags = $inspection->normalizationFlags(); - $hasContextNormalizationBypass = isset($inspection->contextReasons['normalization']); - - if ($strictRelation - && $normalizationFlags === QueryInspection::JOIN - && (count($base->joins ?? []) === 1 || $hasExplicit)) { - $normalizationFlags = 0; - } - - if ($dependencies->safe - && (!$strictRelation || ($normalizationFlags === 0 && !$hasContextNormalizationBypass))) { - return $this->resultPlan($context, $inspection, $dependencies); - } - - return $this->bypassPlan( - $context, - $inspection, - $dependencies, - relaxedRelationNormalization: $strictRelation - && $normalizationFlags === 0 - && !$hasContextNormalizationBypass, - ); - } - - private function trySimpleResultPlan( - Model $model, - CachePlanContext $context, - QueryInspection $inspection, - int $bypassFlags, - ): ?CachePlan { - if (!$this->eligibility->canUseSimpleResult($inspection, $bypassFlags)) { - return null; - } - - return CachePlan::result( - operation: $context->operation, - dependencies: DependencySet::singleModel($model::class), - columns: $context->columns, - ); - } - - private function analyze( - CacheableBuilder $builder, - Model $model, - QueryBuilder $base, - CachePlanContext $context, - bool $explain, - Connection $connection, - ): QueryInspection { - $primaryKeys = $context->operation === CacheOperation::Models - ? [$model->getKeyName(), $model->getQualifiedKeyName()] - : []; - - $capturedContextReasons = $builder->capturedContextReasons(); - $contextReasons = $context->contextReasons === [] && $capturedContextReasons === [] - ? [] - : BypassReasons::merge($context->contextReasons, $capturedContextReasons); - - $table = $model->getTable(); - $softDeleteScopeColumn = $context->operation === CacheOperation::Models - ? $this->activeSoftDeleteScopeColumn($builder, $model) - : null; - - $allowPrimaryKeyFastPath = $context->operation === CacheOperation::Models - && !$explain - && !$builder->hasExplicitDependencies(); - - return $this->analyzer->inspect( - $base, - $table, - $context->columns, - $primaryKeys, - $softDeleteScopeColumn, - fn(): string => $connection->getName() ?? $model->getConnectionName() ?? '', - $builder->capturedDependencies(), - $contextReasons, - $builder->capturedOpaqueJoins(), - $builder->hasCapturedOpaqueFrom(), - $builder->capturedOpaqueWhereSubqueries(), - $allowPrimaryKeyFastPath, - ); - } - - private function activeSoftDeleteScopeColumn(CacheableBuilder $builder, Model $model): ?string - { - if (!$model::hasGlobalScope(SoftDeletingScope::class) - || in_array(SoftDeletingScope::class, $builder->removedScopes(), true)) { - return null; - } - - /** @phpstan-ignore-next-line SoftDeletingScope is provided by SoftDeletes. */ - return $model->getQualifiedDeletedAtColumn(); - } - - private function safetyBypass( - CachePlanContext $context, - QueryInspection $inspection, - DependencySet $dependencies, - bool $insideTransaction, - ): ?CachePlan { - if (!$this->eligibility->hasSafetyBypass($inspection, $insideTransaction)) { - return null; - } - - return CachePlan::bypass( - operation: $context->operation, - dependencies: $dependencies, - bypassReasons: ['safety' => $this->eligibility->safetyReasons($inspection, $insideTransaction)], - ); - } - - private function resultPlan( - CachePlanContext $context, - QueryInspection $inspection, - DependencySet $dependencies, - ): CachePlan { - return CachePlan::result( - operation: $context->operation, - dependencies: $dependencies, - columns: $context->columns, - primaryKeys: $inspection->primaryKeys, - ); - } - - private function bypassPlan( - CachePlanContext $context, - QueryInspection $inspection, - DependencySet $dependencies, - bool $relaxedRelationNormalization = false, - ): CachePlan { - $bypassReasons = $this->mergedBypassReasons($inspection); - - if ($relaxedRelationNormalization) { - unset($bypassReasons['normalization']); - } - - if (!$dependencies->safe) { - $bypassReasons['dependency'] = array_values(array_unique([ - ...($bypassReasons['dependency'] ?? []), - ...$dependencies->reasons, - ])); - } - - return CachePlan::bypass( - operation: $context->operation, - dependencies: $dependencies, - bypassReasons: $bypassReasons, - ); - } - - private function mergedBypassReasons( - QueryInspection $inspection, - bool $insideTransaction = false, - ): array { - return BypassReasons::merge( - $insideTransaction ? ['safety' => ['inside a database transaction']] : [], - BypassReasons::fromInspection($inspection), - ); - } - - private function resolveContextReasons( - array $reasons, - bool $cacheSkipped, - bool $cacheDisabled, - bool $insideTransaction, - ): array { - if ($cacheSkipped) { - $reasons['opted_out'][] = 'withoutCache() was called explicitly'; - } - - if ($cacheDisabled) { - $reasons['opted_out'][] = 'cache is globally disabled'; - } - - if ($insideTransaction) { - $reasons['safety'][] = 'inside a database transaction'; - } - - return array_filter($reasons); - } -} diff --git a/src/Planning/ConnectionSourceResolver.php b/src/Planning/ConnectionSourceResolver.php new file mode 100644 index 0000000..1c236bd --- /dev/null +++ b/src/Planning/ConnectionSourceResolver.php @@ -0,0 +1,37 @@ +getConfig(); + $configured = $config['normcache_scope'] ?? null; + + if ($configured !== null) { + return is_string($configured) && $configured !== '' + ? $configured + : null; + } + + $source = (string) $connection->getName(); + + if ($source === '') { + return null; + } + + // PostgreSQL search_path is part of an unqualified table's identity. + $searchPath = $config['search_path'] ?? $config['schema'] ?? null; + + if (is_array($searchPath)) { + $searchPath = implode(',', $searchPath); + } + + return is_string($searchPath) && $searchPath !== '' + ? $source . "\0" . $searchPath + : $source; + } +} diff --git a/src/Planning/DeleteDependencyResolver.php b/src/Planning/DeleteDependencyResolver.php new file mode 100644 index 0000000..025322d --- /dev/null +++ b/src/Planning/DeleteDependencyResolver.php @@ -0,0 +1,208 @@ +> + * }> + */ + private \WeakMap $connections; + + public function __construct(private readonly TableIdentityResolver $tables) + { + $this->connections = new \WeakMap; + } + + public function clear(): void + { + $this->connections = new \WeakMap; + } + + private const RESTRICTING = 'restrict'; + + /** @return list|null */ + public function affectedByDelete(Connection $connection, TableIdentity $parent): ?array + { + return $this->reachable($connection, $parent, everyReference: false); + } + + /** @return list|null */ + public function affectedByTruncate(Connection $connection, TableIdentity $parent): ?array + { + return $this->reachable($connection, $parent, everyReference: true); + } + + /** @return list|null */ + private function reachable( + Connection $connection, + TableIdentity $parent, + bool $everyReference, + ): ?array { + $graph = $this->graph($connection); + + if ($graph === null) { + return null; + } + + $affected = []; + $visited = [$parent->hash => true]; + $pending = [$parent]; + + while (($current = array_pop($pending)) !== null) { + foreach ($graph[$current->hash] ?? [] as $edge) { + if (!$everyReference && $edge['action'] === self::RESTRICTING) { + continue; + } + + $child = $this->tables->resolve($connection, $edge['table']); + + if ($child === null) { + return null; + } + + $affected[$child->hash] = $child; + $descends = $everyReference || $edge['action'] === 'cascade'; + + if (!$descends || isset($visited[$child->hash])) { + continue; + } + + $visited[$child->hash] = true; + $pending[] = $child; + } + } + + return array_values($affected); + } + + /** @return array>|null */ + private function graph(Connection $connection): ?array + { + $sourceScope = ConnectionSourceResolver::resolve($connection); + + if ($sourceScope === null) { + return null; + } + + $signature = TableIdentity::encodeFields([ + $sourceScope, + (string) $connection->getDriverName(), + (string) $connection->getDatabaseName(), + (string) $connection->getTablePrefix(), + ]); + $metadata = $this->connections[$connection] ?? null; + + if ($metadata !== null && $metadata['signature'] === $signature) { + return $metadata['graph']; + } + + try { + $graph = $this->inspect($connection); + } catch (\Throwable) { + return null; + } + + $this->connections[$connection] = [ + 'signature' => $signature, + 'graph' => $graph, + ]; + + return $graph; + } + + /** @return array> */ + private function inspect(Connection $connection): array + { + $prefix = (string) $connection->getTablePrefix(); + $schema = $connection->getSchemaBuilder(); + $graph = []; + + foreach ($schema->getTables() as $table) { + $childTable = $this->logicalTable($table['name'], $prefix); + + if ($childTable === null) { + continue; + } + + $childReference = $this->reference($table['schema'], $childTable); + + foreach ($schema->getForeignKeys($childReference) as $foreignKey) { + $parentTable = $this->logicalTable($foreignKey['foreign_table'], $prefix); + + if ($parentTable === null) { + continue; + } + + $parentReference = $this->reference($foreignKey['foreign_schema'], $parentTable); + $action = $this->deleteAction($foreignKey['on_delete']); + + if ($action === null) { + throw new \UnexpectedValueException('Foreign-key metadata contained an unknown delete action.'); + } + + $child = $this->tables->resolve($connection, $childReference); + $parent = $this->tables->resolve($connection, $parentReference); + + if ($child === null || $parent === null) { + throw new \UnexpectedValueException('Foreign-key table identity could not be resolved.'); + } + + $graph[$parent->hash][$child->hash . "\0" . $action] = [ + 'table' => $child->qualifiedTable(), + 'action' => $action, + ]; + } + } + + ksort($graph); + + foreach ($graph as $parent => $edges) { + ksort($edges); + $graph[$parent] = array_values($edges); + } + + return $graph; + } + + private function logicalTable(string $physical, string $prefix): ?string + { + if ($prefix === '') { + return $physical !== '' ? $physical : null; + } + + if (!str_starts_with($physical, $prefix)) { + return null; + } + + $logical = substr($physical, strlen($prefix)); + + return $logical !== '' ? $logical : null; + } + + private function reference(?string $schema, string $table): string + { + return $schema === null || $schema === '' ? $table : $schema . '.' . $table; + } + + private function deleteAction(?string $action): ?string + { + if ($action === null) { + return null; + } + + return match (strtolower(trim($action))) { + 'cascade' => 'cascade', + 'set null' => 'set null', + 'set default' => 'set default', + 'no action', 'restrict' => self::RESTRICTING, + default => null, + }; + } +} diff --git a/src/Planning/DependencyAnalyzer.php b/src/Planning/DependencyAnalyzer.php new file mode 100644 index 0000000..58bb493 --- /dev/null +++ b/src/Planning/DependencyAnalyzer.php @@ -0,0 +1,275 @@ +tables->resolve($connection, $query->from); + $dependencies = new DependencyCollection; + $declarations = $query->dependencies(); + $declaredRoot = null; + $authoritative = false; + $unresolved = false; + + foreach ($declarations as $declaration) { + $identity = $declaration->isTable() + ? $this->tables->resolve($connection, $declaration->value) + : $this->modelIdentity($connection, $declaration->value); + + if ($identity === null) { + $unresolved = true; + + continue; + } + + if ($declaredRoot === null || $identity->hash < $declaredRoot->hash) { + $declaredRoot = $identity; + } + + $authoritative = true; + $dependencies->add($identity); + } + + $root = $directRoot + ?? $this->modelRoot($connection, $query) + ?? $declaredRoot; + + if ($root === null) { + return new DependencyAnalysis( + root: null, + tables: $dependencies->all(), + queryScoped: true, + bypassReason: 'unidentifiable_dependency', + ); + } + + $dependencies->add($root); + $this->walk($connection, $query, $dependencies, $directRoot); + + $bypassReason = null; + + if ($unresolved) { + $bypassReason = 'unresolvable_declared_dependency'; + } elseif ($dependencies->isOpaque() && !$authoritative) { + $bypassReason = 'unidentifiable_dependency'; + } + + return new DependencyAnalysis( + root: $root, + tables: $dependencies->all(), + queryScoped: $directRoot === null, + bypassReason: $bypassReason, + volatile: $dependencies->isVolatile(), + ); + } + + private function walk( + Connection $connection, + Builder $query, + DependencyCollection $dependencies, + ?TableIdentity $resolvedSource = null, + ): void { + if (!$dependencies->enter($query)) { + return; + } + + if ($resolvedSource !== null) { + $dependencies->add($resolvedSource); + } elseif ($query->from instanceof Expression) { + $this->walkExpression( + $connection, + $query, + $query->from, + $dependencies, + source: true, + ); + } else { + $this->resolveSource($connection, $query->from, $dependencies); + } + + foreach ($query->joins ?? [] as $join) { + if ($join->table instanceof Expression) { + $this->walkExpression( + $connection, + $query, + $join->table, + $dependencies, + source: true, + ); + } else { + $this->resolveSource($connection, $join->table, $dependencies); + } + + $this->walkValues($connection, $query, $join->wheres, $dependencies); + } + + foreach ([ + $query->wheres, + $query->havings ?? [], + $query->columns ?? [], + (array) ($query->aggregate['columns'] ?? []), + $query->groups ?? [], + $query->orders ?? [], + $query->unionOrders ?? [], + ] as $values) { + $this->walkValues($connection, $query, $values, $dependencies); + } + + foreach ($query->unions ?? [] as $union) { + $nested = $union['query'] ?? null; + $nested = $nested instanceof EloquentBuilder ? $nested->toBase() : $nested; + + if ($nested instanceof Builder) { + $this->walk($connection, $nested, $dependencies); + } else { + $dependencies->markOpaque(); + } + } + } + + /** @param array $values */ + private function walkValues( + Connection $connection, + Builder $query, + array $values, + DependencyCollection $dependencies, + ): void { + foreach ($values as $value) { + $value = $value instanceof EloquentBuilder ? $value->toBase() : $value; + + if ($value instanceof Builder) { + $this->walk($connection, $value, $dependencies); + + continue; + } + + if ($value instanceof Expression) { + $this->walkExpression($connection, $query, $value, $dependencies); + + continue; + } + + if (!is_array($value)) { + continue; + } + + if (in_array($value['type'] ?? null, ['raw', 'Raw', 'Expression'], true)) { + $sql = $value['sql'] ?? $value['column'] ?? null; + + if (is_string($sql)) { + $this->scanRaw($sql, $dependencies); + } else { + $dependencies->markOpaque(); + } + } + + $this->walkValues($connection, $query, array_values($value), $dependencies); + } + } + + private function walkExpression( + Connection $connection, + Builder $query, + Expression $expression, + DependencyCollection $dependencies, + bool $source = false, + ): void { + $subquery = $query instanceof QueryBuilder + ? $query->capturedSubquery($expression) + : null; + + if ($subquery !== null) { + $this->walk($connection, $subquery, $dependencies); + + return; + } + + $sql = (string) $expression->getValue($query->getGrammar()); + + // Declared dependencies suppress opacity, not volatility. + if ($source) { + $dependencies->markOpaque(); + } + + $this->scanRaw($sql, $dependencies); + } + + // Scans raw fragments so function-like identifiers remain cacheable. + private function scanRaw(string $sql, DependencyCollection $dependencies): void + { + if ($this->rawMayReferenceSource($sql)) { + $dependencies->markOpaque(); + } + + if ($this->volatility->isVolatile($sql)) { + $dependencies->markVolatile(); + } + } + + private function rawMayReferenceSource(string $sql): bool + { + return preg_match( + '/(?:--|#|\/\*)|\b(?:select|from|join|table|with|union|intersect|except|using|natural|lateral|apply|only|tablesample)\b/i', + $sql, + ) !== 0; + } + + private function resolveSource( + Connection $connection, + mixed $source, + DependencyCollection $dependencies, + ): void { + $identity = $this->tables->resolve($connection, $source); + + if ($identity === null) { + $dependencies->markOpaque(); + + return; + } + + $dependencies->add($identity); + } + + private function modelRoot( + Connection $connection, + QueryBuilder $query, + ): ?TableIdentity { + $modelClass = $query->modelClass(); + + return $modelClass === null + ? null + : $this->modelIdentity($connection, $modelClass); + } + + /** @param class-string $modelClass */ + private function modelIdentity( + Connection $activeConnection, + string $modelClass, + ): ?TableIdentity { + try { + $model = new $modelClass; + $model->setConnection($activeConnection->getName()); + + return $this->tables->resolve($activeConnection, $model->getTable()); + } catch (\Throwable) { + return null; + } + } +} diff --git a/src/Planning/DependencyCollection.php b/src/Planning/DependencyCollection.php new file mode 100644 index 0000000..0babe77 --- /dev/null +++ b/src/Planning/DependencyCollection.php @@ -0,0 +1,65 @@ + */ + private array $tables = []; + + /** @var array */ + private array $visited = []; + + private bool $opaque = false; + + private bool $volatile = false; + + public function add(TableIdentity $table): void + { + $this->tables[$table->hash] = $table; + } + + public function enter(Builder $query): bool + { + $id = spl_object_id($query); + + if (isset($this->visited[$id])) { + return false; + } + + $this->visited[$id] = true; + + return true; + } + + public function markOpaque(): void + { + $this->opaque = true; + } + + public function isOpaque(): bool + { + return $this->opaque; + } + + public function markVolatile(): void + { + $this->volatile = true; + } + + public function isVolatile(): bool + { + return $this->volatile; + } + + /** @return list */ + public function all(): array + { + ksort($this->tables, SORT_STRING); + + return array_values($this->tables); + } +} diff --git a/src/Planning/DependencyResolver.php b/src/Planning/DependencyResolver.php deleted file mode 100644 index 07a80e6..0000000 --- a/src/Planning/DependencyResolver.php +++ /dev/null @@ -1,92 +0,0 @@ -dependencies; - $required = $context->requiredDependencies; - - if ($hasExplicit) { - return new DependencySet( - models: array_values(array_unique([ - $modelClass, - ...$inferred->models, - ...$required->models, - ...($explicitModels ?? []), - ])), - tables: array_values(array_unique([ - ...$inferred->tables, - ...$required->tables, - ...$explicitTables, - ])), - // An explicit dependsOn() adds deps, it doesn't vouch for ones QueryAnalyzer couldn't infer. - safe: $inferred->safe && $required->safe, - reasons: [...$inferred->reasons, ...$required->reasons], - ); - } - - if ($inspection->hasDependencyBypass() - || isset($inspection->contextReasons['dependency']) - || !$inferred->safe - || !$required->safe) { - return DependencySet::unsafe(array_values(array_unique([ - ...(BypassReasons::fromInspection($inspection)['dependency'] ?? []), - ...($inspection->contextReasons['dependency'] ?? []), - ...$inferred->reasons, - ...$required->reasons, - ]))); - } - - if ($inferred->hasNoDependencies() && $required->hasNoDependencies()) { - return DependencySet::singleModel($modelClass); - } - - return new DependencySet( - models: array_values(array_unique([ - $modelClass, - ...$inferred->models, - ...$required->models, - ])), - tables: array_values(array_unique([ - ...$inferred->tables, - ...$required->tables, - ])), - ); - } - - public function resolveBase( - CacheableBuilder $builder, - Model $model, - CachePlanContext $context, - ): DependencySet { - $required = $context->requiredDependencies; - - return new DependencySet( - models: array_values(array_unique([ - $model::class, - ...$required->models, - ...($builder->explicitDependencies() ?? []), - ])), - tables: array_values(array_unique([ - ...$required->tables, - ...$builder->explicitTableDependencies(), - ])), - safe: $required->safe, - reasons: $required->reasons, - ); - } -} diff --git a/src/Planning/MutationKeyExtractor.php b/src/Planning/MutationKeyExtractor.php new file mode 100644 index 0000000..937943c --- /dev/null +++ b/src/Planning/MutationKeyExtractor.php @@ -0,0 +1,124 @@ +|null $assigned + * @return list|null + */ + public function extractMutation( + QueryBuilder $query, + PrimaryKeyMetadata $primaryKey, + ?array $assigned, + ): ?array { + $tokens = $this->extract($query, $primaryKey); + + if ($tokens === null || $assigned === null) { + return $tokens; + } + + foreach ($assigned as $column => $value) { + if (!$this->isPrimaryKey($column, $primaryKey)) { + continue; + } + + if ($value instanceof Expression) { + return null; + } + + $newToken = $primaryKey->token($value); + + if ($newToken === null) { + return null; + } + + $tokens[] = $newToken; + $tokens = array_values(array_unique($tokens)); + sort($tokens, SORT_STRING); + + return $tokens; + } + + return $tokens; + } + + /** @return list|null */ + public function extract( + QueryBuilder $query, + PrimaryKeyMetadata $primaryKey, + ): ?array { + if (!empty($query->joins)) { + return null; + } + + $tokens = []; + $found = false; + + foreach ($query->wheres as $where) { + // Raw SQL can widen the AND chain through an ungrouped OR. + if ( + strtolower((string) ($where['boolean'] ?? 'and')) !== 'and' + || in_array($where['type'] ?? null, ['Nested', 'raw', 'Raw', 'Exists', 'NotExists'], true) + ) { + return null; + } + + if (!$this->isPrimaryKey($where['column'] ?? null, $primaryKey)) { + continue; + } + + $values = match ($where['type'] ?? null) { + 'Basic' => in_array($where['operator'] ?? null, ['=', '=='], true) + ? [$where['value'] ?? null] + : null, + 'In', 'InRaw' => $where['values'] ?? null, + default => null, + }; + + if (!is_array($values)) { + return null; + } + + $found = true; + + foreach ($values as $value) { + if ($value instanceof Expression) { + return null; + } + + $token = $primaryKey->token($value); + + if ($token === null) { + return null; + } + + $tokens[$token] = true; + } + } + + if (!$found) { + return null; + } + + $result = array_keys($tokens); + sort($result, SORT_STRING); + + return $result; + } + + private function isPrimaryKey(mixed $column, PrimaryKeyMetadata $primaryKey): bool + { + if (!is_string($column)) { + return false; + } + + return ColumnName::unqualified($column) === strtolower($primaryKey->column); + } +} diff --git a/src/Planning/PredicateColumnExtractor.php b/src/Planning/PredicateColumnExtractor.php new file mode 100644 index 0000000..0126fdc --- /dev/null +++ b/src/Planning/PredicateColumnExtractor.php @@ -0,0 +1,97 @@ +|null */ + public function extract(QueryBuilder $query): ?array + { + $columns = []; + + if (!$this->collectWheres($query->wheres, $columns)) { + return null; + } + + foreach ($query->orders ?? [] as $order) { + $column = $order['column'] ?? null; + + if (!is_string($column) || str_contains($column, '->')) { + return null; + } + + $columns[ColumnName::unqualified($column)] = true; + } + + $names = array_map(strval(...), array_keys($columns)); + sort($names, SORT_STRING); + + return $names; + } + + /** @param array $columns */ + private function collectWheres(array $wheres, array &$columns): bool + { + foreach ($wheres as $where) { + $type = $where['type'] ?? null; + + if (!in_array($type, self::UNDERSTOOD_WHERE_TYPES, true)) { + return false; + } + + if ($type === 'Nested') { + $nested = $where['query'] ?? null; + + if (!$nested instanceof Builder || !$this->collectWheres($nested->wheres, $columns)) { + return false; + } + + continue; + } + + if (in_array($type, ['In', 'InRaw', 'NotIn', 'NotInRaw', 'between'], true) + && !$this->isLiteralValueList($where['values'] ?? null)) { + return false; + } + + if ($type === 'Basic' && ($where['value'] ?? null) instanceof Expression) { + return false; + } + + $column = $where['column'] ?? null; + + if (!is_string($column) || str_contains($column, '->')) { + return false; + } + + $columns[ColumnName::unqualified($column)] = true; + } + + return true; + } + + // Subquery and between expressions are stored inline in `values`. + private function isLiteralValueList(mixed $values): bool + { + if (!is_iterable($values)) { + return false; + } + + foreach ($values as $value) { + if ($value instanceof Expression) { + return false; + } + } + + return true; + } +} diff --git a/src/Planning/QueryAnalyzer.php b/src/Planning/QueryAnalyzer.php deleted file mode 100644 index 4dd5fc4..0000000 --- a/src/Planning/QueryAnalyzer.php +++ /dev/null @@ -1,446 +0,0 @@ - true, - 'NotExists' => true, - ]; - - public const SUBQUERY_WHERE_TYPES = [ - 'Sub' => true, - 'InSub' => true, - 'NotInSub' => true, - ]; - - public function inspect( - QueryBuilder $base, - string $table, - ?array $resolvedColumns, - array $primaryKeyIdentifiers = [], - ?string $softDeleteScopeColumn = null, - string|\Closure|null $connection = null, - ?DependencySet $capturedDependencies = null, - array $contextReasons = [], - int $capturedOpaqueJoins = 0, - bool $capturedOpaqueFrom = false, - int $capturedOpaqueWhereSubqueries = 0, - bool $allowPrimaryKeyFastPath = false, - ): QueryInspection { - $primaryKeys = $primaryKeyIdentifiers === [] - ? null - : self::resolvePrimaryKeys($base, $primaryKeyIdentifiers, $softDeleteScopeColumn); - $capturedDependencies ??= DependencySet::empty(); - $structuralFlags = $this->structuralFlags($base, $table, $resolvedColumns); - $rawOrderFlags = $this->rawOrderFlags($base); - - if ($allowPrimaryKeyFastPath - && $contextReasons === [] - && $capturedDependencies->safe - && $capturedDependencies->hasNoDependencies() - && $capturedOpaqueJoins === 0 - && !$capturedOpaqueFrom - && $capturedOpaqueWhereSubqueries === 0 - && $primaryKeys !== null - && ($structuralFlags & self::DIRECT_PRIMARY_KEY_BLOCKERS) === 0) { - return new QueryInspection($rawOrderFlags, $primaryKeys, $capturedDependencies); - } - - $dependencies = $connection === null - ? $capturedDependencies - : $this->inferQueryDependencies( - $base, - $connection instanceof \Closure ? $connection() : $connection, - $table, - $capturedOpaqueJoins, - $capturedOpaqueFrom, - $capturedOpaqueWhereSubqueries, - )->merge($capturedDependencies); - - return new QueryInspection( - flags: $structuralFlags | $rawOrderFlags | $this->inspectWheres((array) $base->wheres), - primaryKeys: $primaryKeys, - dependencies: $dependencies, - contextReasons: $contextReasons, - ); - } - - public function flags( - QueryBuilder $base, - string $table, - ?array $resolvedColumns, - ): int { - $flags = $this->structuralFlags($base, $table, $resolvedColumns); - - $flags |= $this->rawOrderFlags($base); - $flags |= $this->inspectWheres((array) $base->wheres); - - return $flags; - } - - private function rawOrderFlags(QueryBuilder $base): int - { - foreach ((array) $base->orders as $order) { - if (($order['type'] ?? null) === 'Raw') { - return QueryInspection::RAW_ORDER; - } - } - - return 0; - } - - private function structuralFlags(QueryBuilder $base, string $table, ?array $resolvedColumns): int - { - $flags = 0; - - if ($base->from !== $table) { - $flags |= QueryInspection::NON_CANONICAL_FROM; - } - - if (!empty($base->joins)) { - $flags |= QueryInspection::JOIN; - } - - if (!empty($base->groups)) { - $flags |= QueryInspection::GROUP; - } - - if (!empty($base->havings)) { - $flags |= QueryInspection::HAVING; - } - - if (!empty($base->unions)) { - $flags |= QueryInspection::UNION; - } - - if (!empty($base->aggregate)) { - $flags |= QueryInspection::AGGREGATE; - } - - if (!empty($base->distinct)) { - $flags |= QueryInspection::DISTINCT; - } - - if ($base->lock !== null) { - $flags |= QueryInspection::LOCK; - } - - if (ProjectionClassifier::hasCalculatedColumns($resolvedColumns)) { - $flags |= QueryInspection::CALCULATED_COLUMNS; - } - - return $flags; - } - - public function extractTables(QueryBuilder $base, string $table): array - { - if (empty($base->joins)) { - return [$table]; - } - - $tables = [$table]; - - foreach ((array) $base->joins as $join) { - if (is_string($join->table)) { - $tables[] = CacheKeyBuilder::stripTableAlias($join->table); - } - } - - return array_values(array_unique($tables)); - } - - /** @param string $connection Eloquent connection name, e.g. $model->getConnection()->getName() */ - public function inferQueryDependencies( - QueryBuilder $base, - string $connection, - ?string $primaryTable = null, - int $capturedOpaqueJoins = 0, - bool $capturedOpaqueFrom = false, - int $capturedOpaqueWhereSubqueries = 0, - ): DependencySet { - $connection = $this->connectionName($base, $connection); - $tables = []; - $unsafe = $this->collectQueryTables( - $base, - $connection, - $tables, - $capturedOpaqueJoins, - $capturedOpaqueFrom, - ); - - if ($unsafe !== null) { - return DependencySet::unsafe($unsafe); - } - - if ($this->countOpaqueWhereSubqueries($base) > $capturedOpaqueWhereSubqueries) { - return DependencySet::unsafe('subquery predicate dependency could not be inferred'); - } - - if ($primaryTable !== null) { - unset($tables[$this->keys->tableKey($connection, $primaryTable)]); - } - - return new DependencySet(tables: array_keys($tables)); - } - - /** @param array $tables */ - private function collectQueryTables( - QueryBuilder $base, - string $connection, - array &$tables, - int $capturedOpaqueJoins = 0, - bool $capturedOpaqueFrom = false, - ): ?string { - $connection = $this->connectionName($base, $connection); - - if (is_string($base->from)) { - if ($this->joinTableHasImplicitAlias($base->from)) { - return 'query source dependency could not be inferred'; - } - - $tables[$this->keys->tableKey($connection, $base->from)] = true; - } elseif (!$capturedOpaqueFrom) { - return 'query source dependency could not be inferred'; - } - - $opaqueJoins = 0; - foreach ($base->joins ?? [] as $join) { - if ($this->joinClauseHasComplexWheres($join->wheres ?? [])) { - return 'join clause dependency could not be inferred'; - } - - if (is_string($join->table)) { - if ($this->joinTableHasImplicitAlias($join->table)) { - return 'join table alias could not be inferred'; - } - - $tables[$this->keys->tableKey($connection, $join->table)] = true; - } else { - $opaqueJoins++; - } - } - - if ($opaqueJoins > $capturedOpaqueJoins) { - return 'joined subquery dependency could not be inferred'; - } - - foreach ($base->wheres as $where) { - $query = $where['query'] ?? null; - - if ($query instanceof QueryBuilder - && ($reason = $this->collectQueryTables($query, $connection, $tables))) { - return $reason; - } - } - - foreach ($base->unions ?? [] as $union) { - $query = $union['query'] ?? null; - - if (!$query instanceof QueryBuilder) { - return 'union dependency could not be inferred'; - } - - if ($reason = $this->collectQueryTables($query, $connection, $tables)) { - return $reason; - } - } - - return null; - } - - private function countOpaqueWhereSubqueries(QueryBuilder $base): int - { - $count = 0; - - foreach ($base->wheres as $where) { - $type = $where['type'] ?? null; - - if ($type === 'Basic' && ($where['column'] ?? null) instanceof Expression) { - $count++; - } - - if (($type === 'In' || $type === 'NotIn') && isset($where['values'])) { - foreach ((array) $where['values'] as $value) { - if ($value instanceof Expression) { - $count++; - } - } - } - - if (($where['query'] ?? null) instanceof QueryBuilder) { - $count += $this->countOpaqueWhereSubqueries($where['query']); - } - } - - return $count; - } - - private function connectionName(QueryBuilder $base, string $fallback): string - { - /** @var Connection $connection */ - $connection = $base->getConnection(); - - return $connection->getName() ?? $fallback; - } - - /** @deprecated Use inferQueryDependencies(). */ - public function inferJoinDependencies(QueryBuilder $base, string $connection): DependencySet - { - $dependencies = $this->inferQueryDependencies($base, $connection, is_string($base->from) ? $base->from : null); - - return new DependencySet( - tables: $dependencies->tables, - safe: $dependencies->safe, - reasons: $dependencies->reasons, - ); - } - - private function joinTableHasImplicitAlias(string $table): bool - { - return (bool) preg_match('/\s+/', trim($table)) && !preg_match('/\s+as\s+/i', $table); - } - - private function joinClauseHasComplexWheres(array $wheres): bool - { - foreach ($wheres as $where) { - if (!in_array($where['type'] ?? null, ['Column', 'Basic', 'Null', 'NotNull'], true)) { - return true; - } - } - - return false; - } - - public static function resolvePrimaryKeys( - QueryBuilder $base, - array $primaryKeyIdentifiers, - ?string $softDeleteScopeColumn = null, - ): ?array { - if ($base->offset > 0) { - return null; - } - - if ($base->limit === 0) { - return []; - } - - $wheres = array_values(array_filter( - $base->wheres, - static fn(array $where): bool => !self::isSoftDeleteScopeConstraint($where, $softDeleteScopeColumn), - )); - - if (count($wheres) !== 1) { - return null; - } - - $where = $wheres[0]; - $column = $where['column'] ?? null; - - if (!in_array($column, $primaryKeyIdentifiers, true)) { - return null; - } - - if (($where['type'] ?? null) === 'Basic' && ($where['operator'] ?? null) === '=') { - return $where['value'] instanceof Expression ? null : [$where['value']]; - } - - if (!empty($base->orders) || $base->limit > 0) { - return null; - } - - if (($where['type'] ?? null) === 'In' || (($where['type'] ?? null) === 'InRaw' && isset($where['values']))) { - $values = (array) $where['values']; - - foreach ($values as $value) { - if ($value instanceof Expression) { - return null; - } - } - - sort($values); - - return $values; - } - - return null; - } - - private static function isSoftDeleteScopeConstraint(array $where, ?string $column): bool - { - return $column !== null - && ($where['type'] ?? null) === 'Null' - && ($where['boolean'] ?? 'and') === 'and' - && !($where['not'] ?? false) - && ($where['column'] ?? null) === $column; - } - - private function inspectWheres(array $wheres): int - { - $flags = 0; - - foreach ($wheres as $where) { - $type = $where['type'] ?? ''; - - if ($type === 'raw') { - $flags |= QueryInspection::RAW_WHERE; - } - - $flags |= match (true) { - isset(self::EXISTS_WHERE_TYPES[$type]) => QueryInspection::EXISTS_WHERE, - isset(self::SUBQUERY_WHERE_TYPES[$type]) => QueryInspection::SUBQUERY_WHERE, - ($type === 'In' || $type === 'NotIn') && $this->containsExpression((array) ($where['values'] ?? [])) => QueryInspection::SUBQUERY_WHERE, - $type === 'Basic' && ($where['column'] ?? null) instanceof Expression => QueryInspection::SUBQUERY_WHERE, - default => 0, - }; - - if (($where['query'] ?? null) instanceof QueryBuilder) { - if ($where['query']->lock !== null) { - $flags |= QueryInspection::LOCK; - } - - $flags |= $this->inspectWheres((array) $where['query']->wheres); - } - - if (($flags & (QueryInspection::RAW_WHERE | QueryInspection::SUBQUERY_WHERE)) - === (QueryInspection::RAW_WHERE | QueryInspection::SUBQUERY_WHERE)) { - break; - } - } - - return $flags; - } - - private function containsExpression(array $values): bool - { - foreach ($values as $value) { - if ($value instanceof Expression) { - return true; - } - } - - return false; - } -} diff --git a/src/Planning/QueryEligibility.php b/src/Planning/QueryEligibility.php deleted file mode 100644 index c864d89..0000000 --- a/src/Planning/QueryEligibility.php +++ /dev/null @@ -1,158 +0,0 @@ -isCacheSkipped() && $cacheEnabled; - } - - public static function usesWritePdo(QueryBuilder $query): bool - { - return $query->useWritePdo; - } - - public static function isInsideTransaction(CacheableBuilder $builder): bool - { - return $builder->getModel()->getConnection()->transactionLevel() > 0; - } - - public static function hasExplicitLock(QueryBuilder $query): bool - { - return $query->lock !== null && $query->lock !== false; - } - - public static function hasGroupedShape(QueryBuilder $query): bool - { - return !empty($query->groups) || !empty($query->havings); - } - - public static function hasUnion(QueryBuilder $query): bool - { - return !empty($query->unions); - } - - public static function blocksSimpleRelation( - CacheableBuilder $builder, - QueryBuilder $query, - bool $cacheEnabled = true, - ): bool { - return !self::isCacheAvailable($builder, $cacheEnabled) - || self::usesWritePdo($query) - || self::isInsideTransaction($builder) - || self::hasGroupedShape($query) - || self::hasUnion($query) - || self::hasExplicitLock($query) - || $builder->hasExplicitDependencies(); - } - - public static function hasOrderingOrJoins(QueryBuilder $query): bool - { - return !empty($query->joins) - || !empty($query->orders) - || $query->limit !== null - || $query->offset > 0 - || $query->distinct; - } - - public function qualifiesForDirectModels( - bool $explain, - bool $insideTransaction, - bool $hasExplicitDependencies, - QueryInspection $inspection, - ): bool { - return !$explain - && !$insideTransaction - && !$hasExplicitDependencies - && $inspection->contextReasons === [] - && $inspection->dependencies->safe - && $inspection->dependencies->hasNoDependencies() - && $inspection->primaryKeys !== null - && $inspection->normalizationFlags() === 0 - && !$inspection->hasSafetyBypass(); - } - - public function dependsOnPrimaryModelOnly( - bool $hasExplicitDependencies, - QueryInspection $inspection, - ): bool { - return !$hasExplicitDependencies - && $inspection->dependencies->safe - && $inspection->dependencies->hasNoDependencies() - && !$inspection->hasDependencyBypass() - && !isset($inspection->contextReasons['dependency']); - } - - public function canUseModelIndex( - QueryInspection $inspection, - DependencySet $dependencies, - ): bool { - return $inspection->dependencies->hasNoDependencies() - && !$inspection->hasDependencyBypass() - && !isset($inspection->contextReasons['dependency']) - && $inspection->normalizationFlags() === 0 - && !isset($inspection->contextReasons['normalization']) - && $dependencies->safe; - } - - public function canUseResult( - QueryInspection $inspection, - DependencySet $dependencies, - bool $hasExplicitDependencies, - ): bool { - return $dependencies->safe - && ($hasExplicitDependencies || !$inspection->dependencies->hasNoDependencies()); - } - - public function canUseSimpleResult( - QueryInspection $inspection, - int $blockerFlags, - ): bool { - return $inspection->dependencies->safe - && $inspection->dependencies->hasNoDependencies() - && ($inspection->flags & $blockerFlags) === 0; - } - - public function hasSafetyBypass(QueryInspection $inspection, bool $insideTransaction): bool - { - return $insideTransaction || $inspection->hasSafetyBypass(); - } - - public function safetyReasons(QueryInspection $inspection, bool $insideTransaction): array - { - return BypassReasons::merge( - $insideTransaction ? ['safety' => ['inside a database transaction']] : [], - BypassReasons::fromInspection($inspection), - )['safety'] ?? []; - } - - public function requiresExplicitSelectForJoinResult( - CacheableBuilder $builder, - QueryBuilder $query, - CachePlanContext $context, - ): bool { - return $context->selectAll - && !empty($query->joins) - && empty($query->columns) - && !$builder->hasAggregateColumns(); - } - - public function isCanonicalModelQuery(QueryInspection $inspection): bool - { - return $inspection->normalizationFlags() === 0 - && !isset($inspection->contextReasons['normalization']); - } - - public function fitsCacheSpace(SpaceValidationResult $validation): bool - { - return $validation->isValid; - } -} diff --git a/src/Planning/QueryInspection.php b/src/Planning/QueryInspection.php deleted file mode 100644 index ed20a4b..0000000 --- a/src/Planning/QueryInspection.php +++ /dev/null @@ -1,76 +0,0 @@ -dependencies = $dependencies ?? DependencySet::empty(); - } - - public function has(int $flags): bool - { - return ($this->flags & $flags) !== 0; - } - - public function hasDependencyBypass(): bool - { - return $this->has(self::DEPENDENCY_BYPASS); - } - - public function normalizationFlags(): int - { - return $this->flags & self::NORMALIZATION_BYPASS; - } - - public function hasSafetyBypass(): bool - { - return $this->has(self::LOCK) || isset($this->contextReasons['safety']); - } -} diff --git a/src/Planning/QueryPlanner.php b/src/Planning/QueryPlanner.php new file mode 100644 index 0000000..0f03b33 --- /dev/null +++ b/src/Planning/QueryPlanner.php @@ -0,0 +1,373 @@ + $dependencies + */ + public function plan( + QueryBuilder $query, + TableIdentity $root, + array $dependencies, + bool $forceQueryGroup = false, + string $operation = 'select', + ): QueryPlan { + if ( + $forceQueryGroup + || $query->joins !== null && $query->joins !== [] + || $this->hasCrossTableUnion($root, $dependencies, $query) + ) { + return QueryPlan::queryGroup($root, $dependencies); + } + + if ($query->configuredCacheContext() !== null) { + return QueryPlan::result($root, $dependencies); + } + + $wildcard = $this->isWildcard($query, $root); + $plainColumns = $wildcard ? null : $this->plainColumns($query, $root); + $primaryKey = $this->canUseRowShape($query, $operation) + && ($wildcard || $plainColumns !== null) + ? $query->primaryKey() + : null; + + $direct = $this->directPlan( + $query, + $root, + $dependencies, + $primaryKey, + $wildcard, + $plainColumns, + ); + + if ($direct !== null) { + return $direct; + } + + if ( + $primaryKey !== null + && $wildcard + ) { + return QueryPlan::canonical( + $root, + $dependencies, + $primaryKey, + $this->predicateColumns->extract($query), + ); + } + + if ($primaryKey !== null && $plainColumns !== null) { + return QueryPlan::projectedResult( + $root, + $dependencies, + $primaryKey, + $plainColumns, + $this->predicateColumns->extract($query), + ); + } + + return QueryPlan::result($root, $dependencies, $primaryKey); + } + + /** + * @param list $dependencies + * @param list|null $plainColumns + */ + private function directPlan( + QueryBuilder $query, + TableIdentity $root, + array $dependencies, + ?PrimaryKeyMetadata $primaryKey, + bool $wildcard, + ?array $plainColumns, + ): ?QueryPlan { + if ( + $primaryKey === null + || count($dependencies) !== 1 + || !$this->allowsDirectControls($query) + || (!$wildcard && $plainColumns === null) + ) { + return null; + } + + $directToken = $this->directPrimaryKeyToken($query, $root, $primaryKey); + + if ($directToken === null) { + return null; + } + + [$softDeleteSafe, $softDeleteMode] = $this->softDeleteMode($query, $root); + + if (!$softDeleteSafe) { + return null; + } + + $deletedAtColumn = $query->deletedAtColumn(); + + return $wildcard + ? QueryPlan::directPrimaryKey( + $root, + $dependencies, + $primaryKey, + $directToken, + $softDeleteMode, + $deletedAtColumn, + ) + : QueryPlan::projectedRow( + $root, + $dependencies, + $primaryKey, + $directToken, + (array) $plainColumns, + $softDeleteMode, + $deletedAtColumn, + ); + } + + private function allowsDirectControls(QueryBuilder $query): bool + { + return $query->configuredTag() === null && $query->configuredTtl() === null; + } + + private function isSingleRowShape(QueryBuilder $query): bool + { + return !$query->distinct + && $query->aggregate === null + && $query->groupLimit === null + && empty($query->groups) + && empty($query->havings) + && empty($query->unions); + } + + private function canUseRowShape(QueryBuilder $query, string $operation): bool + { + return $operation === 'select' && $this->isSingleRowShape($query); + } + + private function hasCrossTableUnion( + TableIdentity $root, + array $dependencies, + QueryBuilder $query, + ): bool { + return !empty($query->unions) + && DependencyAnalysis::hasExternalTo($root, $dependencies); + } + + private function isWildcard(QueryBuilder $query, TableIdentity $root): bool + { + if ($query->columns === null || $query->columns === ['*']) { + return true; + } + + if (count($query->columns) !== 1 || !is_string($query->columns[0])) { + return false; + } + + $column = strtolower(trim($query->columns[0])); + + $alias = $this->fromAlias($query); + + return $alias !== null + ? $column === $alias . '.*' + : $column === strtolower($root->table) . '.*'; + } + + private function fromAlias(QueryBuilder $query): ?string + { + if ( + is_string($query->from) + && preg_match('/\\s+(?:as\\s+)?([^\\s]+)$/i', trim($query->from), $matches) === 1 + ) { + return strtolower($matches[1]); + } + + return null; + } + + /** @return list|null */ + private function plainColumns(QueryBuilder $query, TableIdentity $root): ?array + { + if ($query->columns === null || $query->columns === ['*']) { + return null; + } + + $table = strtolower($root->table); + $alias = $this->fromAlias($query); + $columns = []; + + foreach ($query->columns as $column) { + if (!is_string($column)) { + return null; + } + + $normalized = trim($column); + + if (preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $normalized) === 1) { + $columns[] = $normalized; + + continue; + } + + if (preg_match('/^([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)$/', $normalized, $matches) !== 1) { + return null; + } + + $qualifier = strtolower($matches[1]); + + if (!$this->isValidQualifier($qualifier, $table, $alias)) { + return null; + } + + $columns[] = $matches[2]; + } + + return $columns === [] ? null : $columns; + } + + private function directPrimaryKeyToken( + QueryBuilder $query, + TableIdentity $root, + PrimaryKeyMetadata $primaryKey, + ): ?string { + if ( + $query->offset !== null + || ($query->limit !== null && $query->limit !== 1) + || !empty($query->orders) + ) { + return null; + } + + $primaryWhere = null; + + foreach ($query->wheres as $where) { + if ($this->isSoftDeleteWhere($query, $root, $where)) { + continue; + } + + if ($primaryWhere !== null) { + return null; + } + + $primaryWhere = $where; + } + + if ($primaryWhere === null) { + return null; + } + + $where = $primaryWhere; + + if ( + ($where['type'] ?? null) !== 'Basic' + || strtolower((string) ($where['boolean'] ?? 'and')) !== 'and' + || !in_array($where['operator'] ?? null, ['=', '=='], true) + || !is_string($where['column'] ?? null) + || $where['value'] instanceof Expression + ) { + return null; + } + + if (!$this->resolvesToColumn($query, $root, (string) $where['column'], $primaryKey->column)) { + return null; + } + + return $primaryKey->token($where['value'] ?? null); + } + + /** @return array{0: bool, 1: ?string} */ + private function softDeleteMode(QueryBuilder $query, TableIdentity $root): array + { + if ($query->deletedAtColumn() === null) { + return [true, null]; + } + + $modes = []; + + foreach ($query->wheres as $where) { + if (!$this->isDeletedAtColumn($query, $root, $where['column'] ?? null)) { + continue; + } + + $type = $where['type'] ?? null; + + if ( + !in_array($type, ['Null', 'NotNull'], true) + || strtolower((string) ($where['boolean'] ?? 'and')) !== 'and' + ) { + return [false, null]; + } + + $modes[] = $type === 'NotNull' ? 'only' : 'default'; + } + + if (count($modes) > 1) { + return [false, null]; + } + + return [true, $modes[0] ?? 'with']; + } + + /** @param array $where */ + private function isSoftDeleteWhere(QueryBuilder $query, TableIdentity $root, array $where): bool + { + return in_array($where['type'] ?? null, ['Null', 'NotNull'], true) + && strtolower((string) ($where['boolean'] ?? 'and')) === 'and' + && $this->isDeletedAtColumn($query, $root, $where['column'] ?? null); + } + + private function isValidQualifier(string $qualifier, string $table, ?string $alias): bool + { + return $alias !== null + ? $qualifier === $alias + : $qualifier === $table; + } + + private function isDeletedAtColumn(QueryBuilder $query, TableIdentity $root, mixed $column): bool + { + $deletedAt = $query->deletedAtColumn(); + + return is_string($column) + && is_string($deletedAt) + && $this->resolvesToColumn($query, $root, $column, $deletedAt); + } + + private function resolvesToColumn( + QueryBuilder $query, + TableIdentity $root, + string $column, + string $expected, + ): bool { + $segments = ColumnName::segments($column); + $unqualified = array_pop($segments); + + if ($unqualified !== strtolower($expected)) { + return false; + } + + if ($segments === []) { + return true; + } + + $qualifier = (string) array_pop($segments); + + return $segments === [] && $this->isValidQualifier( + $qualifier, + strtolower($root->table), + $this->fromAlias($query), + ); + } +} diff --git a/src/Planning/SqlVolatilityScanner.php b/src/Planning/SqlVolatilityScanner.php new file mode 100644 index 0000000..2bd27b9 --- /dev/null +++ b/src/Planning/SqlVolatilityScanner.php @@ -0,0 +1,73 @@ + */ + private array $memo = []; + + public function isVolatile(string $sql): bool + { + if (isset($this->memo[$sql])) { + return $this->memo[$sql]; + } + + if (count($this->memo) >= self::MEMO_LIMIT) { + $this->memo = []; + } + + return $this->memo[$sql] = $this->scan($sql); + } + + private function scan(string $sql): bool + { + $sql = strtolower($sql); + // An unterminated quote leaves the text intact, and a PCRE limit failure + // returns null. Both fall back to scanning the raw SQL, which fails closed. + $unquoted = preg_replace(self::QUOTED_TEXT, ' ', $sql) ?? $sql; + + return $this->matches(self::VOLATILE_CALLS, $unquoted) + || $this->matches(self::VOLATILE_KEYWORDS, $unquoted) + // This one reads the literal itself, as in date('now'). + || $this->matches(self::NOW_ARGUMENT, $sql); + } + + private function matches(string $pattern, string $sql): bool + { + // preg_match answers false, not 0, when PCRE gives up, so testing against + // no-match keeps an exhausted limit on the volatile side. + return preg_match($pattern, $sql) !== 0; + } +} diff --git a/src/Planning/TableIdentityResolver.php b/src/Planning/TableIdentityResolver.php new file mode 100644 index 0000000..a882241 --- /dev/null +++ b/src/Planning/TableIdentityResolver.php @@ -0,0 +1,213 @@ +, + * unresolvable: array + * }> + */ + private \WeakMap $connections; + + public function __construct() + { + $this->connections = new \WeakMap; + } + + public function resolve(Connection $connection, mixed $from): ?TableIdentity + { + if (!is_string($from)) { + return null; + } + + $sourceScope = ConnectionSourceResolver::resolve($connection); + + if ($sourceScope === null) { + return null; + } + + $metadata = $this->metadata($connection, $sourceScope); + + if (isset($metadata['identities'][$from])) { + return $metadata['identities'][$from]; + } + + if (isset($metadata['unresolvable'][$from])) { + return null; + } + + $identity = $this->resolveIdentity($connection, $from, $sourceScope); + + if ($identity === null) { + // Raw expressions and subquery SQL land here, so unlike the identity map + // this one is not bounded by the number of real tables. + if (count($metadata['unresolvable']) >= self::UNRESOLVABLE_LIMIT) { + $metadata['unresolvable'] = []; + } + + $metadata['unresolvable'][$from] = true; + } else { + $metadata['identities'][$from] = $identity; + } + + $this->connections[$connection] = $metadata; + + return $identity; + } + + private function resolveIdentity( + Connection $connection, + string $from, + string $sourceScope, + ): ?TableIdentity { + $table = $this->physicalTable($from); + + if ($table === null) { + return null; + } + + $driver = (string) $connection->getDriverName(); + $database = (string) $connection->getDatabaseName(); + $parts = array_map($this->unquote(...), explode('.', $table)); + $maximumParts = match ($driver) { + 'mysql', 'mariadb', 'pgsql', 'sqlite' => 2, + 'sqlsrv' => 3, + default => 1, + }; + + if ( + in_array('', $parts, true) + || count($parts) > $maximumParts + ) { + return null; + } + + $resolvedTable = $parts[count($parts) - 1]; + $schema = ''; + + if (in_array($driver, ['mysql', 'mariadb'], true)) { + $database = count($parts) === 2 ? $parts[0] : $database; + $schema = $database; + } elseif ($driver === 'pgsql') { + $schema = count($parts) === 2 + ? $parts[0] + : $this->configuredSchema($connection, 'public'); + } elseif ($driver === 'sqlsrv') { + if (count($parts) === 3) { + $database = $parts[0]; + $schema = $parts[1]; + } else { + $schema = count($parts) === 2 + ? $parts[0] + : $this->configuredSchema($connection, 'dbo'); + } + } elseif ($driver === 'sqlite') { + $schema = strtolower(count($parts) === 2 ? $parts[0] : 'main'); + $resolvedTable = strtolower($resolvedTable); + $database = $this->sqliteDatabase($database); + + if ($database === '') { + return null; + } + } + + return TableIdentity::fromParts( + driver: $driver, + connection: (string) $connection->getName(), + database: $database, + schema: $schema, + prefix: (string) $connection->getTablePrefix(), + table: $resolvedTable, + sourceScope: $sourceScope, + ); + } + + private function physicalTable(string $from): ?string + { + $from = trim($from); + + if (preg_match('/(?:`[^`]*\s[^`]*`|"[^"]*\s[^"]*"|\[[^]]*\s[^]]*\])/u', $from) === 1) { + return null; + } + + if (preg_match('/^([^\s]+)(?:\s+(?:as\s+)?[^\s]+)?$/i', $from, $matches) !== 1) { + return null; + } + + $table = $matches[1]; + + return preg_match('/[(){}:,*]/', $table) === 0 ? $table : null; + } + + private function unquote(string $identifier): string + { + return trim($identifier, '`"[]'); + } + + private function configuredSchema(Connection $connection, string $default): string + { + $config = $connection->getConfig(); + $configured = $config['search_path'] ?? $config['schema'] ?? $default; + $candidates = is_array($configured) + ? $configured + : explode(',', (string) $configured); + + foreach ($candidates as $candidate) { + $schema = trim((string) $candidate, " \t\n\r\0\x0B`\"'"); + + // PostgreSQL resolves $user to the session user. + if ($schema === '$user') { + $schema = trim((string) ($config['username'] ?? '')); + } + + if ($schema !== '') { + return $schema; + } + } + + return $default; + } + + private function sqliteDatabase(string $database): string + { + if ($database === '' || str_contains($database, ':memory:')) { + return ''; + } + + return realpath($database) ?: $database; + } + + /** + * @return array{ + * signature: string, + * identities: array, + * unresolvable: array + * } + */ + private function metadata(Connection $connection, string $sourceScope): array + { + $signature = TableIdentity::encodeFields([ + $sourceScope, + (string) $connection->getDriverName(), + (string) $connection->getDatabaseName(), + (string) $connection->getTablePrefix(), + ]); + $metadata = $this->connections[$connection] ?? null; + + if ($metadata === null || $metadata['signature'] !== $signature) { + $metadata = ['signature' => $signature, 'identities' => [], 'unresolvable' => []]; + $this->connections[$connection] = $metadata; + } + + return $metadata; + } +} diff --git a/src/Relations/CacheableBelongsToMany.php b/src/Relations/CacheableBelongsToMany.php deleted file mode 100644 index acf2911..0000000 --- a/src/Relations/CacheableBelongsToMany.php +++ /dev/null @@ -1,10 +0,0 @@ -applyOneOfManyDependency(); - - return parent::getResults(); - } - - public function get($columns = ['*']) - { - $this->applyOneOfManyDependency(); - - return parent::get($columns); - } - - private function applyOneOfManyDependency(): void - { - if ($this->isOneOfMany() && $this->query instanceof CacheableBuilder) { - $this->query->dependsOn([$this->related::class]); - $this->query->acknowledgeOfManySelfJoin(); - } - } -} diff --git a/src/Relations/CacheableHasOneThrough.php b/src/Relations/CacheableHasOneThrough.php deleted file mode 100644 index 9470390..0000000 --- a/src/Relations/CacheableHasOneThrough.php +++ /dev/null @@ -1,10 +0,0 @@ -query instanceof CacheableBuilder) { - return parent::get($columns); - } - - $query = $this->query; - $this->applyOneOfManyDependency($query); - - $debugbarStart = CacheReporter::beginMeasure(); - - $prepared = $query->prepareScopedQuery(); - $builder = $prepared->builder; - $base = $prepared->base; - $builder->addSelect( - $this->shouldSelect($base->columns ? [] : $columns) - ); - $prepared->applyBeforeCallbacks(); - - $classification = ProjectionClassifier::classifyForRelation( - $base, - (array) $columns, - $this->related->getTable(), - $this->related->getKeyName() - ); - - $shouldCacheModels = $classification['shouldCacheRelatedModels']; - $selectedColumns = $classification['selectedRelatedColumns']; - - $plan = $this->shouldUseCache($builder, $base); - - if ($plan === null || (!$shouldCacheModels && !$classification['relatedKeyInProjection'])) { - return $this->getFromPreparedBuilder($prepared); - } - - $hash = QueryHasher::forResultQuery($builder, $base); - $relatedClass = $this->related::class; - $throughClass = $this->throughParent::class; - $connection = $this->related->getConnection()->getName() - ?? $this->related->getConnectionName() - ?? ''; - $depClasses = array_values(array_unique([ - $throughClass, - ...$plan->dependencies->depClassesFor($relatedClass), - ])); - $depTableKeys = $plan->dependencies->tables; - $tag = $builder->getCacheTag(); - $ttl = $builder->getQueryTtl(); - - $runThrough = fn() => CacheFallback::rescue( - NormCache::config(), - function () use ( - $relatedClass, - $throughClass, - $hash, - $tag, - $depClasses, - $depTableKeys, - $ttl, - $connection, - $prepared, - $shouldCacheModels, - $selectedColumns, - $debugbarStart, - $plan, - ) { - $rawModels = null; - $modelAttrs = []; - - $outcome = NormCache::relationIndexes()->getOrBuildThrough( - build: function () use ($prepared, $shouldCacheModels, &$rawModels, &$modelAttrs) { - $rawModels = $this->getFromPreparedBuilder($prepared, false); - - if ($shouldCacheModels) { - foreach ($rawModels as $model) { - $attrs = $model->getRawOriginal(); - unset($attrs['laravel_through_key']); - $modelAttrs[$model->getKey()] = $attrs; - } - } - - return $this->cachePayloadFromResult($rawModels); - }, - modelClass: $relatedClass, - hash: $hash, - tag: $tag, - depClasses: $depClasses, - depTableKeys: $depTableKeys, - ttl: $ttl, - connection: $connection, - ); - - if ($outcome->status !== CacheStatus::Hit && $outcome->status !== CacheStatus::Empty) { - CacheReporter::queryMiss($relatedClass, $outcome->key, $debugbarStart, [ - ...CacheReporter::cacheMeta(CacheKind::RelationIndex, $outcome->status, ResultKind::Collection, $plan->space), - 'through' => $throughClass, - ], 'through miss'); - - if ($outcome->status === CacheStatus::Miss && $modelAttrs !== []) { - NormCache::modelCache()->storeForBuild( - $relatedClass, - $modelAttrs, - $outcome->build, - NormCache::keys()->activeSpace(), - $connection, - ); - } - - return $prepared->applyAfterCallbacks($rawModels ?? $this->related->newCollection()); - } - - $ids = $outcome->payload['ids']; - $throughKeys = array_combine($ids, $outcome->payload['throughKeys']); - - $resolvedVersion = isset($outcome->build->expectedVersions[0]) - ? (int) $outcome->build->expectedVersions[0] - : null; - $raw = $resolvedVersion !== null - ? NormCache::modelCache()->rawForVersion($relatedClass, $ids, $resolvedVersion, $connection) - : null; - - $models = $this->hydrateFromIds( - $ids, - $relatedClass, - $prepared, - $selectedColumns, - $throughKeys, - $raw, - $resolvedVersion, - ); - CacheReporter::queryHit($relatedClass, $outcome->key, $debugbarStart, [ - ...CacheReporter::cacheMeta(CacheKind::RelationIndex, $outcome->status, ResultKind::Collection, $plan->space), - 'through' => $throughClass, - ], 'through hit'); - - return $models; - }, - fn() => $this->getFromPreparedBuilder($prepared) - ); - - return NormCache::withSpace($plan->space, $runThrough); - } - - private function applyOneOfManyDependency(CacheableBuilder $query): void - { - if (method_exists($this, 'isOneOfMany') && $this->isOneOfMany()) { - $query->dependsOn([$this->throughParent::class]); - $query->acknowledgeOfManySelfJoin(); - } - } - - private function shouldUseCache(CacheableBuilder $builder, Builder $base): ?CachePlan - { - if ($this->isSimpleThroughQuery($base, $builder)) { - $space = NormCache::spaceFor($this->related::class, $builder->getSpace()); - $dependencies = DependencySet::singleModel($this->throughParent::class); - $plan = CachePlan::result( - operation: CacheOperation::Through, - dependencies: $dependencies, - )->withSpace($space); - - $plan = $builder->planner()->applySpaceValidation( - $plan, - $builder, - $this->related, - ); - - return $plan->usesResultCache() ? $plan : null; - } - - $projection = ProjectionClassifier::resolve($base, null); - - $plan = $builder->cachePlan($base, CachePlanContext::through( - $projection ?? [], - DependencySet::singleModel($this->throughParent::class), - )); - - return $plan->usesResultCache() ? $plan : null; - } - - private function isSimpleThroughQuery(Builder $base, CacheableBuilder $builder): bool - { - if (RelationCacheGuards::blocksBypass($builder, $base) - || count($base->joins ?? []) !== 1 - || ProjectionClassifier::hasCalculatedColumns($base->columns)) { - return false; - } - - $flags = (new QueryAnalyzer)->flags($base, $this->related->getTable(), $base->columns); - - return ($flags & ~QueryInspection::JOIN) === 0; - } - - private function cachePayloadFromResult(Collection $result): array - { - $ids = []; - $throughKeys = []; - - foreach ($result as $model) { - $id = $model->getKey(); - $ids[] = $id; - $throughKeys[] = $model->getAttribute('laravel_through_key'); - } - - return [ - 'ids' => $ids, - 'throughKeys' => $throughKeys, - ]; - } - - private function hydrateFromIds( - array $ids, - string $relatedClass, - PreparedQuery $prepared, - ?array $selectedColumns, - array $throughKeys = [], - ?array $raw = null, - ?int $resolvedVersion = null, - ): Collection { - $builder = $prepared->builder; - $models = NormCache::modelCache()->getModels( - $ids, - $relatedClass, - $selectedColumns, - $raw, - $builder, - false, - $this->related, - $resolvedVersion, - ); - - if ($throughKeys !== []) { - $getAttribute = RawAttributes::getAttributeClosure(); - $setAttribute = RawAttributes::setAttributeClosure(); - $keyName = $this->related->getKeyName(); - foreach ($models as $model) { - $id = $getAttribute($model, $keyName); - if (array_key_exists($id, $throughKeys)) { - $setAttribute($model, 'laravel_through_key', $throughKeys[$id]); - } - } - } - - if ($models && $builder->getEagerLoads()) { - $models = $builder->eagerLoadRelations($models); - } - - return $prepared->applyAfterCallbacks($this->related->newCollection($models)); - } - - private function getFromPreparedBuilder(PreparedQuery $prepared, bool $applyAfterCallbacks = true): Collection - { - return $prepared->collect(applyAfterCallbacks: $applyAfterCallbacks); - } -} diff --git a/src/Relations/CachesPivotRelation.php b/src/Relations/CachesPivotRelation.php deleted file mode 100644 index 402ea73..0000000 --- a/src/Relations/CachesPivotRelation.php +++ /dev/null @@ -1,387 +0,0 @@ -prebuiltDictionary)) { - $dictionary = $this->prebuiltDictionary; - $this->prebuiltDictionary = []; - - foreach ($models as $model) { - if (isset($dictionary[$key = $model->getAttribute($this->parentKey)])) { - $model->setRelation( - $relation, $this->related->newCollection($dictionary[$key]) - ); - } - } - - return $models; - } - - return parent::match($models, $results, $relation); - } - - public function addEagerConstraints(array $models): void - { - $this->inEagerLoad = true; - $this->eagerParentIds = $this->getKeys($models, $this->parentKey); - parent::addEagerConstraints($models); - } - - public function get($columns = ['*']): Collection - { - $columns = Arr::wrap($columns); - $cacheParentIds = $this->getCacheParentIds(); - - if (!$this->query instanceof CacheableBuilder) { - return parent::get($columns); - } - - $prepared = $this->query->prepareScopedQuery(); - $builder = $prepared->builder; - $base = $prepared->base; - - $classification = ProjectionClassifier::classifyForRelation( - $base, - $columns, - $this->related->getTable(), - $this->related->getKeyName() - ); - - $selectColumns = $base->columns ? [] : $columns; - $builder->addSelect($this->shouldSelect($selectColumns)); - $prepared->applyBeforeCallbacks(); - $shouldCacheRelatedModels = $classification['shouldCacheRelatedModels']; - $selectedRelatedColumns = $classification['selectedRelatedColumns']; - - $constraintHash = QueryHasher::forRelationQuery($this->getQualifiedForeignPivotKeyName(), $base); - - if (!$this->shouldUsePivotCache($cacheParentIds, $classification['resolvedColumns'], $builder, $base) - || (!$shouldCacheRelatedModels && !$classification['relatedKeyInProjection'])) { - return $this->getFromPreparedPivotBuilder($prepared); - } - - $debugbarStart = CacheReporter::beginMeasure(); - $ttl = $builder->getQueryTtl(); - - $parentClass = $this->parent::class; - $relatedClass = $this->related::class; - $parentClassKey = NormCache::keys()->classKey($parentClass); - $relatedConnection = $this->related->getConnection()->getName() - ?? $this->related->getConnectionName() - ?? ''; - - $runPivot = fn() => CacheFallback::rescue( - NormCache::config(), - fn() => NormCache::relationIndexes()->runPivot( - parentClass: $parentClass, - relatedClass: $relatedClass, - relation: $this->relationName, - parentIds: $cacheParentIds, - constraintHash: $constraintHash, - pivotTableKey: $this->pivotTableKey(), - connection: $relatedConnection, - onBuild: fn() => $this->getFromPreparedPivotBuilder($prepared), - onMiss: function ($pivotResult) use ($parentClass, $parentClassKey, $relatedClass, $cacheParentIds, $debugbarStart, $prepared) { - CacheReporter::queryMiss($parentClass, "pivot:{$parentClassKey}:{$this->relationName}", $debugbarStart, [ - ...CacheReporter::cacheMeta(CacheKind::RelationIndex, $pivotResult->status, ResultKind::Collection, NormCache::keys()->activeSpace()), - 'parents' => $cacheParentIds, - 'related' => $relatedClass, - ], 'pivot miss'); - - $rawModels = $this->getFromPreparedPivotBuilder($prepared, false); - - return [ - $prepared->applyAfterCallbacks($rawModels), - $rawModels, - ]; - }, - onStore: function ($models, $pivotResult) use ($cacheParentIds, $parentClassKey, $relatedClass, $relatedConnection, $constraintHash, $shouldCacheRelatedModels, $ttl) { - $space = NormCache::keys()->activeSpace(); - - CacheFallback::attempt( - NormCache::config(), - function () use ($models, $cacheParentIds, $parentClassKey, $relatedClass, $relatedConnection, $constraintHash, $pivotResult, $shouldCacheRelatedModels, $ttl, $space) { - $relatedKey = NormCache::keys()->classKey($relatedClass, $relatedConnection); - $keyMap = []; - foreach ($cacheParentIds as $parentId) { - $keyMap[$parentId] = NormCache::keys()->pivotKey( - $parentClassKey, $relatedKey, $this->relationName, - $constraintHash, $pivotResult->seg, $parentId - ); - } - $this->populatePivotCache( - $models, - $keyMap, - $relatedClass, - $shouldCacheRelatedModels, - $ttl, - $pivotResult->build, - $space, - $relatedConnection, - ); - }, - ); - }, - onHit: function ($pivotResult) use ($relatedClass, $relatedConnection, $selectedRelatedColumns, $parentClass, $parentClassKey, $cacheParentIds, $debugbarStart, $prepared) { - $resolvedVersion = isset($pivotResult->build->expectedVersions[0]) - ? (int) $pivotResult->build->expectedVersions[0] - : null; - $models = $this->hydrateFromPivotCache( - $pivotResult->data, - $relatedClass, - $relatedConnection, - $selectedRelatedColumns, - $prepared, - $resolvedVersion, - ); - CacheReporter::queryHit($parentClass, "pivot:{$parentClassKey}:{$this->relationName}", $debugbarStart, [ - ...CacheReporter::cacheMeta(CacheKind::RelationIndex, $pivotResult->status, ResultKind::Collection, NormCache::keys()->activeSpace()), - 'parents' => $cacheParentIds, - 'related' => $relatedClass, - ], 'pivot hit'); - - return $models; - }, - ), - fn() => $this->getFromPreparedPivotBuilder($prepared) - ); - - return NormCache::withSpaceForModel($relatedClass, $builder->getSpace(), $runPivot); - } - - private function shouldUsePivotCache( - array $cacheParentIds, - ?array $resolvedColumns, - CacheableBuilder $builder, - QueryBuilder $base, - ): bool { - if (empty($cacheParentIds)) { - return false; - } - - if ($builder->hasExplicitDependencies()) { - return false; - } - - if ($builder->getCacheTag() !== null) { - return false; - } - - $plan = $builder->cachePlan($base, CachePlanContext::pivot($resolvedColumns ?? [])); - - if (!$plan->usesResultCache()) { - return false; - } - - // whereRaw with ? params can't be separated from FK bindings — same SQL, different values would collide. - foreach ($base->wheres as $where) { - if (($where['type'] ?? null) === 'raw' && str_contains(($where['sql'] ?? ''), '?')) { - return false; - } - } - - return true; - } - - private function pivotTableKey(): string - { - return NormCache::keys()->tableKey( - $this->parent->getConnection()->getName(), - $this->table - ); - } - - private function getCacheParentIds(): array - { - if ($this->inEagerLoad) { - return $this->eagerParentIds; - } - - if ($this->parent->exists && $this->parent->getKey() !== null) { - return [$this->parent->getKey()]; - } - - return []; - } - - private function populatePivotCache( - Collection $results, - array $keyMap, - string $relatedClass, - bool $cacheRelatedModels, - ?int $ttl, - BuildHandle $build, - ?CacheSpace $space = null, - ?string $connection = null, - ): void { - $pivotMap = array_fill_keys(array_keys($keyMap), []); - $modelAttrs = []; - $pivotPrefix = $this->accessor . '_'; - - foreach ($results as $model) { - $pivotModel = $model->getRelation($this->accessor); - $parentId = $pivotModel->getAttribute($this->foreignPivotKey); - - if (isset($pivotMap[$parentId])) { - $pivotMap[$parentId][] = [ - 'id' => $model->getKey(), - 'pivot' => $pivotModel->getRawOriginal(), - ]; - } - - if ($cacheRelatedModels) { - $attrs = []; - foreach ($model->getRawOriginal() as $key => $value) { - if (!str_starts_with($key, $pivotPrefix)) { - $attrs[$key] = $value; - } - } - - $modelAttrs[$model->getKey()] = $attrs; - } - } - - $pivotEntriesByKey = []; - foreach ($pivotMap as $parentId => $entries) { - $pivotEntriesByKey[$keyMap[$parentId]] = $entries; - } - - $stored = NormCache::relationIndexes()->storePivotEntries( - $pivotEntriesByKey, - $ttl, - $build, - $relatedClass, - ); - - if ($stored) { - NormCache::modelCache()->storeForBuild( - $relatedClass, - $modelAttrs, - $build, - $space, - $connection, - ); - } - } - - private function hydrateFromPivotCache( - array $cachedByParentId, - string $relatedClass, - ?string $relatedConnection, - ?array $selectedRelatedColumns, - PreparedQuery $prepared, - ?int $resolvedVersion, - ): Collection { - $uniqueRelatedIds = []; - foreach ($cachedByParentId as $entries) { - foreach ($entries as $entry) { - $uniqueRelatedIds[$entry['id']] = true; - } - } - - $modelsById = []; - $getAttribute = RawAttributes::getAttributeClosure(); - $keyName = $this->related->getKeyName(); - $ids = array_keys($uniqueRelatedIds); - $raw = $resolvedVersion === null - ? null - : NormCache::modelCache()->rawForVersion($relatedClass, $ids, $resolvedVersion, $relatedConnection); - foreach (NormCache::modelCache()->getModels( - $ids, - $relatedClass, - $selectedRelatedColumns, - $raw, - resolvedVersion: $resolvedVersion, - prototype: $this->related, - ) as $model) { - $modelsById[$getAttribute($model, $keyName)] = $model; - } - - $result = []; - $dictionary = []; - $templatePivot = $this->newExistingPivot([]); - - foreach ($cachedByParentId as $parentId => $entries) { - foreach ($entries as $entry) { - if (!isset($modelsById[$entry['id']])) { - continue; - } - - $model = clone $modelsById[$entry['id']]; - - $pivot = clone $templatePivot; - RawAttributes::hydrateClosure()($pivot, $entry['pivot'], false); - - $model->setRelation($this->accessor, $pivot); - - $result[] = $model; - $dictionary[$parentId][] = $model; - } - } - - $this->prebuiltDictionary = $dictionary; - - if ($result && $prepared->builder->getEagerLoads()) { - $result = $prepared->builder->eagerLoadRelations($result); - } - - return $prepared->applyAfterCallbacks($this->related->newCollection($result)); - } - - private function getFromPreparedPivotBuilder( - PreparedQuery $prepared, - bool $applyAfterCallbacks = true, - ): Collection { - return $prepared->collect( - applyAfterCallbacks: $applyAfterCallbacks, - beforeEagerLoad: fn(array $models) => $this->hydratePivotRelation($models), - ); - } - - protected function hydratePivotRelation(array $models) - { - $template = null; - - foreach ($models as $model) { - $values = $this->migratePivotAttributes($model); - - if ($template === null) { - $pivot = $template = $this->newExistingPivot($values); - } else { - $pivot = clone $template; - RawAttributes::hydrateClosure()($pivot, $values, false); - } - - $model->setRelation($this->accessor, $pivot); - } - } -} diff --git a/src/Relations/CachesRelationAggregates.php b/src/Relations/CachesRelationAggregates.php deleted file mode 100644 index e35713c..0000000 --- a/src/Relations/CachesRelationAggregates.php +++ /dev/null @@ -1,109 +0,0 @@ -aggregateCaching = false; - $this->aggregateAliases = []; - - if ($this->aggregateRequested) { - $this->addCapturedContextReason('opted_out', 'withoutAggregateCache() was called explicitly'); - } - - return $this; - } - - public function withAggregate($relations, $column, $function = null): static - { - $this->aggregateRequested = true; - - if (!$this->aggregateCaching) { - $this->addCapturedContextReason('opted_out', 'withoutAggregateCache() was called explicitly'); - - return parent::withAggregate($relations, $column, $function); - } - - $names = []; - foreach (Arr::wrap($relations) as $name => $constraint) { - if (is_numeric($name)) { - $name = $constraint; - } - - $segments = explode(' ', (string) $name); - if (count($segments) === 3 && Str::lower($segments[1]) === 'as') { - $name = $segments[0]; - } - - $names[] = $name; - - if (str_contains($name, '.')) { - $this->addCapturedContextReason('dependency', 'nested aggregate relation semantics could not be fully verified'); - } else { - $this->captureRelationSemantics($name); - } - } - - if ($function === 'exists') { - $this->addCapturedContextReason('dependency', 'withExists() compiles its relation subquery to a raw select'); - } - - $result = parent::withAggregate($relations, $column, $function); - $newColumns = array_slice($result->getQuery()->columns ?? [], -count($names)); - - $aliases = []; - foreach ($newColumns as $i => $col) { - $aliases[] = $this->resolveAlias($col, $names[$i] ?? null, $function, $column); - } - - $this->aggregateAliases = array_values(array_unique([...$this->aggregateAliases, ...$aliases])); - - return $result; - } - - private function resolveAlias(mixed $column, ?string $name, ?string $function, mixed $columnArg): string - { - $grammar = $this->getQuery()->getGrammar(); - $sql = $grammar->isExpression($column) ? $grammar->getValue($column) : (string) $column; - - if (preg_match('/\bas\s+([`"\[]?)([A-Za-z0-9_]+)\1\s*$/i', $sql, $m)) { - return $m[2]; - } - - $lowerFunction = strtolower((string) $function); - $colValue = $grammar->isExpression($columnArg) ? $grammar->getValue($columnArg) : $columnArg; - - return Str::snake(preg_replace('/[^[:alnum:][:space:]_]/u', '', "{$name} {$lowerFunction} {$colValue}")); - } - - public function hasAggregateColumns(): bool - { - return $this->aggregateAliases !== []; - } - - public function resultPayloadFromEloquentModels(Collection $models): array - { - $payload = []; - foreach ($models as $model) { - $attributes = $model->getRawOriginal(); - foreach ($this->aggregateAliases as $alias) { - $attributes[$alias] = $model->getAttribute($alias); - } - $payload[] = $attributes; - } - - return $payload; - } -} diff --git a/src/Relations/CachesRelationExistence.php b/src/Relations/CachesRelationExistence.php deleted file mode 100644 index 6a24fbc..0000000 --- a/src/Relations/CachesRelationExistence.php +++ /dev/null @@ -1,73 +0,0 @@ -=', $count = 1, $boolean = 'and', ?Closure $callback = null): static - { - if (!is_string($relation) || str_contains($relation, '.')) { - $this->addCapturedContextReason('dependency', 'whereHas/has relation semantics could not be fully verified'); - } else { - $this->captureRelationSemantics($relation); - } - - if (!($operator === '>=' && $count === 1) - && !($operator === '<' && $count === 1)) { - $this->addCapturedContextReason('dependency', 'has() count threshold requires explicit dependencies'); - } - - if ($callback !== null) { - $constraint = $callback; - $callback = function (EloquentBuilder $query) use ($constraint) { - $result = $constraint($query); - $this->captureConstrainedRelationQuery($query); - - return $result; - }; - } - - return parent::has($relation, $operator, $count, $boolean, $callback); - } - - protected function captureRelationSemantics(string $name): void - { - try { - $relation = $this->getRelationWithoutConstraints($name); - $related = $relation->getRelated(); - $query = $relation->getQuery(); - - if ($relation instanceof MorphTo) { - $this->addCapturedContextReason('dependency', 'polymorphic relation dependency could not be fully inferred'); - } - - if (!in_array(Cacheable::class, class_uses_recursive($related::class), true)) { - $this->addCapturedContextReason('dependency', 'related model does not provide automatic table invalidation'); - } - - if ($query->toBase()->lock !== null) { - $this->addCapturedContextReason('safety', 'relation query uses a lock'); - } - } catch (\Throwable) { - $this->addCapturedContextReason('dependency', 'relation semantics could not be inspected'); - } - } - - private function captureConstrainedRelationQuery(EloquentBuilder $query): void - { - if ($query instanceof CacheableBuilder) { - $this->mergeCapturedBuilderState($query); - } - - if ($query->toBase()->lock !== null) { - $this->addCapturedContextReason('safety', 'relation query uses a lock'); - } - } -} diff --git a/src/Relations/CachesRelationships.php b/src/Relations/CachesRelationships.php deleted file mode 100644 index 7d7b1dc..0000000 --- a/src/Relations/CachesRelationships.php +++ /dev/null @@ -1,77 +0,0 @@ -syncing) { - $this->recordPivotWrite(true); - } - } - - public function detach($ids = null, $touch = true): int - { - $affected = parent::detach($ids, $touch); - - if (!$this->syncing) { - $this->recordPivotWrite($affected > 0); - } - - return $affected; - } - - public function updateExistingPivot($id, array $attributes, $touch = true): int - { - $affected = parent::updateExistingPivot($id, $attributes, $touch); - $this->recordPivotWrite($affected > 0); - - return $affected; - } - - public function sync($ids, $detaching = true): array - { - $this->syncing = true; - - try { - $changes = parent::sync($ids, $detaching); - } finally { - $this->syncing = false; - } - - $this->recordPivotWrite( - $changes['attached'] !== [] - || $changes['detached'] !== [] - || $changes['updated'] !== [], - ); - - return $changes; - } - - private function recordPivotWrite(bool $changed): void - { - NormCache::invalidator()->recordPivotWrite( - $this->parent->getConnection()->getName(), - $this->table, - [$this->parent::class, $this->related::class], - $changed, - ); - } -} diff --git a/src/Spaces/CacheSpaceRegistry.php b/src/Spaces/CacheSpaceRegistry.php deleted file mode 100644 index 4e59eca..0000000 --- a/src/Spaces/CacheSpaceRegistry.php +++ /dev/null @@ -1,398 +0,0 @@ - name => space (memoized) */ - private array $spaces = []; - - /** @var array> model class => spaces (memoized, validated) */ - private array $modelSpaces = []; - - /** @var array> table key => spaces (memoized, validated) */ - private array $tableSpaces = []; - - /** @var list|null */ - private ?array $metadataSpaceNames = null; - - /** @var array> table key => persisted space names */ - private array $metadataTableSpaceNames = []; - - /** @var array hash tag => logical space name */ - private array $hashTagOwners = []; - - /** - * @param array $placement per-space hash-tag overrides - */ - public function __construct( - private readonly int $maxPerModel = 16, - private readonly array $placement = [], - private readonly ?RedisStore $metadataStore = null, - private readonly string $metadataKeyPrefix = '', - ) {} - - public function defaultSpace(): CacheSpace - { - return $this->space(self::DEFAULT_SPACE); - } - - public function space(string $name): CacheSpace - { - return $this->materializeSpace($name); - } - - /** @return list */ - public function knownSpaces(): array - { - $names = array_values(array_unique([ - self::DEFAULT_SPACE, - ...array_keys($this->placement), - ...$this->metadataSpaces(), - ...array_keys($this->spaces), - ])); - - return array_map(fn(string $name) => $this->materializeSpace($name, remember: false), $names); - } - - /** @return list */ - public function spacesForModel(string $modelClass): array - { - return $this->modelSpaces[$modelClass] ??= $this->resolveModelSpaces($modelClass); - } - - /** @return list */ - public function spacesForTable(string $table): array - { - return $this->mergeTableSpaces($table, $this->resolveTableSpaces($table)); - } - - /** @return list */ - public function freshSpacesForTable(string $table): array - { - return $this->mergeTableSpaces($table, $this->resolveTableSpaces($table, fresh: true)); - } - - public function resetMetadataMemo(): void - { - $this->metadataSpaceNames = null; - $this->metadataTableSpaceNames = []; - } - - public function modelAllowedInSpace(string $modelClass, CacheSpace|string $space): bool - { - return $this->isAllowed($this->spacesForModel($modelClass), $space); - } - - public function dependenciesAreOnlyModel(string $modelClass, array $models, array $tables): bool - { - return $models === [$modelClass] && $tables === []; - } - - /** - * Validate operation dependencies against the active space. - * - * @param list $models - * @param list $tables - */ - public function validateDependencies( - CacheSpace $space, - array $models, - array $tables, - bool $includeDependenciesBySpace = false, - ): SpaceValidationResult { - $invalidModels = []; - $dependencySpaces = []; - - foreach ($models as $modelClass) { - $spaces = $this->spacesForModel($modelClass); - $dependencySpaces[$modelClass] = $spaces; - - if (!$this->isAllowed($spaces, $space)) { - $invalidModels[] = $modelClass; - } - } - - foreach ($tables as $table) { - $spaces = $this->spacesForTable($table); - $validatedSpaces = $this->isAllowed($spaces, $space) - ? $spaces - : [...$spaces, $space]; - - $dependencySpaces[$table] = $validatedSpaces; - } - - $ok = $invalidModels === []; - $dependenciesBySpace = $ok && !$includeDependenciesBySpace - ? [] - : $this->dependencySpaceNames($dependencySpaces); - - return new SpaceValidationResult( - isValid: $ok, - space: $space, - invalidModels: $invalidModels, - dependenciesBySpace: $dependenciesBySpace, - ); - } - - /** @return list */ - private function resolveModelSpaces(string $modelClass): array - { - $names = array_values(array_unique($modelClass::normCacheSpaces())); - - if ($names === []) { - return [$this->defaultSpace()]; - } - - if (count($names) > $this->maxPerModel) { - throw new \InvalidArgumentException( - "NormCache model [{$modelClass}] declares " . count($names) . " spaces, exceeding max_per_model ({$this->maxPerModel})." - ); - } - - return array_map(fn($name) => $this->space($name), $names); - } - - /** @return list */ - private function resolveTableSpaces(string $table, bool $fresh = false): array - { - $names = array_values(array_unique([ - self::DEFAULT_SPACE, - ...$this->metadataTableSpaces($table, $fresh), - ])); - - return array_map(fn(string $name) => $this->materializeSpace($name, remember: false), $names); - } - - /** - * @param list $resolved - * @return list - */ - private function mergeTableSpaces(string $table, array $resolved): array - { - $spaces = $this->tableSpaces[$table] ?? [$this->defaultSpace()]; - - foreach ($resolved as $space) { - if (!$this->isAllowed($spaces, $space)) { - $spaces[] = $space; - } - } - - return $this->tableSpaces[$table] = $spaces; - } - - private function materializeSpace(string $name, bool $remember = true): CacheSpace - { - if (!isset($this->spaces[$name])) { - $hashTag = $this->hashTagFor($name); - $owner = $this->hashTagOwners[$hashTag] ?? null; - - if ($owner !== null && $owner !== $name) { - throw new \InvalidArgumentException( - "Cache spaces [{$owner}] and [{$name}] cannot share hash tag [{$hashTag}]." - ); - } - - $this->hashTagOwners[$hashTag] = $name; - $this->spaces[$name] = new CacheSpace($name, $hashTag); - - if ($remember) { - $this->rememberSpace($name); - } - } - - return $this->spaces[$name]; - } - - private function hashTagFor(string $name): string - { - if (!$this->validSpaceName($name)) { - throw new \InvalidArgumentException( - "Invalid cache space name [{$name}]: must be non-empty and contain no ':', '{', '}', or whitespace." - ); - } - - // Placement override; otherwise use the standard hash-tag convention. - $override = $this->placement[$name]['hash_tag'] ?? null; - - if ($override !== null) { - if ($override === '' || preg_match('/[{}*?\[\]\\\\]/', $override)) { - throw new \InvalidArgumentException( - "Invalid hash_tag override [{$override}] for space [{$name}]: must be non-empty and contain no Redis pattern characters." - ); - } - - return $override; - } - - return $name === self::DEFAULT_SPACE - ? self::DEFAULT_HASH_TAG - : self::DEFAULT_HASH_TAG . ':' . $name; - } - - private function validSpaceName(string $name): bool - { - return $name !== '' && !preg_match('/[:{}\s*?\[\]\\\\]/', $name); - } - - /** @return list */ - private function metadataSpaces(): array - { - if ($this->metadataStore === null) { - return []; - } - - if ($this->metadataSpaceNames !== null) { - return $this->metadataSpaceNames; - } - - try { - return $this->metadataSpaceNames = array_values(array_filter( - $this->metadataStore->setMembers($this->metadataSpacesKey()), - fn(string $name) => $this->validSpaceName($name), - )); - } catch (\Throwable) { - return $this->metadataSpaceNames = []; - } - } - - /** @return list */ - private function metadataTableSpaces(string $table, bool $fresh = false): array - { - if ($this->metadataStore === null) { - return []; - } - - if (!$fresh && array_key_exists($table, $this->metadataTableSpaceNames)) { - return $this->metadataTableSpaceNames[$table]; - } - - try { - return $this->metadataTableSpaceNames[$table] = array_values(array_filter( - $this->metadataStore->setMembers($this->metadataTableSpacesKey($table)), - fn(string $name) => $this->validSpaceName($name), - )); - } catch (\Throwable $e) { - report($e); - - return []; - } - } - - private function rememberSpace(string $name): void - { - if ($this->metadataStore === null || $name === self::DEFAULT_SPACE) { - return; - } - - try { - $this->metadataStore->addToSet($this->metadataSpacesKey(), [$name]); - } catch (\Throwable $e) { - report($e); - } - } - - public function registerTableDependencies(CacheSpace $space, array $tables): bool - { - foreach ($tables as $table) { - if (!$this->rememberTableSpace($table, $space)) { - return false; - } - } - - return true; - } - - private function rememberTableSpace(string $table, CacheSpace $space): bool - { - $spaces = $this->spacesForTable($table); - - if ($this->isAllowed($spaces, $space)) { - return true; - } - - if (!$this->persistTableSpace($table, $space)) { - return false; - } - - $spaces[] = $space; - $this->tableSpaces[$table] = $spaces; - - return true; - } - - private function persistTableSpace(string $table, CacheSpace $space): bool - { - if ($this->metadataStore === null || $space->name === self::DEFAULT_SPACE) { - return true; - } - - try { - $this->metadataStore->addToSet($this->metadataTableSpacesKey($table), [$space->name]); - - return true; - } catch (\Throwable $e) { - report($e); - - return false; - } - } - - private function metadataSpacesKey(): string - { - return '{nc:meta}:' . $this->metadataKeyPrefix . 'spaces'; - } - - private function metadataTableSpacesKey(string $table): string - { - return '{nc:meta}:' . $this->metadataKeyPrefix . 'table-spaces:' . sha1($table); - } - - /** @param list $allowed */ - private function isAllowed(array $allowed, CacheSpace|string $space): bool - { - $name = $space instanceof CacheSpace ? $space->name : $space; - - foreach ($allowed as $candidate) { - if ($candidate->name === $name) { - return true; - } - } - - return false; - } - - /** - * @param list $spaces - * @return list - */ - private function spaceNames(array $spaces): array - { - return array_map(fn(CacheSpace $s) => $s->name, $spaces); - } - - /** - * @param array> $dependencies - * @return array> - */ - private function dependencySpaceNames(array $dependencies): array - { - $names = []; - - foreach ($dependencies as $dependency => $spaces) { - $names[$dependency] = $this->spaceNames($spaces); - } - - return $names; - } -} diff --git a/src/Spaces/CacheSpaceResolver.php b/src/Spaces/CacheSpaceResolver.php deleted file mode 100644 index 03a165c..0000000 --- a/src/Spaces/CacheSpaceResolver.php +++ /dev/null @@ -1,28 +0,0 @@ -space(), else the model's first -// declared space, else the default. -final class CacheSpaceResolver -{ - public function __construct(private readonly CacheSpaceRegistry $registry) {} - - public function resolve(string $modelClass, ?string $explicitSpace): CacheSpace - { - if ($explicitSpace === null) { - // [0] is the home space: declared order, or [default] when undeclared. - return $this->registry->spacesForModel($modelClass)[0]; - } - - if (!$this->registry->modelAllowedInSpace($modelClass, $explicitSpace)) { - throw new \InvalidArgumentException( - "NormCache: model [{$modelClass}] is not a member of space [{$explicitSpace}]; declare it in \$normCacheSpaces or remove ->space()." - ); - } - - return $this->registry->space($explicitSpace); - } -} diff --git a/src/Support/AttributeProjector.php b/src/Support/AttributeProjector.php deleted file mode 100644 index 950f513..0000000 --- a/src/Support/AttributeProjector.php +++ /dev/null @@ -1,77 +0,0 @@ - $columns */ - public static function normalizeProjection(array $columns): array - { - $normalized = []; - - foreach ($columns as $column) { - $column = (string) $column; - if ($column === '*' || str_ends_with($column, '.*')) { - $normalized['*'] = '*'; - - continue; - } - - [$source, $output] = self::parseProjection($column); - $normalized[$output] = $source; - } - - return $normalized; - } - - /** - * @param array $attributes - * @param array $projection - */ - public static function projectAttributes(array $attributes, array $projection): array - { - $projected = []; - - if (isset($projection['*'])) { - $projected = $attributes; - } - - foreach ($projection as $output => $source) { - if ($output === '*') { - continue; - } - - if (array_key_exists($source, $attributes)) { - $projected[$output] = $attributes[$source]; - } - } - - return $projected; - } - - private static function parseProjection(string $column): array - { - $column = trim($column); - $segments = preg_split('/\s+as\s+/i', $column); - - if (count($segments) === 2) { - return [self::unqualify($segments[0]), self::unqualify($segments[1])]; - } - - $name = self::unqualify($column); - - return [$name, $name]; - } - - private static function unqualify(string $column): string - { - $column = trim($column); - $dotPos = strrpos($column, '.'); - - if ($dotPos !== false) { - $column = substr($column, $dotPos + 1); - } - - return trim($column, " \t\n\r\0\x0B`\"[]"); - } -} diff --git a/src/Support/CacheFallback.php b/src/Support/CacheFallback.php deleted file mode 100644 index e15f955..0000000 --- a/src/Support/CacheFallback.php +++ /dev/null @@ -1,49 +0,0 @@ -fallbackEnabled) { - throw $e; - } - - report($e); - $config->enabled = false; - } -} diff --git a/src/Support/CacheKeyBuilder.php b/src/Support/CacheKeyBuilder.php index 4ee2d44..171eaa2 100644 --- a/src/Support/CacheKeyBuilder.php +++ b/src/Support/CacheKeyBuilder.php @@ -2,385 +2,121 @@ namespace NormCache\Support; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Facades\DB; -use NormCache\Cache\ModelCache; -use NormCache\Enums\CacheKind; -use NormCache\Enums\ResultKind; -use NormCache\Values\CacheSpace; +use NormCache\Values\TableIdentity; -class CacheKeyBuilder +final class CacheKeyBuilder { - public const K_VER = 'ver'; - - public const K_SCHEDULED = 'scheduled'; - - public const K_QUERY = 'query'; - - public const K_MODEL = 'model'; - - public const K_BUILDING = 'building'; - - public const K_COUNT = 'count'; - - public const K_SCALAR = 'scalar'; - - public const K_PIVOT = 'pivot'; - - public const K_THROUGH = 'through'; - - public const K_WAKE = 'wake'; - - public const K_RESULT = 'result'; - - private static array $classKeys = []; - - private static array $prototypes = []; - - private static array $deletedAtColumns = []; - - private static array $singleDepPairs = []; - - private ?CacheSpace $activeSpace = null; - public function __construct( - private readonly string $hashTagPrefix = '{nc}:', - private readonly string $keyPrefix = '', - ) {} - - // Scope an operation to a space: every key built inside $callback uses its tag. - public function withSpace(?CacheSpace $space, callable $callback): mixed - { - $previous = $this->activeSpace; - $this->activeSpace = $space; - - try { - return $callback(); - } finally { - $this->activeSpace = $previous; + private string $keyPrefix = '', + ) { + if (str_contains($keyPrefix, '{') || str_contains($keyPrefix, '}')) { + throw new \InvalidArgumentException('NormCache key prefix must not contain Redis hash-tag braces.'); } } - public function activeSpace(): ?CacheSpace - { - return $this->activeSpace; - } - - public function namespaceFor(CacheKind $kind, ?ResultKind $resultKind = null): string - { - return match ($kind) { - CacheKind::Model => self::K_MODEL, - CacheKind::ModelIndex => self::K_QUERY, - CacheKind::RelationIndex => self::K_THROUGH, - CacheKind::Result => match ($resultKind) { - ResultKind::Count, ResultKind::PaginationCount => self::K_COUNT, - ResultKind::Collection => self::K_RESULT, - default => self::K_SCALAR, - }, - CacheKind::Version => self::K_VER, - }; - } - - private function full(string $body, ?CacheSpace $space = null): string - { - return $this->tagPrefix($space) . $this->keyPrefix . $body; - } - - // Hash-tag prefix: explicit space, else the active operation's space, else default. - private function tagPrefix(?CacheSpace $space): string - { - $space ??= $this->activeSpace; - - return $space === null ? $this->hashTagPrefix : '{' . $space->hashTag . '}:'; - } - - public function prefixed(string $pattern, ?CacheSpace $space = null): string + public function version(TableIdentity $table): string { - return $this->full($pattern, $space); + return $this->tablePrefix($table) . ':ver'; } - // ------------------------------------------------------------------------- - // Prefixes - // ------------------------------------------------------------------------- - - public function modelPrefix(string $classKey, int|string $version, ?CacheSpace $space = null): string + public function generation(TableIdentity $table): string { - return $this->modelVersionPrefix($classKey, $space) . $version . ':'; + return $this->tablePrefix($table) . ':gen'; } - public function modelVersionPrefix(string $classKey, ?CacheSpace $space = null): string + public function changeRecord(TableIdentity $table, string $version): string { - return $this->full(self::K_MODEL . ':' . $classKey . ':v', $space); + return $this->changeRecordPrefix($table) . $version; } - public function queryPrefix(string $classKey, ?string $tag = null, ?CacheSpace $space = null): string + public function changeRecordPrefix(TableIdentity $table): string { - $base = self::K_QUERY . ':' . $classKey . ':'; - - return $this->full($tag !== null ? $base . $tag . ':' : $base, $space); + return $this->tablePrefix($table) . ':chg:'; } - public function namespacedPrefix(string $namespace, string $classKey, ?string $tag = null, ?CacheSpace $space = null): string - { - return $this->full("{$namespace}:{$classKey}:" . $this->tagSegment($tag), $space); + public function queryEntry( + TableIdentity $table, + string $namespace, + string $queryHash, + ): string { + return $this->tablePrefix($table) . ":q:{$namespace}:{$queryHash}"; } - public function pivotBasePrefix(string $parentKey, string $relatedKey, ?CacheSpace $space = null): string - { - return $this->full(self::K_PIVOT . ':' . $parentKey . ':' . $relatedKey . ':', $space); + public function queryBuild( + TableIdentity $table, + string $version, + string $namespace, + string $queryHash, + ): string { + return $this->tablePrefix($table) . ":build:q:v{$version}:{$namespace}:{$queryHash}"; } - public function pivotPrefix(string $parentKey, string $relatedKey, string $relation, string $constraintHash, string $seg, ?CacheSpace $space = null): string + public function row(TableIdentity $table, string $generation, string $pkToken): string { - return $this->pivotBasePrefix($parentKey, $relatedKey, $space) . $relation . ':' . $constraintHash . ':' . $seg . ':'; + return $this->rowPrefix($table, $generation) . $pkToken; } - public function buildingPrefix(string $classKey, ?CacheSpace $space = null): string + public function rowPrefix(TableIdentity $table, string $generation): string { - return $this->full(self::K_BUILDING . ':' . $classKey . ':', $space); + return $this->tablePrefix($table) . ":r:g{$generation}:"; } - public function wakePrefix(string $classKey, ?CacheSpace $space = null): string + public function rowBuild(TableIdentity $table, string $generation, string $pkToken): string { - return $this->full(self::K_WAKE . ':' . $classKey . ':', $space); + return $this->tablePrefix($table) . ":build:r:g{$generation}:{$pkToken}"; } - // ------------------------------------------------------------------------- - // High-level resolution - // ------------------------------------------------------------------------- - - public function classKey(string $class, ?string $connection = null): string + public function repairBuild(TableIdentity $table, string $generation, string $batchHash): string { - $connection ??= $this->declaredConnection($class); - - return self::$classKeys[$connection][$class] ??= $this->resolveClassKey($class, $connection); + return $this->tablePrefix($table) . ":build:x:g{$generation}:{$batchHash}"; } - public function declaredConnection(string $class): string - { - return self::prototype($class)->getConnectionName() ?? DB::getDefaultConnection(); + public function repairWake( + TableIdentity $table, + string $generation, + string $batchHash, + string $token, + ): string { + return $this->wake($table, 'x', "g{$generation}:{$batchHash}", $token); } - // Clear all static metadata caches. Call this after switching tenant connections. - public static function reset(): void + public function wake(TableIdentity $table, string $family, string $identity, string $token): string { - self::$classKeys = []; - self::$prototypes = []; - self::$deletedAtColumns = []; - self::$singleDepPairs = []; - - ModelCache::reset(); + return $this->tablePrefix($table) . ":wake:{$family}:{$identity}:{$token}"; } - public static function prototype(string $class): Model + public function queryGroupEntry(string $queryHash, string $namespace): string { - return self::$prototypes[$class] ??= new $class; + return $this->keyPrefix . "{nc:x:{$queryHash}}:q:{$namespace}"; } - public static function deletedAtColumn(string $class): ?string + public function queryGroupBuild(string $queryHash): string { - return self::$deletedAtColumns[$class] ??= method_exists(self::prototype($class), 'getDeletedAtColumn') - ? self::prototype($class)->getDeletedAtColumn() - : null; + return $this->keyPrefix . "{nc:x:{$queryHash}}:build"; } - public function tableKey(string $connectionName, string $table): string + public function queryGroupWake(string $queryHash, string $token): string { - return "{$connectionName}:" . self::stripTableAlias($table); + return $this->keyPrefix . "{nc:x:{$queryHash}}:wake:{$token}"; } - public static function stripTableAlias(string $table): string + public function tagVersion(string $tagHash): string { - return preg_replace('/\s+as\s+\S+$/i', '', $table); + return $this->keyPrefix . "{nc:g:{$tagHash}}:ver"; } - public function verKey(string $classKey, ?CacheSpace $space = null): string + public function epoch(): string { - return $this->full(self::K_VER . ':' . $classKey . ':', $space); + return $this->keyPrefix . '{ncm}:epoch'; } - public function scheduledKey(string $classKey, ?CacheSpace $space = null): string + public function disabled(): string { - return $this->full(self::K_SCHEDULED . ':' . $classKey . ':', $space); + return $this->keyPrefix . '{ncm}:disabled'; } - /** @return array{0: string, 1: string} */ - public function versionKeyPair(string $classKey, ?CacheSpace $space = null): array + public function tablePrefix(TableIdentity $table): string { - return [$this->verKey($classKey, $space), $this->scheduledKey($classKey, $space)]; - } - - public function wakeKey(string $classKey, string $lockSuffix, ?CacheSpace $space = null): string - { - return $this->full(self::K_WAKE . ':' . $classKey . ':' . $lockSuffix, $space); - } - - // ------------------------------------------------------------------------- - // Specific Keys - // ------------------------------------------------------------------------- - - public function queryKey(string $classKey, ?string $tag, int|string $version, string $hash, ?CacheSpace $space = null): string - { - return $this->queryPrefix($classKey, $tag, $space) . 'v' . $version . ':' . $hash; - } - - public function namespacedKey(string $namespace, string $classKey, ?string $tag, string $seg, string $hash, ?CacheSpace $space = null): string - { - return $this->namespacedPrefix($namespace, $classKey, $tag, $space) . $seg . ':' . $hash; - } - - public function resultBuildingKey(string $classKey, string $seg, string $lockSuffix, ?CacheSpace $space = null): string - { - return $this->buildingPrefix($classKey, $space) . $seg . ':' . $lockSuffix; - } - - public function pivotKey(string $parentKey, string $relatedKey, string $relation, string $constraintHash, string $seg, mixed $parentId, ?CacheSpace $space = null): string - { - return $this->pivotPrefix($parentKey, $relatedKey, $relation, $constraintHash, $seg, $space) . $parentId; - } - - // ------------------------------------------------------------------------- - // Versioning Helpers - // ------------------------------------------------------------------------- - - public function versionSegment(array $versionKeys, array $resolvedVersions): string - { - $versions = []; - - foreach ($versionKeys as $key) { - $versions[] = 'v' . $resolvedVersions[$key]; - } - - return implode(':', $versions); - } - - public function versionsFromSegment(string $seg): array - { - $parts = explode(':', $seg); - - foreach ($parts as $i => $version) { - $parts[$i] = substr($version, 1); - } - - return $parts; - } - - // ------------------------------------------------------------------------- - // Dependency Resolvers - // ------------------------------------------------------------------------- - - /** - * @return array{0: list, 1: list} [versionKeys, scheduledKeys] - */ - public function depKeyPairs( - string $classKey, - array $depClasses, - array $depTableKeys = [], - ?CacheSpace $space = null, - ): array { - $space ??= $this->activeSpace; - - if ($depClasses === [] && $depTableKeys === []) { - $cacheKey = $this->singleDepPairCacheKey($classKey, $space); - - return self::$singleDepPairs[$cacheKey] ??= [ - [$this->verKey($classKey, $space)], - [$this->scheduledKey($classKey, $space)], - ]; - } - - $all = []; - $seen = []; - - $seen[$classKey] = true; - $all[] = $classKey; - - // Each dependency class resolves its own declared connection — $classKey above is the - // only key that should key off the root query's connection. - foreach ($this->sortClassesByKey($depClasses) as $class) { - $key = $this->classKey($class); - - if (!isset($seen[$key])) { - $seen[$key] = true; - $all[] = $key; - } - } - - foreach ($this->sortKeys($depTableKeys) as $key) { - if (!isset($seen[$key])) { - $seen[$key] = true; - $all[] = $key; - } - } - - $versionKeys = []; - $scheduledKeys = []; - - foreach ($all as $key) { - $versionKeys[] = $this->verKey($key, $space); - $scheduledKeys[] = $this->scheduledKey($key, $space); - } - - return [$versionKeys, $scheduledKeys]; - } - - // ------------------------------------------------------------------------- - // Suffixes / Segments - // ------------------------------------------------------------------------- - - public function tagSegment(?string $tag): string - { - return $tag !== null ? $tag . ':' : ''; - } - - // Tags become raw key segments, so reserved key characters must be rejected. - public static function assertValidTag(string $tag): void - { - if ($tag === '' || preg_match('/[:{}\s*]/', $tag)) { - throw new \InvalidArgumentException( - 'Cache tag must be non-empty and must not contain reserved characters (: { } * or whitespace).' - ); - } - } - - public function resultBuildIdentityHash(string $namespace, ?string $tag, string $hash): string - { - return hash('xxh128', $namespace . ':' . $this->tagSegment($tag) . $hash); - } - - // ------------------------------------------------------------------------- - // Private implementation - // ------------------------------------------------------------------------- - - private function singleDepPairCacheKey(string $classKey, ?CacheSpace $space): string - { - return $this->keyPrefix . '|' . $this->tagPrefix($space) . '|' . $classKey; - } - - private function resolveClassKey(string $class, string $connection): string - { - $model = self::prototype($class); - - if (str_contains($connection, ':')) { - throw new \InvalidArgumentException( - "NormCache connection name [{$connection}] must not contain a colon; the class key is colon-delimited." - ); - } - - return "{$connection}:{$model->getTable()}"; - } - - private function sortClassesByKey(array $classes): array - { - usort($classes, fn($a, $b) => strcmp($this->classKey($a), $this->classKey($b))); - - return $classes; - } - - private function sortKeys(array $keys): array - { - sort($keys, SORT_STRING); - - return $keys; + return $this->keyPrefix . "{nc:t:{$table->hash}}"; } } diff --git a/src/Support/CacheReporter.php b/src/Support/CacheReporter.php deleted file mode 100644 index 2e37f99..0000000 --- a/src/Support/CacheReporter.php +++ /dev/null @@ -1,186 +0,0 @@ - $kind->value, - 'cache_status' => $status->value, - 'result_kind' => $resultKind?->value, - 'cache_space' => $space?->name, - ], static fn(mixed $value): bool => $value !== null); - } - - public static function queryHit(string $modelClass, string $key, ?float $startTime, array $meta = [], string $type = 'query hit'): void - { - if (!self::active()) { - return; - } - - if (self::eventsEnabled()) { - event(new QueryCacheHit($modelClass, $key, $meta)); - } - - NormCacheCollector::recordQuery($type, $modelClass, $key, $startTime, $meta); - } - - public static function queryMiss(string $modelClass, string $key, ?float $startTime, array $meta = [], string $type = 'query miss'): void - { - if (!self::active()) { - return; - } - - if (self::eventsEnabled()) { - event(new QueryCacheMiss($modelClass, $key, $meta)); - } - - NormCacheCollector::recordQuery($type, $modelClass, $key, $startTime, $meta); - } - - public static function modelHit(string $modelClass, array $ids, ?float $startTime, array $meta = []): void - { - if (!self::active()) { - return; - } - - self::modelHitActive($modelClass, $ids, $startTime, $meta); - } - - public static function modelHitActive(string $modelClass, array $ids, ?float $startTime, array $meta = []): void - { - if (self::eventsEnabled() && $ids !== []) { - event(new ModelCacheHit($modelClass, $ids, $meta)); - } - - NormCacheCollector::recordModel('model hit', $modelClass, $ids, $startTime, $meta); - } - - public static function modelMissActive(string $modelClass, array $ids, ?float $startTime, array $meta = []): void - { - if (self::eventsEnabled() && $ids !== []) { - event(new ModelCacheMiss($modelClass, $ids, $meta)); - } - - NormCacheCollector::recordModel('model miss', $modelClass, $ids, $startTime, $meta); - } - - public static function metric( - string $metric, - int|float $value, - CacheKind $kind, - CacheStatus $status, - string $modelClass, - ?ResultKind $resultKind = null, - ?CacheSpace $space = null, - array $meta = [], - ): void { - if (!self::active()) { - return; - } - - $fields = [ - ...self::cacheMeta($kind, $status, $resultKind, $space), - ...$meta, - ]; - - if (self::eventsEnabled()) { - event(new CacheMetricRecorded( - $metric, - $value, - $kind, - $status, - $modelClass, - $resultKind, - $space?->name, - $meta, - )); - } - - NormCacheCollector::recordMetric($metric, $value, $modelClass, $fields); - } - - public static function invalidation( - string $dependencyType, - string $target, - int $count, - array $spaces = [], - ): void { - if (!self::active()) { - return; - } - - if (self::eventsEnabled()) { - event(new CacheInvalidated($dependencyType, $target, $count, $spaces)); - } - - NormCacheCollector::recordInvalidation($dependencyType, $target, $count, $spaces); - } - - /** @param array> $bypassReasons */ - public static function queryBypassed(string $modelClass, array $bypassReasons, ?float $startTime = null): void - { - if (config('app.debug', false) && !empty($bypassReasons['dependency'])) { - Log::warning(sprintf( - 'NormCache Warning: Query on %s bypassed cache due to unsafe dependency inference (%s). Please provide explicit dependsOn() or dependsOnTables() to enable caching.', - $modelClass, - implode(', ', $bypassReasons['dependency']) - )); - } - - if (!self::active()) { - return; - } - - if (self::eventsEnabled()) { - event(new QueryBypassed($modelClass, $bypassReasons)); - } - - NormCacheCollector::recordBypass($modelClass, $bypassReasons, $startTime); - } - - private static function eventsEnabled(): bool - { - return self::$eventsEnabledResolver !== null - ? (bool) (self::$eventsEnabledResolver)() - : (bool) config('normcache.events', false); - } -} diff --git a/src/Support/CacheSerializer.php b/src/Support/CacheSerializer.php index f75ccb0..83b02bc 100644 --- a/src/Support/CacheSerializer.php +++ b/src/Support/CacheSerializer.php @@ -2,55 +2,80 @@ namespace NormCache\Support; -final class CacheSerializer +final readonly class CacheSerializer { - private bool $igbinary; + private const AUTO = 'auto'; - public function __construct() - { - $this->igbinary = extension_loaded('igbinary'); - } + private const PHP = 'php'; - public function serialize(mixed $value): mixed - { - if (is_int($value)) { - return $value; - } + private const IGBINARY = 'igbinary'; - return $this->igbinary ? igbinary_serialize($value) : serialize($value); - } + private const PHP_MARKER = 'P'; + + private const IGBINARY_MARKER = 'I'; - public function unserialize(mixed $value): mixed + private string $serializer; + + private bool $igbinaryAvailable; + + public function __construct(string $serializer = self::AUTO) { - if (is_numeric($value)) { - return str_contains((string) $value, '.') ? (float) $value : (int) $value; - } + $this->igbinaryAvailable = extension_loaded('igbinary'); - if (!is_string($value)) { - return $value; + if (!in_array($serializer, [self::AUTO, self::PHP, self::IGBINARY], true)) { + throw new \InvalidArgumentException( + 'NormCache serializer must be auto, php, or igbinary.', + ); } - if (isset($value[0]) && $value[0] === "\x00") { - return $this->igbinary ? igbinary_unserialize($value) : null; + if ($serializer === self::IGBINARY && !$this->igbinaryAvailable) { + throw new \RuntimeException('The igbinary codec was requested but the extension is unavailable.'); } - if (isset($value[1]) && ($value[1] === ':' || $value[1] === ';')) { - return unserialize($value); - } + $this->serializer = $serializer === self::AUTO + ? ($this->igbinaryAvailable ? self::IGBINARY : self::PHP) + : $serializer; + } - return $value; + public function encode(mixed $value): string + { + return $this->serializer === self::IGBINARY + ? self::IGBINARY_MARKER . igbinary_serialize($value) + : self::PHP_MARKER . serialize($value); + } + + public function decode(string $payload): mixed + { + $marker = $payload[0] ?? ''; + + return match ($marker) { + self::PHP_MARKER => $this->decodePhp(substr($payload, 1)), + self::IGBINARY_MARKER => $this->decodeIgbinary(substr($payload, 1)), + default => null, + }; } - public function unserializeMany(array $raw): array + private function decodePhp(string $payload): mixed { - $values = []; + try { + $value = @unserialize($payload, ['allowed_classes' => false]); - foreach ($raw as $key => $value) { - $values[$key] = $value !== null && $value !== false - ? $this->unserialize($value) - : null; + return $value === false && $payload !== 'b:0;' ? null : $value; + } catch (\Throwable) { + return null; } + } - return $values; + private function decodeIgbinary(string $payload): mixed + { + if (!$this->igbinaryAvailable) { + return null; + } + + try { + return @igbinary_unserialize($payload); + } catch (\Throwable) { + return null; + } } } diff --git a/src/Support/ColumnName.php b/src/Support/ColumnName.php new file mode 100644 index 0000000..49a299e --- /dev/null +++ b/src/Support/ColumnName.php @@ -0,0 +1,27 @@ + + */ + public static function segments(string $column): array + { + return array_map( + static fn(string $part): string => strtolower(trim($part, " \t\n\r\0\x0B`\"[]")), + explode('.', trim($column)), + ); + } + + public static function unqualified(string $column): string + { + $segments = self::segments($column); + + return (string) end($segments); + } +} diff --git a/src/Support/FailureReporter.php b/src/Support/FailureReporter.php new file mode 100644 index 0000000..5a07e2a --- /dev/null +++ b/src/Support/FailureReporter.php @@ -0,0 +1,173 @@ + */ + private array $recorded = []; + + public function __construct(private readonly LoggerInterface $logger) {} + + public function cacheUnavailable(\Throwable $exception): void + { + if (!$this->claim('cache_unavailable', $exception)) { + return; + } + + try { + report($exception); + } catch (\Throwable) { + } + } + + /** @param list $tokens */ + public function invalidationFailed( + \Throwable $exception, + TableIdentity $table, + string $mode, + array $tokens, + ): void { + $this->log( + LogLevel::CRITICAL, + 'invalidation_failed', + 'Invalidation failed; cached reads may be stale.', + $exception, + [ + ...$this->tableContext($table), + 'mode' => $mode, + 'token_count' => count($tokens), + ], + [$table->hash, $mode], + ); + } + + public function opaqueWriteGlobalInvalidation(string $connection): void + { + $this->log( + LogLevel::WARNING, + 'opaque_write_global_invalidation', + 'Globally invalidated after an intercepted write target could not be resolved.', + null, + ['connection' => $connection], + [$connection], + ); + } + + public function deleteDependencyGlobalInvalidation( + TableIdentity $table, + MutationType $mutation, + ): void { + $this->log( + LogLevel::WARNING, + 'delete_dependency_global_invalidation', + 'Globally invalidated because delete dependencies could not be resolved.', + null, + [ + ...$this->tableContext($table), + 'mutation' => strtolower($mutation->name), + ], + [$table->hash, $mutation->name], + ); + } + + public function globalInvalidationFailed(\Throwable $exception, string $reason): void + { + $this->log( + LogLevel::CRITICAL, + 'global_invalidation_failed', + 'Global invalidation failed; cached reads may be stale.', + $exception, + ['reason' => $reason], + [$reason], + ); + } + + public function observationFailed(\Throwable $exception, string $outcome): void + { + $this->log( + LogLevel::WARNING, + 'observation_failed', + 'Diagnostics failed; the query itself was unaffected.', + $exception, + ['outcome' => $outcome], + [$outcome], + ); + } + + public function repairUnreachable( + \Throwable $exception, + TableIdentity $table, + int $tokenCount, + ): void { + $this->log( + LogLevel::WARNING, + 'repair_unreachable', + 'Could not reload rows from the database to repair a membership.', + $exception, + [ + ...$this->tableContext($table), + 'token_count' => $tokenCount, + ], + [$table->hash], + ); + } + + /** + * @param array $context + * @param list $fingerprint + */ + private function log( + string $level, + string $category, + string $message, + ?\Throwable $exception, + array $context, + array $fingerprint = [], + ): void { + if (!$this->claim($category, $exception, ...$fingerprint)) { + return; + } + + $context = [ + 'component' => 'normcache', + 'event' => $category, + ...$context, + ...($exception === null ? [] : ['exception' => $exception]), + ]; + + try { + $this->logger->log($level, $message, $context); + } catch (\Throwable) { + } + } + + private function tableContext(TableIdentity $table): array + { + return [ + 'connection' => $table->connection, + 'table' => $table->qualifiedTable(), + 'table_hash' => $table->hash, + ]; + } + + private function claim(string $category, ?\Throwable $exception, string ...$context): bool + { + $fingerprint = implode('|', [ + $category, + ...($exception === null ? [] : [$exception::class, $exception->getMessage()]), + ...$context, + ]); + + if (isset($this->recorded[$fingerprint])) { + return false; + } + + return $this->recorded[$fingerprint] = true; + } +} diff --git a/src/Support/ProjectionClassifier.php b/src/Support/ProjectionClassifier.php deleted file mode 100644 index a241228..0000000 --- a/src/Support/ProjectionClassifier.php +++ /dev/null @@ -1,119 +0,0 @@ -columns ?? (($fallback === null || $fallback === ['*']) ? null : $fallback); - - if ($columns === null || $columns === ['*']) { - return null; - } - - foreach ($columns as $column) { - if (!is_string($column) || !str_ends_with($column, '*')) { - return $columns; - } - } - - return null; - } - - public static function isExactFullModelProjection(?array $columns, string $table): bool - { - if ($columns === null || $columns === ['*']) { - return true; - } - - foreach ($columns as $column) { - if (!is_string($column)) { - return false; - } - - if ($column === '*' || $column === "{$table}.*") { - continue; - } - - // Internal artifacts (laravel_through_key or pivot_*) are not pollutants. - if (str_contains($column, ' as laravel_through_key') || str_contains($column, ' as pivot_')) { - continue; - } - - return false; - } - - return true; - } - - public static function hasRequiredKey(array $columns, string $table, string $key): bool - { - $qualified = "{$table}.{$key}"; - - return in_array('*', $columns, true) - || in_array("{$table}.*", $columns, true) - || in_array($key, $columns, true) - || in_array($qualified, $columns, true); - } - - public static function hasCalculatedColumns(?array $columns): bool - { - if ($columns === null) { - return false; - } - - foreach ($columns as $column) { - if (!is_string($column) || !self::isCacheableSelectedColumn($column)) { - return true; - } - } - - return false; - } - - public static function classifyForRelation(QueryBuilder $base, array $columns, string $relatedTable, string $relatedKey): array - { - $resolved = $base->columns ?? ($columns === ['*'] ? null : $columns); - $shouldCacheRelatedModels = self::isExactFullModelProjection($resolved, $relatedTable); - $selectedRelatedColumns = $shouldCacheRelatedModels ? null : $resolved; - - return [ - 'shouldCacheRelatedModels' => $shouldCacheRelatedModels, - 'selectedRelatedColumns' => $selectedRelatedColumns, - 'relatedKeyInProjection' => self::hasRequiredKey($resolved ?? ['*'], $relatedTable, $relatedKey), - 'resolvedColumns' => $resolved, - ]; - } - - private static function isCacheableSelectedColumn(string $column): bool - { - $column = trim($column); - - if ($column === '*' || str_ends_with($column, '.*')) { - return true; - } - - if (stripos($column, ' as ') !== false) { - $segments = preg_split('/\s+as\s+/i', trim($column)); - - return count($segments) === 2 - && self::isColumnIdentifier($segments[0]) - && self::isColumnIdentifier($segments[1], false); - } - - return self::isColumnIdentifier($column); - } - - private static function isColumnIdentifier(string $column, bool $allowQualifier = true): bool - { - $columnIdentifier = '[`"]?[A-Za-z_][A-Za-z0-9_]*[`"]?'; - $pattern = $allowQualifier - ? '/^' . $columnIdentifier . '(?:\\.' . $columnIdentifier . ')?$/' - : '/^' . $columnIdentifier . '$/'; - - return (bool) preg_match($pattern, trim($column)); - } -} diff --git a/src/Support/QueryHasher.php b/src/Support/QueryHasher.php deleted file mode 100644 index f1a5db0..0000000 --- a/src/Support/QueryHasher.php +++ /dev/null @@ -1,205 +0,0 @@ -getRawBindings()['select']; - - if (!empty($query->columns) || !empty($selectBindings)) { - $query = $query->cloneWithout(['columns'])->cloneWithoutBindings(['select']); - } - - return self::fromBuilder($builder, $query); - } - - public static function forResultQuery(EloquentBuilder $builder, QueryBuilder $query): string - { - return self::fromBuilder($builder, $query); - } - - public static function forPaginationCountQuery(CacheableBuilder $builder, QueryBuilder $query): string - { - if (!empty($query->orders) || !empty($query->unionOrders)) { - $query = $query->cloneWithout(['orders', 'unionOrders']) - ->cloneWithoutBindings(['order', 'unionOrder']); - } - - return self::hash(self::forModelIndexQuery($builder, $query) . ':pagination_count'); - } - - public static function forScalarQuery(CacheableBuilder $builder, QueryBuilder $query, string $kind, array $columns): string - { - if (self::scalarKindIgnoresOrder($kind) && (!empty($query->orders) || !empty($query->unionOrders))) { - $query = $query->cloneWithout(['orders', 'unionOrders']) - ->cloneWithoutBindings(['order', 'unionOrder']); - } - - $stripped = $query->cloneWithout(['columns'])->cloneWithoutBindings(['select']); - - return self::hashWith($stripped, [ - 'kind' => $kind, - 'columns' => $columns, - 'casts' => $builder->getModel()->getCasts(), - ]); - } - - public static function forRelationQuery( - string $stripKey, - QueryBuilder $base, - ): string { - $shape = []; - - $wheres = []; - foreach ($base->wheres as $where) { - if (($where['column'] ?? null) !== $stripKey) { - $wheres[] = self::normalizeValueForHash($where, $base); - } - } - - if ($wheres !== []) { - $shape['wheres'] = $wheres; - } - - if (!empty($base->joins)) { - $shape['joins'] = array_map(fn($join) => [ - 'type' => $join->type ?? null, - 'table' => self::normalizeValueForHash($join->table, $base), - 'sql' => $join->toSql(), - 'bindings' => self::normalizeValueForHash($join->getBindings(), $base), - ], $base->joins); - } - - foreach (['orders', 'limit', 'offset', 'groups', 'havings', 'distinct', 'unions', 'lock'] as $prop) { - if (isset($base->{$prop}) && $base->{$prop} !== null && $base->{$prop} !== [] && $base->{$prop} !== false) { - $shape[$prop] = self::normalizeValueForHash($base->{$prop}, $base); - } - } - - $nonWhereBindings = array_diff_key($base->getRawBindings(), ['where' => null]); - if (!empty(array_filter($nonWhereBindings))) { - $shape['bindings'] = self::normalizeValueForHash($nonWhereBindings, $base); - } - - return self::hashPayload($shape); - } - - public static function fromBuilder(EloquentBuilder $builder, QueryBuilder $query): string - { - return self::hashWith($query, [ - 'casts' => $builder->getModel()->getCasts(), - ]); - } - - public static function fromQuery(QueryBuilder $query): string - { - return self::hashWith($query); - } - - public static function hash(string $data): string - { - return hash('xxh128', $data); - } - - public static function normalizeValueForHash(mixed $value, ?QueryBuilder $base = null): mixed - { - if ($value instanceof QueryBuilder) { - return [ - 'sql' => $value->toSql(), - 'bindings' => self::normalizeValueForHash($value->getBindings(), $value), - ]; - } - - if ($value instanceof Expression) { - $grammar = $base?->getGrammar() ?: DB::getQueryGrammar(); - - return [ - 'expression' => (string) $value->getValue($grammar), - ]; - } - - if ($value instanceof \BackedEnum) { - return $value->value; - } - - if ($value instanceof \Stringable) { - return (string) $value; - } - - if ($value instanceof \DateTimeInterface) { - return $value->format('Y-m-d H:i:s'); - } - - if (is_array($value)) { - $normalized = []; - foreach ($value as $key => $item) { - $normalized[$key] = self::normalizeValueForHash($item, $base); - } - - return $normalized; - } - - if (is_object($value)) { - return [ - 'class' => $value::class, - 'value' => method_exists($value, '__toString') ? (string) $value : null, - ]; - } - - return $value; - } - - private static function hashWith(QueryBuilder $query, array $extra = []): string - { - return self::hash($query->toSql() . self::encodePayload(array_merge([ - 'bindings' => self::normalizeValueForHash($query->getBindings()), - 'useWritePdo' => $query->useWritePdo, - ], $extra))); - } - - private static function hashPayload(array $payload): string - { - return self::hash(self::encodePayload($payload)); - } - - private static function encodePayload(array $payload): string - { - try { - return json_encode($payload, JSON_THROW_ON_ERROR); - } catch (\JsonException $e) { - return json_encode(self::normalizeBinaryStringsForHash($payload), JSON_THROW_ON_ERROR); - } - } - - private static function normalizeBinaryStringsForHash(mixed $value): mixed - { - if (is_string($value)) { - return mb_check_encoding($value, 'UTF-8') ? $value : ['binary' => base64_encode($value)]; - } - - if (is_array($value)) { - $normalized = []; - foreach ($value as $key => $item) { - $normalized[$key] = self::normalizeBinaryStringsForHash($item); - } - - return $normalized; - } - - return $value; - } - - private static function scalarKindIgnoresOrder(string $kind): bool - { - return str_starts_with($kind, 'count') - || in_array($kind, ['sum', 'avg', 'min', 'max', 'exists'], true); - } -} diff --git a/src/Support/QueryIdentity.php b/src/Support/QueryIdentity.php new file mode 100644 index 0000000..192da6f --- /dev/null +++ b/src/Support/QueryIdentity.php @@ -0,0 +1,92 @@ + $dependencyHashes + * @param list $bindings + */ + public function hash( + string $route, + string $rootHash, + array $dependencyHashes, + string $sql, + array $bindings, + string $namespace, + string $operation, + ): string { + if (count($dependencyHashes) > 1) { + $dependencyHashes = array_values(array_unique($dependencyHashes)); + sort($dependencyHashes, SORT_STRING); + } + + $prepared = ''; + + foreach ($bindings as $binding) { + if ($binding instanceof \Stringable) { + $binding = (string) $binding; + } + + $prepared .= match (true) { + $binding === null => '4:null0:', + is_int($binding) => '3:int' . strlen($digits = (string) $binding) . ':' . $digits, + is_float($binding) => '5:float8:' . pack('E', $binding), + is_string($binding) => '6:string' . strlen($binding) . ':' . $binding, + default => throw new \InvalidArgumentException('NormCache cannot hash an unsupported query binding.'), + }; + } + + return hash('xxh128', TableIdentity::encodeFields([ + 'nc-query', + $route, + $rootHash, + TableIdentity::encodeFields($dependencyHashes), + $sql, + $prepared, + $namespace, + $operation, + ])); + } + + public function namespace(?string $tag, ?string $context = null): string + { + $namespace = $tag === null + ? 'u' + : 'g' . $this->tagHash($tag); + + if ($context === null) { + return $namespace; + } + + $contextNamespace = 'c' . $this->contextHash($context); + + return $tag === null + ? $contextNamespace + : $namespace . ':' . $contextNamespace; + } + + public function tagHash(string $tag): string + { + return $this->namedHash($tag, 'tag', 'nc-tag'); + } + + public function contextHash(string $context): string + { + return $this->namedHash($context, 'cache context', 'nc-context'); + } + + private function namedHash(string $value, string $name, string $domain): string + { + if ($value === '' || strlen($value) > 128 || !mb_check_encoding($value, 'UTF-8')) { + throw new \InvalidArgumentException( + "NormCache {$name} must be non-empty valid UTF-8 and at most 128 bytes." + ); + } + + return hash('xxh128', TableIdentity::encodeFields([$domain, $value])); + } +} diff --git a/src/Support/QueryObserver.php b/src/Support/QueryObserver.php new file mode 100644 index 0000000..9331e71 --- /dev/null +++ b/src/Support/QueryObserver.php @@ -0,0 +1,333 @@ + */ + private array $observedCorruptions = []; + + /** @var list */ + private array $spans = []; + + public function __construct( + private readonly CacheConfig $config, + private readonly ?DebugBarCollector $sink, + private readonly FailureReporter $failures, + ) {} + + private function guard(string $outcome, \Closure $observation): void + { + try { + $observation(); + } catch (\Throwable $exception) { + $this->failures->observationFailed($exception, $outcome); + } + } + + public function begin(): void + { + if (!$this->enabled()) { + return; + } + + if (count($this->spans) >= self::MAX_SPANS) { + array_shift($this->spans); + } + + $this->spans[] = microtime(true); + } + + /** + * Closes the innermost span, so a nested observation cannot consume the span + * of the read enclosing it. + * + * @return array{0: float, 1: float} + */ + private function elapsed(): array + { + $endedAt = microtime(true); + + return [array_pop($this->spans) ?? $endedAt, $endedAt]; + } + + public function hit( + QueryBuilder $query, + QueryPlan $plan, + string $hash, + QueryStatement $statement, + ?string $reason = null, + ): void { + $this->observe( + ReadOutcome::HIT, + $query, + $plan, + $hash, + $statement, + $reason, + ); + } + + public function repaired( + QueryBuilder $query, + QueryPlan $plan, + string $hash, + QueryStatement $statement, + ?string $reason = null, + ): void { + $this->observe( + ReadOutcome::REPAIRED, + $query, + $plan, + $hash, + $statement, + $reason, + ); + } + + public function miss( + QueryBuilder $query, + QueryPlan $plan, + string $hash, + QueryStatement $statement, + ?string $reason = null, + ): void { + $this->observe( + ReadOutcome::MISS, + $query, + $plan, + $hash, + $statement, + $reason, + ); + } + + private function observe( + ReadOutcome $outcome, + QueryBuilder $query, + QueryPlan $plan, + string $hash, + QueryStatement $statement, + ?string $reason, + ): void { + $this->guard($outcome->value, function () use ( + $outcome, $query, $plan, $hash, $statement, $reason, + ): void { + $this->record($outcome, $query, $plan, $hash, $statement, $reason); + }); + } + + private function record( + ReadOutcome $outcome, + QueryBuilder $query, + QueryPlan $plan, + string $hash, + QueryStatement $statement, + ?string $reason, + ): void { + if (!$this->enabled()) { + return; + } + + [$startedAt, $endedAt] = $this->elapsed(); + + if ( + $outcome === ReadOutcome::MISS + && $reason === 'corrupt_payload' + && !$this->firstCorruption($hash) + ) { + return; + } + + $record = new ObservationRecord( + outcome: $outcome->value, + route: $this->route($plan->route), + tableHash: $plan->root->hash, + queryHash: $hash, + reason: $reason, + sql: $statement->sql(), + bindings: $statement->bindings(), + modelClass: $query->modelClass(), + startedAt: $startedAt, + endedAt: $endedAt, + ); + $this->sink?->record($record); + + if (!$this->config->dispatchEvents) { + return; + } + + $eventClass = match ($outcome) { + ReadOutcome::HIT => QueryCacheHit::class, + ReadOutcome::MISS => QueryCacheMiss::class, + ReadOutcome::REPAIRED => QueryCacheRepaired::class, + }; + + event(new $eventClass( + $record->route, + $record->queryHash, + $record->tableHash, + $record->sql, + $record->bindings, + $record->modelClass, + $record->reason, + )); + } + + public function bypass( + QueryBuilder $query, + string $reason, + QueryStatement $statement, + ?QueryPlan $plan = null, + ): void { + $this->guard('bypass', function () use ($query, $reason, $statement, $plan): void { + $this->recordBypass($query, $reason, $statement, $plan); + }); + } + + private function recordBypass( + QueryBuilder $query, + string $reason, + QueryStatement $statement, + ?QueryPlan $plan, + ): void { + if (!$this->enabled()) { + return; + } + + [$startedAt, $endedAt] = $this->elapsed(); + + $record = new ObservationRecord( + outcome: 'bypass', + route: $plan === null ? null : $this->route($plan->route), + tableHash: $plan?->root->hash, + queryHash: null, + reason: $reason, + sql: $statement->sql(), + bindings: $statement->bindings(), + modelClass: $query->modelClass(), + startedAt: $startedAt, + endedAt: $endedAt, + ); + $this->sink?->record($record); + + if ($this->config->dispatchEvents) { + event(new QueryBypassed( + $record->reason, + $record->sql, + $record->bindings, + $record->modelClass, + $record->tableHash, + $record->queryHash, + $record->route, + )); + } + } + + /** @param list $tokens */ + public function invalidated(TableIdentity $table, string $mode, array $tokens): void + { + $this->guard('invalidation', function () use ($table, $mode, $tokens): void { + if (!$this->enabled()) { + return; + } + + [$startedAt, $endedAt] = $this->elapsed(); + + $this->recordInvalidation($table, $mode, $tokens, $startedAt, $endedAt); + }); + } + + /** + * One batched call invalidated every table, so all of them report its span. + * + * @param list}> $invalidations + */ + public function invalidatedMany(array $invalidations): void + { + $this->guard('invalidation', function () use ($invalidations): void { + if (!$this->enabled()) { + return; + } + + [$startedAt, $endedAt] = $this->elapsed(); + + foreach ($invalidations as $invalidation) { + $this->recordInvalidation( + $invalidation['table'], + $invalidation['mode'], + $invalidation['tokens'], + $startedAt, + $endedAt, + ); + } + }); + } + + /** @param list $tokens */ + private function recordInvalidation( + TableIdentity $table, + string $mode, + array $tokens, + float $startedAt, + float $endedAt, + ): void { + $record = new ObservationRecord( + outcome: 'invalidation', + tableHash: $table->hash, + invalidationMode: $mode, + primaryKeyTokens: $tokens, + startedAt: $startedAt, + endedAt: $endedAt, + ); + $this->sink?->record($record); + + if ($this->config->dispatchEvents) { + event(new CacheInvalidated($table->hash, $mode, $tokens)); + } + } + + public function observing(): bool + { + return $this->enabled(); + } + + private function enabled(): bool + { + return $this->config->dispatchEvents || $this->sink !== null; + } + + private function route(string $route): string + { + return str_replace('-', '_', $route); + } + + private function firstCorruption(string $keyHash): bool + { + if (isset($this->observedCorruptions[$keyHash])) { + return false; + } + + $this->observedCorruptions[$keyHash] = true; + + return true; + } +} diff --git a/src/Support/RawAttributes.php b/src/Support/RawAttributes.php deleted file mode 100644 index 068401b..0000000 --- a/src/Support/RawAttributes.php +++ /dev/null @@ -1,55 +0,0 @@ -attributes = $attrs; - $instance->original = $attrs; - $instance->classCastCache = []; - $instance->attributeCastCache = []; - $instance->exists = true; - if ($fire) { - $instance->fireModelEvent('retrieved', false); - } - }, - null, - Model::class - ); - } - - public static function setAttributeClosure(): \Closure - { - return self::$setAttributeClosure ??= \Closure::bind( - static function (Model $instance, string $key, mixed $value): void { - $instance->attributes[$key] = $value; - }, - null, - Model::class - ); - } - - public static function getAttributeClosure(): \Closure - { - return self::$getAttributeClosure ??= \Closure::bind( - static function (Model $instance, string $key): mixed { - return $instance->attributes[$key] ?? null; - }, - null, - Model::class - ); - } -} diff --git a/src/Support/RedisProtocol.php b/src/Support/RedisProtocol.php new file mode 100644 index 0000000..9ee8930 --- /dev/null +++ b/src/Support/RedisProtocol.php @@ -0,0 +1,60 @@ + $reply */ + public static function status(array $reply): ?string + { + $status = $reply[0] ?? null; + + return is_string($status) ? $status : null; + } + + /** @param array $reply */ + public static function version(array $reply, int $index = 1): string + { + $version = $reply[$index] ?? null; + + return is_string($version) ? $version : '0'; + } + + /** @param array $reply */ + public static function value(array $reply, int $index): mixed + { + return $reply[$index] ?? null; + } + + /** @param array $reply */ + public static function resultPayload(array $reply): mixed + { + return self::value($reply, 2); + } + + /** @param array $reply */ + public static function canonicalPayload(array $reply): mixed + { + return self::value($reply, 3); + } + + /** @param array $reply */ + public static function resultGeneration(array $reply): string + { + return self::version($reply, 3); + } + + /** @param array $reply */ + public static function resultMembership(array $reply): mixed + { + return self::value($reply, 4); + } +} diff --git a/src/Support/RedisScanner.php b/src/Support/RedisScanner.php deleted file mode 100644 index e95f440..0000000 --- a/src/Support/RedisScanner.php +++ /dev/null @@ -1,203 +0,0 @@ -connection instanceof PhpRedisClusterConnection => $this->scanPhpRedisClusterKeys($pattern), - $this->connection instanceof PredisClusterConnection => $this->scanPredisClusterKeys($pattern), - default => $this->scanKeys($pattern), - }; - - $prefix = $this->connectionPrefix(); - - if ($prefix === '') { - return $keys; - } - - return array_map( - static fn(string $key) => str_starts_with($key, $prefix) ? substr($key, strlen($prefix)) : $key, - $keys, - ); - } - - public function scanPatterns(array $patterns): array - { - $matched = []; - - foreach ($this->groupPatterns(array_unique(array_filter($patterns, 'is_string'))) as $group) { - $scanPattern = count($group) === 1 ? $group[0] : $this->commonPattern($group); - $scanPatterns = $scanPattern === '*' ? $group : [$scanPattern]; - - foreach ($scanPatterns as $pattern) { - foreach ($this->scanPattern($pattern) as $key) { - foreach ($group as $candidate) { - if (fnmatch($candidate, $key)) { - $matched[$key] = true; - break; - } - } - } - } - } - - return array_keys($matched); - } - - private function scanKeys(string $pattern): array - { - $keys = []; - $prefix = $this->connectionPrefix(); - - $this->executeScan( - function (&$cursor) use ($pattern, $prefix) { - $pattern = $prefix . $pattern; - - return $this->isPhpRedis() - ? $this->connection->client()->scan($cursor, $pattern, 1000) - : $this->connection->scan($cursor, ['match' => $pattern, 'count' => 1000]); - }, - static function (array $chunk) use (&$keys): void { - array_push($keys, ...$chunk); - }, - ); - - return $keys; - } - - private function scanPredisClusterKeys(string $pattern): array - { - $keys = []; - $pattern = $this->connectionPrefix() . $pattern; - - foreach ($this->connection->client() as $node) { - $this->executeScan( - fn($cursor) => $node->scan($cursor, ['match' => $pattern, 'count' => 1000]), - static function (array $chunk) use (&$keys): void { - array_push($keys, ...$chunk); - }, - ); - } - - return array_values(array_unique($keys)); - } - - private function scanPhpRedisClusterKeys(string $pattern): array - { - $keys = []; - $client = $this->connection->client(); - $pattern = $this->connectionPrefix() . $pattern; - - foreach ($client->_masters() as $node) { - $this->executeScan( - fn(&$cursor) => $client->scan($cursor, $node, $pattern, 1000), - static function (array $chunk) use (&$keys): void { - array_push($keys, ...$chunk); - }, - ); - } - - return $keys; - } - - private function groupPatterns(array $patterns): array - { - $groups = []; - - foreach ($patterns as $pattern) { - preg_match('/^\{[^{}]+\}:/', $pattern, $match); - $groups[$match[0] ?? ''][] = $pattern; - } - - return array_values($groups); - } - - private function commonPattern(array $patterns): string - { - $prefix = array_shift($patterns); - - foreach ($patterns as $pattern) { - $length = min(strlen($prefix), strlen($pattern)); - $i = 0; - - while ($i < $length && $prefix[$i] === $pattern[$i]) { - $i++; - } - - $prefix = substr($prefix, 0, $i); - } - - return $prefix . '*'; - } - - private function isPhpRedis(): bool - { - return $this->connection instanceof PhpRedisConnection; - } - - private function connectionPrefix(): string - { - if ($this->connection instanceof PhpRedisConnection) { - return (string) $this->connection->client()->getOption(\Redis::OPT_PREFIX); - } - - if ($this->connection instanceof PredisConnection) { - $prefix = $this->connection->client()->getOptions()->prefix ?? null; - - if (is_object($prefix) && method_exists($prefix, 'getPrefix')) { - return (string) $prefix->getPrefix(); - } - } - - return ''; - } - - /** - * @param \Closure(mixed &): mixed $scanner - * @param \Closure(array): void $processor - */ - private function executeScan(\Closure $scanner, \Closure $processor): void - { - if ($this->isPhpRedis()) { - $cursor = null; - - do { - $chunk = $scanner($cursor); - - if (!empty($chunk)) { - $processor($chunk); - } - } while ($cursor); - - return; - } - - $cursor = '0'; - - do { - $result = $scanner($cursor); - - if (!is_array($result) || !isset($result[1])) { - return; - } - - [$cursor, $chunk] = $result; - - if (!empty($chunk)) { - $processor($chunk); - } - } while ($cursor !== '0'); - } -} diff --git a/src/Support/RedisStore.php b/src/Support/RedisStore.php index f671dc4..6ffef33 100644 --- a/src/Support/RedisStore.php +++ b/src/Support/RedisStore.php @@ -8,98 +8,121 @@ use Illuminate\Redis\Connections\PredisClusterConnection; use Illuminate\Redis\Connections\PredisConnection; use Illuminate\Support\Facades\Redis; +use NormCache\Exceptions\TableInvalidationException; use Predis\NotSupportedException; +use Predis\Response\ServerException; final class RedisStore { - private Connection $connection; + private const WAKE_TOKENS = 64; - private CacheSerializer $serializer; + // Redis runs a script atomically on its single thread, so one call must never + // carry an unbounded row batch. Narrow rows are bound by count and wide rows + // by size; both limits are measured to stall near two milliseconds, and a + // slice ends at whichever is reached first. + private const ROW_PUBLISH_CHUNK = 250; - private ?RedisScanner $scanner = null; + private const ROW_PUBLISH_CHUNK_BYTES = 1_048_576; - /** @var array SHA1 cache — populated on first use of each script */ + private ?Connection $connection = null; + + /** @var array */ private static array $shas = []; public function __construct( - string $redisConnection, - private int $wakeTokenCount = 64, - ) { - $this->serializer = new CacheSerializer; - $this->connection = Redis::connection($redisConnection); - } + private string $redisConnection, + ) {} - // ------------------------------------------------------------------------- - // Operations — singular - // ------------------------------------------------------------------------- - - public function get(string $key): mixed + public function getRaw(string $key): ?string { - $value = $this->connection->get($key); + return $this->withRawValues(static function (Connection $connection) use ($key): ?string { + $value = $connection->get($key); - return ($value !== null && $value !== false) ? $this->unserialize($value) : null; + return $value !== null && $value !== false ? $value : null; + }); } - public function getRaw(string $key): ?string + public function readHashField(string $key, string $field): ?string { - $value = $this->connection->get($key); + return $this->withRawValues(static function (Connection $connection) use ($key, $field): ?string { + $value = $connection->hget($key, $field); - return ($value !== null && $value !== false) ? $value : null; + return is_string($value) ? $value : null; + }); } - /** @return list */ - public function getRawMany(array $keys): array + public function readHashFieldWithValues(string $key, string $field, array $valueKeys): array { - return $this->mgetValues($keys, unserialize: false); - } + $connection = $this->connection(); + + if ( + $valueKeys === [] + || $connection instanceof PredisClusterConnection + || $connection instanceof PhpRedisClusterConnection + ) { + return [$this->readHashField($key, $field), $this->mget($valueKeys)]; + } - public function set(string $key, mixed $value, int $ttl): void - { - $this->connection->setex($key, $ttl, $this->serialize($value)); + return $this->withRawValues(function (Connection $connection) use ($key, $field, $valueKeys): array { + $queue = static function (mixed $pipe) use ($key, $field, $valueKeys): void { + $pipe->hget($key, $field); + $pipe->mget($valueKeys); + }; + + // Predis accepts the callback directly; phpredis needs Laravel's wrapper. + $replies = (array) ($connection instanceof PhpRedisConnection + ? $connection->pipeline($queue) + : $connection->command('pipeline', [$queue])); + + return [ + is_string($replies[0] ?? null) ? $replies[0] : null, + $this->mapMgetValues($valueKeys, $replies[1] ?? []), + ]; + }); } - public function setRaw(string $key, string $value, int $ttl): void + public function writeHashField(string $key, string $field, string $value): void { - $this->connection->setex($key, $ttl, $value); + $this->withRawValues(static function (Connection $connection) use ($key, $field, $value): void { + $connection->hset($key, $field, $value); + }); } - /** @param list $values */ - public function addToSet(string $key, array $values): int + public function deleteHashField(string $key, string $field): void { - if ($values === []) { - return 0; - } - - return (int) $this->connection->command('sadd', [$key, ...$values]); + $this->withRawValues(static function (Connection $connection) use ($key, $field): void { + $connection->hdel($key, $field); + }); } - /** @return list */ - public function setMembers(string $key): array + public function setRawForever(string $key, string $value): void { - $members = $this->connection->command('smembers', [$key]); - - if (!is_array($members)) { - return []; - } - - return array_values(array_filter($members, 'is_string')); + $this->withRawValues(static function (Connection $connection) use ($key, $value): void { + $connection->set($key, $value); + }); } - // SET NX EX — returns true if the lock was claimed. - public function setNxEx(string $key, string $value, int $ttl): bool + /** @return array{0: bool, 1: ?string} */ + public function claimBuild(string $key, string $token, int $ttl): array { - $result = $this->script( - "return redis.call('SET', KEYS[1], ARGV[1], 'NX', 'EX', tonumber(ARGV[2]))", + $result = (array) $this->script( + RedisScripts::get('claim_build'), [$key], - [$value, (string) $ttl] + [$token, (string) $ttl], ); + $owner = is_string($result[1] ?? null) && $result[1] !== '' + ? $result[1] + : null; - return $result !== null && $result !== false; + return [(int) ($result[0] ?? 0) === 1, $owner]; } public function delete(string|array $keys): void { - $keys = array_values(array_filter((array) $keys, fn($k) => $k !== '')); + $keys = array_values(array_filter( + (array) $keys, + static fn(mixed $key): bool => is_string($key) && $key !== '', + )); if ($keys === []) { return; @@ -108,372 +131,642 @@ public function delete(string|array $keys): void $this->del($keys); } - // DEL building key + LPUSH/EXPIRE wake key atomically when the token still owns the lock. - public function releaseBuilding(string $buildingKey, string $wakeKey, ?string $token = null): bool - { - $keys = $wakeKey !== '' ? [$buildingKey, $wakeKey] : [$buildingKey]; - - return (bool) $this->script( - RedisScripts::get('release_building'), - $keys, - [$token ?? '', (string) $this->wakeTokenCount] + public function releaseBuilding( + string $buildingKey, + string $wakeKey, + ?string $token = null, + int $wakeTtl = 10, + ): bool { + return $this->publishVersionedEntries( + entryKeys: [], + entryPayloads: [], + ttl: 1, + versionKeys: [], + expectedVersions: [], + buildingKey: $buildingKey, + wakeKey: $wakeKey, + token: $token, + wakeTtl: $wakeTtl, ); } - public function storeSerializedAndRelease(string $key, mixed $value, int $ttl, ?string $buildingKey = null, ?string $wakeKey = null, ?string $token = null): bool - { - return $this->storeRawAndRelease($key, $this->serialize($value), $ttl, $buildingKey, $wakeKey, $token); - } - - public function storeRawAndRelease(string $key, string $value, int $ttl, ?string $buildingKey = null, ?string $wakeKey = null, ?string $token = null): bool - { - if ($buildingKey === null) { - $this->connection->setex($key, $ttl, $value); - - return true; - } - - return $this->storeVersionedPayload([$key => $value], $ttl, [], [], $buildingKey, $wakeKey, $token); - } - - /** @param array $entries key => pre-encoded payload */ - public function storeVersionedPayload( - array $entries, + /** + * @param list $entryKeys + * @param list $entryPayloads + * @param list $versionKeys + * @param list $expectedVersions + * @param list $entryFields hash field per entry; an empty field writes a + * plain string entry instead + */ + public function publishVersionedEntries( + array $entryKeys, + array $entryPayloads, int $ttl, array $versionKeys, array $expectedVersions, ?string $buildingKey = null, ?string $wakeKey = null, ?string $token = null, + int $wakeTtl = 10, + array $entryFields = [], ): bool { - $keys = array_merge($versionKeys, array_keys($entries)); + if (count($entryKeys) !== count($entryPayloads)) { + throw new \InvalidArgumentException( + 'NormCache versioned entry keys and payloads must have the same length.', + ); + } + + if ($entryFields === []) { + $entryFields = array_fill(0, count($entryKeys), ''); + } elseif (count($entryFields) !== count($entryKeys)) { + throw new \InvalidArgumentException( + 'NormCache versioned entry keys and fields must have the same length.', + ); + } + + $keys = [...$versionKeys, ...$entryKeys]; + if ($buildingKey !== null) { $keys[] = $buildingKey; + if ($wakeKey !== null && $wakeKey !== '') { $keys[] = $wakeKey; } } return (bool) $this->script( - RedisScripts::get('store_versioned_payload'), + RedisScripts::get('publish_versioned_entries'), $keys, - array_merge( - [(string) count($versionKeys), (string) count($entries), (string) $ttl], - $expectedVersions, - array_values($entries), - [$token ?? '', (string) $this->wakeTokenCount] - ) + [ + (string) count($versionKeys), + (string) count($entryKeys), + (string) $ttl, + ...$expectedVersions, + ...$entryFields, + ...$entryPayloads, + $token ?? '', + (string) self::WAKE_TOKENS, + (string) $wakeTtl, + ], ); } - public function fetchVersionedPayload( - array $versionKeys, - array $scheduledKeys, - string $payloadPrefix, - string $buildingPrefix, - string $wakePrefix, - string $hash, - string $lockSuffix, - string $lockToken, - int $lockTtl, - bool $cooldown, + /** @return array */ + public function fetchCanonical( + string $versionKey, + string $generationKey, + string $tablePrefix, + string $namespace, + string $queryHash, ): array { return (array) $this->script( - RedisScripts::get('fetch_versioned_payload'), - array_merge($versionKeys, $cooldown ? $scheduledKeys : [], [$payloadPrefix, $buildingPrefix, $wakePrefix]), - [ - $hash, - $lockSuffix, - (int) floor(microtime(true) * 1000), - $lockTtl, - $lockToken, - (string) count($versionKeys), - $cooldown ? '1' : '0', - ] + RedisScripts::get('fetch_canonical'), + [$versionKey, $generationKey, $tablePrefix], + [$namespace, $queryHash], ); } - public function fetchVersionedPivotSegment(array $versionKeys, array $scheduledKeys): string - { - $result = $this->script( - RedisScripts::get('fetch_versioned_pivot'), - array_merge($versionKeys, $scheduledKeys), - [(string) (int) floor(microtime(true) * 1000)] + /** @return array */ + public function fetchRow( + string $generationKey, + string $tablePrefix, + string $primaryKeyToken, + ): array { + return (array) $this->script( + RedisScripts::get('fetch_row'), + [$generationKey, $tablePrefix], + [$primaryKeyToken], ); - - return (string) ($result ?? ''); } - public function fetchBatchBuildStatus(array $keys, string $lockKey, string $wakeKey, string $token, int $lockTtl): array - { + /** @return array */ + public function fetchResult( + string $versionKey, + string $tablePrefix, + string $namespace, + string $queryHash, + ): array { return (array) $this->script( - RedisScripts::get('fetch_batch_build_status'), - [...$keys, $lockKey, $wakeKey], - [$token, (string) $lockTtl] + RedisScripts::get('fetch_result'), + [$versionKey, $tablePrefix], + [$namespace, $queryHash], ); } - public function fetchVersionWithCooldown(string $verKey, string $scheduledKey): mixed - { - return $this->script( - RedisScripts::get('fetch_version_with_cooldown'), - [$verKey, $scheduledKey], - [(string) (int) floor(microtime(true) * 1000), '0'] + /** @return array */ + public function fetchResultOrCanonical( + string $versionKey, + string $generationKey, + string $tablePrefix, + string $namespace, + string $resultQueryHash, + string $canonicalQueryHash, + ): array { + return (array) $this->script( + RedisScripts::get('fetch_result_or_canonical'), + [$versionKey, $generationKey, $tablePrefix], + [$namespace, $resultQueryHash, $canonicalQueryHash], ); } - public function increment(string $key): int - { - return (int) $this->connection->incr($key); - } + /** + * @param list $rowKeys + * @param list $rowPayloads + */ + public function publishRows( + string $versionKey, + string $generationKey, + string $buildingKey, + array $rowKeys, + array $rowPayloads, + string $expectedVersion, + string $expectedGeneration, + int $rowTtl, + string $token, + int $leaseTtl, + ): bool { + if (count($rowKeys) !== count($rowPayloads)) { + throw new \InvalidArgumentException( + 'NormCache canonical row keys and payloads must have the same length.', + ); + } - public function incrementAndExpire(string $key, int $ttl): int - { - return (int) $this->script( - "local v = redis.call('INCR', KEYS[1]); redis.call('EXPIRE', KEYS[1], tonumber(ARGV[1])); return v", - [$key], - [(string) $ttl] - ); + foreach ($this->rowSlices($rowKeys, $rowPayloads) as [$keys, $payloads]) { + $published = (bool) $this->script( + RedisScripts::get('publish_rows'), + [$versionKey, $generationKey, $buildingKey, ...$keys], + [ + $expectedVersion, + $expectedGeneration, + (string) $rowTtl, + $token, + (string) $leaseTtl, + ...$payloads, + ], + ); + + if (!$published) { + return false; + } + } + + return true; } - /** Blocks until an item appears on the list key or the timeout expires. Returns true if woken. - * Requires Redis 6.0+ for sub-second precision; older Redis rounds the timeout up to 1s. */ - public function brpop(string $key, float $timeoutSeconds): bool + /** + * @param list $rowKeys + * @param list $rowPayloads + * @return list, 1: list}> + */ + private function rowSlices(array $rowKeys, array $rowPayloads): array { - $result = $this->connection->brpop($key, $timeoutSeconds); + $slices = []; + $keys = []; + $payloads = []; + $bytes = 0; - return $result !== null && $result !== false; - } + foreach ($rowKeys as $index => $key) { + $payload = $rowPayloads[$index]; - // ------------------------------------------------------------------------- - // Operations — bulk - // ------------------------------------------------------------------------- + // Never emit an empty slice, so an oversized row still publishes alone. + if ( + $keys !== [] + && (count($keys) >= self::ROW_PUBLISH_CHUNK + || $bytes + strlen($payload) > self::ROW_PUBLISH_CHUNK_BYTES) + ) { + $slices[] = [$keys, $payloads]; + $keys = []; + $payloads = []; + $bytes = 0; + } - public function getMany(array $keys): array - { - return $this->mgetValues($keys, unserialize: true); - } + $keys[] = $key; + $payloads[] = $payload; + $bytes += strlen($payload); + } - public function getManyForCurrentVersion( - string $versionKey, - string $scheduledKey, - string $modelPrefix, - array $ids, - ): array { - $result = (array) $this->script( - RedisScripts::get('fetch_version_with_cooldown'), - [$versionKey, $scheduledKey, $modelPrefix], - [(string) (int) floor(microtime(true) * 1000), '1', ...$ids], - ); - $raw = is_array($result[1] ?? null) ? $result[1] : []; + if ($keys !== []) { + $slices[] = [$keys, $payloads]; + } - return [(int) ($result[0] ?? 0), $this->unserializeMany($raw)]; + return $slices; } - private function mgetValues(array $keys, bool $unserialize): array - { - if (empty($keys)) { - return []; + /** + * @param list $rowKeys + * @param list $rowPayloads + */ + public function publishCanonical( + string $versionKey, + string $generationKey, + string $membershipKey, + array $rowKeys, + array $rowPayloads, + string $expectedVersion, + string $expectedGeneration, + string $membershipPayload, + int $membershipTtl, + int $rowTtl, + string $buildingKey, + string $wakeKey, + string $token, + int $wakeTtl, + ?string $resultPayload = null, + ): bool { + if (count($rowKeys) !== count($rowPayloads)) { + throw new \InvalidArgumentException( + 'NormCache canonical row keys and payloads must have the same length.', + ); } - $raw = $this->connection instanceof PredisClusterConnection - ? $this->connection->command('mget', $keys) - : $this->connection->mget($keys); - $values = []; - - foreach ($raw as $i => $value) { - $values[$i] = $this->mgetValue($value, $unserialize); + $args = [ + (string) count($rowKeys), + $expectedVersion, + $expectedGeneration, + (string) $membershipTtl, + (string) $rowTtl, + $membershipPayload, + ...$rowPayloads, + $token, + (string) self::WAKE_TOKENS, + (string) $wakeTtl, + ]; + + if ($resultPayload !== null) { + $args[] = $resultPayload; } - return $values; + return (bool) $this->script( + RedisScripts::get('publish_canonical'), + [ + $versionKey, + $generationKey, + $membershipKey, + ...$rowKeys, + $buildingKey, + $wakeKey, + ], + $args, + ); } - private function mgetValue(mixed $value, bool $unserialize): mixed + public function increment(string $key): int { - if ($value === null || $value === false) { - return null; - } - - return $unserialize ? $this->unserialize($value) : $value; + return (int) $this->withRetryingConnection( + static fn(Connection $connection): mixed => $connection->incr($key), + ); } - // CAS write of model attribute entries; releases the build lock as part of the write when given. - public function setManyIfVersion( - array $attrsByKey, - int $ttl, + /** @param list $tokens */ + public function invalidateTableState( string $versionKey, - int $expectedVersion, - ?string $buildingKey = null, - ?string $wakeKey = null, - ?string $token = null, - ): void { - if (empty($attrsByKey)) { - if ($buildingKey !== null) { - $this->releaseBuilding($buildingKey, $wakeKey ?? '', $token); - } + string $generationKey, + string $mode, + array $tokens, + string $rowPrefix, + string $changePrefix, + string $changePayload, + int $changeTtl, + ): ?string { + $reply = $this->script( + RedisScripts::get('invalidate_table'), + [$versionKey, $generationKey, $rowPrefix, $changePrefix], + [ + $mode, + $changePayload, + (string) $changeTtl, + ...$tokens, + ], + ); - return; + return $this->replyVersion($reply); + } + + private function replyVersion(mixed $reply): ?string + { + $version = is_array($reply) ? ($reply[0] ?? null) : null; + + return is_int($version) || is_string($version) ? (string) $version : null; + } + + /** + * @param list, + * rowPrefix: string, + * changePrefix: string, + * changePayload: string, + * changeTtl: int + * }> $states + * @return list the new version of each state, index-aligned with $states + */ + public function invalidateTableStates(array $states): array + { + if (count($states) === 1) { + return [$this->invalidateTableState(...$states[0])]; } - $script = RedisScripts::get('store_model_attrs'); - $chunks = array_chunk($attrsByKey, 500, true); - $lastChunk = array_key_last($chunks); + return (array) $this->withRetryingConnection(function (Connection $connection) use ($states): array { + if ( + $connection instanceof PhpRedisClusterConnection + || $connection instanceof PredisClusterConnection + ) { + $versions = []; + + foreach ($states as $index => $state) { + try { + $versions[] = $this->replyVersion($this->evaluate( + $connection, + RedisScripts::get('invalidate_table'), + [ + $state['versionKey'], + $state['generationKey'], + $state['rowPrefix'], + $state['changePrefix'], + ], + [ + $state['mode'], + $state['changePayload'], + (string) $state['changeTtl'], + ...$state['tokens'], + ], + )); + } catch (\Exception $exception) { + throw new TableInvalidationException($index, $exception); + } + } - foreach ($chunks as $i => $chunk) { - $isLast = $i === $lastChunk; + return $versions; + } - // Only the last chunk releases the build lock; trailing lock/wake keys are - // present (and removed below if absent) only on that chunk. - $keys = array_merge([$versionKey], array_keys($chunk)); - if ($isLast && $buildingKey !== null) { - $keys[] = $buildingKey; - if ($wakeKey !== null && $wakeKey !== '') { - $keys[] = $wakeKey; - } + $keys = []; + $args = []; + + foreach ($states as $state) { + $keys[] = $state['versionKey']; + $keys[] = $state['generationKey']; + $keys[] = $state['rowPrefix']; + $keys[] = $state['changePrefix']; + $args[] = $state['mode']; + $args[] = (string) count($state['tokens']); + $args[] = $state['changePayload']; + $args[] = (string) $state['changeTtl']; + array_push($args, ...$state['tokens']); } - $this->script( - $script, + $reply = (array) $this->evaluate( + $connection, + RedisScripts::get('invalidate_tables'), $keys, - array_merge( - [(string) $expectedVersion, (string) $ttl, (string) count($chunk), $isLast ? ($token ?? '') : ''], - array_map(fn($attrs) => $this->serialize($attrs), array_values($chunk)), - [(string) $this->wakeTokenCount] - ) + $args, ); - } + + return array_map( + static fn(mixed $version): ?string => is_int($version) || is_string($version) + ? (string) $version + : null, + array_values($reply), + ); + }); } - public function asyncDel(array $prefixedKeys): void + public function enableCache(string $epochKey, string $disabledKey): int { - if (empty($prefixedKeys)) { - return; - } + return (int) $this->script( + RedisScripts::get('enable_cache'), + [$epochKey, $disabledKey], + ); + } - foreach (array_chunk($prefixedKeys, 1000) as $chunk) { - $this->del($chunk); - } + public function brpop(string $key, float $timeoutSeconds): bool + { + return $this->withRawValues(static function (Connection $connection) use ($key, $timeoutSeconds): bool { + $result = $connection->brpop($key, $timeoutSeconds); + + return $result !== null && $result !== false; + }, retry: false); } - private function del(array $keys): void + /** + * @param list $keys + * @param list $args + */ + private function script(string $script, array $keys, array $args = []): mixed { - if ($this->connection instanceof PredisClusterConnection) { - foreach ($this->groupByHashTag($keys) as $group) { - $this->connection->command('del', $group); - } + return $this->withRetryingConnection( + fn(Connection $connection): mixed => $this->evaluate($connection, $script, $keys, $args), + ); + } - return; - } + /** + * @param list $keys + * @param list $args + */ + private function evaluate( + Connection $connection, + string $script, + array $keys, + array $args, + ): mixed { + $keyCount = count($keys); + $arguments = [...$keys, ...$args]; + $sha = self::$shas[$script] ??= sha1($script); - // Standalone Predis accepts Laravel's array form. - if ($this->connection instanceof PredisConnection) { - $this->connection->del($keys); + try { + if ($connection instanceof PhpRedisConnection) { + $result = $connection->client()->evalSha($sha, $arguments, $keyCount); + } else { + $result = $connection->command('evalsha', [$sha, $keyCount, ...$arguments]); + } + } catch (\Throwable $exception) { + if ( + !str_contains(strtolower($exception->getMessage()), 'noscript') + && !($exception instanceof NotSupportedException + && str_contains($exception->getMessage(), 'EVALSHA')) + ) { + throw $exception; + } - return; + if ($connection instanceof PhpRedisConnection) { + return $connection->eval( + $script, + $keyCount, + ...$arguments, + ); + } + + return $connection->command( + 'eval', + [$script, $keyCount, ...$arguments], + ); } - $this->connection->unlink($keys); - } + if ($result === false && $connection instanceof PhpRedisConnection) { + $client = $connection->client(); + $lastError = strtolower((string) ($client->getLastError() ?? '')); - /** @return list> */ - private function groupByHashTag(array $keys): array - { - $groups = []; + if (str_contains($lastError, 'noscript')) { + $client->clearLastError(); - foreach ($keys as $key) { - $group = preg_match('/\{([^{}]+)\}/', $key, $matches) === 1 - ? 'tag:' . $matches[1] - : 'key:' . $key; - $groups[$group][] = $key; + return $connection->eval( + $script, + $keyCount, + ...$arguments, + ); + } } - return array_values($groups); + return $result; } - public function flushByPatterns(array $patterns): int + /** + * @param list $keys + * @return array + */ + public function mget(array $keys): array { - $keys = ($this->scanner ??= new RedisScanner($this->connection))->scanPatterns($patterns); - if ($keys === []) { - return 0; + return []; } - $this->asyncDel($keys); + return $this->withRawValues(function (Connection $connection) use ($keys): array { + if ($connection instanceof PredisClusterConnection) { + $groups = $this->groupByHashTag($keys); - return count($keys); - } + if (count($groups) === 1) { + return $this->mapMgetValues($groups[0], $connection->mget($groups[0])); + } - // Runs a Lua script via EVALSHA, falling back to EVAL on NOSCRIPT. All KEYS must - // share one hash slot (cluster); optional slots are omitted, never passed empty. - public function script(string $script, array $keys, array $args = []): mixed - { - $n = count($keys); - $allArgs = array_merge($keys, $args); + try { + $replies = $connection->pipeline(static function ($pipeline) use ($groups): void { + foreach ($groups as $group) { + $pipeline->mget(...$group); + } + }); + } catch (ServerException $exception) { + if ( + !str_starts_with($exception->getMessage(), 'MOVED ') + && !str_starts_with($exception->getMessage(), 'ASK ') + ) { + throw $exception; + } + + $replies = array_map( + static fn(array $group): mixed => $connection->command('mget', $group), + $groups, + ); + } - $sha = self::$shas[$script] ??= sha1($script); + $values = []; - try { - // PredisClusterConnection extends PredisConnection, so this covers both. - $shaArgs = $this->connection instanceof PredisConnection - ? [$sha, $n, ...$allArgs] - : [$sha, $allArgs, $n]; + foreach ($groups as $groupIndex => $group) { + $values += $this->mapMgetValues($group, $replies[$groupIndex] ?? []); + } - $result = $this->connection->command('evalsha', $shaArgs); - } catch (\Throwable $e) { - if ( - !str_contains(strtolower($e->getMessage()), 'noscript') && - !($e instanceof NotSupportedException && str_contains($e->getMessage(), 'EVALSHA')) - ) { - throw $e; + return $values; } - return $this->connection->eval($script, $n, ...$allArgs); - } + // PhpRedis handles cross-slot MGET fan-out itself. + return $this->mapMgetValues($keys, $connection->mget($keys)); + }); + } - // PhpRedis may return false with a NOSCRIPT last-error instead of throwing. - if ($result === false && $this->connection instanceof PhpRedisConnection) { - $lastError = strtolower((string) ($this->connection->client()->getLastError() ?? '')); - if (str_contains($lastError, 'noscript')) { - $this->connection->client()->clearLastError(); + /** @param list $keys */ + private function del(array $keys): void + { + $this->withRetryingConnection(function (Connection $connection) use ($keys): void { + if ($connection instanceof PredisClusterConnection) { + foreach ($this->groupByHashTag($keys) as $group) { + $connection->command('del', $group); + } - return $this->connection->eval($script, $n, ...$allArgs); + return; } - } - return $result; - } + if ($connection instanceof PredisConnection) { + $connection->del($keys); - // ------------------------------------------------------------------------- - // Serialization - // ------------------------------------------------------------------------- + return; + } - public function serialize(mixed $value): mixed - { - return $this->serializer->serialize($value); + $connection->unlink($keys); + }); } - public function unserialize(mixed $value): mixed + private function withRawValues(callable $callback, bool $retry = true): mixed { - return $this->serializer->unserialize($value); + $operation = static function (Connection $connection) use ($callback): mixed { + if ($connection instanceof PhpRedisConnection) { + return $connection->withoutSerializationOrCompression( + static fn(): mixed => $callback($connection), + ); + } + + return $callback($connection); + }; + + return $retry + ? $this->withRetryingConnection($operation) + : $operation($this->connection()); } - public function unserializeMany(array $raw): array + private function withRetryingConnection(callable $operation): mixed { - return $this->serializer->unserializeMany($raw); + try { + return $operation($this->connection()); + } catch (\Exception) { + $this->connection = null; + Redis::purge($this->redisConnection); + + return $operation($this->connection()); + } } - // ------------------------------------------------------------------------- - // Private - // ------------------------------------------------------------------------- + private function connection(): Connection + { + return $this->connection ??= Redis::connection($this->redisConnection); + } - public function isCluster(): bool + /** + * @param list $keys + * @return array + */ + private function mapMgetValues(array $keys, mixed $raw): array { - return $this->connection instanceof PhpRedisClusterConnection - || $this->connection instanceof PredisClusterConnection; + if (!is_array($raw)) { + throw new \UnexpectedValueException('Redis MGET must return an array.'); + } + + $values = []; + + foreach ($keys as $i => $key) { + $value = $raw[$i] ?? null; + $values[$key] = $value !== null && $value !== false ? $value : null; + } + + return $values; } - public function scanPattern(string $pattern): array + /** + * @param list $keys + * @return list> + */ + private function groupByHashTag(array $keys): array { - return ($this->scanner ??= new RedisScanner($this->connection))->scanPattern($pattern); + $groups = []; + + foreach ($keys as $key) { + $open = strpos($key, '{'); + $close = $open === false ? false : strpos($key, '}', $open + 1); + $group = $close !== false && $close - $open > 1 + ? 'tag:' . substr($key, $open + 1, $close - $open - 1) + : 'key:' . $key; + + $groups[$group][] = $key; + } + + return array_values($groups); } } diff --git a/src/Support/RelationCacheGuards.php b/src/Support/RelationCacheGuards.php deleted file mode 100644 index 97bf9fa..0000000 --- a/src/Support/RelationCacheGuards.php +++ /dev/null @@ -1,26 +0,0 @@ - true, - 'bool' => true, - 'boolean' => true, - 'collection' => true, - 'custom_datetime' => true, - 'date' => true, - 'datetime' => true, - 'decimal' => true, - 'double' => true, - 'float' => true, - 'immutable_custom_datetime' => true, - 'immutable_date' => true, - 'immutable_datetime' => true, - 'int' => true, - 'integer' => true, - 'json' => true, - 'json:unicode' => true, - 'object' => true, - 'real' => true, - 'string' => true, - 'timestamp' => true, - ]; - - public static function transformScalar(mixed $value, Model $model, string $column): mixed - { - $isCast = self::resolveStatelessScalarMode($model, $column); - - if ($isCast === null) { - return $model->newFromBuilder([$column => $value])->{$column}; - } - - return self::transformScalarClosure()($model, $column, $value, $isCast); - } - - public static function transformScalars(Collection $results, Model $model, string $column): Collection - { - $isCast = self::resolveStatelessScalarMode($model, $column); - $values = $results->all(); - - if ($isCast === null) { - $template = $model->newInstance([], true); - $hydrate = RawAttributes::hydrateClosure(); - - foreach ($values as $key => $value) { - $instance = clone $template; - $hydrate($instance, [$column => $value], true); - $values[$key] = $instance->{$column}; - } - - return new Collection($values); - } - - return new Collection( - self::transformScalarsClosure()($model, $column, $values, $isCast), - ); - } - - private static function resolveStatelessScalarMode(Model $model, string $column): ?bool - { - if ($model->hasAnyGetMutator($column)) { - return null; - } - - $cast = $model->getCasts()[$column] ?? null; - - if ($cast === null && !in_array($column, $model->getDates(), true)) { - return null; - } - - if ($cast !== null && !is_string($cast)) { - return null; - } - - if (is_string($cast) && !isset(self::STATELESS_CASTS[strtolower(explode(':', $cast, 2)[0])])) { - return null; - } - - $isCast = $cast !== null; - - $dispatcher = $model::getEventDispatcher(); - - if ($dispatcher !== null && $dispatcher->hasListeners('eloquent.retrieved: ' . $model::class)) { - return null; - } - - return $isCast; - } - - private static function transformScalarClosure(): \Closure - { - return self::$transformScalarClosure ??= \Closure::bind( - static function (Model $model, string $column, mixed $value, bool $isCast): mixed { - if ($isCast) { - return $model->castAttribute($column, $value); - } - - return $value === null ? null : $model->asDateTime($value); - }, - null, - Model::class - ); - } - - private static function transformScalarsClosure(): \Closure - { - return self::$transformScalarsClosure ??= \Closure::bind( - static function (Model $model, string $column, array $values, bool $isCast): array { - if ($isCast) { - foreach ($values as $key => $value) { - $values[$key] = $model->castAttribute($column, $value); - } - - return $values; - } - - foreach ($values as $key => $value) { - $values[$key] = $value === null ? null : $model->asDateTime($value); - } - - return $values; - }, - null, - Model::class - ); - } -} diff --git a/src/Traits/BuilderInvalidation.php b/src/Traits/BuilderInvalidation.php deleted file mode 100644 index 900f7c5..0000000 --- a/src/Traits/BuilderInvalidation.php +++ /dev/null @@ -1,203 +0,0 @@ -suppressInvalidation; - $this->suppressInvalidation = true; - - try { - return $callback(); - } finally { - $this->suppressInvalidation = $previous; - } - } - - public function insert(array $values): bool - { - return $this->writeWithInvalidation( - WriteOperation::Insert, - fn() => parent::insert($values), - fn(bool $inserted): bool => $values !== [] && $inserted, - ); - } - - public function insertOrIgnore(array $values): int - { - return $this->writeWithInvalidation( - WriteOperation::Insert, - fn() => parent::insertOrIgnore($values), - fn(int $affected): bool => $affected > 0, - ); - } - - public function insertUsing(array $columns, $query): int - { - return $this->writeWithInvalidation( - WriteOperation::Insert, - fn() => parent::insertUsing($columns, $query), - fn(int $affected): bool => $affected > 0, - ); - } - - public function insertOrIgnoreUsing(array $columns, $query): int - { - return $this->writeWithInvalidation( - WriteOperation::Insert, - fn() => parent::insertOrIgnoreUsing($columns, $query), - fn(int $affected): bool => $affected > 0, - ); - } - - public function insertOrIgnoreReturning(array $values, array $returning = ['*'], $uniqueBy = null): mixed - { - return $this->writeWithInvalidation( - WriteOperation::Insert, - fn() => $this->toBase()->insertOrIgnoreReturning($values, $returning, $uniqueBy), - fn(Collection $rows): bool => $rows->isNotEmpty(), - ); - } - - public function insertGetId(array $values, $sequence = null): int - { - return (int) $this->writeWithInvalidation( - WriteOperation::Insert, - fn() => parent::insertGetId($values, $sequence), - fn(int $id): bool => true, - ); - } - - public function update(array $values): int - { - return $this->writeWithInvalidation( - WriteOperation::Update, - fn() => parent::update($values), - fn(int $affected): bool => $affected > 0, - ); - } - - public function updateFrom(array $values): int - { - return $this->writeWithInvalidation( - WriteOperation::Update, - fn() => $this->toBase()->updateFrom($values), - fn(int $affected): bool => $affected > 0, - ); - } - - public function updateOrInsert(array $attributes, $values = []): bool - { - return (bool) $this->writeWithInvalidation( - WriteOperation::Update, - fn() => $this->toBase()->updateOrInsert($attributes, $values), - fn(bool $succeeded): bool => $succeeded, - ); - } - - public function upsert(array $values, $uniqueBy, $update = null): int - { - return $this->writeWithInvalidation( - WriteOperation::Update, - fn() => parent::upsert($values, $uniqueBy, $update), - fn(int $affected): bool => $affected > 0, - ); - } - - public function delete(): mixed - { - return $this->writeWithInvalidation( - WriteOperation::Delete, - fn() => parent::delete(), - fn(int $affected): bool => $affected > 0, - ); - } - - public function forceDelete(): mixed - { - return $this->writeWithInvalidation( - WriteOperation::Delete, - fn() => parent::forceDelete(), - fn(int $affected): bool => $affected > 0, - ); - } - - public function touch($column = null): int|bool - { - return $this->writeWithInvalidation( - WriteOperation::Increment, - fn() => parent::touch($column), - fn(int|bool $affected): bool => $affected !== false && $affected > 0, - ); - } - - public function increment($column, $amount = 1, array $extra = []): int - { - return $this->writeWithInvalidation( - WriteOperation::Increment, - fn() => parent::increment($column, $amount, $extra), - fn(int $affected): bool => $affected > 0, - ); - } - - public function decrement($column, $amount = 1, array $extra = []): int - { - return $this->writeWithInvalidation( - WriteOperation::Increment, - fn() => parent::decrement($column, $amount, $extra), - fn(int $affected): bool => $affected > 0, - ); - } - - public function incrementEach(array $columns, array $extra = []): int - { - return $this->writeWithInvalidation( - WriteOperation::Increment, - fn(): int => $this->toBase()->incrementEach($columns, $this->addUpdatedAtColumn($extra)), - fn(int $affected): bool => $affected > 0, - ); - } - - public function decrementEach(array $columns, array $extra = []): int - { - return $this->writeWithInvalidation( - WriteOperation::Increment, - fn(): int => $this->toBase()->decrementEach($columns, $this->addUpdatedAtColumn($extra)), - fn(int $affected): bool => $affected > 0, - ); - } - - public function truncate(): void - { - parent::truncate(); - $this->recordWrite(WriteOperation::Truncate, true); - } - - private function writeWithInvalidation( - WriteOperation $operation, - callable $callback, - callable $changed, - ): mixed { - $result = $callback(); - $this->recordWrite($operation, $changed($result)); - - return $result; - } - - private function recordWrite(WriteOperation $operation, bool $changed): void - { - if ($this->suppressInvalidation) { - return; - } - - NormCache::invalidator()->recordBuilderWrite($this->model, $operation, $changed); - } -} diff --git a/src/Traits/Cacheable.php b/src/Traits/Cacheable.php index 6282a31..c054512 100644 --- a/src/Traits/Cacheable.php +++ b/src/Traits/Cacheable.php @@ -2,112 +2,58 @@ namespace NormCache\Traits; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; -use NormCache\CacheableBuilder; -use NormCache\Facades\NormCache; -use NormCache\Relations\CachesRelationships; +use Illuminate\Database\Eloquent\SoftDeletes; +use NormCache\Database\QueryBuilder; /** * @mixin Model */ trait Cacheable { - use CachesRelationships; - - private bool $withoutCacheNext = false; - - public function flush(): void - { - NormCache::invalidator()->invalidateVersion($this); - } - - /** @return list */ - public static function normCacheSpaces(): array - { - return property_exists(static::class, 'normCacheSpaces') ? (array) static::$normCacheSpaces : []; - } - - public function newEloquentBuilder($query) - { - if (!config('normcache.enabled', true)) { - return parent::newEloquentBuilder($query); - } - - $builder = new CacheableBuilder($query); - - if ($this->withoutCacheNext) { - $this->withoutCacheNext = false; - $builder->withoutCache(); - } - - return $builder; - } - - public function refresh(): static + protected function newBaseQueryBuilder() { - return $this->runWithoutCache(fn() => parent::refresh()); - } - - public function fresh($with = []): ?static - { - return $this->runWithoutCache(fn() => parent::fresh($with)); - } - - public function save(array $options = []): bool - { - return $this->saveWithCacheInvalidation( - fn() => parent::save($options), - observeBeforeWrite: true, + $connection = $this->getConnection(); + $builder = new QueryBuilder( + $connection, + $connection->getQueryGrammar(), + $connection->getPostProcessor(), ); - } - public function saveQuietly(array $options = []): bool - { - return $this->saveWithCacheInvalidation( - fn() => Model::withoutEvents(fn() => parent::save($options)), - observeBeforeWrite: false, + return $builder->enableCachingForModel( + $this::class, + $this->getKeyName(), + $this->getKeyType(), + $this->normCacheDeletedAtColumn(), + $this->normCacheVolatileColumns(), ); } - protected function performInsert(Builder $query): bool + // Laravel 12 does not expose Model::isSoftDeletable(). + private function normCacheDeletedAtColumn(): ?string { - // $query is a plain Eloquent Builder when normcache.enabled is false; see newEloquentBuilder(). - if (method_exists($query, 'withoutInvalidation')) { - return $query->withoutInvalidation(fn() => parent::performInsert($query)); + if (!isset(class_uses_recursive($this::class)[SoftDeletes::class])) { + return null; } - return parent::performInsert($query); + return method_exists($this, 'getDeletedAtColumn') + ? $this->getDeletedAtColumn() + : null; } - protected function performUpdate(Builder $query): bool + /** @return list */ + private function normCacheVolatileColumns(): array { - if (method_exists($query, 'withoutInvalidation')) { - return $query->withoutInvalidation(fn() => parent::performUpdate($query)); + if (!property_exists($this, 'volatileColumns')) { + return []; } - return parent::performUpdate($query); - } - - private function saveWithCacheInvalidation(callable $save, bool $observeBeforeWrite): bool - { - $invalidator = NormCache::invalidator(); - $state = $invalidator->beginModelSave($this, $observeBeforeWrite); - $result = $save(); - $invalidator->completeModelSave($this, $state, $result); - - return $result; - } - - private function runWithoutCache(callable $callback) - { - $previous = $this->withoutCacheNext; - $this->withoutCacheNext = true; + /** @var mixed $declared */ + $declared = $this->volatileColumns; - try { - return $callback(); - } finally { - $this->withoutCacheNext = $previous; - } + return array_values(array_filter( + is_array($declared) ? $declared : [], + is_string(...), + )); } } diff --git a/src/Traits/CachesScalarResults.php b/src/Traits/CachesScalarResults.php deleted file mode 100644 index f755693..0000000 --- a/src/Traits/CachesScalarResults.php +++ /dev/null @@ -1,199 +0,0 @@ -cacheScalar( - ResultKind::Count, - fn() => parent::count($columns), - (array) $columns, - fn(QueryBuilder $base) => $base->count($columns) - ); - } - - public function sum($column): mixed - { - return $this->cacheScalar( - ResultKind::Sum, - fn() => parent::sum($column), - (array) $column, - fn(QueryBuilder $base) => $base->sum($column) - ); - } - - public function avg($column): mixed - { - return $this->cacheScalar( - ResultKind::Avg, - fn() => parent::avg($column), - (array) $column, - fn(QueryBuilder $base) => $base->avg($column) - ); - } - - public function average($column): mixed - { - return $this->avg($column); - } - - public function min($column): mixed - { - return $this->cacheScalar( - ResultKind::Min, - fn() => parent::min($column), - (array) $column, - fn(QueryBuilder $base) => $base->min($column) - ); - } - - public function max($column): mixed - { - return $this->cacheScalar( - ResultKind::Max, - fn() => parent::max($column), - (array) $column, - fn(QueryBuilder $base) => $base->max($column) - ); - } - - public function exists(): bool - { - return (bool) $this->cacheScalar( - ResultKind::Exists, - fn() => parent::exists() ? 1 : 0, - compute: fn(QueryBuilder $base) => $base->exists() ? 1 : 0 - ); - } - - public function doesntExist(): bool - { - return !$this->exists(); - } - - public function pluck($column, $key = null) - { - $columns = [$column]; - if ($key !== null) { - $columns[] = $key; - } - - return $this->cacheScalar( - ResultKind::Pluck, - fn() => parent::pluck($column, $key), - $columns, - fn(QueryBuilder $base) => $this->pluckFromPreparedBase($base, $column, $key) - ); - } - - public function value($column): mixed - { - return $this->cacheScalar( - ResultKind::Value, - fn() => parent::value($column), - (array) $column, - fn(QueryBuilder $base) => $this->valueFromPreparedBase($base, $column) - ); - } - - private function cacheScalar( - ResultKind $kind, - \Closure $fallback, - array $columns = [], - ?\Closure $compute = null, - ): mixed { - if ($this->isCacheSkipped() || !NormCache::isEnabled()) { - return $fallback(); - } - - if (ProjectionClassifier::hasCalculatedColumns($columns)) { - return $fallback(); - } - - if (($kind === ResultKind::Pluck || $kind === ResultKind::Value) && $this->afterQueryCallbacks !== []) { - return $fallback(); - } - - $prepared = $this->prepareCacheExecution(); - $base = $prepared->base; - $computeValue = $compute === null - ? $fallback - : fn() => $compute($base); - $plan = $this->planPrepared($prepared, fn() => CachePlanContext::scalar($columns)); - - if (!$plan->isCacheable()) { - if (!$plan->hasBypassReason('opted_out')) { - CacheReporter::queryBypassed($this->model::class, $plan->bypassReasons); - } - - return $computeValue(); - } - - $result = NormCache::withSpace($plan->space, fn() => NormCache::resultCache()->execute( - $prepared, - $plan, - $kind, - $columns, - $computeValue - )); - - return $result[0]; - } - - private function pluckFromPreparedBase(QueryBuilder $base, mixed $column, mixed $key): mixed - { - $results = $base->pluck($column, $key); - $column = $column instanceof Expression ? $column->getValue($this->getGrammar()) : $column; - $column = Str::after((string) $column, $this->model->getTable() . '.'); - - if (!$this->model->hasAnyGetMutator($column) - && !$this->model->hasCast($column) - && !in_array($column, $this->model->getDates())) { - return $results; - } - - return ScalarTransformer::transformScalars($results, $this->model, $column); - } - - private function valueFromPreparedBase(QueryBuilder $base, mixed $column): mixed - { - $result = $base->first([$column]); - - if ($result === null) { - return null; - } - - $column = $column instanceof Expression ? $column->getValue($this->getGrammar()) : $column; - $column = Str::afterLast((string) $column, '.'); - // Match native Eloquent, which yields null for an aliased projection ("x as y"). - $value = $result->{$column} ?? null; - - if (!$this->model->hasAnyGetMutator($column) - && !$this->model->hasCast($column) - && !in_array($column, $this->model->getDates())) { - return $value; - } - - return ScalarTransformer::transformScalar($value, $this->model, $column); - } -} diff --git a/src/Traits/HandlesInvalidation.php b/src/Traits/HandlesInvalidation.php deleted file mode 100644 index 9c65f69..0000000 --- a/src/Traits/HandlesInvalidation.php +++ /dev/null @@ -1,82 +0,0 @@ -invalidator()->invalidateVersion($model); - } - - public function flushModel(Model|string $model): void - { - $this->invalidator()->flushModel($model); - } - - public function flushInstance(Model $model): void - { - $this->invalidator()->invalidateVersion($model); - } - - public function invalidateTableVersion(string $connectionName, string $table): void - { - $this->invalidator()->invalidateTableVersion($connectionName, $table); - } - - public function invalidatePivotTableVersion(string $connectionName, string $table, array $modelClasses): void - { - $this->invalidator()->invalidatePivotTableVersion($connectionName, $table, $modelClasses); - } - - public function forceFlushModel(string $modelClass, ?string $connectionName = null): void - { - $this->invalidator()->forceFlushModel($modelClass, $connectionName); - } - - public function flushAll(CacheSpace|string|null $space = null): int - { - return $this->invalidator()->flushAll($space); - } - - public function flushTag(string $modelClass, string $tag): int - { - return $this->invalidator()->flushTag($modelClass, $tag); - } - - public function flushTagAcrossModels(string $tag): int - { - return $this->invalidator()->flushTagAcrossModels($tag); - } - - public function invalidateMultipleVersions(array $modelClasses, ?string $connectionName = null): void - { - $this->invalidator()->invalidateMultipleVersions($modelClasses, $connectionName); - } - - public function commitPending(string $connectionName): void - { - $this->invalidator()->commitPending($connectionName); - } - - public function discardPending(string $connectionName): void - { - $this->invalidator()->discardPending($connectionName); - } - - public function discardAllPending(): void - { - $this->invalidator()->discardAllPending(); - } - - private function modelSpaces(string $modelClass, ?string $connectionName = null, bool $freshTableSpaces = false): array - { - return $this->invalidator()->modelSpaces($modelClass, $connectionName, $freshTableSpaces); - } -} diff --git a/src/Values/BuildHandle.php b/src/Values/BuildHandle.php deleted file mode 100644 index 25f217d..0000000 --- a/src/Values/BuildHandle.php +++ /dev/null @@ -1,15 +0,0 @@ -cooldown > 0; + return $this->buildingLockTtl + (int) ceil($this->stampedeWaitMs / 1000) + 5; + } + + /** @param array $values */ + public static function fromArray(array $values): self + { + $keyPrefix = (string) ($values['key_prefix'] ?? ''); + + if (str_contains($keyPrefix, '{') || str_contains($keyPrefix, '}')) { + throw new \InvalidArgumentException('NormCache key_prefix must not contain Redis hash-tag braces.'); + } + + $rowTtl = self::positive($values, 'row_ttl', 604_800); + $queryTtl = self::positive($values, 'query_ttl', 3_600); + $maxAutoOverlayRows = self::nonNegative( + $values, + 'auto_overlay_max_rows', + 1000, + ); + $buildingLockTtl = self::positive($values, 'building_lock_ttl', 5); + $stampedeWaitMs = self::positive($values, 'stampede_wait_ms', 200); + + return new self( + connection: (string) ($values['connection'] ?? 'cache'), + keyPrefix: $keyPrefix, + serializer: self::serializer($values['serializer'] ?? 'auto'), + rowTtl: $rowTtl, + queryTtl: $queryTtl, + maxAutoOverlayRows: $maxAutoOverlayRows, + buildingLockTtl: $buildingLockTtl, + stampedeWaitMs: $stampedeWaitMs, + enabled: (bool) ($values['enabled'] ?? true), + dispatchEvents: (bool) ($values['events'] ?? false), + debugbar: (bool) ($values['debugbar'] ?? false), + revalidation: (bool) ($values['revalidation'] ?? true), + ); + } + + private static function serializer(mixed $value): string + { + if (!is_string($value) || !in_array($value, ['auto', 'php', 'igbinary'], true)) { + throw new \InvalidArgumentException( + 'NormCache serializer must be auto, php, or igbinary.', + ); + } + + return $value; + } + + /** @param array $values */ + private static function positive(array $values, string $key, int $default): int + { + $value = (int) ($values[$key] ?? $default); + + if ($value < 1) { + throw new \InvalidArgumentException("NormCache {$key} must be at least 1."); + } + + return $value; + } + + /** @param array $values */ + private static function nonNegative(array $values, string $key, int $default): int + { + $value = (int) ($values[$key] ?? $default); + + if ($value < 0) { + throw new \InvalidArgumentException("NormCache {$key} must be at least 0."); + } + + return $value; } } diff --git a/src/Values/CachePlan.php b/src/Values/CachePlan.php deleted file mode 100644 index c8db1fc..0000000 --- a/src/Values/CachePlan.php +++ /dev/null @@ -1,120 +0,0 @@ -> $bypassReasons - */ - public function __construct( - public CacheStrategy $strategy, - public CacheOperation $operation, - public DependencySet $dependencies, - public ?array $columns = null, - public ?array $primaryKeys = null, - public array $bypassReasons = [], - public ?CacheSpace $space = null, - ) {} - - public function withSpace(CacheSpace $space): self - { - return new self( - strategy: $this->strategy, - operation: $this->operation, - dependencies: $this->dependencies, - columns: $this->columns, - primaryKeys: $this->primaryKeys, - bypassReasons: $this->bypassReasons, - space: $space, - ); - } - - public static function modelIndex( - CacheOperation $operation, - DependencySet $dependencies, - ?array $columns = null, - ?array $primaryKeys = null, - ): self { - return new self( - strategy: CacheStrategy::ModelIndex, - operation: $operation, - dependencies: $dependencies, - columns: $columns, - primaryKeys: $primaryKeys, - ); - } - - public static function result( - CacheOperation $operation, - DependencySet $dependencies, - ?array $columns = null, - ?array $primaryKeys = null, - ): self { - return new self( - strategy: CacheStrategy::Result, - operation: $operation, - dependencies: $dependencies, - columns: $columns, - primaryKeys: $primaryKeys, - ); - } - - public static function bypass( - CacheOperation $operation, - DependencySet $dependencies, - array $bypassReasons = [], - ): self { - return new self( - strategy: CacheStrategy::LiveQuery, - operation: $operation, - dependencies: $dependencies, - bypassReasons: $bypassReasons, - ); - } - - public static function direct( - CacheOperation $operation, - DependencySet $dependencies, - array $primaryKeys, - ?array $columns = null, - ): self { - return new self( - strategy: CacheStrategy::DirectModels, - operation: $operation, - dependencies: $dependencies, - columns: $columns, - primaryKeys: $primaryKeys, - ); - } - - public function hasBypassReason(string $category): bool - { - return isset($this->bypassReasons[$category]) && !empty($this->bypassReasons[$category]); - } - - /** @return list all bypass reasons, category order preserved */ - public function flatReasons(): array - { - return array_values(array_unique(array_merge(...array_values($this->bypassReasons ?: [[]])))); - } - - public function isCacheable(): bool - { - return $this->strategy !== CacheStrategy::LiveQuery; - } - - public function usesModelCache(): bool - { - return $this->strategy === CacheStrategy::ModelIndex - || $this->strategy === CacheStrategy::DirectModels; - } - - public function usesResultCache(): bool - { - return $this->strategy === CacheStrategy::Result; - } -} diff --git a/src/Values/CachePlanContext.php b/src/Values/CachePlanContext.php deleted file mode 100644 index 73fde21..0000000 --- a/src/Values/CachePlanContext.php +++ /dev/null @@ -1,50 +0,0 @@ -requiredDependencies = $requiredDependencies ?? DependencySet::empty(); - } - - /** @param bool $selectAll the caller requested the default ['*'] projection */ - public static function models(?array $columns = null, bool $selectAll = false): self - { - return new self(CacheOperation::Models, $columns, selectAll: $selectAll); - } - - public static function scalar(array $columns = [], array $contextReasons = []): self - { - return new self(CacheOperation::Scalar, $columns, $contextReasons); - } - - public static function paginationCount(): self - { - return new self(CacheOperation::PaginationCount); - } - - public static function pivot(array $columns = []): self - { - return new self(CacheOperation::Pivot, $columns); - } - - public static function through(array $columns = [], ?DependencySet $required = null): self - { - return new self( - CacheOperation::Through, - $columns, - requiredDependencies: $required, - ); - } -} diff --git a/src/Values/CacheRead.php b/src/Values/CacheRead.php new file mode 100644 index 0000000..e509bfe --- /dev/null +++ b/src/Values/CacheRead.php @@ -0,0 +1,69 @@ + $rows */ + public function __construct( + public CacheState $state, + public ReadOutcome $outcome, + public array $rows = [], + public ?string $reason = null, + public bool $overlayRejected = false, + public ?MembershipPayload $staleMembership = null, + public ?string $staleMembershipRaw = null, + ) {} + + public function served(): bool + { + return $this->outcome->served(); + } + + public function promotable(): bool + { + return $this->served() && !$this->overlayRejected; + } + + /** @param array $rows */ + public function withRows(array $rows): self + { + return new self( + $this->state, + $this->outcome, + $rows, + $this->reason, + $this->overlayRejected, + $this->staleMembership, + $this->staleMembershipRaw, + ); + } + + public function withReason(?string $reason): self + { + return new self( + $this->state, + $this->outcome, + $this->rows, + $reason, + $this->overlayRejected, + $this->staleMembership, + $this->staleMembershipRaw, + ); + } + + public function asRepaired(?string $reason): self + { + return new self( + $this->state, + ReadOutcome::REPAIRED, + $this->rows, + $reason, + $this->overlayRejected, + $this->staleMembership, + $this->staleMembershipRaw, + ); + } +} diff --git a/src/Values/CacheSpace.php b/src/Values/CacheSpace.php deleted file mode 100644 index 27104f4..0000000 --- a/src/Values/CacheSpace.php +++ /dev/null @@ -1,12 +0,0 @@ - $versions table hash => version, excluding the root outside QUERY_GROUP */ + public function __construct( + public string $key, + public string $epoch, + public string $version, + public string $generation, + public array $versions, + public ?string $tag, + public ?string $tagKey, + ) {} + + // Readonly objects still use identity under ===. + public function equals(self $other): bool + { + return $this == $other; + } +} diff --git a/src/Values/CachedRow.php b/src/Values/CachedRow.php new file mode 100644 index 0000000..67c4d4f --- /dev/null +++ b/src/Values/CachedRow.php @@ -0,0 +1,13 @@ + $columns */ + public function __construct( + public bool $valid, + public string $mutation = '', + public array $columns = [], + public bool $precise = false, + ) {} + + public static function corrupt(): self + { + return new self(false); + } +} diff --git a/src/Values/DependencyAnalysis.php b/src/Values/DependencyAnalysis.php new file mode 100644 index 0000000..b27f052 --- /dev/null +++ b/src/Values/DependencyAnalysis.php @@ -0,0 +1,27 @@ + $tables */ + public function __construct( + public ?TableIdentity $root, + public array $tables, + public bool $queryScoped, + public ?string $bypassReason, + public bool $volatile = false, + ) {} + + /** @param list $tables */ + public static function hasExternalTo(TableIdentity $root, array $tables): bool + { + foreach ($tables as $table) { + if ($table->hash !== $root->hash) { + return true; + } + } + + return false; + } +} diff --git a/src/Values/DependencyDeclaration.php b/src/Values/DependencyDeclaration.php new file mode 100644 index 0000000..872a1f5 --- /dev/null +++ b/src/Values/DependencyDeclaration.php @@ -0,0 +1,39 @@ +|string $value */ + private function __construct( + public string $type, + public string $value, + ) {} + + public static function table(string $table): self + { + return new self(self::TABLE, $table); + } + + /** @param class-string $model */ + public static function model(string $model): self + { + return new self(self::MODEL, $model); + } + + public function key(): string + { + return $this->type . ':' . $this->value; + } + + public function isTable(): bool + { + return $this->type === self::TABLE; + } +} diff --git a/src/Values/DependencySet.php b/src/Values/DependencySet.php deleted file mode 100644 index 341dd1d..0000000 --- a/src/Values/DependencySet.php +++ /dev/null @@ -1,58 +0,0 @@ -models, ...$plan->models])), - tables: array_values(array_unique([...$this->tables, ...$plan->tables])), - safe: $this->safe && $plan->safe, - reasons: [...$this->reasons, ...$plan->reasons], - ); - } - - public function hasNoDependencies(): bool - { - return $this->models === [] && $this->tables === []; - } - - public function depClassesFor(string $primaryModel): array - { - $dependencies = []; - - foreach ($this->models as $model) { - if ($model !== $primaryModel) { - $dependencies[] = $model; - } - } - - return $dependencies; - } -} diff --git a/src/Values/MembershipPayload.php b/src/Values/MembershipPayload.php new file mode 100644 index 0000000..4444111 --- /dev/null +++ b/src/Values/MembershipPayload.php @@ -0,0 +1,25 @@ + $ids + * @param array $versions + */ + public function __construct( + public bool $valid, + public array $ids = [], + public ?string $epoch = null, + public ?string $generation = null, + public array $versions = [], + public ?string $tagVersion = null, + public bool $overlayRejected = false, + public ?string $rootVersion = null, + ) {} + + public static function corrupt(): self + { + return new self(false); + } +} diff --git a/src/Values/ModelFetchContext.php b/src/Values/ModelFetchContext.php deleted file mode 100644 index 0457137..0000000 --- a/src/Values/ModelFetchContext.php +++ /dev/null @@ -1,28 +0,0 @@ - id => hydrated model */ - public array $hits = []; - - public ?string $lockKey = null; - - public ?string $wakeKey = null; - - public ?string $token = null; - - public function __construct( - public readonly string $modelClass, - public readonly string $classKey, - public readonly ?array $projection, - public readonly ?Model $prototype, - public readonly ?CacheableBuilder $missedQuery, - public readonly bool $preserveQueryShape, - public int $modelVersion, - ) {} -} diff --git a/src/Values/ModelWriteState.php b/src/Values/ModelWriteState.php deleted file mode 100644 index f28e726..0000000 --- a/src/Values/ModelWriteState.php +++ /dev/null @@ -1,10 +0,0 @@ - $bindings + * @param list $primaryKeyTokens + */ + public function __construct( + public string $outcome, + public ?string $route = null, + public ?string $tableHash = null, + public ?string $queryHash = null, + public ?string $reason = null, + public ?string $sql = null, + public array $bindings = [], + public ?string $modelClass = null, + public ?string $invalidationMode = null, + public array $primaryKeyTokens = [], + public float $startedAt = 0.0, + public float $endedAt = 0.0, + ) {} +} diff --git a/src/Values/OverlayAdmission.php b/src/Values/OverlayAdmission.php new file mode 100644 index 0000000..92a7976 --- /dev/null +++ b/src/Values/OverlayAdmission.php @@ -0,0 +1,26 @@ + $data parentId => deserialized payload or null on miss - */ - public function __construct( - public string $seg, - public array $data, - public BuildHandle $build = new BuildHandle, - public CacheStatus $status = CacheStatus::Hit, - ) {} - - public function missedIds(): array - { - return array_keys(array_filter( - $this->data, - fn($payload) => !is_array($payload) - )); - } -} diff --git a/src/Values/PreparedQuery.php b/src/Values/PreparedQuery.php deleted file mode 100644 index 8d70527..0000000 --- a/src/Values/PreparedQuery.php +++ /dev/null @@ -1,69 +0,0 @@ -base; - - if (empty($base->columns) && $columns !== ['*']) { - $base->columns = $columns; - } - - return $base; - } - - public function applyBeforeCallbacks(): self - { - if (!$this->beforeCallbacksApplied) { - $this->base->applyBeforeQueryCallbacks(); - $this->beforeCallbacksApplied = true; - } - - return $this; - } - - public function collect( - array $columns = ['*'], - bool $applyAfterCallbacks = true, - ?Closure $beforeEagerLoad = null, - ): Collection { - $models = $this->builder->getModels($columns); - $beforeEagerLoad?->__invoke($models); - - return $this->finalizeModels($models, $applyAfterCallbacks); - } - - public function finalizeModels(array $models, bool $applyAfterCallbacks = true): Collection - { - if ($models !== [] && $this->builder->getEagerLoads() !== []) { - $models = $this->builder->eagerLoadRelations($models); - } - - $collection = $this->builder->getModel()->newCollection($models); - - return $applyAfterCallbacks - ? $this->builder->applyAfterQueryCallbacks($collection) - : $collection; - } - - public function applyAfterCallbacks(mixed $result): mixed - { - return $this->builder->applyAfterQueryCallbacks($result); - } -} diff --git a/src/Values/PrimaryKeyMetadata.php b/src/Values/PrimaryKeyMetadata.php new file mode 100644 index 0000000..a6fd891 --- /dev/null +++ b/src/Values/PrimaryKeyMetadata.php @@ -0,0 +1,91 @@ +family === self::INTEGER + ? $this->integerToken($value) + : $this->stringToken($value); + } + + public function matchesToken(mixed $value, string $token): bool + { + return $this->token($value) === $token; + } + + public function valueFromToken(string $token): int|string|null + { + if ($this->family === self::INTEGER) { + if (!str_starts_with($token, 'i:')) { + return null; + } + + $value = substr($token, 2); + + if ($this->integerToken($value) !== $token) { + return null; + } + + return (string) (int) $value === $value + ? (int) $value + : $value; + } + + if (!str_starts_with($token, 's:')) { + return null; + } + + $encoded = substr($token, 2); + $padding = (4 - strlen($encoded) % 4) % 4; + $decoded = base64_decode( + strtr($encoded, '-_', '+/') . str_repeat('=', $padding), + true, + ); + + return is_string($decoded) && $this->stringToken($decoded) === $token + ? $decoded + : null; + } + + private function integerToken(mixed $value): ?string + { + if (is_int($value)) { + return 'i:' . $value; + } + + if (!is_string($value) || preg_match('/^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$/D', $value) !== 1) { + return null; + } + + return 'i:' . $value; + } + + private function stringToken(mixed $value): ?string + { + if (!is_string($value)) { + return null; + } + + return 's:' . rtrim(strtr(base64_encode($value), '+/', '-_'), '='); + } +} diff --git a/src/Values/QueryPlan.php b/src/Values/QueryPlan.php new file mode 100644 index 0000000..eb0abb0 --- /dev/null +++ b/src/Values/QueryPlan.php @@ -0,0 +1,173 @@ + $dependencies + * @param list|null $projectedColumns + * @param list|null $predicateColumns + */ + private function __construct( + public string $route, + public TableIdentity $root, + public array $dependencies, + public ?PrimaryKeyMetadata $primaryKey = null, + public ?string $primaryKeyToken = null, + public ?string $softDeleteMode = null, + public ?string $deletedAtColumn = null, + public ?array $projectedColumns = null, + public ?array $predicateColumns = null, + ) {} + + /** @param list $dependencies */ + public static function queryGroup(TableIdentity $root, array $dependencies): self + { + return new self(self::QUERY_GROUP, $root, $dependencies); + } + + /** @param list $dependencies */ + public static function directPrimaryKey( + TableIdentity $root, + array $dependencies, + PrimaryKeyMetadata $primaryKey, + string $primaryKeyToken, + ?string $softDeleteMode, + ?string $deletedAtColumn, + ): self { + return new self( + self::DIRECT_PK, + $root, + $dependencies, + $primaryKey, + $primaryKeyToken, + $softDeleteMode, + $deletedAtColumn, + ); + } + + /** + * @param list $dependencies + * @param list|null $predicateColumns + */ + public static function canonical( + TableIdentity $root, + array $dependencies, + PrimaryKeyMetadata $primaryKey, + ?array $predicateColumns = null, + ): self { + return new self( + self::CANONICAL, + $root, + $dependencies, + $primaryKey, + predicateColumns: $predicateColumns, + ); + } + + /** @param list $dependencies */ + public static function result( + TableIdentity $root, + array $dependencies, + ?PrimaryKeyMetadata $primaryKey = null, + ): self { + return new self(self::RESULT, $root, $dependencies, $primaryKey); + } + + /** + * @param list $dependencies + * @param list $projectedColumns + * @param list|null $predicateColumns + */ + public static function projectedResult( + TableIdentity $root, + array $dependencies, + PrimaryKeyMetadata $primaryKey, + array $projectedColumns, + ?array $predicateColumns = null, + ): self { + return new self( + self::RESULT, + $root, + $dependencies, + $primaryKey, + projectedColumns: $projectedColumns, + predicateColumns: $predicateColumns, + ); + } + + /** + * @param list $dependencies + * @param list $projectedColumns + */ + public static function projectedRow( + TableIdentity $root, + array $dependencies, + PrimaryKeyMetadata $primaryKey, + string $primaryKeyToken, + array $projectedColumns, + ?string $softDeleteMode, + ?string $deletedAtColumn, + ): self { + return new self( + self::RESULT, + $root, + $dependencies, + $primaryKey, + $primaryKeyToken, + $softDeleteMode, + $deletedAtColumn, + $projectedColumns, + ); + } + + public function isCanonical(): bool + { + return $this->route === self::CANONICAL; + } + + public function isResult(): bool + { + return $this->route === self::RESULT; + } + + public function isQueryGroup(): bool + { + return $this->route === self::QUERY_GROUP; + } + + public function isDirectPrimaryKey(): bool + { + return $this->route === self::DIRECT_PK; + } + + public function usesGeneration(): bool + { + return $this->isCanonical() || $this->isDirectPrimaryKey(); + } + + public function supportsRowFallback(): bool + { + return $this->isResult() + && $this->primaryKey !== null + && $this->projectedColumns !== null + && $this->primaryKeyToken !== null; + } + + public function supportsCanonicalProjectionFallback(): bool + { + return $this->isResult() + && $this->primaryKey !== null + && $this->projectedColumns !== null + && $this->primaryKeyToken === null; + } +} diff --git a/src/Values/RawResultPayload.php b/src/Values/RawResultPayload.php new file mode 100644 index 0000000..64c148b --- /dev/null +++ b/src/Values/RawResultPayload.php @@ -0,0 +1,23 @@ + $rows + * @param array $versions + */ + public function __construct( + public bool $valid, + public array $rows = [], + public ?string $epoch = null, + public array $versions = [], + public ?string $tagVersion = null, + public ?string $rootVersion = null, + ) {} + + public static function corrupt(): self + { + return new self(false); + } +} diff --git a/src/Values/RowRepair.php b/src/Values/RowRepair.php new file mode 100644 index 0000000..0618c4c --- /dev/null +++ b/src/Values/RowRepair.php @@ -0,0 +1,14 @@ + $rows */ + public function __construct( + public array $rows, + public ReadOutcome $outcome, + ) {} +} diff --git a/src/Values/SpaceValidationResult.php b/src/Values/SpaceValidationResult.php deleted file mode 100644 index 651ce35..0000000 --- a/src/Values/SpaceValidationResult.php +++ /dev/null @@ -1,19 +0,0 @@ - $invalidModels - * @param array> $dependenciesBySpace - */ - public function __construct( - public bool $isValid, - public CacheSpace $space, - public array $invalidModels = [], - public array $dependenciesBySpace = [], - ) {} -} diff --git a/src/Values/TableIdentity.php b/src/Values/TableIdentity.php new file mode 100644 index 0000000..ae45f57 --- /dev/null +++ b/src/Values/TableIdentity.php @@ -0,0 +1,83 @@ +driver) { + 'mysql', 'mariadb' => $this->database . '.' . $this->table, + 'pgsql' => $this->schema . '.' . $this->table, + 'sqlsrv' => $this->database . '.' . $this->schema . '.' . $this->table, + 'sqlite' => $this->schema === '' + ? $this->table + : $this->schema . '.' . $this->table, + default => $this->table, + }; + } + + /** @param list $fields */ + public static function encodeFields(array $fields): string + { + $encoded = ''; + + foreach ($fields as $field) { + $encoded .= strlen($field) . ':' . $field; + } + + return $encoded; + } +} diff --git a/src/Values/VersionedPayloadOutcome.php b/src/Values/VersionedPayloadOutcome.php deleted file mode 100644 index e780ba8..0000000 --- a/src/Values/VersionedPayloadOutcome.php +++ /dev/null @@ -1,15 +0,0 @@ -> */ + protected array $measures = []; + /** @param array $params */ - public function addMeasure(string $label, float $start, float $end, array $params = []): void {} + public function addMeasure(string $label, float $start, float $end, array $params = []): void + { + $this->measures[] = [ + 'label' => $label, + 'start' => $start, + 'end' => $end, + 'duration' => $end - $start, + 'params' => $params, + ]; + } /** @return array */ public function collect(): array { - return ['measures' => []]; + return ['measures' => $this->measures]; } } diff --git a/tests/Concerns/CacheAssertions.php b/tests/Concerns/CacheAssertions.php new file mode 100644 index 0000000..43ec5de --- /dev/null +++ b/tests/Concerns/CacheAssertions.php @@ -0,0 +1,310 @@ + */ + protected function cacheKeysMatching(string $needle): array + { + $connection = Redis::connection('normcache-test'); + $client = $connection->client(); + $keys = []; + + if ($client instanceof \RedisCluster) { + $raw = $client->keys('*'); + $keys = is_array($raw) ? array_merge([], ...array_map(static fn(mixed $v): array => (array) $v, $raw)) : []; + } elseif (class_exists(Client::class) && $client instanceof Client && $this->isClusterRun()) { + foreach ($client as $node) { + $keys = [...$keys, ...(array) $node->keys('*')]; + } + } else { + $keys = (array) $connection->keys('*'); + } + + return array_values(array_filter( + array_unique(array_map(strval(...), $keys)), + static fn(string $key): bool => str_contains($key, $needle), + )); + } + + protected function deleteResultOverlays(): void + { + foreach ($this->cacheQueryKeysWithField('r') as $key) { + $this->cacheStore()->deleteHashField($key, 'r'); + } + } + + /** @return list */ + protected function cacheEntryKeys(): array + { + return array_values(array_filter( + $this->cacheKeysMatching(':q:'), + static fn(string $key): bool => !str_contains($key, ':build:q:') + && !str_contains($key, ':wake:q:'), + )); + } + + /** @return list */ + protected function cacheQueryKeysWithField(string $field): array + { + return array_values(array_filter( + $this->cacheEntryKeys(), + fn(string $key): bool => $this->cacheStore()->readHashField($key, $field) !== null, + )); + } + + private function isClusterRun(): bool + { + return env('REDIS_CLUSTER') === 'true' || env('REDIS_CLUSTER') === true; + } + + /** @return list> */ + protected function contract( + callable $cached, + callable $native, + bool $expectNoStrayQueries = true, + ?callable $mutate = null, + ): array { + $this->cacheManager()->flushAll(); + $expected = $this->nativeResult($native); + [$cold] = $this->observedCacheCall($cached, 'miss'); + [$warm, $strayQueries] = $this->observedCacheCall($cached, 'hit', captureQueries: true); + + $this->assertSame($expected, $cold, 'cold cache result differs from native Eloquent'); + $this->assertSame($cold, $warm, 'warm cache result differs from cold'); + + if ($expectNoStrayQueries) { + $this->assertSame([], $strayQueries, 'expected the warm result to be served entirely from NormCache'); + } + + if ($mutate !== null) { + $mutate(); + $expectedAfterMutation = $this->nativeResult($native); + [$fresh] = $this->observedCacheCall($cached, 'miss'); + [$rewarmed, $rewarmedQueries] = $this->observedCacheCall( + $cached, + 'hit', + captureQueries: true, + ); + + $this->assertSame( + $expectedAfterMutation, + $fresh, + 'first read after dependency mutation differs from native Eloquent', + ); + $this->assertSame($fresh, $rewarmed, 'rewarmed result differs from refreshed result'); + + if ($expectNoStrayQueries) { + $this->assertSame( + [], + $rewarmedQueries, + 'expected the rewarmed result to be served entirely from NormCache', + ); + } + } + + return $strayQueries; + } + + protected function assertWarmCacheHit(callable $query): void + { + [, $queries] = $this->observedCacheCall($query, 'hit', captureQueries: true); + $this->assertSame([], $queries, 'expected the query to be served entirely from NormCache'); + } + + protected function assertColdCacheMiss(callable $query): void + { + $this->observedCacheCall($query, 'miss'); + } + + /** @return list> */ + protected function missContract(callable $query, callable $native): array + { + $this->cacheManager()->flushAll(); + $expected = $this->nativeResult($native); + [$first] = $this->observedCacheCall($query, 'miss', captureQueries: true); + [$second, $queries] = $this->observedCacheCall($query, 'miss', captureQueries: true); + + $this->assertSame($expected, $first, 'uncached result differs from native Eloquent'); + $this->assertSame($first, $second, 'repeated uncached result differs from the first execution'); + $this->assertNotSame([], $queries, 'expected an uncached operation to execute SQL'); + + return $queries; + } + + /** @return list> */ + protected function databaseContract(callable $query, callable $native): array + { + $this->cacheManager()->flushAll(); + $expected = $this->nativeResult($native); + [$first] = $this->observedCacheCall($query, 'none', captureQueries: true); + [$second, $queries] = $this->observedCacheCall($query, 'none', captureQueries: true); + + $this->assertSame($expected, $first, 'direct database result differs from native Eloquent'); + $this->assertSame($first, $second, 'repeated direct database result differs from the first execution'); + $this->assertNotSame([], $queries, 'expected a direct database operation to execute SQL'); + + return $queries; + } + + /** @return list> */ + protected function bypassContract( + callable $query, + callable $native, + ?string $reason = null, + ): array { + $this->cacheManager()->flushAll(); + $expected = $this->nativeResult($native); + [$first] = $this->observedCacheCall($query, 'bypass', captureQueries: true, bypassReason: $reason); + [$second, $queries] = $this->observedCacheCall( + $query, + 'bypass', + captureQueries: true, + bypassReason: $reason, + ); + + $this->assertSame($expected, $first, 'bypassed result differs from native Eloquent'); + $this->assertSame($first, $second, 'repeated bypass result differs from the first execution'); + $this->assertNotSame([], $queries, 'expected a bypassed operation to execute SQL'); + + return $queries; + } + + protected function nativeResult(callable $query): mixed + { + return $this->normalize($this->cacheManager()->withoutCache($query)); + } + + /** + * @return array{0: mixed, 1: list>} + */ + private function observedCacheCall( + callable $callback, + string $expectedOutcome, + bool $captureQueries = false, + ?string $bypassReason = null, + ): array { + $dispatcher = Event::getFacadeRoot(); + Event::fake([ + QueryCacheHit::class, + QueryCacheMiss::class, + QueryBypassed::class, + ]); + + if ($captureQueries) { + DB::flushQueryLog(); + DB::enableQueryLog(); + } + + try { + $result = $this->normalize($callback()); + $queries = $captureQueries ? DB::getQueryLog() : []; + + match ($expectedOutcome) { + 'hit' => $this->assertCacheHitEvents(), + 'miss' => $this->assertCacheMissEvents(), + 'bypass' => $this->assertCacheBypassEvents($bypassReason), + 'none' => $this->assertNoCacheEvents(), + default => throw new \InvalidArgumentException("Unknown cache contract outcome [{$expectedOutcome}]."), + }; + + return [$result, $queries]; + } finally { + if ($captureQueries) { + DB::disableQueryLog(); + } + + Event::swap($dispatcher); + } + } + + private function assertCacheHitEvents(): void + { + Event::assertDispatched(QueryCacheHit::class); + Event::assertNotDispatched(QueryCacheMiss::class); + Event::assertNotDispatched(QueryBypassed::class); + } + + private function assertCacheMissEvents(): void + { + Event::assertDispatched(QueryCacheMiss::class); + Event::assertNotDispatched(QueryCacheHit::class); + Event::assertNotDispatched(QueryBypassed::class); + } + + private function assertCacheBypassEvents(?string $reason): void + { + Event::assertNotDispatched(QueryCacheHit::class); + Event::assertNotDispatched(QueryCacheMiss::class); + Event::assertDispatched( + QueryBypassed::class, + $reason === null + ? null + : static fn(QueryBypassed $event): bool => $event->reason === $reason, + ); + } + + private function assertNoCacheEvents(): void + { + Event::assertNotDispatched(QueryCacheHit::class); + Event::assertNotDispatched(QueryCacheMiss::class); + Event::assertNotDispatched(QueryBypassed::class); + } + + protected function normalize(mixed $value): mixed + { + if ($value instanceof LengthAwarePaginator) { + return [ + 'data' => collect($value->items())->map->toArray()->values()->all(), + 'total' => $value->total(), + 'current_page' => $value->currentPage(), + 'has_more' => $value->hasMorePages(), + ]; + } + + if ($value instanceof Paginator) { + return [ + 'data' => collect($value->items())->map->toArray()->values()->all(), + 'current_page' => $value->currentPage(), + 'has_more' => $value->hasMorePages(), + ]; + } + + if ($value instanceof CursorPaginator) { + return [ + 'data' => collect($value->items())->map->toArray()->values()->all(), + 'has_more' => $value->hasMorePages(), + 'cursor' => $value->cursor()?->toArray(), + ]; + } + + if ($value instanceof EloquentCollection) { + return $value->map->toArray()->values()->all(); + } + + if ($value instanceof Collection) { + return $value->all(); + } + + if ($value instanceof Model) { + return $value->toArray(); + } + + return $value; + } +} diff --git a/tests/Fixtures/Models/AbstractComment.php b/tests/Fixtures/Models/AbstractComment.php new file mode 100644 index 0000000..cbf8216 --- /dev/null +++ b/tests/Fixtures/Models/AbstractComment.php @@ -0,0 +1,12 @@ +hasManyThrough(SpacedPost::class, SpacedAuthor::class, 'country_id', 'author_id'); - } - - public function crossSpacePosts(): HasManyThrough - { - return $this->hasManyThrough(SpacedPost::class, ReportingAuthor::class, 'country_id', 'author_id'); - } -} diff --git a/tests/Fixtures/Models/SpacedAuthor.php b/tests/Fixtures/Models/SpacedAuthor.php deleted file mode 100644 index a04545d..0000000 --- a/tests/Fixtures/Models/SpacedAuthor.php +++ /dev/null @@ -1,18 +0,0 @@ -belongsTo(SpacedAuthor::class, 'author_id'); - } - - public function catalogTags(): MorphToMany - { - return $this->morphToMany(CatalogTag::class, 'taggable', 'taggables', 'taggable_id', 'tag_id'); - } -} diff --git a/tests/Fixtures/Models/UncachedAuthor.php b/tests/Fixtures/Models/UncachedAuthor.php deleted file mode 100644 index 00e6f02..0000000 --- a/tests/Fixtures/Models/UncachedAuthor.php +++ /dev/null @@ -1,18 +0,0 @@ -hasMany(UncachedPost::class, 'author_id'); - } -} diff --git a/tests/Fixtures/Models/MultiSpacePost.php b/tests/Fixtures/Models/VolatilePost.php similarity index 53% rename from tests/Fixtures/Models/MultiSpacePost.php rename to tests/Fixtures/Models/VolatilePost.php index 1e598e2..1ebfdfd 100644 --- a/tests/Fixtures/Models/MultiSpacePost.php +++ b/tests/Fixtures/Models/VolatilePost.php @@ -5,8 +5,7 @@ use Illuminate\Database\Eloquent\Model; use NormCache\Traits\Cacheable; -// Multi-space model on the posts table used to verify invalidation fan-out. -class MultiSpacePost extends Model +final class VolatilePost extends Model { use Cacheable; @@ -14,5 +13,6 @@ class MultiSpacePost extends Model protected $guarded = []; - protected static array $normCacheSpaces = ['content', 'reporting']; + /** @var list */ + protected array $volatileColumns = ['published', 'title_length']; } diff --git a/tests/Integration/Cache/CacheAccuracyTest.php b/tests/Integration/Cache/CacheAccuracyTest.php deleted file mode 100644 index b406fce..0000000 --- a/tests/Integration/Cache/CacheAccuracyTest.php +++ /dev/null @@ -1,354 +0,0 @@ - 'Alice']); - - Author::find($author->id); - - $cached = Author::whereKey($author->id) - ->select(['display_name' => 'name']) - ->first(); - - $this->assertArrayHasKey('name', $cached->getAttributes()); - $this->assertSame('Alice', $cached->name); - $this->assertArrayNotHasKey('display_name', $cached->getAttributes()); - } - - public function test_expression_alias_columns_bypass_cache(): void - { - Author::create(['name' => 'Alice']); - - Event::fake([ModelCacheHit::class, ModelCacheMiss::class, QueryCacheHit::class, QueryCacheMiss::class]); - - $author = Author::select(DB::raw('count(*) as total'))->first(); - - $this->assertSame(1, (int) $author->total); - Event::assertNotDispatched(ModelCacheHit::class); - Event::assertNotDispatched(ModelCacheMiss::class); - Event::assertNotDispatched(QueryCacheHit::class); - Event::assertNotDispatched(QueryCacheMiss::class); - } - - public function test_json_selector_alias_columns_bypass_cache(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create([ - 'title' => 'Hello', - 'author_id' => $author->id, - 'metadata' => ['section' => 'tech'], - ]); - - Event::fake([ModelCacheHit::class, ModelCacheMiss::class, QueryCacheHit::class, QueryCacheMiss::class]); - - $post = Post::select('metadata->section as section_name')->first(); - - $this->assertArrayHasKey('section_name', $post->getAttributes()); - Event::assertNotDispatched(ModelCacheHit::class); - Event::assertNotDispatched(ModelCacheMiss::class); - Event::assertNotDispatched(QueryCacheHit::class); - Event::assertNotDispatched(QueryCacheMiss::class); - } - - public function test_json_selector_columns_without_alias_bypass_cache(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create([ - 'title' => 'Hello', - 'author_id' => $author->id, - 'metadata' => ['section' => 'tech'], - ]); - - Event::fake([ModelCacheHit::class, ModelCacheMiss::class, QueryCacheHit::class, QueryCacheMiss::class]); - - Post::select('metadata->section')->first(); - - Event::assertNotDispatched(ModelCacheHit::class); - Event::assertNotDispatched(ModelCacheMiss::class); - Event::assertNotDispatched(QueryCacheHit::class); - Event::assertNotDispatched(QueryCacheMiss::class); - } - - public function test_pivot_eager_load_with_selected_columns_does_not_poison_model_cache(): void - { - $author = Author::create(['name' => 'Alice']); - $tag = Tag::create(['name' => 'Fiction']); - $author->tags()->attach($tag->id); - - Author::with(['tags' => fn($query) => $query->select('tags.id')])->get(); - - $cachedTag = Tag::find($tag->id); - - $this->assertSame('Fiction', $cachedTag->name); - } - - public function test_pivot_selected_columns_are_preserved_on_warm_hit(): void - { - $author = Author::create(['name' => 'Alice']); - $tag = Tag::create(['name' => 'Fiction']); - $author->tags()->attach($tag->id); - - Tag::find($tag->id); // populate full model cache before the constrained eager load runs - - Author::with(['tags' => fn($query) => $query->select('tags.id')])->get(); - $warm = Author::with(['tags' => fn($query) => $query->select('tags.id')])->get(); - - $attributes = $warm->first()->tags->first()->getAttributes(); - - $this->assertArrayHasKey('id', $attributes); - $this->assertArrayNotHasKey('name', $attributes); - } - - public function test_through_relation_selected_columns_are_preserved_on_warm_hit(): void - { - $country = Country::create(['name' => 'Australia']); - $author = Author::create(['name' => 'Alice', 'country_id' => $country->id]); - $post = $author->posts()->create(['title' => 'Hello']); - - $post::find($post->id); // populate full model cache before the constrained eager load runs - - $country->posts()->select('posts.id')->get(); - $warm = $country->posts()->select('posts.id')->get(); - - $attributes = $warm->first()->getAttributes(); - - $this->assertArrayHasKey('id', $attributes); - $this->assertArrayNotHasKey('title', $attributes); - } - - public function test_through_relation_with_selected_columns_does_not_poison_model_cache(): void - { - $country = Country::create(['name' => 'Australia']); - $author = Author::create(['name' => 'Alice', 'country_id' => $country->id]); - $post = $author->posts()->create(['title' => 'Hello']); - - $country->posts()->select('posts.id')->get(); - - $cachedPost = $post::find($post->id); - - $this->assertSame('Hello', $cachedPost->title); - } - - public function test_subclass_hydrates_with_own_class_not_parent_data(): void - { - Author::create(['id' => 1, 'name' => 'Alice']); - Author::find(1); - - $admin = AdminAuthor::find(1); - - $this->assertInstanceOf(AdminAuthor::class, $admin); - $this->assertSame('ALICE', $admin->display_name); - } - - public function test_pivot_relation_with_join_does_not_poison_unconstrained_warm_hit(): void - { - $author = Author::create(['name' => 'Alice']); - $tag = Tag::create(['name' => 'Fiction']); - $author->tags()->attach($tag->id); - - Post::create(['title' => 'First', 'author_id' => $author->id]); - Post::create(['title' => 'Second', 'author_id' => $author->id]); - - Author::with([ - 'tags' => fn($query) => $query->join('posts', 'posts.author_id', '=', 'author_tag.author_id'), - ])->get(); - - $warm = Author::with('tags')->get()->first()->tags; - - $this->assertSame([$tag->id], $warm->modelKeys()); - } - - public function test_mixed_pk_and_non_pk_bulk_update_does_not_leave_outdated_model_cache_entries(): void - { - $a1 = Author::create(['name' => 'Alice']); - $a2 = Author::create(['name' => 'Bob']); - - Author::all(); - - Author::where('id', $a1->id) - ->orWhere('name', 'Bob') - ->update(['name' => 'Updated']); - - $this->assertSame('Updated', Author::find($a1->id)->name); - $this->assertSame('Updated', Author::find($a2->id)->name); - } - - public function test_through_relation_with_trashed_scope_survives_related_model_cache_miss(): void - { - $country = Country::create(['name' => 'Australia']); - $author = Author::create(['name' => 'Alice', 'country_id' => $country->id]); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id]); - $post->delete(); - - $warm = $country->posts()->withTrashed()->get(); - $this->assertCount(1, $warm); - $this->assertTrue($warm->first()->trashed()); - - $this->evictModelCache(Post::class, $post->id); - - $cached = $country->posts()->withTrashed()->get(); - - $this->assertCount(1, $cached); - $this->assertTrue($cached->first()->trashed()); - } - - public function test_through_aggregate_cache_invalidates_when_intermediate_membership_changes(): void - { - $source = Country::create(['name' => 'Australia']); - $target = Country::create(['name' => 'Canada']); - $author = Author::create(['name' => 'Alice', 'country_id' => $source->id]); - $author->posts()->create(['title' => 'Hello']); - - $warm = Country::orderBy('id')->withCount('posts')->get()->keyBy('id'); - - $this->assertSame(1, $warm[$source->id]->posts_count); - $this->assertSame(0, $warm[$target->id]->posts_count); - - $author->update(['country_id' => $target->id]); - - $cached = Country::orderBy('id')->withCount('posts')->get()->keyBy('id'); - - $this->assertSame(0, $cached[$source->id]->posts_count); - $this->assertSame(1, $cached[$target->id]->posts_count); - } - - public function test_belongs_to_aggregate_cache_invalidates_when_parent_foreign_key_changes(): void - { - $country = Country::create(['name' => 'Australia']); - $author = Author::create(['name' => 'Alice']); - - $warm = Author::orderBy('id')->withCount('country')->get()->first(); - - $this->assertSame(0, $warm->country_count); - - $author->update(['country_id' => $country->id]); - - $cached = Author::orderBy('id')->withCount('country')->get()->first(); - - $this->assertSame(1, $cached->country_count); - } - - public function test_through_relation_selected_column_warm_hit_preserves_laravel_through_key_attribute(): void - { - $country = Country::create(['name' => 'Australia']); - $author = Author::create(['name' => 'Alice', 'country_id' => $country->id]); - $post = $author->posts()->create(['title' => 'Hello']); - - $coldAttributes = $country->posts()->select('posts.id')->get()->first()->getAttributes(); - $warmAttributes = $country->posts()->select('posts.id')->get()->first()->getAttributes(); - - $this->assertArrayHasKey('laravel_through_key', $coldAttributes); - $this->assertSame($post->id, $warmAttributes['id']); - $this->assertArrayHasKey('laravel_through_key', $warmAttributes); - $this->assertSame($coldAttributes['laravel_through_key'], $warmAttributes['laravel_through_key']); - } - - public function test_subclass_get_deleted_at_column_override_is_respected(): void - { - $author = Author::create(['name' => 'Alice']); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - app('normcache')->modelCache()->getModels([$post->id], Post::class); - - $resolved = (new ReflectionProperty(CacheKeyBuilder::class, 'deletedAtColumns'))->getValue(); - $this->assertSame('deleted_at', $resolved[Post::class] ?? null); - - AltDeletedAtPost::resolveSoftDelete(); - $resolved = (new ReflectionProperty(CacheKeyBuilder::class, 'deletedAtColumns'))->getValue(); - - $this->assertSame('archived_at', $resolved[AltDeletedAtPost::class] ?? null); - } - - public function test_same_table_models_on_different_connections_do_not_share_cached_data(): void - { - config()->set('database.connections.secondary_testing', [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'prefix' => '', - ]); - - Schema::connection('secondary_testing')->create('authors', function (Blueprint $table) { - $table->id(); - $table->string('name'); - $table->foreignId('country_id')->nullable(); - $table->timestamps(); - }); - - Author::create(['id' => 1, 'name' => 'Primary Alice']); - SecondaryConnectionAuthor::create(['id' => 1, 'name' => 'Secondary Alice']); - - Author::find(1); - $secondary = SecondaryConnectionAuthor::find(1); - - $this->assertSame('Secondary Alice', $secondary->name); - $this->assertSame(DB::getDefaultConnection() . ':authors', app('normcache')->keys()->classKey(Author::class)); - $this->assertSame('secondary_testing:authors', app('normcache')->keys()->classKey(SecondaryConnectionAuthor::class)); - } -} - -class SecondaryConnectionAuthor extends Model -{ - use Cacheable; - - protected $connection = 'secondary_testing'; - - protected $table = 'authors'; - - protected $guarded = []; -} - -class AdminAuthor extends Author -{ - protected $table = 'authors'; - - public function getDisplayNameAttribute(): string - { - return strtoupper($this->name); - } -} - -class AltDeletedAtPost extends Post -{ - protected $table = 'posts'; - - public function getDeletedAtColumn() - { - return 'archived_at'; - } - - public static function resolveSoftDelete(): void - { - $prototype = new self; - $col = $prototype->getDeletedAtColumn(); - $prop = new ReflectionProperty(CacheKeyBuilder::class, 'deletedAtColumns'); - $current = $prop->getValue(); - $current[self::class] = $col; - $prop->setValue(null, $current); - } -} diff --git a/tests/Integration/Cache/CacheContextTest.php b/tests/Integration/Cache/CacheContextTest.php new file mode 100644 index 0000000..d23843d --- /dev/null +++ b/tests/Integration/Cache/CacheContextTest.php @@ -0,0 +1,88 @@ + 'Author']); + $post = Post::create(['title' => 'Tenant A', 'author_id' => $author->id]); + $read = static fn(string $context): ?Post => Post::query() + ->cacheContext($context) + ->whereKey($post->id) + ->first(); + + $this->assertSame('Tenant A', $read('tenant:a')?->title); + DB::connection()->update( + 'update posts set title = ? where id = ?', + ['Tenant B', $post->id], + ); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $tenantB = $read('tenant:b'); + $tenantA = $read('tenant:a'); + DB::disableQueryLog(); + + $this->assertSame('Tenant B', $tenantB?->title); + $this->assertSame('Tenant A', $tenantA?->title); + $this->assertCount(1, DB::getQueryLog()); + $this->assertSame([], $this->cacheKeysMatching(':r:g')); + $this->assertCount(2, $this->cacheQueryKeysWithField('r')); + } + + public function test_contexts_isolate_identical_canonical_shapes(): void + { + $author = Author::create(['name' => 'Tenant A']); + $read = static fn(string $context): string => (string) Author::query() + ->cacheContext($context) + ->orderBy('id') + ->value('name'); + + $this->assertSame('Tenant A', $read('tenant:a')); + DB::connection()->update( + 'update authors set name = ? where id = ?', + ['Tenant B', $author->id], + ); + + $this->assertSame('Tenant B', $read('tenant:b')); + $this->assertSame('Tenant A', $read('tenant:a')); + $this->assertSame([], $this->cacheQueryKeysWithField('m')); + $this->assertSame([], $this->cacheKeysMatching(':r:g')); + } + + public function test_tag_flushes_still_invalidate_context_namespaces(): void + { + $author = Author::create(['name' => 'Before']); + $read = static fn(): string => (string) Author::query() + ->cacheContext('tenant:a') + ->tag('homepage') + ->value('name'); + + $this->assertSame('Before', $read()); + DB::connection()->update( + 'update authors set name = ? where id = ?', + ['After', $author->id], + ); + $this->assertSame('Before', $read()); + + $this->cacheManager()->flushTag('homepage'); + + $this->assertSame('After', $read()); + } + + public function test_cache_context_rejects_invalid_values(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('NormCache cache context'); + + RawPost::query()->toBase()->cacheContext(''); + } +} diff --git a/tests/Integration/Cache/CacheStateResolverTest.php b/tests/Integration/Cache/CacheStateResolverTest.php new file mode 100644 index 0000000..bb441b2 --- /dev/null +++ b/tests/Integration/Cache/CacheStateResolverTest.php @@ -0,0 +1,154 @@ +app->make(CacheStateResolver::class); + $plan = $this->canonicalPlan(); + + $state = $resolver->resolve($plan, 'n', 'hash'); + + $this->assertTrue($resolver->isCurrent($plan, $state)); + } + + public function test_a_state_stops_being_current_after_its_table_is_invalidated(): void + { + $resolver = $this->app->make(CacheStateResolver::class); + $plan = $this->canonicalPlan(); + + $state = $resolver->resolve($plan, 'n', 'hash'); + $this->cacheStore()->increment($this->cacheKeys()->version($plan->root)); + + $this->assertFalse($resolver->isCurrent($plan, $state)); + } + + public function test_a_state_stops_being_current_after_an_epoch_flush_it_has_already_memoized(): void + { + $resolver = $this->app->make(CacheStateResolver::class); + $plan = $this->canonicalPlan(); + + $state = $resolver->resolve($plan, 'n', 'hash'); + $this->assertTrue($resolver->isCurrent($plan, $state)); + + $this->cacheStore()->increment($this->cacheKeys()->epoch()); + + $this->assertFalse($resolver->isCurrent($plan, $state)); + } + + public function test_a_dependency_bump_invalidates_a_multi_table_state(): void + { + $resolver = $this->app->make(CacheStateResolver::class); + $root = $this->table('posts'); + $dependency = $this->table('authors'); + $plan = QueryPlan::canonical( + $root, + [$root, $dependency], + new PrimaryKeyMetadata('id', PrimaryKeyMetadata::INTEGER), + ); + + $state = $resolver->resolve($plan, 'n', 'hash'); + $this->assertTrue($resolver->isCurrent($plan, $state)); + + $this->cacheStore()->increment($this->cacheKeys()->version($dependency)); + + $this->assertFalse($resolver->isCurrent($plan, $state)); + } + + #[DataProvider('generationlessPlans')] + public function test_routes_without_a_generation_survive_a_broad_invalidation( + string $factory, + ): void { + $resolver = $this->app->make(CacheStateResolver::class); + $plan = $this->{$factory}(); + + $state = $resolver->resolve($plan, 'n', 'hash'); + $this->assertSame('0', $state->generation); + $this->assertTrue($resolver->isCurrent($plan, $state)); + + $this->cacheStore()->increment($this->cacheKeys()->generation($plan->root)); + + $this->assertTrue($resolver->isCurrent($plan, $state)); + } + + public static function generationlessPlans(): array + { + return [ + 'result' => ['resultPlan'], + 'query group' => ['queryGroupPlan'], + ]; + } + + public function test_canonical_routes_still_fail_after_a_generation_bump(): void + { + $resolver = $this->app->make(CacheStateResolver::class); + $plan = $this->canonicalPlan(); + + $state = $resolver->resolve($plan, 'n', 'hash'); + $this->cacheStore()->increment($this->cacheKeys()->generation($plan->root)); + + $this->assertFalse($resolver->isCurrent($plan, $state)); + } + + public function test_a_canonical_result_overlay_can_resolve_without_row_generation(): void + { + $resolver = $this->app->make(CacheStateResolver::class); + $plan = $this->canonicalPlan(); + + $state = $resolver->resolve($plan, 'n', 'hash', usesGeneration: false); + $this->assertSame('0', $state->generation); + + $this->cacheStore()->increment($this->cacheKeys()->generation($plan->root)); + + $this->assertTrue($resolver->isCurrent($plan, $state, usesGeneration: false)); + } + + private function resultPlan(): QueryPlan + { + $root = $this->table('posts'); + + return QueryPlan::result( + $root, + [$root], + new PrimaryKeyMetadata('id', PrimaryKeyMetadata::INTEGER), + ); + } + + private function queryGroupPlan(): QueryPlan + { + $root = $this->table('posts'); + + return QueryPlan::queryGroup($root, [$root, $this->table('authors')]); + } + + private function canonicalPlan(): QueryPlan + { + $root = $this->table('posts'); + + return QueryPlan::canonical( + $root, + [$root], + new PrimaryKeyMetadata('id', PrimaryKeyMetadata::INTEGER), + ); + } + + private function table(string $name): TableIdentity + { + $table = $this->app->make(TableIdentityResolver::class) + ->resolve(DB::connection(), $name); + $this->assertNotNull($table); + + return $table; + } +} diff --git a/tests/Integration/Cache/CacheableBuilderTest.php b/tests/Integration/Cache/CacheableBuilderTest.php deleted file mode 100644 index 842e628..0000000 --- a/tests/Integration/Cache/CacheableBuilderTest.php +++ /dev/null @@ -1,782 +0,0 @@ - 'Alice']); - Author::withoutCache()->get(); - - $this->assertEmpty($this->redisKeys('query:*')); - } - - public function test_ttl_uses_custom_ttl(): void - { - Author::create(['name' => 'Alice']); - Author::query()->ttl(9999)->get(); - - $queryKey = collect($this->redisKeys('query:*'))->first(); - - $this->assertNotNull($queryKey); - $this->assertGreaterThan(9000, Redis::connection('normcache-test')->ttl($queryKey)); - } - - public function test_query_with_group_by_bypasses_cache(): void - { - Author::create(['name' => 'Alice']); - Author::query()->groupBy('name')->get(); - - $this->assertEmpty($this->redisKeys('query:*')); - } - - public function test_query_from_subquery_bypasses_cache(): void - { - Author::create(['name' => 'Alice']); - - Author::fromSub(Author::query()->select('id', 'name'), 'authors')->get(); - - $this->assertEmpty($this->redisKeys('query:*')); - } - - public function test_query_with_raw_select_expression_bypasses_cache(): void - { - Author::create(['name' => 'Alice']); - Author::query()->selectRaw('id, name, 1 + 1 as computed')->get(); - - $this->assertEmpty($this->redisKeys('query:*')); - } - - public function test_bulk_update_invalidates_version(): void - { - Author::create(['name' => 'Alice']); - $versionBefore = NormCache::currentVersion(Author::class); - - Author::where('name', 'Alice')->update(['name' => 'Alicia']); - - $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Author::class)); - } - - public function test_bulk_delete_invalidates_version(): void - { - Author::create(['name' => 'Alice']); - $versionBefore = NormCache::currentVersion(Author::class); - - Author::where('name', 'Alice')->delete(); - - $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Author::class)); - } - - public function test_cache_aggregates_with_count_respects_runtime_global_scope_state(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'A', 'author_id' => $author->id, 'views' => 10]); - Post::create(['title' => 'B', 'author_id' => $author->id, 'views' => 20]); - - $threshold = 0; - $enabled = true; - Post::addGlobalScope('viewsScope', function ($query) use (&$threshold, &$enabled) { - if ($enabled) { - $query->where('views', '>=', $threshold); - } - }); - - try { - $threshold = 0; - $first = Author::withCount('posts')->get()->firstWhere('id', $author->id); - $this->assertSame(2, (int) $first->posts_count); - - $threshold = 15; - $second = Author::withCount('posts')->get()->firstWhere('id', $author->id); - $this->assertSame(1, (int) $second->posts_count); - } finally { - $enabled = false; - $this->clearGlobalScope(Post::class, 'viewsScope'); - } - } - - public function test_with_count_result_is_cached_and_invalidated_on_version_bump(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Post 1', 'author_id' => $author->id]); - - $this->assertSame(1, Author::withCount('posts')->find($author->id)->posts_count); - - $queryCount = 0; - DB::listen(function () use (&$queryCount) { - $queryCount++; - }); - Author::withCount('posts')->find($author->id); - $this->assertSame(0, $queryCount, 'Expected cache hit — no DB queries'); - - Post::create(['title' => 'Post 2', 'author_id' => $author->id]); - - $this->assertSame(2, Author::withCount('posts')->find($author->id)->posts_count); - } - - public function test_without_aggregate_cache_before_with_count_uses_live_queries(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - $this->assertAggregateCacheOptOutRunsLive( - fn() => Author::withoutAggregateCache()->withCount('posts')->get(), - ); - } - - public function test_without_aggregate_cache_after_with_count_uses_live_queries(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - $this->assertAggregateCacheOptOutRunsLive( - fn() => Author::withCount('posts')->withoutAggregateCache()->get(), - ); - } - - public function test_flush_model_invalidates_aggregate_blob_cache(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Post 1', 'author_id' => $author->id]); - - $this->assertSame(1, Author::withCount('posts')->find($author->id)->posts_count); - - NormCache::forceFlushModel(Author::class); - - $this->assertSame(1, Author::withCount('posts')->find($author->id)->posts_count); - } - - public function test_with_count_on_non_cacheable_relation_falls_through_to_eloquent(): void - { - $author = Author::create(['name' => 'Alice']); - UncachedPost::create(['title' => 'Post 1', 'author_id' => $author->id]); - UncachedPost::create(['title' => 'Post 2', 'author_id' => $author->id]); - - $result = Author::withCount('uncachedPosts')->get()->firstWhere('id', $author->id); - - $this->assertSame(2, (int) $result->uncached_posts_count); - $this->assertEmpty($this->redisKeys('result:*')); - } - - public function test_belongs_to_warm_hit_runs_after_query_callbacks(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - $count = 0; - - Post::with(['author' => function ($query) use (&$count) { - $query->afterQuery(function () use (&$count) { - $count++; - }); - }])->get(); - - Post::with(['author' => function ($query) use (&$count) { - $query->afterQuery(function () use (&$count) { - $count++; - }); - }])->get(); - - $this->assertSame(2, $count); - } - - public function test_belongs_to_eager_load_respects_join_only_global_scope_on_warm_hit(): void - { - Author::create(['name' => 'Alice', 'country_id' => null]); - Post::create(['title' => 'Hello', 'author_id' => 1]); - - Post::with('author')->get(); - - $enabled = true; - Author::addGlobalScope('joinCountry', function ($query) use (&$enabled) { - if ($enabled) { - $query->join('countries', 'authors.country_id', '=', 'countries.id'); - } - }); - - try { - $post = Post::with('author')->find(1); - - $this->assertNull($post->author); - } finally { - $enabled = false; - $this->clearGlobalScope(Author::class, 'joinCountry'); - } - } - - public function test_in_random_order_bypasses_cache(): void - { - Author::create(['name' => 'Alice']); - Author::inRandomOrder()->get(); - - $this->assertEmpty($this->redisKeys('query:*')); - } - - public function test_primary_key_query_with_limit_uses_model_cache_without_query_cache(): void - { - $author = Author::create(['name' => 'Alice']); - - $authors = Author::whereKey($author->id)->limit(1)->get(); - - $this->assertCount(1, $authors); - $this->assertSame('Alice', $authors->first()->name); - $this->assertEmpty($this->redisKeys('query:*')); - } - - public function test_primary_key_query_with_zero_limit_returns_empty_without_query_cache(): void - { - $author = Author::create(['name' => 'Alice']); - - $authors = Author::whereKey($author->id)->limit(0)->get(); - - $this->assertCount(0, $authors); - $this->assertEmpty($this->redisKeys('query:*')); - } - - public function test_increment_invalidates_version(): void - { - $author = Author::create(['name' => 'Alice']); - Author::all(); - - $versionBefore = NormCache::currentVersion(Author::class); - - Author::where('id', $author->id)->increment('id', 0); - - $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Author::class)); - } - - public function test_decrement_invalidates_version(): void - { - $author = Author::create(['name' => 'Alice']); - Author::all(); - - $versionBefore = NormCache::currentVersion(Author::class); - - Author::where('id', $author->id)->decrement('id', 0); - - $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Author::class)); - } - - public function test_query_inside_transaction_bypasses_cache(): void - { - Author::create(['name' => 'Alice']); - - DB::transaction(function () { - Author::all(); - }); - - $this->assertEmpty($this->redisKeys('query:*')); - } - - public function test_refresh_issues_a_db_query_not_a_cache_read(): void - { - $author = Author::create(['name' => 'Alice']); - Author::find($author->id); - - DB::enableQueryLog(); - $author->refresh(); - $queries = DB::getQueryLog(); - DB::disableQueryLog(); - - $this->assertCount(1, $queries, 'refresh() must issue exactly one DB query, not read from cache'); - $this->assertStringContainsString('authors', $queries[0]['query']); - $this->assertSame('Alice', $author->name); - } - - public function test_truncate_flushes_model_cache_and_increments_version(): void - { - $author = Author::create(['name' => 'Alice']); - Author::all(); - - $this->assertNotNull($this->modelCacheEntry(Author::class, $author->id)); - - $versionBefore = NormCache::currentVersion(Author::class); - - Author::query()->truncate(); - - $this->assertNull($this->modelCacheEntry(Author::class, $author->id)); - $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Author::class)); - } - - public function test_paginate_fires_query_cache_miss_on_first_call(): void - { - Event::fake([QueryCacheMiss::class]); - - Author::create(['name' => 'Alice']); - Author::paginate(10); - - Event::assertDispatched(QueryCacheMiss::class, function (QueryCacheMiss $e) { - return $e->modelClass === Author::class; - }); - } - - public function test_paginate_fires_query_cache_hit_on_second_call(): void - { - Author::create(['name' => 'Alice']); - Author::paginate(10); - - Event::fake([QueryCacheHit::class]); - - Author::paginate(10); - - Event::assertDispatched(QueryCacheHit::class, function (QueryCacheHit $e) { - return $e->modelClass === Author::class; - }); - } - - public function test_paginate_fires_query_bypassed_for_bypassed_query(): void - { - Event::fake([QueryBypassed::class]); - - Author::create(['name' => 'Alice']); - Author::query()->groupBy('name')->paginate(10); - - Event::assertDispatched(QueryBypassed::class, function (QueryBypassed $e) { - return $e->modelClass === Author::class - && isset($e->reasons['normalization']); - }); - } - - public function test_paginate_count_cache_is_select_independent(): void - { - Author::create(['name' => 'Alice']); - Author::create(['name' => 'Bob']); - - Author::query()->paginate(10); - Author::query()->select('name')->paginate(10); - - $this->assertCount(1, $this->redisKeys('count:*')); - } - - public function test_simple_paginate_invalidates_on_change(): void - { - $this->createAuthors(3); - - Author::orderBy('id')->simplePaginate(2); // Warm cache - - Event::fake([QueryCacheHit::class]); - Author::orderBy('id')->simplePaginate(2); - Event::assertDispatched(QueryCacheHit::class); - - // Change data - Author::first()->update(['name' => 'Updated Name']); - - Event::fake([QueryCacheMiss::class]); - Author::orderBy('id')->simplePaginate(2); - Event::assertDispatched(QueryCacheMiss::class); - } - - public function test_cursor_paginate_invalidates_on_change(): void - { - $this->createAuthors(3); - - Author::orderBy('id')->cursorPaginate(2); // Warm cache - - Event::fake([QueryCacheHit::class]); - Author::orderBy('id')->cursorPaginate(2); - Event::assertDispatched(QueryCacheHit::class); - - // Change data - Author::first()->update(['name' => 'Updated Name']); - - Event::fake([QueryCacheMiss::class]); - Author::orderBy('id')->cursorPaginate(2); - Event::assertDispatched(QueryCacheMiss::class); - } - - private function createAuthors(int $count): void - { - for ($i = 1; $i <= $count; $i++) { - Author::create(['name' => "Author {$i}"]); - } - } - - public function test_raw_builder_insert_invalidates_version(): void - { - $versionBefore = NormCache::currentVersion(Author::class); - - Author::query()->insert(['name' => 'Alice', 'created_at' => now(), 'updated_at' => now()]); - - $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Author::class)); - } - - public function test_raw_builder_insert_is_reflected_in_subsequent_queries(): void - { - Author::create(['name' => 'Alice']); - Author::all(); - - Author::query()->insert(['name' => 'Bob', 'created_at' => now(), 'updated_at' => now()]); - - $names = Author::all()->pluck('name'); - - $this->assertContains('Bob', $names); - } - - public function test_result_cache_get_columns_does_not_mutate_builder_projection(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - $query = Author::query() - ->whereHas('posts') - ->dependsOn([Post::class]); - - $projected = $query->get(['id']); - - $this->assertSame($author->id, $projected->first()->id); - $this->assertNull($projected->first()->getRawOriginal('name')); - - $full = $query->get(); - - $this->assertSame('Alice', $full->first()->name); - } - - public function test_updating_related_model_busts_aggregate_cache(): void - { - $author = Author::create(['name' => 'Alice']); - $post = Post::create(['title' => 'Post 1', 'author_id' => $author->id]); - - Author::withCount('posts')->get(); - - DB::table('posts')->insert([ - 'title' => 'Post 2', - 'author_id' => $author->id, - 'created_at' => now(), - 'updated_at' => now(), - ]); - - $post->update(['title' => 'Updated']); - $result = Author::withCount('posts')->get() - ->firstWhere('id', $author->id); - - $this->assertSame(2, $result->posts_count); - } - - public function test_bulk_delete_with_rows_affected_invalidates_cache(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'P1', 'author_id' => $author->id]); - Post::create(['title' => 'P2', 'author_id' => $author->id]); - - Post::all(); - $versionBefore = NormCache::currentVersion(Post::class); - - Post::where('author_id', $author->id)->delete(); - - $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Post::class)); - - $posts = Post::all(); - $this->assertCount(0, $posts); - } - - public function test_bulk_update_affecting_zero_rows_does_not_invalidate_cache(): void - { - Author::create(['name' => 'Alice']); - - Author::all(); - $versionBefore = NormCache::currentVersion(Author::class); - - $affected = Author::where('id', 99999)->update(['name' => 'Ghost']); - - $this->assertSame(0, $affected); - $this->assertSame($versionBefore, NormCache::currentVersion(Author::class)); - } - - // explain() + QueryBypassed event - - public function test_explain_returns_cached_for_simple_query(): void - { - $this->assertSame('cached', Author::query()->explain()); - } - - public function test_explain_caches_simple_wherehas_via_inferred_dependency(): void - { - $result = Author::whereHas('posts')->explain(); - - $this->assertSame('cached: result', $result); - } - - public function test_explain_groups_join_as_normalization(): void - { - $result = Author::query() - ->join('posts', 'posts.author_id', '=', 'authors.id') - ->explain(); - - $this->assertStringContainsString("can't be normalized", $result); - $this->assertStringContainsString('join_result_requires_explicit_select', $result); - } - - public function test_explain_uses_result_cache_for_inferable_from_subquery(): void - { - $result = Author::fromSub(Author::query()->select('id', 'name'), 'authors')->explain(); - - $this->assertSame('cached: result', $result); - } - - public function test_explain_groups_group_by_as_normalization(): void - { - $result = Author::query()->groupBy('name')->explain(); - - $this->assertStringContainsString("can't be normalized", $result); - $this->assertStringContainsString('GROUP BY', $result); - } - - public function test_explain_groups_explicit_skip_as_opted_out(): void - { - $result = Author::withoutCache()->explain(); - - $this->assertStringContainsString('explicitly disabled', $result); - $this->assertStringContainsString('withoutCache()', $result); - } - - public function test_explain_shows_all_categories_when_multiple_apply(): void - { - $result = Author::query() - ->whereRaw('1 = 1') - ->groupBy('name') - ->explain(); - - $this->assertStringContainsString("can't infer cache dependency", $result); - $this->assertStringContainsString("can't be normalized", $result); - $this->assertStringContainsString('raw WHERE', $result); - $this->assertStringContainsString('GROUP BY', $result); - } - - public function test_get_fires_query_bypassed_event_with_dependency_category_for_where_has(): void - { - Event::fake([QueryBypassed::class]); - - Author::create(['name' => 'Alice']); - Author::whereHas('posts', fn($q) => $q->whereRaw('1 = 1'))->get(); - - Event::assertDispatched(QueryBypassed::class, function (QueryBypassed $e) { - return $e->modelClass === Author::class - && isset($e->reasons['dependency']) - && collect($e->reasons['dependency'])->contains(fn($r) => str_contains($r, 'raw WHERE')); - }); - } - - public function test_get_fires_query_bypassed_event_with_normalization_category_for_group_by(): void - { - Event::fake([QueryBypassed::class]); - - Author::create(['name' => 'Alice']); - Author::query()->groupBy('name')->get(); - - Event::assertDispatched(QueryBypassed::class, function (QueryBypassed $e) { - return $e->modelClass === Author::class - && isset($e->reasons['normalization']) - && collect($e->reasons['normalization'])->contains(fn($r) => str_contains($r, 'GROUP BY')); - }); - } - - public function test_get_fires_query_bypassed_event_with_normalization_for_calculated_column(): void - { - Event::fake([QueryBypassed::class]); - - Author::create(['name' => 'Alice']); - Author::query()->selectRaw('id, name, 1 + 1 as computed')->get(); - - Event::assertDispatched(QueryBypassed::class, function (QueryBypassed $e) { - return $e->modelClass === Author::class - && isset($e->reasons['normalization']) - && collect($e->reasons['normalization'])->contains(fn($r) => str_contains($r, 'calculated')); - }); - } - - public function test_get_does_not_fire_query_bypassed_event_for_pure_query(): void - { - Event::fake([QueryBypassed::class]); - - Author::create(['name' => 'Alice']); - Author::all(); - - Event::assertNotDispatched(QueryBypassed::class); - } - - public function test_warm_hit_runs_after_query_callbacks(): void - { - Author::create(['name' => 'Alice']); - - $count = 0; - - Author::query()->afterQuery(function () use (&$count) { - $count++; - })->get(); - - Author::query()->afterQuery(function () use (&$count) { - $count++; - })->get(); - - $this->assertSame(2, $count); - } - - public function test_belongs_to_constrained_select_with_pk_serves_from_cache_correctly(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - Post::with('author')->get(); - - DB::enableQueryLog(); - $posts = Post::with(['author' => fn($q) => $q->select('id', 'name')])->get(); - $queries = DB::getQueryLog(); - DB::disableQueryLog(); - - $authorQueries = array_filter($queries, fn($q) => str_contains($q['query'], '"authors"')); - $this->assertEmpty($authorQueries, 'PK in projection → fast path, no DB round-trip'); - $this->assertNotNull($posts->first()->author); - $this->assertSame('Alice', $posts->first()->author->name); - } - - public function test_belongs_to_constrained_select_with_raw_expression_does_not_crash(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - Post::with('author')->get(); - - $posts = Post::with(['author' => fn($q) => $q->select('id', DB::raw('name'))])->get(); - - $this->assertNotNull($posts->first()->author); - $this->assertSame('Alice', $posts->first()->author->name); - } - - public function test_model_hydrated_from_cache_has_exists_true(): void - { - Author::create(['name' => 'Alice']); - Author::all(); - - $this->assertTrue(Author::first()->exists); - } - - public function test_model_hydrated_from_cache_has_was_recently_created_false(): void - { - Author::create(['name' => 'Alice']); - Author::all(); - - // Retrieved from cache, not just created — wasRecentlyCreated must be false. - $this->assertFalse(Author::first()->wasRecentlyCreated); - } - - public function test_exists_and_count_do_not_share_a_cache_entry(): void - { - Author::create(['name' => 'Alice']); - - $this->assertTrue(Author::where('name', 'Alice')->exists()); - $this->assertSame(1, Author::where('name', 'Alice')->count()); - $this->assertTrue(Author::where('name', 'Alice')->exists()); - $this->assertSame(1, Author::where('name', 'Alice')->count()); - } - - public function test_doesnt_exist_is_consistent_with_exists(): void - { - $this->assertFalse(Author::where('name', 'Alice')->exists()); - $this->assertTrue(Author::where('name', 'Alice')->doesntExist()); - - Author::create(['name' => 'Alice']); - - $this->assertTrue(Author::where('name', 'Alice')->exists()); - $this->assertFalse(Author::where('name', 'Alice')->doesntExist()); - - Author::where('name', 'Alice')->exists(); // warm - $this->assertFalse(Author::where('name', 'Alice')->doesntExist()); - } - - private function clearGlobalScope(string $modelClass, string $name): void - { - $prop = new \ReflectionProperty(Model::class, 'globalScopes'); - $scopes = $prop->getValue(); - unset($scopes[$modelClass][$name]); - $prop->setValue(null, $scopes); - } - - public function test_complex_query_without_depends_on_bypasses(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Event::fake([QueryBypassed::class]); - - Author::whereHas('posts.comments')->get(); - - Event::assertDispatched(QueryBypassed::class); - $this->assertEmpty($this->redisKeys('query:*')); - $this->assertEmpty($this->redisKeys('result:*')); - } - - public function test_complex_aggregate_without_explicit_dependencies_bypasses(): void - { - $author = Author::create(['name' => 'Alice']); - - Event::fake([QueryBypassed::class]); - - Author::withCount([ - 'posts' => fn($q) => $q->whereRaw('1=1'), // complex/unsafe - ])->get(); - - Event::assertDispatched(QueryBypassed::class); - } - - public function test_corrupt_query_cache_ids_are_treated_as_miss(): void - { - Author::create(['name' => 'Alice']); - Author::all(); // warm - - $queryKey = collect($this->redisKeys('query:*'))->first(); - $this->assertNotNull($queryKey); - - Redis::connection('normcache-test')->set($queryKey, 'NOT_JSON'); - - $results = Author::all(); - - $this->assertCount(1, $results); - $this->assertSame('Alice', $results->first()->name); - } - - private function assertAggregateCacheOptOutRunsLive(callable $query): void - { - $query(); - - DB::flushQueryLog(); - DB::enableQueryLog(); - try { - $query(); - $queries = DB::getQueryLog(); - } finally { - DB::disableQueryLog(); - } - - $this->assertEmpty($this->redisKeys('result:*')); - $this->assertNotEmpty($queries, 'withoutAggregateCache() must not serve a result-cache hit.'); - } - - public function test_normalized_cache_preserves_wildcard_plus_alias_projection(): void - { - $author = Author::create(['name' => 'Alice']); - - Author::query()->select('authors.*', 'authors.name as display_name')->get(); - $second = Author::query()->select('authors.*', 'authors.name as display_name')->get(); - - $this->assertSame($author->id, $second->first()->id); - $this->assertSame('Alice', $second->first()->name); - $this->assertSame('Alice', $second->first()->display_name); - } -} diff --git a/tests/Integration/Cache/CanonicalProjectionFallbackTest.php b/tests/Integration/Cache/CanonicalProjectionFallbackTest.php new file mode 100644 index 0000000..0d91e38 --- /dev/null +++ b/tests/Integration/Cache/CanonicalProjectionFallbackTest.php @@ -0,0 +1,384 @@ +create(['name' => 'Author']); + + foreach ([ + [1, 'One', 10, true], + [2, 'Two', 20, true], + [3, 'Three', 30, false], + [4, 'Four', 40, true], + ] as [$id, $title, $views, $published]) { + RawPost::query()->toBase()->insert([ + 'id' => $id, + 'title' => $title, + 'views' => $views, + 'published' => $published, + 'author_id' => $author->getKey(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + + public function test_plain_projection_reuses_wildcard_membership_without_sql(): void + { + RawPost::query()->toBase() + ->where('published', true) + ->orderByDesc('id') + ->limit(2) + ->get(); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $rows = RawPost::query()->toBase() + ->where('published', true) + ->orderByDesc('id') + ->limit(2) + ->select(['id', 'title']) + ->get(); + DB::disableQueryLog(); + + $this->assertSame([ + ['id' => 4, 'title' => 'Four'], + ['id' => 2, 'title' => 'Two'], + ], $rows->map(fn(object $row): array => (array) $row)->all()); + $this->assertSame([], DB::getQueryLog()); + } + + public function test_projection_fallback_promotes_compact_result_payload(): void + { + $wildcard = RawPost::query()->toBase() + ->where('published', true) + ->orderBy('id'); + $wildcard->get(); + $this->deleteResultOverlays(); + + $projected = fn() => RawPost::query()->toBase() + ->where('published', true) + ->orderBy('id') + ->select(['id', 'title']) + ->get(); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $first = $projected(); + DB::disableQueryLog(); + + $this->assertSame([], DB::getQueryLog()); + $this->assertCount(1, $this->cacheQueryKeysWithField('r')); + + $this->cacheStore()->delete([ + ...$this->cacheQueryKeysWithField('m'), + ...$this->cacheKeysMatching(':r:g'), + ]); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $second = $projected(); + DB::disableQueryLog(); + + $this->assertSame( + $first->map(fn(object $row): array => (array) $row)->all(), + $second->map(fn(object $row): array => (array) $row)->all(), + ); + $this->assertSame([], DB::getQueryLog()); + } + + public function test_corrupt_projected_result_falls_back_to_canonical_and_rebuilds(): void + { + RawPost::query()->toBase() + ->where('published', true) + ->orderBy('id') + ->get(); + $this->deleteResultOverlays(); + + $projected = fn() => RawPost::query()->toBase() + ->where('published', true) + ->orderBy('id') + ->select(['id', 'title']) + ->get(); + + $expected = $projected()->pluck('id')->all(); + $resultKey = $this->cacheQueryKeysWithField('r')[0]; + $this->cacheStore()->writeHashField($resultKey, 'r', 'corrupt'); + Event::fake([QueryCacheRepaired::class]); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $actual = $projected()->pluck('id')->all(); + DB::disableQueryLog(); + + $this->assertSame($expected, $actual); + $this->assertSame([], DB::getQueryLog()); + $payload = $this->cacheStore()->readHashField($resultKey, 'r'); + $this->assertIsString($payload); + $this->assertNotSame('corrupt', $payload); + Event::assertDispatched( + QueryCacheRepaired::class, + static fn(QueryCacheRepaired $event): bool => $event->reason === 'result_overlay_rebuilt', + ); + } + + public function test_where_in_projection_reuses_the_same_canonical_membership(): void + { + RawPost::query()->toBase() + ->whereIn('id', [1, 2, 4]) + ->orderByDesc('id') + ->get(); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $rows = RawPost::query()->toBase() + ->whereIn('id', [1, 2, 4]) + ->orderByDesc('id') + ->select(['id', 'title']) + ->get(); + DB::disableQueryLog(); + + $this->assertSame([4, 2, 1], $rows->pluck('id')->all()); + $this->assertSame([], DB::getQueryLog()); + } + + public function test_projection_fallback_preserves_membership_order_limit_and_offset(): void + { + RawPost::query()->toBase() + ->where('published', true) + ->orderBy('views') + ->offset(1) + ->limit(2) + ->get(); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $rows = RawPost::query()->toBase() + ->where('published', true) + ->orderBy('views') + ->offset(1) + ->limit(2) + ->select(['title', 'views']) + ->get(); + DB::disableQueryLog(); + + $this->assertSame([ + ['title' => 'Two', 'views' => 20], + ['title' => 'Four', 'views' => 40], + ], $rows->map(fn(object $row): array => (array) $row)->all()); + $this->assertSame([], DB::getQueryLog()); + } + + public function test_qualified_wildcard_and_projection_share_normalized_membership_identity(): void + { + RawPost::query()->toBase()->from('posts as p') + ->where('p.published', true) + ->orderBy('p.id') + ->select('p.*') + ->get(); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $rows = RawPost::query()->toBase()->from('posts as p') + ->where('p.published', true) + ->orderBy('p.id') + ->select(['p.id', 'p.title']) + ->get(); + DB::disableQueryLog(); + + $this->assertSame([1, 2, 4], $rows->pluck('id')->all()); + $this->assertSame(['One', 'Two', 'Four'], $rows->pluck('title')->all()); + $this->assertSame([], DB::getQueryLog()); + } + + public function test_eloquent_projection_reuses_canonical_membership(): void + { + Post::query() + ->where('published', true) + ->orderBy('id') + ->get(); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $posts = Post::query() + ->where('published', true) + ->orderBy('id') + ->select(['id', 'title']) + ->get(); + DB::disableQueryLog(); + + $this->assertSame([1, 2, 4], $posts->modelKeys()); + $this->assertSame( + [['id' => 1, 'title' => 'One'], ['id' => 2, 'title' => 'Two'], ['id' => 4, 'title' => 'Four']], + $posts->map(fn(Post $post): array => $post->getAttributes())->all(), + ); + $this->assertSame([], DB::getQueryLog()); + } + + public function test_empty_canonical_membership_is_a_projection_hit(): void + { + RawPost::query()->toBase()->where('views', '>', 1000)->get(); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $rows = RawPost::query()->toBase() + ->where('views', '>', 1000) + ->select(['id', 'title']) + ->get(); + DB::disableQueryLog(); + + $this->assertSame([], $rows->all()); + $this->assertSame([], DB::getQueryLog()); + } + + public function test_result_payload_wins_before_canonical_membership_fallback(): void + { + $projected = fn() => RawPost::query()->toBase() + ->where('published', true) + ->orderBy('id') + ->select(['id', 'title']) + ->get(); + + $projected(); + RawPost::query()->toBase()->where('published', true)->orderBy('id')->get(); + $this->cacheStore()->delete($this->rowKeyFor(2)); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $rows = $projected(); + DB::disableQueryLog(); + + $this->assertSame([1, 2, 4], $rows->pluck('id')->all()); + $this->assertSame([], DB::getQueryLog()); + } + + public function test_missing_canonical_row_declines_projection_fallback_without_repair(): void + { + $query = fn() => RawPost::query()->toBase() + ->where('published', true) + ->orderBy('id'); + + $query()->get(); + $rowKey = $this->rowKeyFor(2); + $this->cacheStore()->delete($rowKey); + Event::fake([QueryCacheRepaired::class]); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $rows = $query()->select(['id', 'title'])->get(); + DB::disableQueryLog(); + + $this->assertSame([1, 2, 4], $rows->pluck('id')->all()); + $this->assertCount(1, DB::getQueryLog()); + $this->assertNull($this->cacheStore()->getRaw($rowKey)); + Event::assertNotDispatched(QueryCacheRepaired::class); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $this->assertSame([1, 2, 4], $query()->select(['id', 'title'])->get()->pluck('id')->all()); + DB::disableQueryLog(); + $this->assertSame([], DB::getQueryLog()); + } + + public function test_raw_or_aliased_projection_does_not_use_canonical_membership(): void + { + RawPost::query()->toBase()->where('published', true)->orderBy('id')->get(); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $rows = RawPost::query()->toBase() + ->where('published', true) + ->orderBy('id') + ->selectRaw('id, upper(title) as heading') + ->get(); + DB::disableQueryLog(); + + $this->assertSame(['ONE', 'TWO', 'FOUR'], $rows->pluck('heading')->all()); + $this->assertCount(1, DB::getQueryLog()); + } + + public function test_missing_selected_column_preserves_native_error_without_deleting_valid_rows(): void + { + RawPost::query()->toBase()->where('published', true)->orderBy('id')->get(); + $rowKey = $this->rowKeyFor(1); + $this->assertNotNull($this->cacheStore()->getRaw($rowKey)); + + try { + RawPost::query()->toBase() + ->where('published', true) + ->orderBy('id') + ->select(['posts.id', 'posts.missing_column']) + ->get(); + $this->fail('Expected the database to reject the missing column.'); + } catch (QueryException) { + $this->addToAssertionCount(1); + } + + $this->assertNotNull($this->cacheStore()->getRaw($rowKey)); + } + + public function test_projection_membership_respects_tag_namespace(): void + { + RawPost::query()->toBase() + ->where('published', true) + ->orderBy('id') + ->tag('homepage') + ->get(); + + DB::flushQueryLog(); + DB::enableQueryLog(); + RawPost::query()->toBase() + ->where('published', true) + ->orderBy('id') + ->select(['id', 'title']) + ->get(); + DB::disableQueryLog(); + + $this->assertCount(1, DB::getQueryLog()); + } + + public function test_projection_fallback_reports_a_hit_without_a_miss(): void + { + RawPost::query()->toBase()->where('published', true)->orderBy('id')->get(); + Event::fake([QueryCacheHit::class, QueryCacheMiss::class]); + + RawPost::query()->toBase() + ->where('published', true) + ->orderBy('id') + ->select(['id', 'title']) + ->get(); + + Event::assertDispatched( + QueryCacheHit::class, + static fn(QueryCacheHit $event): bool => $event->reason === 'canonical_projection_fallback', + ); + Event::assertNotDispatched(QueryCacheMiss::class); + } + + private function rowKeyFor(int $id): string + { + foreach ($this->cacheKeysMatching(':r:g') as $key) { + if (str_ends_with($key, ':i:' . $id)) { + return $key; + } + } + + throw new \RuntimeException("Canonical row key for [{$id}] was not found."); + } +} diff --git a/tests/Integration/Cache/CanonicalReadTest.php b/tests/Integration/Cache/CanonicalReadTest.php new file mode 100644 index 0000000..5e97488 --- /dev/null +++ b/tests/Integration/Cache/CanonicalReadTest.php @@ -0,0 +1,434 @@ +create(['name' => 'Author']); + $this->postId = (int) RawPost::query()->toBase()->insertGetId([ + 'title' => 'Canonical', + 'views' => 10, + 'published' => true, + 'author_id' => $author->getKey(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + public function test_canonical_list_populates_rows_reused_by_direct_pk_reads(): void + { + RawPost::query()->toBase()->orderBy('id')->get(); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $row = RawPost::query()->toBase()->where('id', $this->postId)->first(); + DB::disableQueryLog(); + + $this->assertSame('Canonical', $row->title); + $this->assertSame([], DB::getQueryLog()); + } + + public function test_implicit_and_explicit_wildcards_share_the_canonical_cache_entry(): void + { + $expected = RawPost::query()->toBase()->orderBy('id')->get(); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $actual = RawPost::query()->toBase()->select('*')->orderBy('id')->get(); + DB::disableQueryLog(); + + $this->assertSame( + $expected->map(static fn(object $row): array => (array) $row)->all(), + $actual->map(static fn(object $row): array => (array) $row)->all(), + ); + $this->assertSame([], DB::getQueryLog()); + } + + public function test_canonical_row_payload_must_match_the_primary_key_in_its_key(): void + { + $secondId = RawPost::query()->toBase()->insertGetId([ + 'title' => 'Second', + 'views' => 20, + 'published' => true, + 'author_id' => Author::query()->toBase()->value('id'), + 'created_at' => now(), + 'updated_at' => now(), + ]); + RawPost::query()->toBase()->where('id', $this->postId)->first(); + + $query = RawPost::query()->toBase(); + $table = $this->app->make(TableIdentityResolver::class) + ->resolve($query->getConnection(), $query->from); + $this->assertNotNull($table); + $generation = $this->cacheStore()->getRaw($this->cacheKeys()->generation($table)) ?? '0'; + $epoch = $this->cacheStore()->getRaw($this->cacheKeys()->epoch()) ?? '0'; + $rowKey = $this->cacheKeys()->row($table, $generation, 'i:' . $this->postId); + $second = RawPost::query()->toBase()->withoutCache()->where('id', $secondId)->first(); + $this->assertNotNull($second); + $this->cacheStore()->setRawForever( + $rowKey, + $this->app->make(RawResultCodec::class)->encodeRow($second, $epoch), + ); + + $row = RawPost::query()->toBase()->where('id', $this->postId)->first(); + + $this->assertSame($this->postId, $row?->id); + $this->assertSame('Canonical', $row?->title); + } + + public function test_canonical_membership_tokens_must_match_the_primary_key_family(): void + { + $expected = RawPost::query()->toBase()->orderBy('id')->get(); + $this->deleteResultOverlays(); + $query = RawPost::query()->toBase(); + $table = $this->app->make(TableIdentityResolver::class) + ->resolve($query->getConnection(), $query->from); + $this->assertNotNull($table); + + $membershipKey = $this->cacheQueryKeysWithField('m')[0] ?? null; + $this->assertIsString($membershipKey); + $membership = $this->app->make(MembershipCodec::class) + ->decode((string) $this->cacheStore()->readHashField($membershipKey, 'm')); + $this->assertTrue($membership->valid); + + $generation = $this->cacheStore()->getRaw($this->cacheKeys()->generation($table)) ?? '0'; + $validRowKey = $this->cacheKeys()->row($table, $generation, 'i:' . $this->postId); + $invalidRowKey = $this->cacheKeys()->row($table, $generation, 's:' . $this->postId); + $validRow = $this->cacheStore()->getRaw($validRowKey); + $this->assertNotNull($validRow); + $this->cacheStore()->setRawForever($invalidRowKey, $validRow); + + // Preserve the hash so token-family validation runs. + $this->cacheStore()->writeHashField( + $membershipKey, + 'm', + $this->app->make(MembershipCodec::class)->encode( + epoch: $membership->epoch, + generation: $membership->generation, + rootVersion: (string) $membership->rootVersion, + ids: ['s:' . $this->postId], + versions: $membership->versions, + tagVersion: $membership->tagVersion, + ), + ); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $actual = RawPost::query()->toBase()->orderBy('id')->get(); + DB::disableQueryLog(); + + $this->assertSame( + $expected->map(static fn(object $row): array => (array) $row)->all(), + $actual->map(static fn(object $row): array => (array) $row)->all(), + ); + $this->assertCount(1, DB::getQueryLog()); + } + + public function test_sqlite_attached_schema_repairs_rows_from_the_qualified_table(): void + { + $path = sys_get_temp_dir() . '/normcache-attached-' . bin2hex(random_bytes(8)) . '.sqlite'; + touch($path); + DB::statement('ATTACH DATABASE ? AS tenant', [$path]); + + try { + DB::statement(<<<'SQL' + CREATE TABLE tenant.posts ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + views INTEGER NOT NULL DEFAULT 0, + published INTEGER NOT NULL DEFAULT 1, + metadata TEXT NULL, + author_id INTEGER NOT NULL, + created_at TEXT NULL, + updated_at TEXT NULL, + deleted_at TEXT NULL + ) + SQL); + RawPost::query()->toBase()->from('tenant.posts')->insert([ + 'id' => 1, + 'title' => 'Tenant', + 'views' => 20, + 'published' => true, + 'author_id' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $read = fn() => RawPost::query()->toBase()->from('tenant.posts')->orderBy('id')->get(); + $expected = $read(); + $this->deleteResultOverlays(); + + $identity = $this->app->make(TableIdentityResolver::class) + ->resolve(DB::connection(), 'tenant.posts'); + $this->assertNotNull($identity); + $generation = $this->cacheStore()->getRaw( + $this->cacheKeys()->generation($identity), + ) ?? '0'; + $rowKey = $this->cacheKeys()->row($identity, $generation, 'i:1'); + $this->assertNotNull($this->cacheStore()->getRaw($rowKey)); + $this->cacheStore()->delete($rowKey); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $actual = $read(); + DB::disableQueryLog(); + + $this->assertSame('Tenant', $expected->first()?->title); + $this->assertSame('Tenant', $actual->first()?->title); + $this->assertCount(1, DB::getQueryLog()); + $this->assertStringContainsString( + '"tenant"."posts"', + strtolower(DB::getQueryLog()[0]['query']), + ); + } finally { + DB::statement('DETACH DATABASE tenant'); + + if (is_file($path)) { + unlink($path); + } + } + } + + public function test_large_canonical_query_is_published_without_an_admission_limit(): void + { + $timestamp = now(); + + foreach (array_chunk(range(1, 1_000), 200) as $indexes) { + RawPost::query()->toBase()->insert(array_map( + static fn(int $index): array => [ + 'title' => "Post {$index}", + 'views' => $index, + 'published' => true, + 'author_id' => Author::query()->toBase()->value('id'), + 'created_at' => $timestamp, + 'updated_at' => $timestamp, + ], + $indexes, + )); + } + + $rows = RawPost::query()->toBase()->orderBy('id')->get(); + + $this->assertCount(1_001, $rows); + $this->assertCount(1, $this->cacheQueryKeysWithField('m')); + $this->assertCount(1_001, $this->cacheKeysMatching(':r:g')); + } + + public function test_narrow_projection_uses_a_result_payload_without_widening_sql(): void + { + $cold = RawPost::query()->toBase() + ->where('id', $this->postId) + ->select('title as heading') + ->first(); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $warm = RawPost::query()->toBase() + ->where('id', $this->postId) + ->select('title as heading') + ->first(); + DB::disableQueryLog(); + + $this->assertSame(['heading' => 'Canonical'], (array) $cold); + $this->assertSame((array) $cold, (array) $warm); + $this->assertSame([], DB::getQueryLog()); + } + + public function test_missing_canonical_rows_are_repaired_by_primary_key_batch(): void + { + $expected = RawPost::query()->toBase()->orderBy('id')->get(); + $this->deleteResultOverlays(); + $rowKey = $this->cacheKeysMatching(':r:g')[0] ?? null; + + $this->assertIsString($rowKey); + $this->cacheStore()->delete($rowKey); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $actual = RawPost::query()->toBase()->orderBy('id')->get(); + DB::disableQueryLog(); + + $this->assertSame($expected->map(fn($row) => (array) $row)->all(), $actual->map(fn($row) => (array) $row)->all()); + $this->assertCount(1, DB::getQueryLog()); + $this->assertStringContainsString('where "id" in', strtolower(DB::getQueryLog()[0]['query'])); + } + + public function test_precise_invalidation_between_canonical_phases_cannot_mix_membership_and_row_versions(): void + { + $read = fn() => RawPost::query()->toBase() + ->where('published', true) + ->orderBy('id') + ->get(); + + $this->assertCount(1, $read()); + $this->assertCount(1, $read()); + $this->deleteResultOverlays(); + + $postId = $this->postId; + $store = $this->cacheStore(); + $storeReflection = new \ReflectionClass($store); + $connectionProperty = $storeReflection->getProperty('connection'); + $connection = $connectionProperty->getValue($store); + + $beforeFirstMget = function () use ($postId): void { + RawPost::query()->toBase()->where('id', $postId)->update(['published' => false]); + RawPost::query()->toBase()->where('id', $postId)->first(); + }; + + if ($connection instanceof PhpRedisConnection) { + $interceptingConnection = new class($connection->client(), $beforeFirstMget) extends PhpRedisConnection + { + private bool $intercepted = false; + + public function __construct( + mixed $client, + private Closure $beforeFirstMget, + ) { + parent::__construct($client); + } + + public function mget(array $keys): array + { + if (!$this->intercepted) { + $this->intercepted = true; + ($this->beforeFirstMget)(); + } + + return parent::mget($keys); + } + }; + } else { + $this->assertInstanceOf(PredisConnection::class, $connection); + + $interceptingConnection = new class($connection->client(), $beforeFirstMget) extends PredisConnection + { + private bool $intercepted = false; + + public function __construct( + mixed $client, + private Closure $beforeFirstMget, + ) { + parent::__construct($client); + } + + public function mget(array $keys): array + { + if (!$this->intercepted) { + $this->intercepted = true; + ($this->beforeFirstMget)(); + } + + return parent::__call('mget', [$keys]); + } + }; + } + $connectionProperty->setValue($store, $interceptingConnection); + + try { + $rows = $read(); + } finally { + $connectionProperty->setValue($store, $connection); + } + + $currentRows = RawPost::query()->toBase() + ->where('published', true) + ->orderBy('id') + ->withoutCache() + ->get(); + + $this->assertCount(0, $currentRows); + $this->assertCount(0, $rows); + } + + public function test_direct_primary_key_rows_apply_builtin_soft_delete_visibility(): void + { + Post::query()->whereKey($this->postId)->delete(); + $trashed = Post::withTrashed()->findOrFail($this->postId); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $default = Post::query()->find($this->postId); + $only = Post::onlyTrashed()->find($this->postId); + DB::disableQueryLog(); + + $this->assertNull($default); + $this->assertSame($trashed->getKey(), $only?->getKey()); + $this->assertSame([], DB::getQueryLog()); + } + + public function test_or_deleted_at_predicate_is_not_misclassified_as_a_direct_lookup(): void + { + $second = Post::query()->create([ + 'title' => 'Second', + 'views' => 0, + 'published' => true, + 'author_id' => Author::query()->toBase()->value('id'), + ]); + + $read = fn() => Post::withTrashed() + ->whereKey($this->postId) + ->orWhereNull('deleted_at') + ->get(); + + $this->assertCount(2, $read()); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $ids = $read()->modelKeys(); + DB::disableQueryLog(); + + $this->assertContains($this->postId, $ids); + $this->assertContains($second->getKey(), $ids); + $this->assertSame([], DB::getQueryLog()); + } + + public function test_foreign_qualified_deleted_at_predicate_is_not_misclassified_as_a_direct_lookup(): void + { + $read = fn() => Post::withTrashed() + ->where('id', $this->postId) + ->whereNull('wrong.deleted_at') + ->first(); + + // Warm direct-row state without compiling the invalid query. + $this->assertSame('Canonical', Post::withTrashed()->find($this->postId)?->title); + $this->assertSame('Canonical', Post::withTrashed()->find($this->postId)?->title); + + $this->expectException(QueryException::class); + $read(); + } + + public function test_string_primary_keys_share_canonical_rows_with_direct_reads(): void + { + $id = 'uuid:with/{delimiters}'; + UuidItem::query()->create(['id' => $id, 'name' => 'String key']); + UuidItem::query()->get(); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $item = UuidItem::query()->findOrFail($id); + DB::disableQueryLog(); + + $this->assertSame('String key', $item->name); + $this->assertSame([], DB::getQueryLog()); + } +} diff --git a/tests/Integration/Cache/CanonicalRowRepositoryTest.php b/tests/Integration/Cache/CanonicalRowRepositoryTest.php new file mode 100644 index 0000000..796e753 --- /dev/null +++ b/tests/Integration/Cache/CanonicalRowRepositoryTest.php @@ -0,0 +1,103 @@ +app->make(CanonicalRowRepository::class)->read($this->plan('i:404')); + + $this->assertNull($cached->row); + $this->assertNull($cached->epoch); + $this->assertNull($cached->reason); + } + + public function test_a_corrupt_payload_is_reported_as_such(): void + { + $plan = $this->plan('i:1'); + $repository = $this->app->make(CanonicalRowRepository::class); + $generation = $repository->read($plan)->generation; + + $this->cacheStore()->setRawForever( + $this->cacheKeys()->row($plan->root, $generation, 'i:1'), + 'not-a-payload', + ); + + $this->assertSame('corrupt_payload', $repository->read($plan)->reason); + } + + public function test_soft_delete_visibility_filters_by_plan_mode(): void + { + $repository = $this->app->make(CanonicalRowRepository::class); + $live = (object) ['id' => 1, 'deleted_at' => null]; + $trashed = (object) ['id' => 2, 'deleted_at' => '2026-01-01 00:00:00']; + + $this->assertSame([$live], $repository->visibleRows($this->plan('i:1', 'default'), $live)); + $this->assertSame([], $repository->visibleRows($this->plan('i:2', 'default'), $trashed)); + $this->assertSame([$trashed], $repository->visibleRows($this->plan('i:2', 'only'), $trashed)); + $this->assertSame([], $repository->visibleRows($this->plan('i:1', 'only'), $live)); + $this->assertSame([$trashed], $repository->visibleRows($this->plan('i:2', 'with'), $trashed)); + } + + public function test_visibility_reports_a_row_missing_its_deleted_at_column(): void + { + $repository = $this->app->make(CanonicalRowRepository::class); + + $this->assertNull( + $repository->visibleRows($this->plan('i:1', 'default'), (object) ['id' => 1]), + ); + } + + public function test_a_plan_without_soft_deletes_passes_every_row_through(): void + { + $row = (object) ['id' => 1]; + + $this->assertSame( + [$row], + $this->app->make(CanonicalRowRepository::class)->visibleRows($this->plan('i:1'), $row), + ); + } + + public function test_row_state_is_scoped_to_the_generation_and_token(): void + { + $plan = $this->plan('i:7'); + $state = $this->app->make(CanonicalRowRepository::class)->state($plan, '3', '9'); + + $this->assertSame($this->cacheKeys()->row($plan->root, '3', 'i:7'), $state->key); + $this->assertSame('3', $state->generation); + $this->assertSame('9', $state->epoch); + $this->assertSame('0', $state->version); + } + + private function plan(string $token, ?string $softDeleteMode = null): QueryPlan + { + $root = $this->table(); + + return QueryPlan::directPrimaryKey( + $root, + [$root], + new PrimaryKeyMetadata('id', PrimaryKeyMetadata::INTEGER), + $token, + $softDeleteMode, + $softDeleteMode === null ? null : 'deleted_at', + ); + } + + private function table(): TableIdentity + { + $table = $this->app->make(TableIdentityResolver::class) + ->resolve(DB::connection(), 'posts'); + $this->assertNotNull($table); + + return $table; + } +} diff --git a/tests/Integration/Cache/CastAttributeTest.php b/tests/Integration/Cache/CastAttributeTest.php deleted file mode 100644 index 89db1d1..0000000 --- a/tests/Integration/Cache/CastAttributeTest.php +++ /dev/null @@ -1,152 +0,0 @@ - 'tech', 'tags' => ['php', 'redis'], 'views' => 0]; - - public function test_array_cast_survives_query_cache_round_trip(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'A', 'author_id' => $author->id, 'metadata' => $this->metadata]); - - Post::all(); - $post = Post::all()->first(); - - $this->assertIsArray($post->metadata); - $this->assertSame($this->metadata, $post->metadata); - } - - public function test_boolean_cast_survives_query_cache_round_trip(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'A', 'author_id' => $author->id, 'published' => false]); - - Post::all(); - $post = Post::all()->first(); - - $this->assertIsBool($post->published); - $this->assertFalse($post->published); - } - - public function test_boolean_cast_is_applied_to_cached_pluck_values(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'A', 'author_id' => $author->id, 'published' => false]); - Post::create(['title' => 'B', 'author_id' => $author->id, 'published' => true]); - - $cold = Post::orderBy('id')->pluck('published'); - $warm = Post::orderBy('id')->pluck('published'); - - $this->assertSame([false, true], $cold->all()); - $this->assertSame($cold->all(), $warm->all()); - } - - public function test_boolean_cast_is_applied_to_cached_value(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'A', 'author_id' => $author->id, 'published' => true]); - - $cold = Post::value('published'); - $warm = Post::value('published'); - - $this->assertTrue($cold); - $this->assertSame($cold, $warm); - } - - public function test_array_cast_is_applied_to_cached_value_and_pluck(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'A', 'author_id' => $author->id, 'metadata' => $this->metadata]); - - $this->assertSame($this->metadata, Post::value('metadata')); - $this->assertSame([$this->metadata], Post::pluck('metadata')->all()); - } - - public function test_date_attributes_are_applied_to_cached_value_and_pluck(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'A', 'author_id' => $author->id]); - - $value = Post::value('created_at'); - $pluck = Post::pluck('created_at')->first(); - - $this->assertInstanceOf(\DateTimeInterface::class, $value); - $this->assertInstanceOf(\DateTimeInterface::class, $pluck); - $this->assertSame($value->format('Y-m-d H:i:s'), $pluck->format('Y-m-d H:i:s')); - } - - public function test_pluck_preserves_retrieved_events_for_casted_values(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'A', 'author_id' => $author->id, 'published' => true]); - $retrieved = 0; - - Event::listen('eloquent.retrieved: ' . Post::class, function () use (&$retrieved): void { - $retrieved++; - }); - - Post::pluck('published'); - - $this->assertSame(1, $retrieved); - } - - public function test_pluck_fires_retrieved_once_per_row_not_once_per_batch(): void - { - // ScalarTransformer::transformScalars() clones a template pivot-like instance per - // row when a retrieved listener forces the slow path. Guard against the clone - // optimization collapsing N rows into a single fired event. - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'A', 'author_id' => $author->id, 'published' => true]); - Post::create(['title' => 'B', 'author_id' => $author->id, 'published' => false]); - Post::create(['title' => 'C', 'author_id' => $author->id, 'published' => true]); - - $retrieved = 0; - Event::listen('eloquent.retrieved: ' . Post::class, function () use (&$retrieved): void { - $retrieved++; - }); - - $values = Post::orderBy('id')->pluck('published'); - - $this->assertSame([true, false, true], $values->all()); - $this->assertSame(3, $retrieved); - } - - public function test_array_cast_survives_model_cache_db_fallback(): void - { - $author = Author::create(['name' => 'Alice']); - $post = Post::create(['title' => 'A', 'author_id' => $author->id, 'metadata' => $this->metadata]); - - Post::all(); - $this->evictModelCache(Post::class, $post->id); - - $fromFallback = Post::all()->first(); - - $this->assertIsArray($fromFallback->metadata); - $this->assertSame($this->metadata, $fromFallback->metadata); - } - - public function test_array_cast_survives_through_relation_cache(): void - { - $country = Country::create(['name' => 'AU']); - $author = Author::create(['name' => 'Alice', 'country_id' => $country->id]); - Post::create(['title' => 'A', 'author_id' => $author->id, 'metadata' => $this->metadata]); - - $country->posts()->get(); - $post = $country->posts()->get()->first(); - - $this->assertIsArray($post->metadata); - $this->assertSame($this->metadata, $post->metadata); - } -} diff --git a/tests/Integration/Cache/ChangeRecordTest.php b/tests/Integration/Cache/ChangeRecordTest.php new file mode 100644 index 0000000..a7a64ad --- /dev/null +++ b/tests/Integration/Cache/ChangeRecordTest.php @@ -0,0 +1,232 @@ +authorId = (int) Author::query()->create(['name' => 'Author'])->getKey(); + } + + private function seedPosts(int $count): void + { + for ($index = 1; $index <= $count; $index++) { + RawPost::query()->toBase()->insert([ + 'title' => 'Post ' . $index, + 'views' => 0, + 'published' => true, + 'author_id' => $this->authorId, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + + private function changeRecordKey(string $table, string $version): string + { + $identity = $this->app->make(TableIdentityResolver::class) + ->resolve(DB::connection(), $table); + + $this->assertNotNull($identity); + + return $this->cacheKeys()->changeRecord($identity, $version); + } + + private function readChangeRecord(string $table, string $version): ChangeRecord + { + $payload = $this->cacheStore()->getRaw($this->changeRecordKey($table, $version)); + + return $payload === null + ? ChangeRecord::corrupt() + : $this->app->make(ChangeRecordCodec::class)->decode($payload); + } + + private function ttlOf(string $key): int + { + return (int) Redis::connection('normcache-test')->ttl($key); + } + + private function currentVersion(string $table): string + { + $identity = $this->app->make(TableIdentityResolver::class) + ->resolve(DB::connection(), $table); + + $this->assertNotNull($identity); + + return (string) ($this->cacheStore()->getRaw($this->cacheKeys()->version($identity)) ?? '0'); + } + + public function test_a_precise_update_records_its_assigned_columns(): void + { + $this->seedPosts(3); + + RawPost::query()->toBase()->where('id', 1)->update(['title' => 'x']); + + $record = $this->readChangeRecord('posts', $this->currentVersion('posts')); + + $this->assertTrue($record->valid); + $this->assertSame('update', $record->mutation); + $this->assertTrue($record->precise); + $this->assertContains('title', $record->columns); + } + + public function test_eloquent_timestamps_reach_the_change_record(): void + { + $this->seedPosts(3); + + $post = RawPost::query()->findOrFail(1); + $post->title = 'changed'; + $post->save(); + + $record = $this->readChangeRecord('posts', $this->currentVersion('posts')); + + $this->assertTrue($record->valid); + $this->assertEqualsCanonicalizing(['title', 'updated_at'], $record->columns); + } + + public function test_a_base_builder_update_records_no_timestamp(): void + { + $this->seedPosts(3); + + RawPost::query()->toBase()->where('id', 1)->update(['title' => 'changed']); + + $this->assertSame( + ['title'], + $this->readChangeRecord('posts', $this->currentVersion('posts'))->columns, + ); + } + + public function test_a_delete_writes_no_record(): void + { + $this->seedPosts(3); + + RawPost::query()->toBase()->where('id', 1)->delete(); + + $this->assertFalse($this->readChangeRecord('posts', $this->currentVersion('posts'))->valid); + } + + public function test_a_broad_update_writes_no_record(): void + { + $this->seedPosts(3); + + RawPost::query()->toBase()->where('title', 'like', 'Post%')->update(['title' => 'x']); + + $this->assertFalse($this->readChangeRecord('posts', $this->currentVersion('posts'))->valid); + } + + public function test_a_transaction_mixing_an_update_and_an_insert_writes_no_record(): void + { + $this->seedPosts(3); + + DB::transaction(function (): void { + RawPost::query()->toBase()->where('id', 1)->update(['title' => 'x']); + RawPost::query()->toBase()->insert([ + 'title' => 'new', + 'views' => 0, + 'published' => true, + 'author_id' => $this->authorId, + 'created_at' => now(), + 'updated_at' => now(), + ]); + }); + + $this->assertFalse($this->readChangeRecord('posts', $this->currentVersion('posts'))->valid); + } + + public function test_a_transaction_of_updates_unions_their_columns(): void + { + $this->seedPosts(3); + + DB::transaction(function (): void { + RawPost::query()->toBase()->where('id', 1)->update(['title' => 'x']); + RawPost::query()->toBase()->where('id', 2)->update(['views' => 7]); + }); + + $record = $this->readChangeRecord('posts', $this->currentVersion('posts')); + + $this->assertTrue($record->valid); + $this->assertSame('update', $record->mutation); + $this->assertEqualsCanonicalizing(['title', 'views'], $record->columns); + } + + public function test_change_records_expire(): void + { + $this->seedPosts(3); + + RawPost::query()->toBase()->where('id', 1)->update(['title' => 'x']); + + $this->assertGreaterThan( + 0, + $this->ttlOf($this->changeRecordKey('posts', $this->currentVersion('posts'))), + ); + } + + public function test_change_records_track_the_query_ttl_not_a_longer_entry_ttl(): void + { + config()->set('normcache.query_ttl', 60); + $this->app->forgetInstance(CacheConfig::class); + $this->app->forgetScopedInstances(); + + $this->seedPosts(3); + + RawPost::query()->toBase()->where('published', true)->orderBy('id')->ttl(600)->get(); + + $entryKeys = $this->cacheQueryKeysWithField('m'); + $this->assertNotSame([], $entryKeys); + + RawPost::query()->toBase()->where('id', 1)->update(['title' => 'x']); + + $this->assertEqualsWithDelta(600, $this->ttlOf($entryKeys[0]), 2); + $this->assertEqualsWithDelta( + 60, + $this->ttlOf($this->changeRecordKey('posts', $this->currentVersion('posts'))), + 2, + ); + } + + public function test_a_json_path_assignment_records_its_base_column(): void + { + $this->seedPosts(3); + + RawPost::query()->toBase()->where('id', 1)->update(['metadata->flag' => 'x']); + + $record = $this->readChangeRecord('posts', $this->currentVersion('posts')); + + $this->assertTrue($record->valid); + $this->assertSame(['metadata'], $record->columns); + } + + public function test_a_record_is_written_per_table_in_a_multi_table_transaction(): void + { + $this->seedPosts(3); + + DB::transaction(function (): void { + RawPost::query()->toBase()->where('id', 1)->update(['title' => 'x']); + Author::query()->toBase()->where('id', $this->authorId)->update(['name' => 'y']); + }); + + $posts = $this->readChangeRecord('posts', $this->currentVersion('posts')); + $authors = $this->readChangeRecord('authors', $this->currentVersion('authors')); + + $this->assertTrue($posts->valid); + $this->assertSame(['title'], $posts->columns); + + $this->assertTrue($authors->valid); + $this->assertSame(['name'], $authors->columns); + } +} diff --git a/tests/Integration/Cache/ConcurrencyTest.php b/tests/Integration/Cache/ConcurrencyTest.php new file mode 100644 index 0000000..aaaa036 --- /dev/null +++ b/tests/Integration/Cache/ConcurrencyTest.php @@ -0,0 +1,298 @@ +app->make(BuildLeaseCoordinator::class); + $plan = $this->plan(); + + $first = $leases->claim($plan, $this->state(), 'n', 'hash'); + $second = $leases->claim($plan, $this->state(), 'n', 'hash'); + + $this->assertTrue($first->owner); + $this->assertFalse($second->owner); + $this->assertSame($first->buildingKey, $second->buildingKey); + } + + public function test_a_waiter_learns_the_owner_wake_key(): void + { + $leases = $this->app->make(BuildLeaseCoordinator::class); + $plan = $this->plan(); + + $owner = $leases->claim($plan, $this->state(), 'n', 'hash'); + $waiter = $leases->claim($plan, $this->state(), 'n', 'hash'); + + $this->assertSame($owner->wakeKey, $waiter->wakeKey); + $this->assertSame($owner->token, $waiter->token); + } + + public function test_claiming_with_the_same_token_is_idempotent(): void + { + $key = 'test:{claim-build}:lease'; + $token = str_repeat('a', 32); + + [$firstOwner, $firstToken] = $this->cacheStore()->claimBuild($key, $token, 30); + [$retryOwner, $retryToken] = $this->cacheStore()->claimBuild($key, $token, 30); + [$otherOwner, $observedToken] = $this->cacheStore()->claimBuild( + $key, + str_repeat('b', 32), + 30, + ); + + $this->assertTrue($firstOwner); + $this->assertSame($token, $firstToken); + $this->assertTrue($retryOwner); + $this->assertSame($token, $retryToken); + $this->assertFalse($otherOwner); + $this->assertSame($token, $observedToken); + } + + public function test_releasing_a_lease_lets_the_next_caller_claim_it(): void + { + $leases = $this->app->make(BuildLeaseCoordinator::class); + $plan = $this->plan(); + + $first = $leases->claim($plan, $this->state(), 'n', 'hash'); + $leases->release($first); + + $this->assertTrue($leases->claim($plan, $this->state(), 'n', 'hash')->owner); + } + + public function test_releasing_a_lease_this_caller_does_not_own_is_a_no_op(): void + { + $leases = $this->app->make(BuildLeaseCoordinator::class); + $plan = $this->plan(); + + $owner = $leases->claim($plan, $this->state(), 'n', 'hash'); + $waiter = $leases->claim($plan, $this->state(), 'n', 'hash'); + + $leases->release($waiter); + + $this->assertFalse($leases->claim($plan, $this->state(), 'n', 'hash')->owner); + $this->assertTrue($owner->owner); + } + + public function test_matching_row_repairs_share_one_lease(): void + { + $leases = $this->app->make(BuildLeaseCoordinator::class); + $root = $this->table(); + + $owner = $leases->claimRepair($root, '4', 'batch'); + $waiter = $leases->claimRepair($root, '4', 'batch'); + + $this->assertTrue($owner->owner); + $this->assertFalse($waiter->owner); + $this->assertSame($owner->buildingKey, $waiter->buildingKey); + $this->assertSame($owner->wakeKey, $waiter->wakeKey); + } + + public function test_row_repairs_from_different_generations_do_not_share_a_lease(): void + { + $leases = $this->app->make(BuildLeaseCoordinator::class); + $root = $this->table(); + + $this->assertTrue($leases->claimRepair($root, '4', 'batch')->owner); + $this->assertTrue($leases->claimRepair($root, '5', 'batch')->owner); + } + + public function test_a_request_that_loses_the_lease_race_serves_from_the_database(): void + { + Author::create(['name' => 'Alice']); + $query = static fn() => Author::orderBy('id')->get(); + + $buildKey = null; + DB::listen(function () use (&$buildKey): void { + $buildKey ??= $this->cacheKeysMatching(':build:')[0] ?? null; + }); + + $query(); + $this->assertIsString($buildKey, 'expected a build lease to be held across the database read'); + + $this->cacheStore()->delete([ + ...$this->cacheEntryKeys(), + ...$this->cacheKeysMatching(':r:g'), + ]); + + $this->cacheStore()->claimBuild($buildKey, str_repeat('f', 32), 5); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $contended = $query(); + $queries = DB::getQueryLog(); + DB::disableQueryLog(); + + $this->assertCount(1, $contended); + $this->assertSame('Alice', $contended->first()->name); + $this->assertNotSame([], $queries, 'a losing claimant must fall through to the database'); + $published = array_values(array_filter( + $this->cacheQueryKeysWithField('m'), + static fn(string $key): bool => !str_contains($key, ':build:'), + )); + + $this->assertSame([], $published, 'a losing claimant must not publish'); + } + + /** A planted query overlay detects retries through the wrong cache route. */ + public function test_a_direct_primary_key_waiter_retries_through_the_row_cache(): void + { + $author = Author::create(['name' => 'Alice']); + $token = 'i:' . $author->id; + + $statement = null; + DB::listen(function ($query) use (&$statement): void { + $statement ??= [$query->sql, $query->bindings]; + }); + + Author::find($author->id); + $this->assertIsArray($statement, 'expected the warming read to compile a statement'); + $this->assertNotSame([], $this->cacheKeysMatching(':r:g'), 'expected a canonical row'); + + $table = $this->table('authors'); + $keys = $this->cacheKeys(); + $store = $this->cacheStore(); + $queryHash = $this->app->make(QueryIdentity::class)->hash( + route: QueryPlan::DIRECT_PK, + rootHash: $table->hash, + dependencyHashes: [$table->hash], + sql: $statement[0], + bindings: DB::connection()->prepareBindings($statement[1]), + namespace: 'u', + operation: 'select', + ); + + $store->delete($this->cacheKeysMatching(':r:g')); + $store->writeHashField( + $keys->queryEntry($table, 'u', $queryHash), + 'r', + $this->app->make(RawResultCodec::class)->encode( + [(object) ['id' => $author->id, 'name' => 'Planted']], + $this->app->make(CacheRuntime::class)->epoch(), + $store->getRaw($keys->version($table)) ?? '0', + ), + ); + + $store->claimBuild($keys->rowBuild($table, '0', $token), str_repeat('f', 32), 30); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $found = Author::find($author->id); + $queries = DB::getQueryLog(); + DB::disableQueryLog(); + + $this->assertNotNull($found); + $this->assertSame( + 'Alice', + $found->name, + 'a direct-pk waiter must not serve the query-entry key its route never writes', + ); + $this->assertNotSame( + [], + $queries, + 'a direct-pk waiter whose row is still missing must fall through to the database', + ); + } + + public function test_a_waiter_stops_waiting_as_soon_as_the_owner_wakes_it(): void + { + $leases = $this->app->make(BuildLeaseCoordinator::class); + $plan = $this->plan(); + + $owner = $leases->claim($plan, $this->state(), 'n', 'woken'); + $waiter = $leases->claim($plan, $this->state(), 'n', 'woken'); + + $this->assertTrue($owner->owner); + $this->assertFalse($waiter->owner); + $this->assertSame($owner->wakeKey, $waiter->wakeKey); + + $leases->release($owner); + + $started = hrtime(true); + $woken = $this->cacheStore()->brpop((string) $waiter->wakeKey, 2.0); + $elapsedMs = (hrtime(true) - $started) / 1e6; + + $this->assertTrue($woken); + $this->assertLessThan(1000, $elapsedMs); + } + + public function test_an_empty_lease_token_neither_publishes_nor_releases(): void + { + $table = $this->table(); + $keys = $this->cacheKeys(); + $buildKey = $keys->queryBuild($table, '1', 'n', 'hash'); + $entryKey = $keys->queryEntry($table, 'n', 'hash'); + $owner = str_repeat('a', 32); + $this->cacheStore()->claimBuild($buildKey, $owner, 30); + + $published = $this->cacheStore()->publishVersionedEntries( + entryKeys: [$entryKey], + entryPayloads: ['payload'], + ttl: 30, + versionKeys: [], + expectedVersions: [], + buildingKey: $buildKey, + wakeKey: null, + token: null, + wakeTtl: 10, + ); + + $this->assertFalse($published); + $this->assertSame( + $owner, + $this->cacheStore()->getRaw($buildKey), + 'an empty token owns nothing, so another claimant\'s lease must survive', + ); + $this->assertNull( + $this->cacheStore()->getRaw($entryKey), + 'nothing may be published while another claimant holds the lease', + ); + } + + private function plan(): QueryPlan + { + $root = $this->table(); + + return QueryPlan::canonical( + $root, + [$root], + new PrimaryKeyMetadata('id', PrimaryKeyMetadata::INTEGER), + ); + } + + private function state(): CacheState + { + return new CacheState( + key: 'k', + epoch: '1', + version: '1', + generation: '1', + versions: [], + tag: null, + tagKey: null, + ); + } + + private function table(string $name = 'posts'): TableIdentity + { + $table = $this->app->make(TableIdentityResolver::class) + ->resolve(DB::connection(), $name); + $this->assertNotNull($table); + + return $table; + } +} diff --git a/tests/Integration/Cache/ConnectionAwareCachingTest.php b/tests/Integration/Cache/ConnectionAwareCachingTest.php deleted file mode 100644 index 3c3da43..0000000 --- a/tests/Integration/Cache/ConnectionAwareCachingTest.php +++ /dev/null @@ -1,376 +0,0 @@ -seedDifferentAuthorsOnTwoConnections(); - - $primary = Author::find(1); - $secondaryBuilder = Author::on('secondary_testing'); - $this->assertSame('secondary_testing', $secondaryBuilder->getModel()->getConnectionName()); - $secondary = $secondaryBuilder->find(1); - - $this->assertSame('Primary Alice', $primary?->name); - $this->assertSame('Secondary Alice', $secondary?->name); - $this->assertSame('secondary_testing', $secondary?->getConnectionName()); - - $manager = $this->cacheManager(); - $defaultKey = $manager->keys()->classKey(Author::class); - $secondaryKey = $manager->keys()->classKey(Author::class, 'secondary_testing'); - $defaultVersion = $manager->currentVersion(Author::class); - $secondaryVersion = $manager->currentVersion(Author::class, 'secondary_testing'); - - $this->assertSame( - 'Primary Alice', - $manager->store()->get($manager->keys()->modelPrefix($defaultKey, $defaultVersion) . '1')['name'] ?? null, - ); - $this->assertSame( - 'Secondary Alice', - $manager->store()->get($manager->keys()->modelPrefix($secondaryKey, $secondaryVersion) . '1')['name'] ?? null, - ); - - DB::connection('secondary_testing')->flushQueryLog(); - DB::connection('secondary_testing')->enableQueryLog(); - - try { - $this->assertSame('Secondary Alice', Author::on('secondary_testing')->find(1)?->name); - $this->assertSame([], DB::connection('secondary_testing')->getQueryLog()); - } finally { - DB::connection('secondary_testing')->disableQueryLog(); - } - } - - public function test_on_connection_query_does_not_poison_default_cache_namespace(): void - { - $this->seedDifferentAuthorsOnTwoConnections(); - - $this->assertSame('Secondary Alice', Author::on('secondary_testing')->find(1)?->name); - $this->assertSame('Primary Alice', Author::find(1)?->name); - } - - public function test_normalized_and_scalar_caches_are_connection_scoped(): void - { - $this->seedDifferentAuthorsOnTwoConnections(); - DB::connection('secondary_testing')->table('authors')->insert([ - 'id' => 2, - 'name' => 'Secondary Bob', - 'country_id' => null, - 'created_at' => now(), - 'updated_at' => now(), - ]); - - $this->assertSame(['Primary Alice'], Author::orderBy('id')->get()->pluck('name')->all()); - $this->assertSame( - ['Secondary Alice', 'Secondary Bob'], - Author::on('secondary_testing')->orderBy('id')->get()->pluck('name')->all(), - ); - $this->assertSame(1, Author::count()); - $this->assertSame(2, Author::on('secondary_testing')->count()); - - DB::connection('secondary_testing')->flushQueryLog(); - DB::connection('secondary_testing')->enableQueryLog(); - - try { - $this->assertSame( - ['Secondary Alice', 'Secondary Bob'], - Author::on('secondary_testing')->orderBy('id')->get()->pluck('name')->all(), - ); - $this->assertSame(2, Author::on('secondary_testing')->count()); - $this->assertSame([], DB::connection('secondary_testing')->getQueryLog()); - } finally { - DB::connection('secondary_testing')->disableQueryLog(); - } - } - - public function test_secondary_model_write_invalidates_only_secondary_namespace(): void - { - $this->seedDifferentAuthorsOnTwoConnections(); - - Author::find(1); - $secondary = Author::on('secondary_testing')->find(1); - $manager = $this->cacheManager(); - $defaultBefore = $manager->currentVersion(Author::class); - $secondaryBefore = $manager->currentVersion(Author::class, 'secondary_testing'); - - $secondary?->update(['name' => 'Secondary Alicia']); - - $this->assertSame($defaultBefore, $manager->currentVersion(Author::class)); - $this->assertGreaterThan( - $secondaryBefore, - $manager->currentVersion(Author::class, 'secondary_testing'), - ); - $this->assertSame('Primary Alice', Author::find(1)?->name); - $this->assertSame('Secondary Alicia', Author::on('secondary_testing')->find(1)?->name); - } - - public function test_on_connection_query_explain_reports_cached_strategy(): void - { - $this->seedDifferentAuthorsOnTwoConnections(); - - $this->assertSame( - 'cached', - Author::on('secondary_testing')->whereKey(1)->explain(), - ); - } - - public function test_use_write_pdo_direct_lookup_does_not_reuse_replica_model_payload(): void - { - $paths = $this->seedReplicatedAuthorConnection(); - - try { - $this->assertSame('Replica Alice', Author::on('replicated_testing')->find(1)?->name); - $this->assertSame( - 'Primary Alice', - Author::on('replicated_testing')->withoutCache()->useWritePdo()->find(1)?->name, - ); - $this->assertSame( - 'Primary Alice', - Author::on('replicated_testing')->useWritePdo()->find(1)?->name, - ); - } finally { - $this->cleanupReplicatedAuthorConnection($paths); - } - } - - public function test_use_write_pdo_normalized_query_does_not_reuse_replica_model_payload(): void - { - $paths = $this->seedReplicatedAuthorConnection(); - - try { - $this->assertSame( - ['Replica Alice'], - Author::on('replicated_testing')->orderBy('id')->get()->pluck('name')->all(), - ); - $this->assertSame( - ['Primary Alice'], - Author::on('replicated_testing')->withoutCache()->useWritePdo()->orderBy('id')->get()->pluck('name')->all(), - ); - $this->assertSame( - ['Primary Alice'], - Author::on('replicated_testing')->useWritePdo()->orderBy('id')->get()->pluck('name')->all(), - ); - } finally { - $this->cleanupReplicatedAuthorConnection($paths); - } - } - - public function test_through_relation_on_secondary_connection_does_not_leak_default_connection_model_cache(): void - { - $this->seedThroughRelationOnTwoConnections(); - - $cold = Country::on('secondary_testing')->with('posts')->find(1); - $this->assertSame('SecondaryPost', $cold->posts->first()->title); - - // Poisons the default connection's cache entry for the same Post id, at the same - // version — a connection-resolution bug would make the warm read below return this. - DB::table('posts')->where('id', 1)->update(['title' => 'DefaultPostOverwrite']); - Post::find(1); - $this->assertSame('DefaultPostOverwrite', $this->modelCacheEntry(Post::class, 1)['title'] ?? null); - - $warm = Country::on('secondary_testing')->with('posts')->find(1); - - $this->assertSame('SecondaryPost', $warm->posts->first()->title); - } - - public function test_pivot_relation_on_secondary_connection_does_not_leak_default_connection_model_cache(): void - { - $this->seedPivotRelationOnTwoConnections(); - - $cold = Author::on('secondary_testing')->with('tags')->find(1); - $this->assertSame('SecondaryTag', $cold->tags->first()->name); - - // Poisons the default connection's cache entry for the same Tag id — see above. - DB::table('tags')->where('id', 1)->update(['name' => 'DefaultTagOverwrite']); - Tag::find(1); - $this->assertSame('DefaultTagOverwrite', $this->modelCacheEntry(Tag::class, 1)['name'] ?? null); - - DB::connection('secondary_testing')->flushQueryLog(); - DB::connection('secondary_testing')->enableQueryLog(); - - try { - $warm = Author::on('secondary_testing')->with('tags')->find(1); - // A write/read key mismatch falls through to a live query but still returns - // correct data, masking the bug — so assert on cache hits, not just the result. - $this->assertSame([], DB::connection('secondary_testing')->getQueryLog()); - } finally { - DB::connection('secondary_testing')->disableQueryLog(); - } - - $this->assertSame('SecondaryTag', $warm->tags->first()->name); - } - - private function seedPivotRelationOnTwoConnections(): void - { - $author = Author::create(['id' => 1, 'name' => 'DefaultAuthor']); - $tag = Tag::create(['id' => 1, 'name' => 'DefaultTag']); - $author->tags()->attach($tag->id); - - config()->set('database.connections.secondary_testing', [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'prefix' => '', - ]); - DB::purge('secondary_testing'); - - Schema::connection('secondary_testing')->create('authors', function (Blueprint $table) { - $table->id(); - $table->string('name'); - $table->foreignId('country_id')->nullable(); - $table->timestamps(); - }); - Schema::connection('secondary_testing')->create('tags', function (Blueprint $table) { - $table->id(); - $table->string('name'); - $table->timestamps(); - }); - Schema::connection('secondary_testing')->create('author_tag', function (Blueprint $table) { - $table->foreignId('author_id'); - $table->foreignId('tag_id'); - $table->string('notes')->nullable(); - $table->primary(['author_id', 'tag_id']); - }); - - DB::connection('secondary_testing')->table('authors')->insert([ - 'id' => 1, 'name' => 'SecondaryAuthor', 'created_at' => now(), 'updated_at' => now(), - ]); - DB::connection('secondary_testing')->table('tags')->insert([ - 'id' => 1, 'name' => 'SecondaryTag', 'created_at' => now(), 'updated_at' => now(), - ]); - DB::connection('secondary_testing')->table('author_tag')->insert([ - 'author_id' => 1, 'tag_id' => 1, - ]); - } - - private function seedThroughRelationOnTwoConnections(): void - { - Country::create(['id' => 1, 'name' => 'DefaultCountry']); - $author = Author::create(['id' => 1, 'name' => 'DefaultAuthor', 'country_id' => 1]); - Post::create(['id' => 1, 'title' => 'DefaultPost', 'author_id' => $author->id]); - - config()->set('database.connections.secondary_testing', [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'prefix' => '', - ]); - DB::purge('secondary_testing'); - - Schema::connection('secondary_testing')->create('countries', function (Blueprint $table) { - $table->id(); - $table->string('name'); - $table->timestamps(); - }); - Schema::connection('secondary_testing')->create('authors', function (Blueprint $table) { - $table->id(); - $table->string('name'); - $table->foreignId('country_id')->nullable(); - $table->timestamps(); - }); - Schema::connection('secondary_testing')->create('posts', function (Blueprint $table) { - $table->id(); - $table->string('title'); - $table->unsignedInteger('views')->default(0); - $table->boolean('published')->default(false); - $table->json('metadata')->nullable(); - $table->foreignId('author_id'); - $table->timestamps(); - $table->softDeletes(); - }); - - DB::connection('secondary_testing')->table('countries')->insert([ - 'id' => 1, 'name' => 'SecondaryCountry', 'created_at' => now(), 'updated_at' => now(), - ]); - DB::connection('secondary_testing')->table('authors')->insert([ - 'id' => 1, 'name' => 'SecondaryAuthor', 'country_id' => 1, 'created_at' => now(), 'updated_at' => now(), - ]); - DB::connection('secondary_testing')->table('posts')->insert([ - 'id' => 1, 'title' => 'SecondaryPost', 'author_id' => 1, 'created_at' => now(), 'updated_at' => now(), - ]); - } - - private function seedDifferentAuthorsOnTwoConnections(): void - { - config()->set('database.connections.secondary_testing', [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'prefix' => '', - ]); - - DB::purge('secondary_testing'); - - Schema::connection('secondary_testing')->create('authors', function (Blueprint $table) { - $table->id(); - $table->string('name'); - $table->foreignId('country_id')->nullable(); - $table->timestamps(); - }); - - Author::create(['id' => 1, 'name' => 'Primary Alice']); - - DB::connection('secondary_testing')->table('authors')->insert([ - 'id' => 1, - 'name' => 'Secondary Alice', - 'country_id' => null, - 'created_at' => now(), - 'updated_at' => now(), - ]); - } - - /** @return array{read: string, write: string} */ - private function seedReplicatedAuthorConnection(): array - { - $readPath = tempnam(sys_get_temp_dir(), 'normcache-read-'); - $writePath = tempnam(sys_get_temp_dir(), 'normcache-write-'); - - $this->assertNotFalse($readPath); - $this->assertNotFalse($writePath); - - config()->set('database.connections.replicated_testing', [ - 'driver' => 'sqlite', - 'database' => $writePath, - 'prefix' => '', - 'read' => ['database' => $readPath], - 'write' => ['database' => $writePath], - ]); - - DB::purge('replicated_testing'); - - $connection = DB::connection('replicated_testing'); - $this->seedAuthorPdo($connection->getReadPdo(), 'Replica Alice'); - $this->seedAuthorPdo($connection->getPdo(), 'Primary Alice'); - $this->resetClassKeyCache(); - - return ['read' => $readPath, 'write' => $writePath]; - } - - private function seedAuthorPdo(\PDO $pdo, string $name): void - { - $pdo->exec('CREATE TABLE authors (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR NOT NULL, country_id INTEGER NULL, created_at DATETIME NULL, updated_at DATETIME NULL)'); - - $insert = $pdo->prepare('INSERT INTO authors (id, name, country_id, created_at, updated_at) VALUES (1, :name, NULL, NULL, NULL)'); - $insert->execute(['name' => $name]); - } - - /** @param array{read: string, write: string} $paths */ - private function cleanupReplicatedAuthorConnection(array $paths): void - { - DB::purge('replicated_testing'); - - foreach ($paths as $path) { - if (is_file($path)) { - unlink($path); - } - } - } -} diff --git a/tests/Integration/Cache/CrossTableDependencySafetyTest.php b/tests/Integration/Cache/CrossTableDependencySafetyTest.php deleted file mode 100644 index ecf9bca..0000000 --- a/tests/Integration/Cache/CrossTableDependencySafetyTest.php +++ /dev/null @@ -1,146 +0,0 @@ - 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - $query = fn() => Author::query() - ->join('posts', 'posts.author_id', '=', 'authors.id') - ->dependsOn([Post::class]) - ->count(); - - $this->assertSame(1, $query()); - $this->assertSame(1, $query()); - - Post::create(['title' => 'World', 'author_id' => $author->id]); - - $this->assertSame(2, $query()); - } - - public function test_simple_count_without_join_still_caches(): void - { - Author::create(['name' => 'Alice']); - - Author::query()->count(); - - $this->assertNotEmpty($this->redisKeys('count:*')); - } - - public function test_count_with_group_by_single_table_still_caches(): void - { - Author::create(['name' => 'Alice']); - - Author::query()->groupBy('name')->count('name'); - - $this->assertNotEmpty($this->redisKeys('count:*')); - } - - public function test_aggregate_constraint_with_join_uses_inferred_table_dependencies(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Author::withCount([ - 'posts' => fn($q) => $q->join('authors', 'authors.id', '=', 'posts.author_id'), - ])->get(); - - $this->assertNotEmpty($this->redisKeys('result:*')); - } - - public function test_simple_aggregate_constraint_still_infers_dependencies(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Author::withCount(['posts' => fn($q) => $q->where('title', 'Hello')])->get(); - - $this->assertNotEmpty($this->redisKeys('result:*')); - } - - public function test_aggregate_constraint_with_wherehas_uses_recursive_dependencies(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Author::withCount([ - 'posts' => fn($q) => $q->whereHas('author'), - ])->get(); - - $this->assertNotEmpty($this->redisKeys('result:*')); - } - - public function test_aliased_from_query_bypasses_normalized_cache(): void - { - Author::create(['name' => 'Alice']); - - Author::from('authors as a')->where('a.name', 'Alice')->get(); - - $this->assertEmpty($this->redisKeys('query:*')); - } - - public function test_aliased_from_with_depends_on_uses_result_cache(): void - { - Author::create(['name' => 'Alice']); - - Author::from('authors as a') - ->where('a.name', 'Alice') - ->dependsOn([Author::class]) - ->get(); - - $this->assertNotEmpty($this->redisKeys('result:*')); - } - - public function test_canonical_from_still_uses_normalized_cache(): void - { - Author::create(['name' => 'Alice']); - - Author::from('authors')->get(); - - $this->assertNotEmpty($this->redisKeys('query:*')); - } - - public function test_pluck_with_falsey_key_hashes_differently_from_no_key(): void - { - Author::create(['name' => 'Alice']); - - // Warm cache for pluck without a key - Author::query()->pluck('name'); - $noKeyCache = $this->redisKeys('scalar:*'); - - // Pluck with an integer key (falsey: 0) - Author::query()->pluck('name', 'id'); - $withKeyCache = $this->redisKeys('scalar:*'); - - // Should have two separate cache entries, not share one - $this->assertCount(2, $withKeyCache); - $this->assertNotEmpty(array_diff($withKeyCache, $noKeyCache)); - } - - public function test_pluck_with_null_key_behaves_same_as_no_key(): void - { - Author::create(['name' => 'Alice']); - - Author::query()->pluck('name', null); - $nullKeyCache = $this->redisKeys('scalar:*'); - - Author::query()->pluck('name'); - $noKeyCache = $this->redisKeys('scalar:*'); - - // null key should hash the same as no key (both use only [$column]) - $this->assertCount(1, $noKeyCache); - $this->assertSame(count($nullKeyCache), count($noKeyCache)); - } -} diff --git a/tests/Integration/Cache/DeleteInvalidationTest.php b/tests/Integration/Cache/DeleteInvalidationTest.php new file mode 100644 index 0000000..c966ac9 --- /dev/null +++ b/tests/Integration/Cache/DeleteInvalidationTest.php @@ -0,0 +1,462 @@ +getDriverName() === 'sqlite') { + DB::statement('PRAGMA foreign_keys = ON'); + } + } + + protected function tearDown(): void + { + foreach (['cascade_delete_notes', 'restricted_delete_notes', 'nocase_items'] as $table) { + Schema::dropIfExists($table); + } + + parent::tearDown(); + } + + private function restrictedNote(): Post + { + Schema::create('restricted_delete_notes', function (Blueprint $table): void { + $table->id(); + $table->foreignId('post_id')->constrained('posts'); + $table->string('body'); + }); + + [, $post] = $this->authorAndPost(); + $note = RestrictedDeleteNote::query()->create([ + 'post_id' => $post->getKey(), + 'body' => 'Restricted child', + ]); + RestrictedDeleteNote::query()->findOrFail($note->getKey()); + RestrictedDeleteNote::query()->where('post_id', $post->getKey())->delete(); + + return $post; + } + + public function test_delete_precisely_invalidates_the_root_and_broadly_invalidates_cascade_children(): void + { + [$author, $post] = $this->authorAndPost(); + $unrelated = UuidItem::query()->create(['id' => 'unrelated', 'name' => 'Unrelated']); + + Author::query()->findOrFail($author->getKey()); + Post::query()->findOrFail($post->getKey()); + Post::query()->findOrFail($post->getKey()); + UuidItem::query()->findOrFail($unrelated->getKey()); + + $authorTable = $this->identity('authors'); + $postTable = $this->identity('posts'); + $uuidTable = $this->identity('uuid_items'); + $authorGeneration = $this->generation($authorTable); + $authorVersion = $this->version($authorTable); + $postGeneration = $this->generation($postTable); + $uuidGeneration = $this->generation($uuidTable); + $authorRow = $this->cacheKeys()->row( + $authorTable, + $authorGeneration, + 'i:' . $author->getKey(), + ); + $epoch = $this->epoch(); + + $author->delete(); + + $this->assertSame($epoch, $this->epoch()); + $this->assertSame($authorGeneration, $this->generation($authorTable)); + $this->assertSame((string) ((int) $authorVersion + 1), $this->version($authorTable)); + $this->assertNull($this->cacheStore()->getRaw($authorRow)); + $this->assertSame((string) ((int) $postGeneration + 1), $this->generation($postTable)); + $this->assertSame($uuidGeneration, $this->generation($uuidTable)); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $this->assertSame('Unrelated', UuidItem::query()->findOrFail($unrelated->getKey())->name); + DB::disableQueryLog(); + + $this->assertSame([], DB::getQueryLog()); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $this->assertNull(Post::query()->find($post->getKey())); + DB::disableQueryLog(); + + $this->assertCount(1, DB::getQueryLog()); + } + + public function test_delete_invalidation_waits_for_commit(): void + { + [$author, $post] = $this->authorAndPost(); + Author::query()->findOrFail($author->getKey()); + Post::query()->findOrFail($post->getKey()); + + $authorTable = $this->identity('authors'); + $postTable = $this->identity('posts'); + $authorVersion = $this->version($authorTable); + $postGeneration = $this->generation($postTable); + $epoch = $this->epoch(); + + DB::beginTransaction(); + $author->delete(); + $this->assertSame($authorVersion, $this->version($authorTable)); + $this->assertSame($postGeneration, $this->generation($postTable)); + DB::commit(); + + $this->assertSame($epoch, $this->epoch()); + $this->assertSame((string) ((int) $authorVersion + 1), $this->version($authorTable)); + $this->assertSame((string) ((int) $postGeneration + 1), $this->generation($postTable)); + $this->assertNull(Post::query()->find($post->getKey())); + } + + public function test_rolled_back_delete_discards_surgical_invalidation(): void + { + [$author, $post] = $this->authorAndPost(); + $this->assertSame('Cached child', Post::query()->findOrFail($post->getKey())->title); + + $authorTable = $this->identity('authors'); + $postTable = $this->identity('posts'); + $authorVersion = $this->version($authorTable); + $postGeneration = $this->generation($postTable); + $epoch = $this->epoch(); + + DB::beginTransaction(); + $author->delete(); + DB::rollBack(); + + $this->assertSame($epoch, $this->epoch()); + $this->assertSame($authorVersion, $this->version($authorTable)); + $this->assertSame($postGeneration, $this->generation($postTable)); + $this->assertSame('Cached child', Post::query()->findOrFail($post->getKey())->title); + } + + public function test_unproven_delete_broadly_invalidates_only_its_table(): void + { + UuidItem::query()->create(['id' => 'first', 'name' => 'Delete']); + UuidItem::query()->create(['id' => 'second', 'name' => 'Keep']); + UuidItem::query()->orderBy('id')->get(); + + $table = $this->identity('uuid_items'); + $generation = $this->generation($table); + $epoch = $this->epoch(); + + UuidItem::query()->where('name', 'Delete')->delete(); + + $this->assertSame($epoch, $this->epoch()); + $this->assertSame((string) ((int) $generation + 1), $this->generation($table)); + } + + public function test_set_null_invalidates_the_changed_child_but_does_not_walk_its_descendants(): void + { + $country = Country::query()->create(['name' => 'Parent country']); + $author = Author::query()->create([ + 'name' => 'Author', + 'country_id' => $country->getKey(), + ]); + $post = Post::query()->create([ + 'title' => 'Unchanged post', + 'author_id' => $author->getKey(), + ]); + + Author::query()->findOrFail($author->getKey()); + Post::query()->findOrFail($post->getKey()); + Post::query()->findOrFail($post->getKey()); + + $authorTable = $this->identity('authors'); + $postTable = $this->identity('posts'); + $authorGeneration = $this->generation($authorTable); + $postGeneration = $this->generation($postTable); + $epoch = $this->epoch(); + + $country->delete(); + + $this->assertSame($epoch, $this->epoch()); + $this->assertSame((string) ((int) $authorGeneration + 1), $this->generation($authorTable)); + $this->assertSame($postGeneration, $this->generation($postTable)); + $this->assertNull(Author::query()->findOrFail($author->getKey())->country_id); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $this->assertSame('Unchanged post', Post::query()->findOrFail($post->getKey())->title); + DB::disableQueryLog(); + + $this->assertSame([], DB::getQueryLog()); + } + + public function test_cascade_dependencies_are_followed_recursively(): void + { + Schema::create('cascade_delete_notes', function (Blueprint $table): void { + $table->id(); + $table->foreignId('post_id')->constrained('posts')->cascadeOnDelete(); + $table->string('body'); + }); + + [$author, $post] = $this->authorAndPost(); + $note = CascadeDeleteNote::query()->create([ + 'post_id' => $post->getKey(), + 'body' => 'Nested child', + ]); + CascadeDeleteNote::query()->findOrFail($note->getKey()); + + $noteTable = $this->identity('cascade_delete_notes'); + $noteGeneration = $this->generation($noteTable); + $epoch = $this->epoch(); + + $author->delete(); + + $this->assertSame($epoch, $this->epoch()); + $this->assertSame( + (string) ((int) $noteGeneration + 1), + $this->generation($noteTable), + ); + $this->assertNull(CascadeDeleteNote::query()->find($note->getKey())); + } + + public function test_truncate_broadly_invalidates_only_its_table(): void + { + [, $post] = $this->authorAndPost(); + UuidItem::query()->create(['id' => 'truncate-me', 'name' => 'Delete']); + Post::query()->findOrFail($post->getKey()); + UuidItem::query()->get(); + + $postTable = $this->identity('posts'); + $uuidTable = $this->identity('uuid_items'); + $postGeneration = $this->generation($postTable); + $uuidGeneration = $this->generation($uuidTable); + $epoch = $this->epoch(); + + UuidItem::query()->toBase()->truncate(); + + $this->assertSame($epoch, $this->epoch()); + $this->assertSame($postGeneration, $this->generation($postTable)); + $this->assertSame((string) ((int) $uuidGeneration + 1), $this->generation($uuidTable)); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $this->assertSame('Cached child', Post::query()->findOrFail($post->getKey())->title); + DB::disableQueryLog(); + + $this->assertSame([], DB::getQueryLog()); + } + + public function test_truncate_invalidation_survives_a_rolled_back_transaction(): void + { + UuidItem::query()->create(['id' => 'truncate-me', 'name' => 'Delete']); + UuidItem::query()->get(); + + $uuidTable = $this->identity('uuid_items'); + $uuidGeneration = $this->generation($uuidTable); + $epoch = $this->epoch(); + + DB::beginTransaction(); + UuidItem::query()->toBase()->truncate(); + $this->assertSame( + (string) ((int) $uuidGeneration + 1), + $this->generation($uuidTable), + 'truncate is not rollback-safe on every driver, so it must invalidate before commit', + ); + DB::rollBack(); + + $this->assertSame($epoch, $this->epoch()); + $this->assertSame( + (string) ((int) $uuidGeneration + 1), + $this->generation($uuidTable), + 'a rollback must not restore cache entries a truncate may already have orphaned', + ); + } + + public function test_truncate_invalidates_children_whose_foreign_key_does_not_cascade(): void + { + if (DB::connection()->getDriverName() !== 'sqlite') { + $this->markTestSkipped('InnoDB refuses to truncate a table a foreign key references.'); + } + + $this->restrictedNote(); + + $noteTable = $this->identity('restricted_delete_notes'); + $noteGeneration = $this->generation($noteTable); + $epoch = $this->epoch(); + + Post::query()->toBase()->truncate(); + + $this->assertSame($epoch, $this->epoch()); + $this->assertSame( + (string) ((int) $noteGeneration + 1), + $this->generation($noteTable), + 'truncate cascades to every referencing table on some drivers regardless of the delete action', + ); + } + + public function test_delete_does_not_invalidate_children_whose_foreign_key_does_not_cascade(): void + { + $post = $this->restrictedNote(); + + $noteTable = $this->identity('restricted_delete_notes'); + $noteGeneration = $this->generation($noteTable); + + Post::query()->whereKey($post->getKey())->delete(); + + $this->assertSame($noteGeneration, $this->generation($noteTable)); + } + + public function test_case_insensitive_string_key_delete_invalidates_the_cached_row(): void + { + if (DB::connection()->getDriverName() !== 'sqlite') { + $this->markTestSkipped('The nocase collation is built into SQLite and named differently elsewhere.'); + } + + Schema::create('nocase_items', function (Blueprint $table): void { + $table->string('id', 36)->collation('nocase')->primary(); + $table->string('name'); + }); + + NocaseItem::query()->create(['id' => 'abc', 'name' => 'Cached']); + $this->assertSame('Cached', NocaseItem::query()->findOrFail('abc')->name); + + $deleted = NocaseItem::query()->where('id', 'ABC')->delete(); + $this->assertSame(1, $deleted, 'the column collation must match the two spellings'); + + $this->assertNull(NocaseItem::query()->find('abc')); + } + + public function test_unavailable_delete_metadata_falls_back_to_the_global_epoch(): void + { + // The stub connection opens the database name as a SQLite file, which on a + // server driver is a name rather than a path. + if (DB::connection()->getDriverName() !== 'sqlite') { + $this->markTestSkipped('The unavailable-metadata stub is a SQLite connection.'); + } + + $name = 'delete-metadata-unavailable'; + $database = (string) DB::connection()->getDatabaseName(); + + config()->set("database.connections.{$name}", [ + 'driver' => 'sqlite', + 'database' => $database, + 'prefix' => '', + 'name' => $name, + 'normcache_scope' => $name, + ]); + DB::extend($name, static fn(array $config) => new UnavailableDeleteMetadataConnection( + new \PDO('sqlite:' . $database), + $database, + '', + $config, + )); + DB::purge($name); + + try { + UuidItem::on($name)->create(['id' => 'fallback', 'name' => 'Fallback']); + $epoch = $this->epoch(); + + UuidItem::on($name)->whereKey('fallback')->delete(); + + $this->assertSame($epoch + 1, $this->epoch()); + } finally { + DB::disconnect($name); + DB::purge($name); + DB::forgetExtension($name); + } + } + + /** @return array{Author, Post} */ + private function authorAndPost(): array + { + $author = Author::query()->create(['name' => 'Parent']); + $post = Post::query()->create([ + 'title' => 'Cached child', + 'author_id' => $author->getKey(), + ]); + + return [$author, $post]; + } + + private function identity(string $table): TableIdentity + { + $identity = app(TableIdentityResolver::class)->resolve(DB::connection(), $table); + + if ($identity === null) { + self::fail("Expected table identity for [{$table}]."); + } + + return $identity; + } + + private function generation(TableIdentity $table): string + { + return $this->cacheStore()->getRaw($this->cacheKeys()->generation($table)) ?? '0'; + } + + private function version(TableIdentity $table): string + { + return $this->cacheStore()->getRaw($this->cacheKeys()->version($table)) ?? '0'; + } + + private function epoch(): int + { + return (int) ($this->cacheStore()->getRaw($this->cacheKeys()->epoch()) ?? '0'); + } +} diff --git a/tests/Integration/Cache/DependencyVectorTest.php b/tests/Integration/Cache/DependencyVectorTest.php new file mode 100644 index 0000000..c3f2406 --- /dev/null +++ b/tests/Integration/Cache/DependencyVectorTest.php @@ -0,0 +1,741 @@ +pipeline($parameters[0] ?? null); + } + + return parent::command($method, $parameters); + } + + public function pipeline(?callable $callback = null) + { + $recorder = new class + { + /** @var list}> */ + public array $calls = []; + + public function __call(string $method, array $arguments): static + { + $this->calls[] = [$method, $arguments]; + + return $this; + } + }; + + $callback($recorder); + $replies = []; + + foreach ($recorder->calls as $index => [$method, $arguments]) { + $replies[] = $this->command($method, $arguments); + + if ($index === 0 && !$this->intercepted) { + $this->intercepted = true; + ($this->afterFirstCommand)(); + } + } + + return $replies; + } +} + +final class PostTitlesView extends Model +{ + use Cacheable; + + protected $table = 'post_titles'; +} + +final class DependencyVectorTest extends TestCase +{ + private int $postId; + + protected function setUp(): void + { + parent::setUp(); + + $author = Author::query()->create(['name' => 'Author']); + $this->postId = (int) RawPost::query()->toBase()->insertGetId([ + 'title' => 'Post', + 'views' => 0, + 'published' => true, + 'author_id' => $author->getKey(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + Comment::query()->toBase()->insert([ + 'body' => 'Before', + 'commentable_type' => 'post', + 'commentable_id' => $this->postId, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + public function test_join_result_misses_after_any_dependency_version_changes(): void + { + $read = fn() => RawPost::query()->toBase() + ->join('comments', 'comments.commentable_id', '=', 'posts.id') + ->select(['posts.id', 'comments.body']) + ->get(); + + $read(); + $read(); + Comment::query()->toBase()->where('commentable_id', $this->postId)->update(['body' => 'After']); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $result = $read(); + DB::disableQueryLog(); + + $this->assertSame('After', $result[0]->body); + $this->assertCount(1, DB::getQueryLog()); + } + + public function test_predicate_subquery_membership_tracks_the_extra_dependency(): void + { + $read = fn() => RawPost::query()->toBase() + ->whereExists(function ($query) { + $query->from('comments') + ->whereColumn('comments.commentable_id', 'posts.id') + ->where('comments.body', 'Before'); + }) + ->get(); + + $this->assertCount(1, $read()); + $this->assertCount(1, $read()); + + Comment::query()->toBase()->where('commentable_id', $this->postId)->update(['body' => 'After']); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $result = $read(); + DB::disableQueryLog(); + + $this->assertCount(0, $result); + $this->assertCount(1, DB::getQueryLog()); + } + + public function test_where_in_subquery_requires_declared_dependencies(): void + { + $build = fn(bool $declared = false) => RawPost::query()->toBase() + ->whereIn('id', Comment::query()->toBase()->select('commentable_id')) + ->when($declared, fn($query) => $query->dependsOn(['comments'])); + + $this->bypassContract( + fn() => $build()->get()->map(static fn($row): array => (array) $row), + fn() => $build()->get()->map(static fn($row): array => (array) $row), + reason: 'unidentifiable_dependency', + ); + $this->contract( + fn() => $build(true)->get()->map(static fn($row): array => (array) $row), + fn() => $build()->get()->map(static fn($row): array => (array) $row), + mutate: fn() => Comment::query()->toBase() + ->where('commentable_id', $this->postId) + ->delete(), + ); + } + + public function test_explicit_model_dependencies_are_additive_and_invalidate_results(): void + { + $author = Author::query()->firstOrFail(); + $read = fn() => Post::query() + ->dependsOn([Author::class]) + ->whereKey($this->postId) + ->get(); + + $read(); + $read(); + Author::query()->whereKey($author->getKey())->update(['name' => 'After']); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $read(); + DB::disableQueryLog(); + + $this->assertCount(1, DB::getQueryLog()); + } + + public function test_explicit_model_dependencies_use_the_active_query_connection(): void + { + $database = sys_get_temp_dir() . '/normcache-dependency-' . getmypid() . '.sqlite'; + copy((string) DB::connection()->getDatabaseName(), $database); + config()->set('database.connections.tenant', [ + 'driver' => 'sqlite', + 'database' => $database, + 'prefix' => '', + ]); + DB::purge('tenant'); + + try { + $read = fn() => RawPost::on('tenant') + ->dependsOn([Author::class]) + ->selectRaw( + 'posts.*, (select name from authors where authors.id = posts.author_id) as author_name' + ) + ->whereKey($this->postId) + ->firstOrFail(); + + $this->assertSame('Author', $read()->author_name); + $this->assertSame('Author', $read()->author_name); + Author::on('tenant')->whereKey(1)->update(['name' => 'Tenant author']); + + DB::connection('tenant')->flushQueryLog(); + DB::connection('tenant')->enableQueryLog(); + $result = $read(); + DB::connection('tenant')->disableQueryLog(); + + $this->assertSame('Tenant author', $result->author_name); + $this->assertCount(1, DB::connection('tenant')->getQueryLog()); + } finally { + DB::disconnect('tenant'); + DB::purge('tenant'); + @unlink($database); + } + } + + public function test_depends_on_accepts_model_and_table_dependencies_together(): void + { + $read = fn() => Post::query() + ->dependsOn([Author::class, 'comments']) + ->whereKey($this->postId) + ->get(); + + $read(); + $read(); + Comment::query()->toBase()->where('commentable_id', $this->postId)->update(['body' => 'After']); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $read(); + DB::disableQueryLog(); + + $this->assertCount(1, DB::getQueryLog()); + } + + public function test_depends_on_accepts_models_without_the_cacheable_trait(): void + { + $read = fn() => Author::query() + ->dependsOn([UncachedPost::class]) + ->get(); + + $read(); + DB::flushQueryLog(); + DB::enableQueryLog(); + $read(); + DB::disableQueryLog(); + + $this->assertSame([], DB::getQueryLog()); + } + + public function test_depends_on_rejects_missing_model_class_names(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('does not exist'); + + Post::query()->dependsOn(['App\\Models\\MissingDependency']); + } + + public function test_depends_on_rejects_existing_non_model_classes(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('must be an Eloquent model'); + + Post::query()->dependsOn([\DateTime::class]); + } + + public function test_raw_predicate_subquery_requires_declared_dependencies(): void + { + $build = fn(bool $declared = false) => RawPost::query()->toBase() + ->whereRaw( + 'exists (select 1 from comments where comments.commentable_id = posts.id)' + ) + ->when($declared, fn($query) => $query->dependsOn(['comments'])); + + $this->bypassContract( + fn() => $build()->get()->map(static fn($row): array => (array) $row), + fn() => $build()->get()->map(static fn($row): array => (array) $row), + reason: 'unidentifiable_dependency', + ); + $this->contract( + fn() => $build(true)->get()->map(static fn($row): array => (array) $row), + fn() => $build()->get()->map(static fn($row): array => (array) $row), + mutate: fn() => Comment::query()->toBase() + ->where('commentable_id', $this->postId) + ->delete(), + ); + } + + public function test_derived_raw_subquery_requires_an_explicit_authoritative_declaration(): void + { + $build = fn() => RawPost::query()->toBase() + ->whereRaw( + 'exists (select 1 from (select commentable_id from comments) as recent_comments where recent_comments.commentable_id = posts.id)' + ); + + $build()->get(); + DB::flushQueryLog(); + DB::enableQueryLog(); + $build()->get(); + DB::disableQueryLog(); + $this->assertCount(1, DB::getQueryLog()); + + $cached = fn() => $build()->dependsOn(['comments'])->get(); + $cached(); + DB::flushQueryLog(); + DB::enableQueryLog(); + $cached(); + DB::disableQueryLog(); + $this->assertSame([], DB::getQueryLog()); + } + + public function test_unresolvable_declaration_does_not_authorize_an_opaque_query(): void + { + $build = fn() => RawPost::query()->toBase() + ->whereRaw( + 'exists (select 1 from comments where comments.commentable_id = posts.id)' + ) + ->dependsOn([AbstractComment::class]) + ->get(); + + $build(); + DB::flushQueryLog(); + DB::enableQueryLog(); + $build(); + DB::disableQueryLog(); + + $this->assertCount(1, DB::getQueryLog()); + } + + public function test_unresolvable_declaration_bypasses_an_otherwise_cacheable_query(): void + { + $build = fn() => RawPost::query()->toBase() + ->where('id', $this->postId) + ->dependsOn([Author::class, AbstractComment::class]) + ->get(); + + $build(); + DB::flushQueryLog(); + DB::enableQueryLog(); + $build(); + DB::disableQueryLog(); + + $this->assertCount(1, DB::getQueryLog()); + } + + public function test_unresolvable_declaration_never_serves_stale_rows(): void + { + $build = fn() => RawPost::query()->toBase() + ->whereRaw('exists (select 1 from comments where comments.body = ?)', ['Before']) + ->dependsOn([AbstractComment::class]) + ->get(); + + $this->assertCount(1, $build()); + Comment::query()->toBase()->where('commentable_id', $this->postId)->update(['body' => 'After']); + + $this->assertCount(0, $build()); + } + + public function test_declared_table_dependencies_are_trusted_without_schema_queries(): void + { + $build = fn() => RawPost::query()->toBase() + ->where('id', $this->postId) + ->dependsOn(['definitely_missing_table']) + ->get(); + + $build(); + DB::flushQueryLog(); + DB::enableQueryLog(); + $build(); + DB::disableQueryLog(); + + $this->assertSame([], DB::getQueryLog()); + } + + public function test_unresolvable_declaration_is_reported_as_incomplete(): void + { + $connection = DB::connection(); + $query = RawPost::query()->toBase()->dependsOn(['comments', AbstractComment::class]); + $root = $this->app->make(TableIdentityResolver::class) + ->resolve($connection, 'posts'); + $this->assertNotNull($root); + + $analysis = $this->app->make(DependencyAnalyzer::class) + ->analyze($connection, $query); + + $this->assertSame('unresolvable_declared_dependency', $analysis->bypassReason); + } + + public function test_explicit_dependencies_authorize_a_hashable_derived_result_as_query_group(): void + { + $build = fn() => RawPost::query()->toBase() + ->from(DB::raw('(select id, title from posts) as derived')) + ->dependsOn(['posts']) + ->select(['id', 'title']); + + $this->assertCount(1, $build()->get()); + DB::flushQueryLog(); + DB::enableQueryLog(); + $this->assertCount(1, $build()->get()); + DB::disableQueryLog(); + + $this->assertSame([], DB::getQueryLog()); + } + + public function test_query_group_invalidation_between_payload_and_state_reads_cannot_serve_stale_data(): void + { + $read = fn() => RawPost::query()->toBase() + ->from(DB::raw('(select id, title from posts) as derived')) + ->dependsOn(['posts']) + ->where('id', $this->postId) + ->first(); + + $this->assertSame('Post', $read()?->title); + + $store = $this->cacheStore(); + $connectionProperty = (new \ReflectionClass($store))->getProperty('connection'); + $connection = $connectionProperty->getValue($store); + $invalidate = function (): void { + RawPost::query()->toBase()->where('id', $this->postId)->update(['title' => 'After']); + }; + + // Redis permits another client's write between pipelined commands. + if ($connection instanceof PhpRedisConnection) { + $interceptingConnection = new class($connection->client(), $invalidate) extends PhpRedisConnection + { + use SplitsPipelineAroundInvalidation; + + public function __construct( + mixed $client, + private \Closure $afterFirstCommand, + ) { + parent::__construct($client); + } + }; + } else { + $this->assertInstanceOf(PredisConnection::class, $connection); + $interceptingConnection = new class($connection->client(), $invalidate) extends PredisConnection + { + use SplitsPipelineAroundInvalidation; + + public function __construct( + mixed $client, + private \Closure $afterFirstCommand, + ) { + parent::__construct($client); + } + }; + } + + $connectionProperty->setValue($store, $interceptingConnection); + + try { + $result = $read(); + } finally { + $connectionProperty->setValue($store, $connection); + } + + $this->assertSame('After', $result?->title); + } + + public function test_explicit_dependency_order_does_not_change_opaque_query_identity(): void + { + $build = fn(array $dependencies) => RawPost::query()->toBase() + ->from(DB::raw('(select id, title from posts) as derived')) + ->dependsOn($dependencies) + ->select(['id', 'title']); + + $this->assertCount(1, $build(['posts', Author::class])->get()); + DB::flushQueryLog(); + DB::enableQueryLog(); + $this->assertCount(1, $build([Author::class, 'posts'])->get()); + DB::disableQueryLog(); + + $this->assertSame([], DB::getQueryLog()); + } + + public function test_select_subquery_context_is_captured_before_compilation(): void + { + $base = fn() => RawPost::query()->toBase()->selectSub( + Comment::query()->toBase() + ->selectRaw('count(*)') + ->whereColumn('comments.commentable_id', 'posts.id'), + 'comment_count', + ); + + $this->assertSame(1, (int) $base()->first()->comment_count); + DB::flushQueryLog(); + DB::enableQueryLog(); + $this->assertSame(1, (int) $base()->first()->comment_count); + DB::disableQueryLog(); + $this->assertSame([], DB::getQueryLog()); + + Comment::query()->toBase()->insert([ + 'body' => 'Second', + 'commentable_type' => 'post', + 'commentable_id' => $this->postId, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->assertSame( + 2, + (int) $base()->first()->comment_count, + 'a captured select subquery must invalidate with its own table', + ); + + $declared = fn() => $base()->dependsOn(['comments'])->get(); + $declared(); + DB::flushQueryLog(); + DB::enableQueryLog(); + $declared(); + DB::disableQueryLog(); + $this->assertSame([], DB::getQueryLog()); + } + + public function test_direct_root_calculated_projection_uses_table_local_result_caching(): void + { + $read = fn() => RawPost::query()->toBase() + ->selectRaw('upper(title) as heading') + ->where('id', $this->postId) + ->get(); + + $this->assertSame('POST', $read()[0]->heading); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $this->assertSame('POST', $read()[0]->heading); + DB::disableQueryLog(); + + $this->assertSame([], DB::getQueryLog()); + } + + public function test_volatile_projection_is_never_cached(): void + { + $read = fn() => RawPost::query()->toBase() + ->selectRaw('random() as value') + ->where('id', $this->postId) + ->dependsOn(['posts']) + ->get(); + + $read(); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $read(); + DB::disableQueryLog(); + + $this->assertCount(1, DB::getQueryLog()); + } + + public function test_random_bytes_projection_is_never_cached(): void + { + $this->createSqliteFunction( + 'random_bytes', + static fn(int $length): string => bin2hex(\random_bytes($length)), + ); + $read = fn(): string => (string) RawPost::query()->toBase() + ->selectRaw('random_bytes(16) as value') + ->where('id', $this->postId) + ->value('value'); + + $first = $read(); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $second = $read(); + DB::disableQueryLog(); + + $this->assertNotSame($first, $second); + $this->assertCount(1, DB::getQueryLog()); + } + + #[DataProvider('previouslyUncoveredVolatileExpressions')] + public function test_connection_and_random_state_expressions_are_volatile(string $expression): void + { + $query = RawPost::query()->toBase()->selectRaw("{$expression} as observed_value"); + $this->assertTrue( + $this->app->make(SqlVolatilityScanner::class)->isVolatile($query->toSql()), + ); + } + + public static function previouslyUncoveredVolatileExpressions(): array + { + return [ + ['RANDOM_BYTES(16)'], + ['GEN_RANDOM_BYTES(16)'], + ['CRYPT_GEN_RANDOM(16)'], + ['CURRENT_ROLE'], + ['USER()'], + ['DATABASE()'], + ['CURRENT_SCHEMA()'], + ]; + } + + #[DataProvider('driverTimeExpressions')] + public function test_driver_time_expressions_are_volatile(string $expression): void + { + $query = RawPost::query()->toBase()->selectRaw("{$expression} as observed_at"); + $this->assertTrue( + $this->app->make(SqlVolatilityScanner::class)->isVolatile($query->toSql()), + ); + } + + public static function driverTimeExpressions(): array + { + return [ + ['UTC_TIMESTAMP()'], + ['UTC_DATE()'], + ['UTC_TIME()'], + ['CURDATE()'], + ['CURTIME()'], + ]; + } + + public function test_volatile_raw_source_is_never_cached_even_with_dependencies(): void + { + $read = fn() => Author::query() + ->fromRaw('(select random() as value) as sample') + ->dependsOn([Author::class]) + ->value('value'); + + $read(); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $read(); + DB::disableQueryLog(); + + $this->assertCount(1, DB::getQueryLog()); + } + + public function test_eloquent_now_function_is_never_cached(): void + { + $this->createSqliteFunction( + 'now', + static fn(): string => (string) hrtime(true), + ); + $read = fn() => Author::query()->selectRaw('now() as value')->value('value'); + + $read(); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $read(); + DB::disableQueryLog(); + + $this->assertCount(1, DB::getQueryLog()); + } + + private function createSqliteFunction(string $name, callable $callback): void + { + $pdo = DB::connection()->getPdo(); + + if (method_exists($pdo, 'createFunction')) { + $pdo->createFunction($name, $callback); + } elseif (method_exists($pdo, 'sqliteCreateFunction')) { + /** @var \PDO $pdo */ + $pdo->sqliteCreateFunction($name, $callback); + } + } + + public function test_raw_ordering_subquery_requires_declared_dependencies(): void + { + $build = fn(bool $declared = false) => RawPost::query()->toBase() + ->orderByRaw( + '(select count(*) from comments where comments.commentable_id = posts.id) desc' + ) + ->when($declared, fn($query) => $query->dependsOn(['comments'])); + + $this->bypassContract( + fn() => $build()->get()->map(static fn($row): array => (array) $row), + fn() => $build()->get()->map(static fn($row): array => (array) $row), + reason: 'unidentifiable_dependency', + ); + $this->contract( + fn() => $build(true)->get()->map(static fn($row): array => (array) $row), + fn() => $build()->get()->map(static fn($row): array => (array) $row), + mutate: fn() => Comment::query()->toBase() + ->where('commentable_id', $this->postId) + ->delete(), + ); + } + + public function test_unnamed_custom_connection_bypasses_without_a_source_scope(): void + { + $name = 'unnamed-source'; + $database = (string) DB::connection()->getDatabaseName(); + config()->set("database.connections.{$name}", [ + 'driver' => 'sqlite', + 'database' => $database, + 'prefix' => '', + ]); + DB::extend($name, static fn(array $config) => new class(new \PDO('sqlite:' . $database), $database, '', $config) extends SQLiteConnection {}); + DB::purge($name); + + try { + $read = fn() => RawPost::on($name)->whereKey($this->postId)->firstOrFail(); + $this->assertSame('Post', $read()->title); + + $connection = DB::connection($name); + $connection->flushQueryLog(); + $connection->enableQueryLog(); + $this->assertSame('Post', $read()->title); + $connection->disableQueryLog(); + + $this->assertCount(1, $connection->getQueryLog()); + } finally { + DB::disconnect($name); + DB::purge($name); + DB::forgetExtension($name); + } + } + + public function test_database_views_use_explicit_physical_dependencies(): void + { + DB::statement('create view post_titles as select id, title from posts'); + + $explicit = fn() => PostTitlesView::query()->toBase() + ->dependsOn(['posts']) + ->where('id', $this->postId) + ->first(); + $this->assertSame('Post', $explicit()?->title); + DB::flushQueryLog(); + DB::enableQueryLog(); + $this->assertSame('Post', $explicit()?->title); + DB::disableQueryLog(); + $this->assertSame([], DB::getQueryLog()); + + RawPost::query()->toBase()->where('id', $this->postId)->update(['title' => 'After']); + DB::flushQueryLog(); + DB::enableQueryLog(); + $this->assertSame('After', $explicit()?->title); + DB::disableQueryLog(); + $this->assertCount(1, DB::getQueryLog()); + } +} diff --git a/tests/Integration/Cache/DependsOnTest.php b/tests/Integration/Cache/DependsOnTest.php deleted file mode 100644 index 28b44b3..0000000 --- a/tests/Integration/Cache/DependsOnTest.php +++ /dev/null @@ -1,790 +0,0 @@ - 'Alice']); - - Author::query()->dependsOn([Post::class])->get(); - - $this->assertNotEmpty($this->redisKeys('query:*')); - $this->assertEmpty($this->redisKeys('result:*')); - } - - public function test_depends_on_invalidates_on_primary_model_version_bump(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - $first = Author::whereHas('posts')->dependsOn([Post::class])->get(); - $this->assertCount(1, $first); - - Author::create(['name' => 'Bob']); - - $second = Author::whereHas('posts')->dependsOn([Post::class])->get(); - $this->assertCount(1, $second); - } - - public function test_depends_on_invalidates_on_dep_model_version_bump(): void - { - $author = Author::create(['name' => 'Alice']); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id, 'published' => true]); - - $first = Author::whereHas('posts', fn($q) => $q->where('published', true)) - ->dependsOn([Post::class]) - ->get(); - $this->assertCount(1, $first); - - $post->update(['published' => false]); - - $second = Author::whereHas('posts', fn($q) => $q->where('published', true)) - ->dependsOn([Post::class]) - ->get(); - $this->assertCount(0, $second); - } - - public function test_depends_on_multiple_deps_invalidates_on_any_dep_bump(): void - { - $alice = Author::create(['name' => 'Alice']); - $bob = Author::create(['name' => 'Bob']); - Post::create(['title' => 'Hello', 'author_id' => $alice->id]); - - $first = Author::whereHas('posts')->dependsOn([Post::class, Author::class])->get(); - $this->assertCount(1, $first); - - Post::create(['title' => 'Bob Post', 'author_id' => $bob->id]); - - $second = Author::whereHas('posts')->dependsOn([Post::class, Author::class])->get(); - $this->assertCount(2, $second); - } - - public function test_depends_on_dep_order_does_not_affect_key(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Author::whereHas('posts')->dependsOn([Post::class, Author::class])->get(); - $keysAB = $this->redisKeys('query:*'); - - // dep order is sorted before hashing, so reversed order must hit the same key - Author::whereHas('posts')->dependsOn([Author::class, Post::class])->get(); - $keysBA = $this->redisKeys('query:*'); - - $this->assertSame( - array_map(fn($k) => str_replace('test:', '', $k), $keysAB), - array_map(fn($k) => str_replace('test:', '', $k), $keysBA) - ); - } - - public function test_depends_on_paginate_caches_count_with_dep_versions(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Author::whereHas('posts')->dependsOn([Post::class])->paginate(10); - - $this->assertNotEmpty($this->redisKeys('count:*')); - - // inserting bumps Post's version; the old count key becomes unreachable but is not deleted - Post::create(['title' => 'World', 'author_id' => $author->id]); - - $firstKeys = $this->redisKeys('count:*'); - - Author::whereHas('posts')->dependsOn([Post::class])->paginate(10); - - $secondKeys = $this->redisKeys('count:*'); - - // two distinct versioned count keys: the orphaned (old) one and the new one - $this->assertCount(2, $secondKeys); - $this->assertNotEmpty(array_diff($secondKeys, $firstKeys)); - } - - public function test_join_with_depends_on_paginate_caches_count(): void - { - $alice = Author::create(['name' => 'Alice']); - $bob = Author::create(['name' => 'Bob']); - Post::create(['title' => 'Hello', 'author_id' => $alice->id]); - Post::create(['title' => 'World', 'author_id' => $bob->id]); - - $pageOne = Author::query() - ->join('posts', 'posts.author_id', '=', 'authors.id') - ->orderBy('authors.id') - ->dependsOn([Post::class]) - ->paginate(1, ['authors.*'], 'page', 1); - - $this->assertSame(2, $pageOne->total()); - $this->assertCount(1, $pageOne->items()); - $this->assertSame('Alice', $pageOne->first()->name); - $this->assertNotEmpty($this->redisKeys('count:*')); - - $pageTwo = Author::query() - ->join('posts', 'posts.author_id', '=', 'authors.id') - ->orderBy('authors.id') - ->dependsOn([Post::class]) - ->paginate(1, ['authors.*'], 'page', 2); - - $this->assertSame(2, $pageTwo->total()); - $this->assertCount(1, $pageTwo->items()); - $this->assertSame('Bob', $pageTwo->first()->name); - - DB::enableQueryLog(); - DB::flushQueryLog(); - - $cachedPageTwo = Author::query() - ->join('posts', 'posts.author_id', '=', 'authors.id') - ->orderBy('authors.id') - ->dependsOn([Post::class]) - ->paginate(1, ['authors.*'], 'page', 2); - - $queries = DB::getQueryLog(); - DB::disableQueryLog(); - - $this->assertSame(2, $cachedPageTwo->total()); - $this->assertCount(1, $cachedPageTwo->items()); - $this->assertSame('Bob', $cachedPageTwo->first()->name); - $this->assertEmpty($queries, 'The JOIN count and paginated rows should both hit cache.'); - } - - public function test_join_with_depends_on_paginate_count_invalidates_on_dep_model_version_bump(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Author::query() - ->join('posts', 'posts.author_id', '=', 'authors.id') - ->select('authors.*') - ->dependsOn([Post::class]) - ->paginate(10); - - $firstKeys = $this->redisKeys('count:*'); - - Post::create(['title' => 'World', 'author_id' => $author->id]); - - Author::query() - ->join('posts', 'posts.author_id', '=', 'authors.id') - ->select('authors.*') - ->dependsOn([Post::class]) - ->paginate(10); - - $secondKeys = $this->redisKeys('count:*'); - - $this->assertCount(2, $secondKeys); - $this->assertNotEmpty(array_diff($secondKeys, $firstKeys)); - } - - public function test_depends_on_count_invalidates_on_dep_model_version_bump(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id, 'published' => true]); - - $first = Author::whereHas('posts', fn($q) => $q->where('published', true)) - ->dependsOn([Post::class]) - ->count(); - $this->assertSame(1, $first); - - Post::query()->update(['published' => false]); - - $second = Author::whereHas('posts', fn($q) => $q->where('published', true)) - ->dependsOn([Post::class]) - ->count(); - $this->assertSame(0, $second); - } - - public function test_join_count_with_depends_on_caches_as_scalar_result(): void - { - $this->seedAuthorsWithPosts(); - - $first = Author::query() - ->join('posts', 'posts.author_id', '=', 'authors.id') - ->dependsOn([Post::class]) - ->count(); - - $this->assertSame(3, $first); - - DB::enableQueryLog(); - DB::flushQueryLog(); - - $second = Author::query() - ->join('posts', 'posts.author_id', '=', 'authors.id') - ->dependsOn([Post::class]) - ->count(); - - $queries = DB::getQueryLog(); - DB::disableQueryLog(); - - $this->assertSame(3, $second); - $this->assertEmpty($queries); - $this->assertNotEmpty($this->redisKeys('count:*')); - } - - public function test_distinct_count_with_depends_on_caches_as_scalar_result(): void - { - $this->seedAuthorsWithPosts(); - - $first = Post::query() - ->distinct() - ->dependsOn([Post::class]) - ->count('author_id'); - - $this->assertSame(2, $first); - - DB::enableQueryLog(); - DB::flushQueryLog(); - - $second = Post::query() - ->distinct() - ->dependsOn([Post::class]) - ->count('author_id'); - - $queries = DB::getQueryLog(); - DB::disableQueryLog(); - - $this->assertSame(2, $second); - $this->assertEmpty($queries); - } - - public function test_locked_count_with_depends_on_still_bypasses_scalar_cache(): void - { - $this->seedAuthorsWithPosts(); - - Author::query() - ->join('posts', 'posts.author_id', '=', 'authors.id') - ->dependsOn([Post::class]) - ->count(); - - DB::enableQueryLog(); - DB::flushQueryLog(); - - Author::query() - ->join('posts', 'posts.author_id', '=', 'authors.id') - ->lockForUpdate() - ->dependsOn([Post::class]) - ->count(); - - $queries = DB::getQueryLog(); - DB::disableQueryLog(); - - $this->assertNotEmpty($queries); - } - - public function test_from_subquery_with_depends_on_caches_as_blob(): void - { - Author::create(['name' => 'Alice']); - - Author::fromSub(Author::query()->select('id', 'name'), 'authors') - ->dependsOn([Author::class]) - ->get(); - - $this->assertNotEmpty($this->redisKeys('result:*')); - } - - public function test_raw_order_with_depends_on_can_cache(): void - { - Author::create(['name' => 'Alice']); - - Author::query() - ->orderByRaw('CASE WHEN name = ? THEN 0 ELSE 1 END', ['Alice']) - ->dependsOn([Author::class]) - ->get(); - - $this->assertNotEmpty($this->redisKeys('result:*')); - } - - public function test_where_in_subquery_requires_depends_on(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Author::query() - ->whereIn('id', Post::query()->select('author_id')) - ->get(); - - $this->assertEmpty($this->redisKeys('query:*')); - } - - public function test_where_in_subquery_with_depends_on_can_cache(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Author::query() - ->whereIn('id', Post::query()->select('author_id')) - ->dependsOn([Post::class]) - ->get(); - - $this->assertNotEmpty($this->redisKeys('result:*')); - } - - public function test_distinct_with_depends_on_preserves_distinct_semantics(): void - { - $a = Author::create(['name' => 'A']); - $b = Author::create(['name' => 'B']); - Post::create(['title' => 'p1', 'author_id' => $a->id]); - Post::create(['title' => 'p2', 'author_id' => $a->id]); - Post::create(['title' => 'p3', 'author_id' => $b->id]); - - $uncached = Post::query()->select('author_id')->distinct()->withoutCache()->get(); - $cached = Post::query()->select('author_id')->distinct()->dependsOn([Post::class])->get(); - - $this->assertSame( - count($uncached), - count($cached), - 'DISTINCT queries with dependsOn() use the blob path — both return the same deduplicated row count.' - ); - } - - public function test_lock_for_update_with_depends_on_hits_the_db(): void - { - $a = Author::create(['name' => 'A']); - Post::create(['title' => 'p1', 'author_id' => $a->id, 'published' => true]); - - Post::query()->where('published', true)->dependsOn([Post::class])->get(); - - DB::enableQueryLog(); - DB::flushQueryLog(); - - Post::query()->where('published', true)->lockForUpdate()->dependsOn([Post::class])->get(); - - $queries = DB::getQueryLog(); - DB::disableQueryLog(); - - $this->assertNotEmpty($queries, 'lockForUpdate queries hit the DB even when dependsOn() is set.'); - } - - public function test_aggregate_columns_fall_through_to_db_with_depends_on(): void - { - $this->seedAuthorsWithPosts(); - - $uncached = Post::query() - ->select('author_id', DB::raw('SUM(views) as sum_views')) - ->groupBy('author_id') - ->withoutCache() - ->get(); - - $cached = Post::query() - ->select('author_id', DB::raw('SUM(views) as sum_views')) - ->groupBy('author_id') - ->dependsOn([Post::class]) - ->get(); - - $this->assertNotNull($cached->first(), 'Query returns results.'); - $this->assertNotNull( - $cached->first()->getAttribute('sum_views'), - 'sum_views is populated — GROUP BY queries use the blob path with dependsOn().' - ); - } - - public function test_complex_aggregate_with_explicit_dependencies_uses_result_cache(): void - { - $author = Author::create(['name' => 'Alice']); - - Author::withCount([ - 'posts' => fn($q) => $q->whereRaw('1=1'), - ])->dependsOn([Post::class])->get(); - - $this->assertNotEmpty($this->redisKeys('result:*'), 'Complex aggregate with dependsOn should use result cache'); - } - - public function test_scalar_count_with_depends_on_caches_as_result(): void - { - Author::create(['name' => 'Alice']); - - Author::where('name', 'Alice')->dependsOn([Post::class])->count(); - - $this->assertNotEmpty($this->redisKeys('count:*'), 'Scalar count with dependsOn should use count namespace'); - } - - // tag() — manual flush grouping - - public function test_tag_rejects_reserved_characters(): void - { - $this->expectException(\InvalidArgumentException::class); - - Author::query()->tag('homepage:{bad}:*')->get(); - } - - public function test_tag_rejects_empty_string(): void - { - $this->expectException(\InvalidArgumentException::class); - - Author::query()->tag('')->get(); - } - - public function test_tag_is_embedded_in_computed_key(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Author::whereHas('posts')->dependsOn([Post::class])->tag('homepage')->get(); - - $this->assertNotEmpty($this->redisKeys('result:*:homepage:*')); - $this->assertEmpty($this->redisKeys('result:*[^:]homepage*')); - } - - public function test_tagged_keys_are_isolated_from_untagged_keys(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Author::whereHas('posts')->dependsOn([Post::class])->get(); - Author::whereHas('posts')->dependsOn([Post::class])->tag('homepage')->get(); - - $all = $this->redisKeys('result:*'); - $tagged = $this->redisKeys('result:*:homepage:*'); - - $this->assertCount(2, $all); - $this->assertCount(1, $tagged); - } - - public function test_flush_tag_removes_only_matching_keys(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Author::whereHas('posts')->dependsOn([Post::class])->get(); - Author::whereHas('posts')->dependsOn([Post::class])->tag('homepage')->get(); - - $removed = NormCache::flushTag(Author::class, 'homepage'); - - $this->assertSame(1, $removed); - $this->assertNotEmpty($this->redisKeys('result:*')); - $this->assertEmpty($this->redisKeys('result:*:homepage:*')); - } - - public function test_flush_tag_across_models_removes_all_matching(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Author::whereHas('posts')->dependsOn([Post::class])->tag('deploy')->get(); - Post::query()->dependsOn([Author::class])->tag('deploy')->get(); - - $removed = NormCache::flushTagAcrossModels('deploy'); - - $this->assertSame(2, $removed); - $this->assertEmpty($this->redisKeys('result:*:deploy:*')); - } - - public function test_flush_tag_removes_tagged_paginate_count_key(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Author::whereHas('posts')->dependsOn([Post::class])->tag('homepage')->paginate(10); - - $this->assertNotEmpty($this->redisKeys('count:*:homepage:*')); - - $removed = NormCache::flushTag(Author::class, 'homepage'); - - $this->assertGreaterThan(0, $removed); - $this->assertEmpty($this->redisKeys('count:*:homepage:*')); - } - - public function test_flush_tag_rejects_unsafe_characters(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->cacheManager()->flushTag(Author::class, 'tag:with:colons'); - } - - public function test_flush_tag_across_models_rejects_unsafe_characters(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->cacheManager()->flushTagAcrossModels('tag*with*stars'); - } - - public function test_tagged_result_cache_invalidates_on_dep_version_bump(): void - { - $author = Author::create(['name' => 'Alice']); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id, 'published' => true]); - - $first = Author::whereHas('posts', fn($q) => $q->where('published', true)) - ->dependsOn([Post::class]) - ->tag('homepage') - ->get(); - $this->assertCount(1, $first); - - $post->update(['published' => false]); - - $second = Author::whereHas('posts', fn($q) => $q->where('published', true)) - ->dependsOn([Post::class]) - ->tag('homepage') - ->get(); - $this->assertCount(0, $second); - } - - // Projection isolation - - public function test_depends_on_queries_differing_only_in_select_use_separate_cache_keys(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Author::whereHas('posts')->dependsOn([Post::class])->get(); - - // SELECT clause is part of the cache key — a narrower projection must not return the full-column blob. - $projected = Author::whereHas('posts')->dependsOn([Post::class])->select('id')->get(); - - $this->assertArrayNotHasKey('name', $projected->first()->getAttributes()); - } - - public function test_depends_on_warm_hit_preserves_projected_columns(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Author::whereHas('posts')->dependsOn([Post::class])->select('id')->get(); - - $cached = Author::whereHas('posts')->dependsOn([Post::class])->select('id')->get(); - - $this->assertArrayNotHasKey('name', $cached->first()->getAttributes()); - } - - // Helpers - - private function seedAuthorsWithPosts(): void - { - $a = Author::create(['name' => 'A']); - $b = Author::create(['name' => 'B']); - Post::create(['title' => 'p1', 'author_id' => $a->id, 'views' => 10, 'published' => true]); - Post::create(['title' => 'p2', 'author_id' => $a->id, 'views' => 20, 'published' => false]); - Post::create(['title' => 'p3', 'author_id' => $b->id, 'views' => 30, 'published' => true]); - } - - public function test_join_with_depends_on_and_explicit_select_does_not_collide_with_joined_id(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Earlier', 'author_id' => $author->id]); - $post = Post::create(['title' => 'Target', 'author_id' => $author->id]); - - $result = Author::query() - ->join('posts', 'posts.author_id', '=', 'authors.id') - ->select('authors.*') - ->dependsOn([Post::class]) - ->first(); - - $this->assertSame($author->id, $result->id); - $this->assertNotSame($post->id, $result->id); - } - - public function test_where_raw_cross_table_dependency_is_not_cached_as_normal_model_query(): void - { - $author = Author::create(['name' => 'Alice']); - - $post = Post::create([ - 'title' => 'Hello', - 'author_id' => $author->id, - 'published' => true, - ]); - - $comment = Comment::create([ - 'body' => 'Looks good', - 'commentable_type' => Post::class, - 'commentable_id' => $post->id, - ]); - - $sql = <<<'SQL' - exists ( - select 1 - from comments - where comments.commentable_id = posts.id - and comments.commentable_type = ? - ) - SQL; - - $first = Post::whereRaw($sql, [Post::class])->get(); - - $this->assertCount(1, $first); - $this->assertTrue($first->first()->is($post)); - - $comment->delete(); - - $second = Post::whereRaw($sql, [Post::class])->get(); - - $this->assertCount(0, $second); - } - - // ------------------------------------------------------------------------- - // dependsOnTables() — explicit pivot/intermediate table dependencies - // ------------------------------------------------------------------------- - - public function test_depends_on_tables_caches_query(): void - { - $author = Author::create(['name' => 'Alice']); - Tag::create(['name' => 'php']); - - Author::whereHas('tags')->dependsOnTables(['author_tag'])->get(); - - $this->assertNotEmpty($this->redisKeys('result:*')); - } - - public function test_depends_on_tables_invalidates_when_pivot_table_version_bumps(): void - { - $author = Author::create(['name' => 'Alice']); - $tag = Tag::create(['name' => 'php']); - - $first = Author::whereHas('tags')->dependsOnTables(['author_tag'])->get(); - $this->assertCount(0, $first); - - $author->tags()->attach($tag->id); // bumps author_tag table version - - $second = Author::whereHas('tags')->dependsOnTables(['author_tag'])->get(); - $this->assertCount(1, $second); - } - - public function test_depends_on_tables_can_be_combined_with_depends_on(): void - { - $author = Author::create(['name' => 'Alice']); - $tag = Tag::create(['name' => 'php']); - $author->tags()->attach($tag->id); - - $first = Author::whereHas('tags')->dependsOn([Tag::class])->dependsOnTables(['author_tag'])->get(); - $this->assertCount(1, $first); - - $author->tags()->detach($tag->id); // bumps author_tag version; Tag version unchanged - - $second = Author::whereHas('tags')->dependsOn([Tag::class])->dependsOnTables(['author_tag'])->get(); - $this->assertCount(0, $second); - } - - public function test_depends_on_tables_alone_without_depends_on(): void - { - $author = Author::create(['name' => 'Alice']); - $tag = Tag::create(['name' => 'php']); - $author->tags()->attach($tag->id); - - // No dependsOn() — just table dep. Should still use result cache. - Author::whereHas('tags')->dependsOnTables(['author_tag'])->get(); - - $this->assertNotEmpty($this->redisKeys('result:*'), 'dependsOnTables() alone should trigger result cache'); - } - - public function test_depends_on_tables_rejects_empty_array(): void - { - $this->expectException(\InvalidArgumentException::class); - Author::query()->dependsOnTables([]); - } - - public function test_depends_on_tables_rejects_non_string_entries(): void - { - $this->expectException(\InvalidArgumentException::class); - Author::query()->dependsOnTables([123]); - } - - public function test_depends_on_tables_rejects_reserved_key_characters(): void - { - $this->expectException(\InvalidArgumentException::class); - Author::query()->dependsOnTables(['users:{bad}'])->get(); - } - - public function test_depends_on_merges_previous_model_dependencies(): void - { - $builder = Post::query() - ->dependsOn([Post::class]) - ->dependsOn([Author::class]); - - $this->assertContains(Post::class, $builder->explicitDependencies()); - $this->assertContains(Author::class, $builder->explicitDependencies()); - $this->assertCount(2, $builder->explicitDependencies()); - } - - public function test_depends_on_tables_merges_previous_table_dependencies(): void - { - $conn = (new Post)->getConnection()->getName(); - - $builder = Post::query() - ->dependsOnTables(['posts']) - ->dependsOnTables(['authors']); - - $this->assertContains("{$conn}:posts", $builder->explicitTableDependencies()); - $this->assertContains("{$conn}:authors", $builder->explicitTableDependencies()); - $this->assertCount(2, $builder->explicitTableDependencies()); - } - - public function test_plain_join_table_is_auto_inferred_and_does_not_warn(): void - { - config(['app.debug' => true]); - - Log::shouldReceive('warning')->never(); - - Post::query() - ->join('authors', 'authors.id', '=', 'posts.author_id') - ->dependsOn([Post::class]) - ->get(); - } - - public function test_bypassed_query_with_join_does_not_log_warning(): void - { - config(['app.debug' => true]); - - Log::shouldReceive('warning')->never(); - - Post::query() - ->join('authors', 'authors.id', '=', 'posts.author_id') - ->get(); - } - - public function test_depends_on_tables_does_not_false_warn_for_declared_table(): void - { - config(['app.debug' => true]); - - Author::create(['name' => 'Alice']); - - Log::shouldReceive('warning')->never(); - - Author::query() - ->join('author_tag', 'author_tag.author_id', '=', 'authors.id') - ->dependsOnTables(['author_tag']) - ->get(); - } - - public function test_join_alias_does_not_produce_false_warning(): void - { - config(['app.debug' => true]); - - Author::create(['name' => 'Alice']); - - Log::shouldReceive('warning')->never(); - - Author::query() - ->join('posts as p', 'p.author_id', '=', 'authors.id') - ->dependsOn([Post::class]) - ->get(); - } - - public function test_deep_multi_model_dependency_chain_invalidates_correctly(): void - { - $author = Author::create(['name' => 'Alice']); - $post = Post::create(['title' => 'P1', 'author_id' => $author->id]); - Comment::create(['body' => 'C1', 'commentable_type' => Post::class, 'commentable_id' => $post->id]); - - $first = Author::with(['posts.comments']) - ->dependsOn([Post::class, Comment::class]) - ->get(); - - $this->assertCount(1, $first->first()->posts->first()->comments); - - Comment::create(['body' => 'C2', 'commentable_type' => Post::class, 'commentable_id' => $post->id]); - - $second = Author::with(['posts.comments']) - ->dependsOn([Post::class, Comment::class]) - ->get(); - - $this->assertCount(2, $second->first()->posts->first()->comments); - } -} diff --git a/tests/Integration/Cache/DiagnosticsTest.php b/tests/Integration/Cache/DiagnosticsTest.php new file mode 100644 index 0000000..1b1e5a3 --- /dev/null +++ b/tests/Integration/Cache/DiagnosticsTest.php @@ -0,0 +1,168 @@ +create(['name' => 'Author']); + $this->postId = (int) RawPost::query()->toBase()->insertGetId([ + 'title' => 'Events', + 'views' => 0, + 'published' => true, + 'author_id' => $author->getKey(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + public function test_cold_warm_bypass_and_invalidation_emit_events(): void + { + Event::fake([ + QueryCacheHit::class, + QueryCacheMiss::class, + QueryBypassed::class, + CacheInvalidated::class, + ]); + + RawPost::query()->toBase()->where('id', $this->postId)->get(); + RawPost::query()->toBase()->where('id', $this->postId)->get(); + RawPost::query()->toBase()->where('id', $this->postId)->withoutCache()->get(); + RawPost::query()->toBase()->where('id', $this->postId)->update(['title' => 'Changed']); + + Event::assertDispatched(QueryCacheMiss::class); + Event::assertDispatched(QueryCacheHit::class); + Event::assertDispatched( + QueryBypassed::class, + fn(QueryBypassed $event): bool => $event->reason === 'explicit_without_cache', + ); + Event::assertDispatched( + CacheInvalidated::class, + fn(CacheInvalidated $event): bool => $event->mode === 'precise' + && $event->primaryKeyTokens === ['i:' . $this->postId], + ); + } + + public function test_unmarked_queries_do_not_emit_bypass_events(): void + { + Event::fake([QueryBypassed::class]); + + DB::query()->from('posts')->where('id', $this->postId)->get(); + + Event::assertNotDispatched(QueryBypassed::class); + } + + public function test_cursor_and_explain_are_not_overridden_and_report_nothing(): void + { + Event::fake([QueryBypassed::class]); + + RawPost::query()->toBase()->cursor()->all(); + RawPost::query()->toBase()->explain(); + + Event::assertNotDispatched(QueryBypassed::class); + } + + public function test_execution_safety_bypass_reasons_remain_stable(): void + { + Event::fake([QueryBypassed::class]); + + RawPost::query()->toBase()->where('id', $this->postId)->withoutCache()->get(); + RawPost::query()->toBase()->where('id', $this->postId)->useWritePdo()->get(); + DB::transaction(fn() => RawPost::query()->toBase()->where('id', $this->postId)->get()); + RawPost::query()->toBase()->where('id', $this->postId)->lockForUpdate()->get(); + + Event::assertDispatchedTimes(QueryBypassed::class, 4); + Event::assertDispatched( + QueryBypassed::class, + fn(QueryBypassed $event): bool => $event->reason === 'explicit_without_cache', + ); + Event::assertDispatched( + QueryBypassed::class, + fn(QueryBypassed $event): bool => $event->reason === 'transaction_active', + ); + Event::assertDispatched( + QueryBypassed::class, + fn(QueryBypassed $event): bool => $event->reason === 'write_pdo', + ); + $this->assertCount( + 2, + Event::dispatched( + QueryBypassed::class, + fn(QueryBypassed $event): bool => $event->reason === 'write_pdo', + ), + ); + } + + public function test_exists_uses_the_same_bypass_decision_and_reason(): void + { + Event::fake([QueryBypassed::class]); + + $this->assertTrue( + RawPost::query()->toBase()->where('id', $this->postId)->withoutCache()->exists(), + ); + + Event::assertDispatchedTimes(QueryBypassed::class, 1); + Event::assertDispatched( + QueryBypassed::class, + fn(QueryBypassed $event): bool => $event->reason === 'explicit_without_cache', + ); + } + + public function test_corrupt_result_payload_self_heals_as_a_miss(): void + { + $query = fn() => RawPost::query()->toBase() + ->where('id', $this->postId) + ->select('title') + ->get(); + $query(); + $key = $this->cacheQueryKeysWithField('r')[0] ?? null; + + $this->assertIsString($key); + $this->cacheStore()->writeHashField($key, 'r', 'corrupt'); + Event::fake([QueryCacheMiss::class]); + + $query(); + DB::flushQueryLog(); + DB::enableQueryLog(); + $query(); + DB::disableQueryLog(); + + $this->assertSame([], DB::getQueryLog()); + Event::assertDispatched( + QueryCacheMiss::class, + fn(QueryCacheMiss $event): bool => $event->reason === 'corrupt_payload', + ); + } + + public function test_absent_canonical_row_repairs_without_reporting_corruption(): void + { + RawPost::query()->toBase()->orderBy('id')->get(); + $rowKey = $this->cacheKeysMatching(':r:g')[0] ?? null; + + $this->assertIsString($rowKey); + $this->cacheStore()->delete($rowKey); + Event::fake([QueryCacheMiss::class]); + + RawPost::query()->toBase()->orderBy('id')->get(); + + Event::assertNotDispatched( + QueryCacheMiss::class, + fn(QueryCacheMiss $event): bool => $event->reason === 'corrupt_payload', + ); + } +} diff --git a/tests/Integration/Cache/EloquentEdgeCasesTest.php b/tests/Integration/Cache/EloquentEdgeCasesTest.php deleted file mode 100644 index 20c2f5b..0000000 --- a/tests/Integration/Cache/EloquentEdgeCasesTest.php +++ /dev/null @@ -1,89 +0,0 @@ - 'Alice']); - Post::create(['title' => 'Post', 'author_id' => $author->id, 'views' => 10]); - - Event::fake([QueryCacheMiss::class]); - $p1 = Post::withCasts(['views' => 'string'])->first(); - $this->assertIsString($p1->views); - $this->assertSame('10', $p1->views); - Event::assertDispatched(QueryCacheMiss::class); - - Event::fake([QueryCacheHit::class]); - $p2 = Post::withCasts(['views' => 'string'])->first(); - $this->assertIsString($p2->views); - $this->assertSame('10', $p2->views); - Event::assertDispatched(QueryCacheHit::class); - - Event::fake([QueryCacheMiss::class]); - $p3 = Post::withCasts(['views' => 'int'])->first(); - $this->assertIsInt($p3->views); - $this->assertSame(10, $p3->views); - Event::assertDispatched(QueryCacheMiss::class); - } - - public function test_select_aliases_in_normalized_mode(): void - { - $author = Author::create(['name' => 'Alice']); - - Event::fake([QueryCacheMiss::class]); - $a1 = Author::select('id', 'name as display_name')->first(); - $this->assertSame('Alice', $a1->display_name); - Event::assertDispatched(QueryCacheMiss::class); - - Event::fake([QueryCacheHit::class]); - $a2 = Author::select('id', 'name as display_name')->first(); - $this->assertSame('Alice', $a2->display_name); - Event::assertDispatched(QueryCacheHit::class); - } - - public function test_select_aliases_in_result_mode(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'P1', 'author_id' => $author->id]); - - $query = Post::join('authors', 'authors.id', '=', 'posts.author_id') - ->select('posts.id', 'posts.title as headline', 'authors.name as author_name') - ->dependsOn([Author::class]); - - Event::fake([QueryCacheMiss::class]); - $p1 = $query->first(); - $this->assertSame('P1', $p1->headline); - $this->assertSame('Alice', $p1->author_name); - Event::assertDispatched(QueryCacheMiss::class); - - Event::fake([QueryCacheHit::class]); - $p2 = $query->first(); - $this->assertSame('P1', $p2->headline); - $this->assertSame('Alice', $p2->author_name); - Event::assertDispatched(QueryCacheHit::class); - } - - public function test_make_hidden_on_warm_cache_hit_hides_attribute(): void - { - Author::create(['name' => 'Alice']); - - $cold = Author::where('name', 'Alice')->get()->first(); - $this->assertArrayHasKey('name', $cold->toArray()); - - $warm = Author::where('name', 'Alice')->get()->first()->makeHidden('name'); - $this->assertArrayNotHasKey('name', $warm->toArray(), 'makeHidden() must work on warm-cache hydrated models'); - } -} diff --git a/tests/Integration/Cache/EntryKeyStabilityTest.php b/tests/Integration/Cache/EntryKeyStabilityTest.php new file mode 100644 index 0000000..44f7475 --- /dev/null +++ b/tests/Integration/Cache/EntryKeyStabilityTest.php @@ -0,0 +1,56 @@ +create(['name' => 'Author']); + $this->postId = (int) RawPost::query()->toBase()->insertGetId([ + 'title' => 'Post 0', + 'views' => 0, + 'published' => true, + 'author_id' => $author->getKey(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + for ($i = 1; $i < 3; $i++) { + RawPost::query()->toBase()->insert([ + 'title' => "Post {$i}", + 'views' => $i, + 'published' => true, + 'author_id' => $author->getKey(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + + public function test_a_version_bump_neither_orphans_nor_resurrects_an_entry(): void + { + $query = fn() => RawPost::query()->toBase()->orderBy('id')->get(); + + $query(); + $before = $this->cacheQueryKeysWithField('m'); + + RawPost::query()->toBase()->where('id', $this->postId)->update(['title' => 'Updated']); + + $this->assertSame('Updated', collect($query())->firstWhere('id', $this->postId)->title); + + $this->assertSame( + $before, + $this->cacheQueryKeysWithField('m'), + 'a version bump must reuse the entry key rather than orphaning it under a new one', + ); + } +} diff --git a/tests/Integration/Cache/EpochInvalidationTest.php b/tests/Integration/Cache/EpochInvalidationTest.php new file mode 100644 index 0000000..47d5636 --- /dev/null +++ b/tests/Integration/Cache/EpochInvalidationTest.php @@ -0,0 +1,156 @@ +toBase()->insert(['id' => 1, 'name' => 'Author']); + RawPost::query()->toBase()->insert([ + 'id' => 1, 'title' => 'Before', 'views' => 0, 'published' => true, + 'author_id' => 1, 'created_at' => now(), 'updated_at' => now(), + ]); + + $read = fn() => RawPost::query()->toBase()->where('id', 1)->value('title'); + $this->assertSame('Before', $read()); + + DB::connection()->getPdo()->exec("update posts set title = 'Changed' where id = 1"); + + $this->assertTrue(NormCache::flushAll()); + $this->app->forgetScopedInstances(); + + $this->assertSame('Changed', $read()); + } + + public function test_a_flush_all_from_another_process_reaches_a_live_worker(): void + { + Author::query()->toBase()->insert(['id' => 1, 'name' => 'Author']); + RawPost::query()->toBase()->insert([ + 'id' => 1, 'title' => 'Before', 'views' => 0, 'published' => true, + 'author_id' => 1, 'created_at' => now(), 'updated_at' => now(), + ]); + + $read = fn() => RawPost::query()->toBase()->where('id', 1)->value('title'); + $this->assertSame('Before', $read()); + + DB::connection()->getPdo()->exec("update posts set title = 'Changed' where id = 1"); + $this->assertTrue($this->foreignCacheManager()->flushAll()); + $this->expireEpochMemo(); + + $this->assertSame( + 'Changed', + $read(), + 'a global flush must reach a live worker once the refresh interval lapses', + ); + } + + private function foreignCacheManager(): CacheManager + { + $config = $this->app->make(CacheConfig::class); + $store = $this->app->make(RedisStore::class); + $keys = $this->app->make(CacheKeyBuilder::class); + + return new CacheManager( + $config, + new CacheRuntime($config, $store, $keys, $this->app->make(FailureReporter::class)), + $store, + $keys, + $this->app->make(Invalidator::class), + $this->app->make(TableIdentityResolver::class), + new DeleteDependencyResolver(new TableIdentityResolver), + $this->app->make(QueryIdentity::class), + ); + } + + public function test_an_epoch_advanced_by_another_process_is_observed_next_scope(): void + { + Author::query()->toBase()->insert(['id' => 1, 'name' => 'Author']); + RawPost::query()->toBase()->insert([ + 'id' => 1, 'title' => 'Before', 'views' => 0, 'published' => true, + 'author_id' => 1, 'created_at' => now(), 'updated_at' => now(), + ]); + + $read = fn() => RawPost::query()->toBase()->where('id', 1)->value('title'); + $this->assertSame('Before', $read()); + $this->assertSame('Before', $read()); + + DB::connection()->getPdo()->exec("update posts set title = 'Changed' where id = 1"); + $this->cacheStore()->increment($this->cacheKeys()->epoch()); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $this->assertSame('Before', $read()); + DB::disableQueryLog(); + + $this->assertSame([], DB::getQueryLog()); + + $this->app->forgetScopedInstances(); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $this->assertSame('Changed', $read()); + DB::disableQueryLog(); + + $this->assertCount(1, DB::getQueryLog()); + } + + public function test_completed_migrations_advance_the_epoch_while_cache_is_disabled_by_configuration(): void + { + $epochKey = $this->cacheKeys()->epoch(); + $before = (int) ($this->cacheStore()->getRaw($epochKey) ?? '0'); + $original = $this->app->make(CacheConfig::class); + $config = (array) config('normcache'); + $config['enabled'] = false; + + $this->app->instance(CacheConfig::class, CacheConfig::fromArray($config)); + $this->app->forgetScopedInstances(); + + try { + $this->app['events']->dispatch(new MigrationsEnded('up')); + } finally { + $this->app->instance(CacheConfig::class, $original); + $this->app->forgetScopedInstances(); + } + + $this->assertSame( + $before + 1, + (int) $this->cacheStore()->getRaw($epochKey), + ); + } + + public function test_completed_migrations_advance_the_epoch(): void + { + Author::query()->toBase()->insert(['id' => 1, 'name' => 'Author']); + RawPost::query()->toBase()->insert([ + 'id' => 1, 'title' => 'Before', 'views' => 0, 'published' => true, + 'author_id' => 1, 'created_at' => now(), 'updated_at' => now(), + ]); + + $read = fn() => RawPost::query()->toBase()->where('id', 1)->value('title'); + $this->assertSame('Before', $read()); + + DB::connection()->getPdo()->exec("update posts set title = 'Changed' where id = 1"); + $this->app['events']->dispatch(new MigrationsEnded('up')); + $this->app->forgetScopedInstances(); + + $this->assertSame('Changed', $read()); + } +} diff --git a/tests/Integration/Cache/JoinResultCacheTest.php b/tests/Integration/Cache/JoinResultCacheTest.php deleted file mode 100644 index 1436a0e..0000000 --- a/tests/Integration/Cache/JoinResultCacheTest.php +++ /dev/null @@ -1,305 +0,0 @@ - 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Author::query() - ->join('posts', 'posts.author_id', '=', 'authors.id') - ->dependsOn([Post::class]) - ->get(); - - $this->assertEmpty($this->redisKeys('result:*')); - } - - public function test_join_with_depends_on_and_explicit_root_select_caches_as_result(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Author::query() - ->join('posts', 'posts.author_id', '=', 'authors.id') - ->select('authors.*') - ->dependsOn([Post::class]) - ->get(); - - $this->assertNotEmpty($this->redisKeys('result:*')); - } - - public function test_join_with_explicit_select_serves_subsequent_calls_from_cache(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Author::query() - ->join('posts', 'posts.author_id', '=', 'authors.id') - ->select('authors.*') - ->dependsOn([Post::class]) - ->get(); - - $queryCount = 0; - DB::listen(function () use (&$queryCount) { - $queryCount++; - }); - - $results = Author::query() - ->join('posts', 'posts.author_id', '=', 'authors.id') - ->select('authors.*') - ->dependsOn([Post::class]) - ->get(); - - $this->assertSame(0, $queryCount); - $this->assertCount(1, $results); - $this->assertSame('Alice', $results->first()->name); - } - - public function test_plain_join_without_depends_on_infers_table_dependency_and_caches(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Author::query() - ->join('posts', 'posts.author_id', '=', 'authors.id') - ->select('authors.*') - ->get(); - - $this->assertNotEmpty($this->redisKeys('result:*')); - } - - public function test_plain_join_without_explicit_root_select_bypasses_despite_inferred_tables(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Author::query() - ->join('posts', 'posts.author_id', '=', 'authors.id') - ->get(); - - $this->assertEmpty($this->redisKeys('result:*')); - $this->assertEmpty($this->redisKeys('query:*')); - } - - public function test_inferred_join_invalidates_when_joined_table_is_written(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - $first = Author::query() - ->join('posts', 'posts.author_id', '=', 'authors.id') - ->select('authors.*') - ->get(); - $this->assertCount(1, $first); - - $author2 = Author::create(['name' => 'Bob']); - Post::create(['title' => 'World', 'author_id' => $author2->id]); - - $second = Author::query() - ->join('posts', 'posts.author_id', '=', 'authors.id') - ->select('authors.*') - ->get(); - $this->assertCount(2, $second); - } - - public function test_join_get_star_string_without_explicit_root_select_bypasses_result_cache(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - $results = Author::query() - ->join('posts', 'posts.author_id', '=', 'authors.id') - ->dependsOn([Post::class]) - ->get('*'); - - $this->assertCount(1, $results); - $this->assertEmpty($this->redisKeys('result:*')); - } - - public function test_expression_join_bypasses_inferred_dependency(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Author::query() - ->join(DB::raw('posts p'), 'p.author_id', '=', 'authors.id') - ->select('authors.*') - ->get(); - - $this->assertEmpty($this->redisKeys('result:*')); - } - - public function test_join_with_where_exists_clause_bypasses_auto_inference(): void - { - $author = Author::create(['name' => 'Alice']); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id]); - DB::table('comments')->insert([ - 'body' => 'c1', - 'commentable_type' => Post::class, - 'commentable_id' => $post->id, - ]); - - Author::query() - ->join('posts', function ($join) { - $join->on('posts.author_id', '=', 'authors.id') - ->whereExists(function ($query) { - $query - ->from('comments') - ->whereColumn('comments.commentable_id', 'posts.id') - ->where('comments.commentable_type', Post::class); - }); - }) - ->select('authors.*') - ->get(); - - $this->assertEmpty($this->redisKeys('result:*')); - } - - public function test_join_with_raw_clause_bypasses_auto_inference(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Author::query() - ->join('posts', function ($join) { - $join->on('posts.author_id', '=', 'authors.id') - ->whereRaw('posts.title <> ?', ['Draft']); - }) - ->select('authors.*') - ->get(); - - $this->assertEmpty($this->redisKeys('result:*')); - } - - public function test_implicit_join_alias_bypasses_auto_inference(): void - { - $builder = Author::query() - ->join('posts p', 'p.author_id', '=', 'authors.id') - ->select('authors.*'); - $prepared = $builder->prepareCacheExecution(); - - $dependencies = (new QueryAnalyzer)->inferJoinDependencies( - $prepared->base, - $builder->getModel()->getConnection()->getName() - ); - - $this->assertFalse($dependencies->safe); - } - - public function test_multiple_joins_all_table_deps_collected_and_invalidated(): void - { - $country = Country::create(['name' => 'AU']); - $author = Author::create(['name' => 'Alice', 'country_id' => $country->id]); - Post::create(['title' => 'P1', 'author_id' => $author->id]); - - $first = Author::query() - ->join('posts', 'posts.author_id', '=', 'authors.id') - ->join('countries', 'countries.id', '=', 'authors.country_id') - ->where('countries.name', 'AU') - ->select('authors.*') - ->get(); - $this->assertCount(1, $first); - - $this->assertNotEmpty($this->redisKeys('result:*')); - - $country->update(['name' => 'NZ']); - - $second = Author::query() - ->join('posts', 'posts.author_id', '=', 'authors.id') - ->join('countries', 'countries.id', '=', 'authors.country_id') - ->where('countries.name', 'AU') - ->select('authors.*') - ->get(); - - $this->assertCount(0, $second); - } - - public function test_plain_join_count_infers_join_dependency_and_invalidates(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'P1', 'author_id' => $author->id]); - - $count1 = Author::query() - ->join('posts', 'posts.author_id', '=', 'authors.id') - ->count(); - - $this->assertSame(1, $count1); - - Post::create(['title' => 'P2', 'author_id' => $author->id]); - - $count2 = Author::query() - ->join('posts', 'posts.author_id', '=', 'authors.id') - ->count(); - - $this->assertSame(2, $count2); - } - - public function test_plain_join_paginate_infers_join_dependency_and_invalidates(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'P1', 'author_id' => $author->id]); - - $page1 = Author::query() - ->join('posts', 'posts.author_id', '=', 'authors.id') - ->select('authors.*') - ->paginate(10); - - $this->assertSame(1, $page1->total()); - - Post::create(['title' => 'P2', 'author_id' => $author->id]); - - $page2 = Author::query() - ->join('posts', 'posts.author_id', '=', 'authors.id') - ->select('authors.*') - ->paginate(10); - - $this->assertSame(2, $page2->total()); - } - - public function test_join_to_non_cacheable_table_infers_table_dep_and_caches(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'P1', 'author_id' => $author->id]); - - // author_tag has no Cacheable model — dep is tracked as a table version key - Author::query() - ->join('author_tag', 'author_tag.author_id', '=', 'authors.id') - ->select('authors.*') - ->get(); - - $this->assertNotEmpty($this->redisKeys('result:*')); - } - - public function test_corrupt_result_cache_payload_is_treated_as_miss(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'P1', 'author_id' => $author->id]); - - $query = Author::query()->whereHas('posts')->dependsOn([Post::class]); - $query->get(); // warm - - $resultKey = collect($this->redisKeys('result:*'))->first(); - Redis::connection('normcache-test')->set($resultKey, 'CORRUPT'); - - $results = $query->get(); - - $this->assertCount(1, $results); - $this->assertSame('Alice', $results->first()->name); - } -} diff --git a/tests/Integration/Cache/MembershipRevalidationTest.php b/tests/Integration/Cache/MembershipRevalidationTest.php new file mode 100644 index 0000000..52c16fe --- /dev/null +++ b/tests/Integration/Cache/MembershipRevalidationTest.php @@ -0,0 +1,714 @@ +authorId = (int) Author::query()->create(['name' => 'Author'])->getKey(); + } + + /** Mirrors MembershipRevalidator::MAX_VERSION_GAP. */ + private const MAX_VERSION_GAP = 128; + + private function enableRevalidation(): void + { + config()->set('normcache.revalidation', true); + + $this->app->forgetInstance(CacheConfig::class); + $this->app->forgetScopedInstances(); + } + + private function seedPosts(int $count): void + { + $rows = []; + + for ($index = 1; $index <= $count; $index++) { + $rows[] = [ + 'title' => 'Post ' . $index, + 'views' => $index, + 'published' => true, + 'author_id' => $this->authorId, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + + RawPost::query()->toBase()->insert($rows); + } + + /** @return list> */ + private function captureQueries(callable $callback): array + { + DB::flushQueryLog(); + DB::enableQueryLog(); + + try { + $callback(); + } finally { + DB::disableQueryLog(); + } + + return DB::getQueryLog(); + } + + private function assertServedWithoutSql(callable $callback): void + { + $this->assertSame( + [], + $this->captureQueries($callback), + 'expected the read to be served entirely from NormCache', + ); + } + + private function assertRevalidated(callable $callback): void + { + $dispatcher = Event::getFacadeRoot(); + Event::fake([QueryCacheHit::class, QueryCacheMiss::class, QueryCacheRepaired::class]); + + try { + $callback(); + Event::assertNotDispatched(QueryCacheMiss::class); + Event::assertDispatched(QueryCacheRepaired::class); + } finally { + Event::swap($dispatcher); + } + } + + private function assertNotRevalidated(callable $callback): void + { + $dispatcher = Event::getFacadeRoot(); + Event::fake([QueryCacheHit::class, QueryCacheMiss::class, QueryCacheRepaired::class]); + + try { + $callback(); + Event::assertDispatched(QueryCacheMiss::class); + Event::assertNotDispatched(QueryCacheRepaired::class); + } finally { + Event::swap($dispatcher); + } + } + + private function forgetChangeRecord(string $table, string $version): void + { + $this->cacheStore()->delete( + $this->cacheKeys()->changeRecord($this->tableIdentity($table), $version), + ); + } + + private function tableIdentity(string $table): TableIdentity + { + $identity = $this->app->make(TableIdentityResolver::class) + ->resolve(DB::connection(), $table); + + $this->assertNotNull($identity); + + return $identity; + } + + private function currentVersion(string $table): string + { + return (string) ($this->cacheStore()->getRaw( + $this->cacheKeys()->version($this->tableIdentity($table)), + ) ?? '0'); + } + + /** @param list $columns */ + private function forgeChangeRecord( + string $table, + string $version, + string $mutation, + array $columns, + bool $precise, + ): void { + $this->cacheStore()->setRawForever( + $this->cacheKeys()->changeRecord($this->tableIdentity($table), $version), + $this->app->make(ChangeRecordCodec::class)->encode($mutation, $columns, $precise), + ); + } + + private function deleteAllCanonicalRows(): void + { + $rows = array_values(array_filter( + $this->cacheKeysMatching(':r:g'), + static fn(string $key): bool => !str_contains($key, ':build:'), + )); + + $this->assertNotSame([], $rows, 'expected canonical rows to exist'); + + $this->cacheStore()->delete($rows); + } + + public function test_an_update_to_a_non_predicate_column_keeps_the_membership(): void + { + $this->enableRevalidation(); + $this->seedPosts(50); + RawPost::query()->toBase()->get(); + + RawPost::query()->toBase()->where('id', 7)->update(['title' => 'changed']); + + $this->assertRevalidated(function () use (&$queries): void { + $queries = $this->captureQueries(fn() => RawPost::query()->toBase()->get()); + }); + + $this->assertCount(1, $queries, 'only the repair of row 7'); + $this->assertStringContainsString('in (?)', $queries[0]['query']); + } + + public function test_the_revalidated_read_returns_the_new_value(): void + { + $this->enableRevalidation(); + $this->seedPosts(50); + RawPost::query()->toBase()->get(); + + RawPost::query()->toBase()->where('id', 7)->update(['title' => 'changed']); + + $rows = collect(RawPost::query()->toBase()->get()); + + $this->assertSame('changed', $rows->firstWhere('id', 7)->title); + $this->assertCount(50, $rows); + } + + public function test_the_read_after_a_revalidation_is_an_ordinary_hit(): void + { + $this->enableRevalidation(); + $this->seedPosts(50); + RawPost::query()->toBase()->get(); + + RawPost::query()->toBase()->where('id', 7)->update(['title' => 'changed']); + RawPost::query()->toBase()->get(); + + $this->assertServedWithoutSql(fn() => RawPost::query()->toBase()->get()); + } + + public function test_revalidation_is_on_by_default(): void + { + $this->seedPosts(50); + RawPost::query()->toBase()->get(); + + RawPost::query()->toBase()->where('id', 7)->update(['title' => 'changed']); + + $this->assertRevalidated(fn() => RawPost::query()->toBase()->get()); + } + + public function test_revalidation_can_be_turned_off(): void + { + config()->set('normcache.revalidation', false); + $this->app->forgetInstance(CacheConfig::class); + $this->app->forgetScopedInstances(); + + $this->seedPosts(50); + RawPost::query()->toBase()->get(); + + RawPost::query()->toBase()->where('id', 7)->update(['title' => 'changed']); + + $this->assertNotRevalidated(fn() => RawPost::query()->toBase()->get()); + } + + public function test_an_update_to_a_predicate_column_does_not_revalidate(): void + { + $this->enableRevalidation(); + $this->seedPosts(50); + RawPost::query()->toBase()->where('published', true)->get(); + + RawPost::query()->toBase()->where('id', 7)->update(['published' => false]); + + $this->assertNotRevalidated( + fn() => RawPost::query()->toBase()->where('published', true)->get(), + ); + $this->assertCount(49, RawPost::query()->toBase()->where('published', true)->get()); + } + + public function test_a_volatile_column_changed_by_a_trigger_does_not_revalidate(): void + { + $this->enableRevalidation(); + $this->seedPosts(3); + DB::statement(<<<'SQL' + CREATE TRIGGER posts_title_unpublishes + AFTER UPDATE OF title ON posts + FOR EACH ROW + WHEN NEW.title = 'hidden' + BEGIN + UPDATE posts SET published = 0 WHERE id = NEW.id; + END + SQL); + + $read = fn() => VolatilePost::query()->toBase() + ->where('published', true) + ->orderBy('id') + ->get(); + + $read(); + VolatilePost::query()->toBase()->where('id', 1)->update(['title' => 'hidden']); + + $this->assertNotRevalidated($read); + $this->assertEquals( + VolatilePost::query()->toBase() + ->where('published', true) + ->orderBy('id') + ->internal() + ->get(), + $read(), + ); + } + + public function test_a_query_guarding_no_volatile_column_still_revalidates(): void + { + $this->enableRevalidation(); + $this->seedPosts(50); + + $read = fn() => VolatilePost::query()->toBase()->orderBy('views')->get(); + + $read(); + VolatilePost::query()->toBase()->where('id', 7)->update(['title' => 'changed']); + + $this->assertRevalidated($read); + $this->assertSame('changed', $read()->firstWhere('id', 7)->title); + } + + public function test_a_volatile_column_is_recorded_alongside_the_assigned_columns(): void + { + $this->enableRevalidation(); + $this->seedPosts(3); + + VolatilePost::query()->toBase()->where('id', 1)->update(['title' => 'changed']); + + $record = $this->app->make(ChangeRecordCodec::class)->decode( + (string) $this->cacheStore()->getRaw( + $this->cacheKeys()->changeRecord( + $this->tableIdentity('posts'), + $this->currentVersion('posts'), + ), + ), + ); + + $this->assertTrue($record->valid); + $this->assertEqualsCanonicalizing( + ['title', 'published', 'title_length'], + $record->columns, + ); + } + + public function test_integer_in_raw_predicates_can_revalidate(): void + { + $this->enableRevalidation(); + $this->seedPosts(5); + + $read = fn() => RawPost::query()->toBase() + ->whereIntegerInRaw('id', [1, 2, 3]) + ->orderBy('id') + ->get(); + + $read(); + RawPost::query()->toBase()->where('id', 2)->update(['title' => 'changed']); + + $this->assertRevalidated($read); + $this->assertSame('changed', $read()->firstWhere('id', 2)->title); + } + + public function test_a_volatile_generated_predicate_column_does_not_revalidate(): void + { + $this->enableRevalidation(); + DB::statement( + 'alter table posts add column title_length integer generated always as (length(title)) virtual', + ); + $this->seedPosts(5); + + $read = fn() => VolatilePost::query()->toBase() + ->where('title_length', '>', 4) + ->orderBy('id') + ->get(); + + $read(); + VolatilePost::query()->toBase()->where('id', 1)->update(['title' => 'x']); + + $this->assertNotRevalidated($read); + $this->assertCount(4, $read()); + } + + public function test_an_undeclared_generated_predicate_column_revalidates_wrongly(): void + { + $this->enableRevalidation(); + DB::statement( + 'alter table posts add column title_length integer generated always as (length(title)) virtual', + ); + $this->seedPosts(5); + + $read = fn() => RawPost::query()->toBase() + ->where('title_length', '>', 4) + ->orderBy('id') + ->get(); + + $read(); + RawPost::query()->toBase()->where('id', 1)->update(['title' => 'x']); + + $this->assertRevalidated($read); + $this->assertCount(5, $read(), 'the stale membership still holds the shortened row'); + $this->assertCount( + 4, + RawPost::query()->toBase() + ->where('title_length', '>', 4) + ->orderBy('id') + ->internal() + ->get(), + ); + } + + public function test_an_update_to_an_order_column_does_not_revalidate(): void + { + $this->enableRevalidation(); + $this->seedPosts(50); + RawPost::query()->toBase()->orderBy('views')->get(); + + RawPost::query()->toBase()->where('id', 7)->update(['views' => 999]); + + $this->assertNotRevalidated(fn() => RawPost::query()->toBase()->orderBy('views')->get()); + + $rows = collect(RawPost::query()->toBase()->orderBy('views')->get()); + $this->assertSame(999, (int) $rows->last()->views); + } + + public function test_an_insert_does_not_revalidate(): void + { + $this->enableRevalidation(); + $this->seedPosts(50); + RawPost::query()->toBase()->get(); + + RawPost::query()->toBase()->insert([ + 'title' => 'new', + 'views' => 0, + 'published' => true, + 'author_id' => $this->authorId, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->assertNotRevalidated(fn() => RawPost::query()->toBase()->get()); + $this->assertCount(51, RawPost::query()->toBase()->get()); + } + + public function test_a_delete_does_not_revalidate(): void + { + $this->enableRevalidation(); + $this->seedPosts(50); + RawPost::query()->toBase()->get(); + + RawPost::query()->toBase()->where('id', 7)->delete(); + + $this->assertNotRevalidated(fn() => RawPost::query()->toBase()->get()); + $this->assertCount(49, RawPost::query()->toBase()->get()); + } + + public function test_a_non_precise_update_does_not_revalidate(): void + { + $this->enableRevalidation(); + $this->seedPosts(50); + RawPost::query()->toBase()->get(); + + RawPost::query()->toBase()->where('title', 'like', 'Post 1%')->update(['title' => 'x']); + + $this->assertNotRevalidated(fn() => RawPost::query()->toBase()->get()); + } + + public function test_a_missing_change_record_does_not_revalidate(): void + { + $this->enableRevalidation(); + $this->seedPosts(50); + RawPost::query()->toBase()->get(); + RawPost::query()->toBase()->where('id', 7)->update(['title' => 'changed']); + + $this->forgetChangeRecord('posts', '2'); + + $this->assertNotRevalidated(fn() => RawPost::query()->toBase()->get()); + } + + public function test_a_record_naming_a_non_update_mutation_does_not_revalidate(): void + { + $this->enableRevalidation(); + $this->seedPosts(50); + RawPost::query()->toBase()->get(); + RawPost::query()->toBase()->where('id', 7)->update(['title' => 'changed']); + + $this->forgeChangeRecord( + 'posts', + $this->currentVersion('posts'), + mutation: 'delete', + columns: ['title'], + precise: true, + ); + + $this->assertNotRevalidated(fn() => RawPost::query()->toBase()->get()); + } + + public function test_a_non_precise_record_does_not_revalidate(): void + { + $this->enableRevalidation(); + $this->seedPosts(50); + RawPost::query()->toBase()->get(); + RawPost::query()->toBase()->where('id', 7)->update(['title' => 'changed']); + + $this->forgeChangeRecord( + 'posts', + $this->currentVersion('posts'), + mutation: 'update', + columns: ['title'], + precise: false, + ); + + $this->assertNotRevalidated(fn() => RawPost::query()->toBase()->get()); + } + + public function test_a_corrupt_change_record_does_not_revalidate(): void + { + $this->enableRevalidation(); + $this->seedPosts(50); + RawPost::query()->toBase()->get(); + RawPost::query()->toBase()->where('id', 7)->update(['title' => 'changed']); + + $this->cacheStore()->setRawForever( + $this->cacheKeys()->changeRecord( + $this->tableIdentity('posts'), + $this->currentVersion('posts'), + ), + 'not-a-payload', + ); + + $this->assertNotRevalidated(fn() => RawPost::query()->toBase()->get()); + } + + public function test_a_version_gap_beyond_the_cap_does_not_revalidate(): void + { + $this->enableRevalidation(); + $this->seedPosts(50); + RawPost::query()->toBase()->get(); + + // The seed insert already consumed one version. + foreach (range(1, self::MAX_VERSION_GAP + 1) as $pass) { + RawPost::query()->toBase()->where('id', 7)->update(['title' => "t{$pass}"]); + } + + $this->assertNotRevalidated(fn() => RawPost::query()->toBase()->get()); + } + + public function test_a_version_gap_within_the_cap_still_revalidates(): void + { + $this->enableRevalidation(); + $this->seedPosts(50); + RawPost::query()->toBase()->get(); + + foreach ([1, 2, 3] as $id) { + RawPost::query()->toBase()->where('id', $id)->update(['title' => "t{$id}"]); + } + + $this->assertRevalidated(function () use (&$queries): void { + $queries = $this->captureQueries(fn() => RawPost::query()->toBase()->get()); + }); + + $this->assertCount(1, $queries, 'only the repair of rows 1-3'); + + $rows = collect(RawPost::query()->toBase()->get()); + $this->assertSame('t1', $rows->firstWhere('id', 1)->title); + $this->assertSame('t3', $rows->firstWhere('id', 3)->title); + } + + public function test_an_unparseable_predicate_does_not_revalidate(): void + { + $this->enableRevalidation(); + $this->seedPosts(50); + RawPost::query()->toBase()->whereRaw('length(title) > ?', [1])->get(); + + RawPost::query()->toBase()->where('id', 7)->update(['title' => 'changed']); + + $this->assertNotRevalidated( + fn() => RawPost::query()->toBase()->whereRaw('length(title) > ?', [1])->get(), + ); + } + + public function test_revalidation_does_not_leave_a_stale_overlay(): void + { + $this->enableRevalidation(); + $this->seedPosts(50); + RawPost::query()->toBase()->get(); + RawPost::query()->toBase()->where('id', 7)->update(['title' => 'changed']); + + RawPost::query()->toBase()->get(); + + // Force the next read through the overlay. + $this->deleteAllCanonicalRows(); + + $rows = collect(RawPost::query()->toBase()->get()); + $this->assertSame('changed', $rows->firstWhere('id', 7)->title); + } + + public function test_a_revalidation_without_an_overlay_drops_the_stale_one(): void + { + $this->enableRevalidation(); + $this->seedPosts(50); + RawPost::query()->toBase()->get(); + + $this->assertNotSame( + [], + $this->cacheQueryKeysWithField('r'), + 'expected the first read to publish an overlay', + ); + + RawPost::query()->toBase()->where('id', 7)->update([ + 'metadata' => json_encode(['blob' => str_repeat('y', 200_000)]), + ]); + + $this->assertRevalidated(fn() => RawPost::query()->toBase()->get()); + + $this->assertSame( + [], + $this->cacheQueryKeysWithField('r'), + 'the pre-update overlay must not survive a re-stamp that writes no overlay', + ); + + $rows = collect(RawPost::query()->toBase()->get()); + $this->assertCount(50, $rows); + $this->assertStringContainsString('yyy', (string) $rows->firstWhere('id', 7)->metadata); + } + + public function test_a_rejected_restamp_leaves_the_overlay_alone(): void + { + $this->enableRevalidation(); + $this->seedPosts(3); + RawPost::query()->toBase()->get(); + + $entryKeys = $this->cacheQueryKeysWithField('r'); + $this->assertNotSame([], $entryKeys, 'expected the first read to publish an overlay'); + $key = $entryKeys[0]; + + $overlay = $this->cacheStore()->readHashField($key, 'r'); + $membership = $this->cacheStore()->readHashField($key, 'm'); + + $identity = $this->tableIdentity('posts'); + $query = RawPost::query()->toBase(); + $primaryKey = $query->primaryKey(); + $this->assertNotNull($primaryKey); + + $this->app->make(QueryEntryRepository::class)->restampCanonical( + $query, + QueryPlan::canonical($identity, [$identity], $primaryKey), + new CacheState( + key: $key, + epoch: '0', + // Unreachable table version forces rejection. + version: '99999', + generation: '0', + versions: [], + tag: null, + tagKey: null, + ), + [(object) ['id' => 1], (object) ['id' => 2], (object) ['id' => 3]], + OverlayAdmission::rejected(), + ); + + $this->assertSame($membership, $this->cacheStore()->readHashField($key, 'm')); + $this->assertSame($overlay, $this->cacheStore()->readHashField($key, 'r')); + } + + /** @return callable(): mixed */ + private function projectedRead(): callable + { + return fn() => RawPost::query()->toBase()->select('id', 'title')->orderBy('views')->get(); + } + + /** @return callable(): mixed */ + private function wildcardRead(): callable + { + return fn() => RawPost::query()->toBase()->orderBy('views')->get(); + } + + public function test_a_projected_query_revalidates_through_the_canonical_membership(): void + { + $this->enableRevalidation(); + $this->seedPosts(50); + + ($this->wildcardRead())(); + ($this->projectedRead())(); + + RawPost::query()->toBase()->where('id', 7)->update(['title' => 'changed']); + + $this->assertRevalidated($this->projectedRead()); + } + + public function test_a_revalidated_projection_reflects_an_updated_projected_column(): void + { + $this->enableRevalidation(); + $this->seedPosts(50); + + ($this->wildcardRead())(); + ($this->projectedRead())(); + + RawPost::query()->toBase()->where('id', 7)->update(['title' => 'changed']); + + $rows = collect(($this->projectedRead())()); + + $this->assertSame('changed', $rows->firstWhere('id', 7)->title); + $this->assertCount(50, $rows); + $this->assertSame(['id', 'title'], array_keys((array) $rows->first())); + } + + public function test_a_projected_query_still_does_not_revalidate_a_predicate_column(): void + { + $this->enableRevalidation(); + $this->seedPosts(50); + + ($this->wildcardRead())(); + ($this->projectedRead())(); + + RawPost::query()->toBase()->where('id', 7)->update(['views' => 999]); + + $this->assertNotRevalidated($this->projectedRead()); + } + + public function test_a_projected_query_without_a_canonical_sibling_still_misses(): void + { + $this->enableRevalidation(); + $this->seedPosts(50); + + ($this->projectedRead())(); + + RawPost::query()->toBase()->where('id', 7)->update(['title' => 'changed']); + + $this->assertNotRevalidated($this->projectedRead()); + } + + public function test_a_stale_membership_is_still_rejected_without_approval(): void + { + $this->enableRevalidation(); + $this->seedPosts(50); + RawPost::query()->toBase()->get(); + + RawPost::query()->toBase()->where('published', true)->get(); + RawPost::query()->toBase()->where('id', 7)->update(['published' => false]); + + $this->assertNotRevalidated( + fn() => RawPost::query()->toBase()->where('published', true)->get(), + ); + } +} diff --git a/tests/Integration/Cache/ModelCacheColdHydrationTest.php b/tests/Integration/Cache/ModelCacheColdHydrationTest.php deleted file mode 100644 index 8422f9f..0000000 --- a/tests/Integration/Cache/ModelCacheColdHydrationTest.php +++ /dev/null @@ -1,201 +0,0 @@ - 'Eli']); - $post = NewFromBuilderOverridingPost::create(['title' => 'Custom', 'author_id' => $author->id]); - NewFromBuilderOverridingPost::$newFromBuilderCalls = 0; - $this->evictModelCache(NewFromBuilderOverridingPost::class, $post->id); - - $manager = $this->buildManager(); - $models = $manager->modelCache()->getModels([$post->id], NewFromBuilderOverridingPost::class); - - $this->assertCount(1, $models); - $this->assertSame('Custom', $models[0]->title); - $this->assertSame(1, NewFromBuilderOverridingPost::$newFromBuilderCalls, 'Models overriding newFromBuilder() must use the Eloquent fallback'); - } - - public function test_projection_caches_full_attributes_and_returns_projected_model(): void - { - $author = Author::create(['name' => 'Fay']); - $post = Post::create(['title' => 'Projected', 'author_id' => $author->id, 'views' => 5, 'published' => true]); - $this->evictModelCache(Post::class, $post->id); - - $projected = Post::select('id', 'title')->whereKey($post->id)->get(); - - $this->assertSame(['Projected'], $projected->pluck('title')->all()); - $this->assertArrayNotHasKey('views', $projected->first()->getAttributes()); - - $cached = $this->modelCacheEntry(Post::class, $post->id); - $this->assertSame(5, $cached['views']); - $this->assertSame($post->author_id, $cached['author_id']); - - DB::enableQueryLog(); - $full = Post::whereKey($post->id)->get()->first(); - $queries = DB::getQueryLog(); - DB::disableQueryLog(); - - $this->assertSame(5, $full->views); - $this->assertCount(0, $queries, 'full payload should already be cached from the projected miss'); - } - - public function test_cold_miss_fires_retrieved_event_when_cached_retrieved_events_are_disabled(): void - { - $author = Author::create(['name' => 'Gail']); - $post = Post::create(['title' => 'Retrieved', 'author_id' => $author->id]); - $this->evictModelCache(Post::class, $post->id); - - $calls = 0; - Post::retrieved(function () use (&$calls) { - $calls++; - }); - - $manager = $this->buildManager(fireRetrieved: false); - $models = $manager->modelCache()->getModels([$post->id], Post::class); - - $this->assertCount(1, $models); - $this->assertSame(1, $calls, 'Cold-miss hydration must fire retrieved exactly once'); - } - - public function test_partial_hit_fires_retrieved_once_per_returned_model(): void - { - $author = Author::create(['name' => 'Half Warm']); - $cached = Post::create(['title' => 'Cached', 'author_id' => $author->id]); - $missing = Post::create(['title' => 'Missing', 'author_id' => $author->id]); - - $manager = $this->buildManager(fireRetrieved: true); - $manager->modelCache()->getModels([$cached->id], Post::class); - - $retrievedIds = []; - Post::retrieved(function (Post $post) use (&$retrievedIds): void { - $retrievedIds[] = $post->id; - }); - - $models = $manager->modelCache()->getModels([$cached->id, $missing->id], Post::class); - - $this->assertSame([$cached->id, $missing->id], array_map(static fn(Post $post) => $post->id, $models)); - $this->assertSame( - [$cached->id => 1, $missing->id => 1], - array_count_values($retrievedIds), - 'The all-hit probe must not hydrate cached rows before falling back to partial-miss handling.', - ); - } - - public function test_connection_name_matches_native_eloquent_after_cold_miss(): void - { - $author = Author::create(['name' => 'Hank']); - $post = InstrumentedPost::create(['title' => 'Conn', 'author_id' => $author->id]); - $this->evictModelCache(InstrumentedPost::class, $post->id); - - $manager = $this->buildManager(); - $models = $manager->modelCache()->getModels([$post->id], InstrumentedPost::class); - - $native = InstrumentedPost::find($post->id); - - $this->assertSame($native->getConnectionName(), $models[0]->getConnectionName()); - $this->assertSame('testing', $models[0]->getConnectionName()); - } - - public function test_after_query_callback_runs_exactly_once_per_call_on_cold_miss(): void - { - $author = Author::create(['name' => 'Jack']); - $post = Post::create(['title' => 'Callback', 'author_id' => $author->id]); - $this->evictModelCache(Post::class, $post->id); - - $calls = 0; - $query = function () use ($post, &$calls) { - return Post::whereKey($post->id) - ->afterQuery(function ($posts) use (&$calls) { - $calls++; - - return $posts; - }) - ->get(); - }; - - $cold = $query(); - $warm = $query(); - - $this->assertSame(['Callback'], $cold->pluck('title')->all()); - $this->assertSame(['Callback'], $warm->pluck('title')->all()); - $this->assertSame(2, $calls, 'callback should run exactly once per get() call, not once per fetched row'); - } - - public function test_joined_miss_refetches_the_requested_model_without_ambiguous_columns(): void - { - $author = Author::create(['name' => 'Owen']); - $post = InstrumentedPost::create(['title' => 'JoinAmbiguous', 'author_id' => $author->id]); - $this->evictModelCache(InstrumentedPost::class, $post->id); - - $manager = $this->buildManager(); - $joinedQuery = InstrumentedPost::query()->withoutCache() - ->join('authors', 'authors.id', '=', 'posts.author_id'); - - $models = $manager->modelCache()->getModels([$post->id], InstrumentedPost::class, null, null, $joinedQuery, true); - - $this->assertCount(1, $models); - $this->assertSame('JoinAmbiguous', $models[0]->title); - $this->assertSame($post->id, $models[0]->id, 'Must resolve the post id, not the colliding authors.id from the join'); - } - - public function test_cold_miss_with_grouped_missed_query_returns_one_row_per_requested_id(): void - { - $author = Author::create(['name' => 'Petra']); - $post1 = InstrumentedPost::create(['title' => 'GroupedOne', 'author_id' => $author->id]); - $post2 = InstrumentedPost::create(['title' => 'GroupedTwo', 'author_id' => $author->id]); - $this->evictModelCache(InstrumentedPost::class, $post1->id); - $this->evictModelCache(InstrumentedPost::class, $post2->id); - - $manager = $this->buildManager(); - $groupedQuery = InstrumentedPost::query()->withoutCache()->groupBy('author_id'); - - $models = $manager->modelCache()->getModels([$post1->id, $post2->id], InstrumentedPost::class, null, null, $groupedQuery, true); - - $this->assertCount(2, $models, 'Both requested ids must be resolved, not collapsed by the original query\'s GROUP BY'); - $titles = array_map(fn($m) => $m->title, $models); - $this->assertEqualsCanonicalizing(['GroupedOne', 'GroupedTwo'], $titles); - } - - public function test_cold_miss_with_unioned_missed_query_does_not_run_the_union_for_the_refetch(): void - { - $author = Author::create(['name' => 'Quinn']); - $wanted = InstrumentedPost::create(['title' => 'Wanted', 'author_id' => $author->id]); - InstrumentedPost::create(['title' => 'Unrequested', 'author_id' => $author->id]); - $this->evictModelCache(InstrumentedPost::class, $wanted->id); - - $manager = $this->buildManager(); - $unionedQuery = InstrumentedPost::query()->withoutCache()->where('title', 'Wanted'); - $unionedQuery->getQuery()->union( - InstrumentedPost::query()->withoutCache()->where('title', 'Unrequested')->getQuery() - ); - - DB::enableQueryLog(); - $models = $manager->modelCache()->getModels([$wanted->id], InstrumentedPost::class, null, null, $unionedQuery, true); - $queries = DB::getQueryLog(); - DB::disableQueryLog(); - - $this->assertCount(1, $models); - $this->assertSame('Wanted', $models[0]->title); - - $refetchRanAUnion = array_filter($queries, fn($q) => str_contains(strtolower($q['query']), 'union')); - $this->assertSame([], $refetchRanAUnion, 'The original union must not be reused for the by-id refetch'); - } -} diff --git a/tests/Integration/Cache/ModelCacheStampedeTest.php b/tests/Integration/Cache/ModelCacheStampedeTest.php deleted file mode 100644 index f6bd695..0000000 --- a/tests/Integration/Cache/ModelCacheStampedeTest.php +++ /dev/null @@ -1,99 +0,0 @@ -buildManager(buildingLockTtl: 5, stampedeWaitMs: 50); - $author = Author::create(['name' => 'Alice']); - $this->evictModelCache(Author::class, $author->id); - - $keys = $manager->keys(); - $classKey = $keys->classKey(Author::class); - $modelVersion = $manager->currentVersion(Author::class); - $lockSegment = 'model:v' . $modelVersion; - $lockSuffix = $keys->resultBuildIdentityHash($lockSegment, null, (string) $author->id); - $lockKey = $keys->resultBuildingKey($classKey, $lockSegment, $lockSuffix); - - DB::enableQueryLog(); - $models = $manager->modelCache()->getModels([$author->id], Author::class); - $queries = DB::getQueryLog(); - DB::disableQueryLog(); - - $this->assertCount(1, $models); - $this->assertSame('Alice', $models[0]->name); - $this->assertCount(1, $queries, 'Expected exactly one DB query to hydrate the missed model'); - $this->assertNotNull($this->modelCacheEntry(Author::class, $author->id)); - $this->assertNull($manager->store()->getRaw($lockKey), 'Building lock must be released after a miss'); - } - - public function test_falls_back_to_database_without_releasing_someone_elses_lock(): void - { - $manager = $this->buildManager(buildingLockTtl: 5, stampedeWaitMs: 50); - $author = Author::create(['name' => 'Bob']); - $this->evictModelCache(Author::class, $author->id); - - $keys = $manager->keys(); - $classKey = $keys->classKey(Author::class); - $modelVersion = $manager->currentVersion(Author::class); - $lockSegment = 'model:v' . $modelVersion; - $lockSuffix = $keys->resultBuildIdentityHash($lockSegment, null, (string) $author->id); - $lockKey = $keys->resultBuildingKey($classKey, $lockSegment, $lockSuffix); - - $store = $manager->store(); - $this->assertTrue($store->setNxEx($lockKey, 'other-token', 5)); - - $models = $manager->modelCache()->getModels([$author->id], Author::class); - - $this->assertCount(1, $models); - $this->assertSame('Bob', $models[0]->name); - $this->assertSame('other-token', $store->getRaw($lockKey)); - $this->assertNull($this->modelCacheEntry(Author::class, $author->id)); - } - - public function test_retry_mget_resolves_a_concurrent_fill_without_claiming_lock(): void - { - $manager = $this->buildManager(); - $store = $manager->store(); - $keys = $manager->keys(); - $versions = $manager->versionStore(); - $modelCache = $manager->modelCache(); - - $author = Author::create(['name' => 'Carol']); - $classKey = $keys->classKey(Author::class); - $version = $versions->normalizeVersion($store->getRaw($keys->verKey($classKey))); - $modelKey = $keys->modelPrefix($classKey, $version) . $author->id; - $store->set($modelKey, $author->getRawOriginal(), 3600); - - $lockKey = $keys->resultBuildingKey($classKey, 'model', 'test-lock'); - $context = new ModelFetchContext( - modelClass: Author::class, - classKey: $classKey, - projection: null, - prototype: null, - missedQuery: null, - preserveQueryShape: true, - modelVersion: $version, - ); - $context->lockKey = $lockKey; - $context->wakeKey = $keys->wakeKey($classKey, 'test-lock'); - $context->token = 'token'; - - $method = new \ReflectionMethod($modelCache, 'fetchMissedStatus'); - [$status, $missed] = $method->invokeArgs($modelCache, [[$author->id], $context]); - - $this->assertSame(LuaStatus::Hit, $status); - $this->assertSame([], $missed); - $this->assertArrayHasKey($author->id, $context->hits); - $this->assertSame('Carol', $context->hits[$author->id]->name); - $this->assertNull($store->getRaw($lockKey)); - } -} diff --git a/tests/Integration/Cache/ModelCachingTest.php b/tests/Integration/Cache/ModelCachingTest.php deleted file mode 100644 index 0dbd2f5..0000000 --- a/tests/Integration/Cache/ModelCachingTest.php +++ /dev/null @@ -1,62 +0,0 @@ -app['config']->set('normcache.enabled', false); - - Author::create(['name' => 'Alice']); - Author::all(); - - $this->assertEmpty($this->redisKeys('query:*')); - } - - public function test_flush_command_without_model_flushes_all_keys(): void - { - Author::create(['name' => 'Alice']); - Author::all(); - - $this->assertNotEmpty($this->redisKeys('*')); - - $this->artisan('normcache:flush')->assertSuccessful(); - - $this->assertEmpty($this->redisKeys('*')); - } - - public function test_flush_command_with_model_flushes_only_that_model(): void - { - $author = Author::create(['name' => 'Alice']); - Author::all(); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id]); - Post::all(); - - $this->artisan('normcache:flush', ['--model' => Author::class])->assertSuccessful(); - - // Author entries are unreachable after the version bump; Post cache is unaffected. - $this->assertNull($this->modelCacheEntry(Author::class, $author->id)); - $this->assertNotNull($this->modelCacheEntry(Post::class, $post->id)); - } - - public function test_flush_command_rejects_nonexistent_class(): void - { - $this->artisan('normcache:flush', ['--model' => 'App\\Models\\DoesNotExist']) - ->assertFailed(); - } - - public function test_flush_command_rejects_class_without_cacheable_trait(): void - { - $this->artisan('normcache:flush', ['--model' => stdClass::class]) - ->assertFailed(); - } -} diff --git a/tests/Integration/Cache/MorphToCacheTest.php b/tests/Integration/Cache/MorphToCacheTest.php deleted file mode 100644 index 93d3b71..0000000 --- a/tests/Integration/Cache/MorphToCacheTest.php +++ /dev/null @@ -1,179 +0,0 @@ - 'Alice']); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id]); - Comment::create(['body' => 'Nice', 'commentable_id' => $post->id, 'commentable_type' => Post::class]); - - Comment::with('commentable')->get(); - - DB::enableQueryLog(); - Comment::with('commentable')->get(); - $queries = DB::getQueryLog(); - DB::disableQueryLog(); - - $sqlTables = array_column($queries, 'query'); - $this->assertNotContains(true, array_map(fn($q) => str_contains($q, '"posts"') || str_contains($q, '"authors"'), $sqlTables), - 'MorphTo eager load should serve from cache after warm-up, but hit DB for related models'); - } - - public function test_morph_to_falls_back_when_related_type_is_not_cacheable(): void - { - $uncachedAuthor = UncachedAuthor::create(['name' => 'Bob']); - Comment::create(['body' => 'Hi', 'commentable_id' => $uncachedAuthor->id, 'commentable_type' => UncachedAuthor::class]); - - DB::enableQueryLog(); - $comments = Comment::with('commentable')->get(); - $queries = DB::getQueryLog(); - DB::disableQueryLog(); - - $this->assertNotNull($comments->first()->commentable); - $sqlCount = count(array_filter($queries, fn($q) => str_contains($q['query'], '"authors"'))); - $this->assertGreaterThan(0, $sqlCount, 'Non-cacheable type should fall back to DB'); - } - - public function test_morph_to_falls_back_when_macro_buffer_is_set(): void - { - $post = Post::create(['title' => 'Hello', 'author_id' => Author::create(['name' => 'Alice'])->id]); - Comment::create(['body' => 'Hi', 'commentable_id' => $post->id, 'commentable_type' => Post::class]); - - Post::find($post->id); // Warm up cache - - DB::enableQueryLog(); - Comment::with(['commentable' => fn($q) => $q->withTrashed()])->get(); - $queries = DB::getQueryLog(); - DB::disableQueryLog(); - - $sqlTables = array_column($queries, 'query'); - $hitDb = count(array_filter($sqlTables, fn($q) => str_contains($q, '"posts"'))) > 0; - $this->assertFalse($hitDb, 'withTrashed() macro should now use cache instead of forcing DB fallback'); - } - - public function test_morph_to_falls_back_when_per_type_constraint_set(): void - { - $post = Post::create(['title' => 'Hello', 'published' => true, 'author_id' => Author::create(['name' => 'Alice'])->id]); - Comment::create(['body' => 'Hi', 'commentable_id' => $post->id, 'commentable_type' => Post::class]); - - DB::enableQueryLog(); - $comments = Comment::with(['commentable' => fn($q) => $q->constrain([ - Post::class => fn($q) => $q->where('published', true), - ])])->get(); - $queries = DB::getQueryLog(); - DB::disableQueryLog(); - - $hitDb = count(array_filter($queries, fn($q) => str_contains($q['query'], '"posts"'))) > 0; - $this->assertTrue($hitDb, 'constrain() should force DB fallback for that type'); - $this->assertInstanceOf(Post::class, $comments->first()->commentable); - } - - public function test_morph_to_any_constraint_forces_db_fallback_via_macro_buffer(): void - { - $post = Post::create(['title' => 'Hello', 'author_id' => Author::create(['name' => 'Alice'])->id]); - Comment::create(['body' => 'Nice', 'commentable_id' => $post->id, 'commentable_type' => Post::class]); - - Post::find($post->id); // Warm up cache - - DB::enableQueryLog(); - $comments = Comment::with(['commentable' => fn($q) => $q->select('id', 'title', 'author_id')])->get(); - $queries = DB::getQueryLog(); - DB::disableQueryLog(); - - // Any constraint triggers the macroBuffer, but we now support caching it if it's a simple projection. - $postQueries = array_filter($queries, fn($q) => str_contains($q['query'], '"posts"')); - $this->assertEmpty($postQueries, 'select() constraint should now use cache instead of forcing DB fallback'); - $this->assertNotNull($comments->first()->commentable); - $this->assertSame('Hello', $comments->first()->commentable->title); - } - - // ------------------------------------------------------------------------- - // Morph map aliases - // ------------------------------------------------------------------------- - - public function test_morph_alias_uses_cache_fast_path(): void - { - // DB stores the alias ('post'), not the FQCN. The fast path must resolve - // it to Post::class via getActualClassNameForMorph() and serve from cache. - Relation::morphMap(['post' => Post::class]); - - try { - $post = Post::create(['title' => 'Hello', 'author_id' => Author::create(['name' => 'Alice'])->id]); - Comment::create(['body' => 'Hi', 'commentable_id' => $post->id, 'commentable_type' => 'post']); - - Comment::with('commentable')->get(); // cold — model payload stored under Post classKey - - DB::enableQueryLog(); - $comments = Comment::with('commentable')->get(); // warm - $queries = DB::getQueryLog(); - DB::disableQueryLog(); - - $this->assertNotNull($comments->first()->commentable); - $this->assertSame('Hello', $comments->first()->commentable->title); - - $postQueries = array_filter($queries, fn($q) => str_contains($q['query'], '"posts"')); - $this->assertEmpty($postQueries, 'Morph alias should resolve to Post::class and serve from model cache'); - } finally { - Relation::morphMap([], false); // clear the morph map - } - } - - public function test_morph_alias_invalidation_is_consistent_with_fqcn(): void - { - // When stored as an alias, invalidation still bumps the correct version key - // because the model's class drives invalidation, not the DB-stored type string. - Relation::morphMap(['post' => Post::class]); - - try { - $post = Post::create(['title' => 'Hello', 'author_id' => Author::create(['name' => 'Alice'])->id]); - Comment::create(['body' => 'Hi', 'commentable_id' => $post->id, 'commentable_type' => 'post']); - - Comment::with('commentable')->get(); // warm - - $post->update(['title' => 'Updated']); // triggers flushInstance(Post) — same key as FQCN path - - $comments = Comment::with('commentable')->get(); - $this->assertSame('Updated', $comments->first()->commentable->title); - } finally { - Relation::morphMap([], false); - } - } - - public function test_morph_to_deduplicates_ids_when_multiple_comments_share_morphable(): void - { - $post = Post::create(['title' => 'Shared', 'author_id' => Author::create(['name' => 'Alice'])->id]); - Comment::create(['body' => 'A', 'commentable_id' => $post->id, 'commentable_type' => Post::class]); - Comment::create(['body' => 'B', 'commentable_id' => $post->id, 'commentable_type' => Post::class]); - Comment::create(['body' => 'C', 'commentable_id' => $post->id, 'commentable_type' => Post::class]); - - Comment::with('commentable')->get(); - - DB::enableQueryLog(); - $comments = Comment::with('commentable')->get(); - $queries = DB::getQueryLog(); - DB::disableQueryLog(); - - foreach ($comments as $comment) { - $this->assertSame($post->id, $comment->commentable->id); - } - - $postQueries = array_filter($queries, fn($q) => str_contains($q['query'], '"posts"')); - $this->assertCount(0, $postQueries, 'Three comments pointing to same post should not trigger a DB query'); - } -} diff --git a/tests/Integration/Cache/ObserverFailureTest.php b/tests/Integration/Cache/ObserverFailureTest.php new file mode 100644 index 0000000..747b4ea --- /dev/null +++ b/tests/Integration/Cache/ObserverFailureTest.php @@ -0,0 +1,73 @@ +create(['name' => 'Author']); + } + + public function test_a_throwing_miss_listener_does_not_abort_the_query(): void + { + Event::listen(QueryCacheMiss::class, static function (): void { + throw new \RuntimeException('diagnostics exploded'); + }); + + $authors = Author::query()->toBase()->orderBy('id')->get(); + + $this->assertCount(1, $authors, 'a broken listener must not cost the caller its rows'); + } + + public function test_a_throwing_miss_listener_does_not_strand_the_build_lease(): void + { + Event::listen(QueryCacheMiss::class, static function (): void { + throw new \RuntimeException('diagnostics exploded'); + }); + + try { + Author::query()->toBase()->orderBy('id')->get(); + } catch (\Throwable) { + } + + $this->assertSame( + [], + $this->cacheKeysMatching(':build:'), + 'an owned build lease must not outlive the request that claimed it', + ); + } + + public function test_a_throwing_hit_listener_does_not_abort_the_query(): void + { + Author::query()->toBase()->orderBy('id')->get(); + + Event::listen(QueryCacheHit::class, static function (): void { + throw new \RuntimeException('diagnostics exploded'); + }); + + $authors = Author::query()->toBase()->orderBy('id')->get(); + + $this->assertCount(1, $authors); + } + + public function test_a_throwing_bypass_listener_does_not_abort_the_query(): void + { + Event::listen(QueryBypassed::class, static function (): void { + throw new \RuntimeException('diagnostics exploded'); + }); + + $authors = Author::query()->toBase()->whereRaw('1 = 1 /* opaque */')->get(); + + $this->assertCount(1, $authors); + } +} diff --git a/tests/Integration/Cache/OptimizationsTest.php b/tests/Integration/Cache/OptimizationsTest.php deleted file mode 100644 index 9994770..0000000 --- a/tests/Integration/Cache/OptimizationsTest.php +++ /dev/null @@ -1,278 +0,0 @@ - 'Fast Path Author']); - - Event::fake([QueryCacheHit::class, QueryCacheMiss::class]); - - $found = Author::where('id', $author->id)->get(); - - $this->assertCount(1, $found); - $this->assertEquals('Fast Path Author', $found->first()->name); - - Event::assertNotDispatched(QueryCacheHit::class); - Event::assertNotDispatched(QueryCacheMiss::class); - } - - public function test_fast_path_is_used_for_where_in_primary_key(): void - { - $a1 = Author::create(['name' => 'A1']); - $a2 = Author::create(['name' => 'A2']); - - Event::fake([QueryCacheHit::class, QueryCacheMiss::class]); - - $found = Author::whereIn('id', [$a1->id, $a2->id])->get(); - - $this->assertCount(2, $found); - Event::assertNotDispatched(QueryCacheHit::class); - Event::assertNotDispatched(QueryCacheMiss::class); - } - - public function test_corrupt_query_cache_payload_degrades_to_miss_and_repairs(): void - { - Author::create(['name' => 'Corruptible Author']); - - $query = Author::where('name', 'Corruptible Author'); - $query->get(); - - $hash = QueryHasher::forModelIndexQuery($query, $query->toBase()); - $classKey = app('normcache')->keys()->classKey(Author::class); - $version = app('normcache')->currentVersion(Author::class); - - $manager = app('normcache'); - $store = $manager->store(); - $fullQueryKey = $manager->keys()->prefixed("query:{$classKey}:v{$version}:{$hash}"); - Redis::connection(config('normcache.connection'))->set( - $fullQueryKey, - '{not-json' - ); - - Event::fake([QueryCacheMiss::class]); - - $found = Author::where('name', 'Corruptible Author')->get(); - - $this->assertCount(1, $found); - Event::assertDispatched(QueryCacheMiss::class); - - $raw = $store->getRaw($fullQueryKey); - $repaired = $raw !== null ? json_decode($raw, true) : null; - $this->assertSame([(string) $found->first()->id], $repaired); - } - - public function test_multi_dependency_query_corrupt_payload_degrades_to_miss_and_repairs(): void - { - $this->setClusterMode(false); - - Author::create(['name' => 'Multi Dep Author']); - - Author::query()->dependsOn([Post::class])->get(); - - $queryKey = collect($this->redisKeys('query:*'))->first(); - $this->assertNotNull($queryKey); - - Redis::connection(config('normcache.connection'))->set($queryKey, '{not-json'); - - Event::fake([QueryCacheMiss::class]); - - $found = Author::query()->dependsOn([Post::class])->get(); - - $this->assertCount(1, $found); - Event::assertDispatched(QueryCacheMiss::class); - - $store = app('normcache')->store(); - $raw = $store->getRaw($queryKey); - $repaired = $raw !== null ? json_decode($raw, true) : null; - $this->assertSame([(string) $found->first()->id], $repaired); - } - - public function test_malformed_count_cache_payload_is_recomputed_and_repaired(): void - { - Author::create(['name' => 'Alice']); - Author::create(['name' => 'Bob']); - - $this->assertSame(2, Author::count()); - - $countKey = collect($this->redisKeys('count:*'))->first(); - $this->assertNotNull($countKey); - $this->corruptResultCacheEntry($countKey); - - $queryCount = 0; - DB::listen(function () use (&$queryCount) { - $queryCount++; - }); - - $this->assertSame(2, Author::count()); - $this->assertGreaterThan(0, $queryCount, 'Malformed payload must trigger a DB recompute'); - - $queryCount = 0; - $this->assertSame(2, Author::count()); - $this->assertSame(0, $queryCount, 'Malformed entry must be repaired so the next read is a clean hit'); - } - - public function test_malformed_exists_cache_payload_is_recomputed_and_repaired(): void - { - Author::create(['name' => 'Alice']); - - $this->assertTrue(Author::exists()); - - $scalarKey = collect($this->redisKeys('scalar:*'))->first(); - $this->assertNotNull($scalarKey); - $this->corruptResultCacheEntry($scalarKey); - - $queryCount = 0; - DB::listen(function () use (&$queryCount) { - $queryCount++; - }); - - $this->assertTrue(Author::exists()); - $this->assertGreaterThan(0, $queryCount, 'Malformed payload must trigger a DB recompute'); - - $queryCount = 0; - $this->assertTrue(Author::exists()); - $this->assertSame(0, $queryCount, 'Malformed entry must be repaired so the next read is a clean hit'); - } - - public function test_malformed_scalar_cache_payload_is_recomputed_and_repaired(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id, 'views' => 10]); - - $this->assertSame(10, Post::sum('views')); - - $scalarKey = collect($this->redisKeys('scalar:*'))->first(); - $this->assertNotNull($scalarKey); - $this->corruptResultCacheEntry($scalarKey); - - $queryCount = 0; - DB::listen(function () use (&$queryCount) { - $queryCount++; - }); - - $this->assertSame(10, Post::sum('views')); - $this->assertGreaterThan(0, $queryCount, 'Malformed payload must trigger a DB recompute'); - - $queryCount = 0; - $this->assertSame(10, Post::sum('views')); - $this->assertSame(0, $queryCount, 'Malformed entry must be repaired so the next read is a clean hit'); - } - - // Overwrites a result-cache entry with a serialized [] — the wrong shape for any scalar/count cache. - private function corruptResultCacheEntry(string $key): void - { - $serialized = $this->cacheManager()->store()->serialize([]); - Redis::connection('normcache-test')->set($key, $serialized); - } - - public function test_large_id_list_round_trips_correctly(): void - { - $names = []; - for ($i = 0; $i < 1200; $i++) { - $names[] = ['name' => "Bulk Author {$i}"]; - } - Author::insert($names); - - $cold = Author::orderBy('id')->get(); - $warm = Author::orderBy('id')->get(); - - $this->assertCount(1200, $cold); - $this->assertSame( - $cold->pluck('id')->all(), - $warm->pluck('id')->all() - ); - $this->assertSame( - $cold->pluck('name')->all(), - $warm->pluck('name')->all() - ); - } - - public function test_fast_path_is_used_for_single_primary_key_lookup_with_order_by(): void - { - $author = Author::create(['name' => 'Order Author']); - - Event::fake([QueryCacheHit::class, QueryCacheMiss::class]); - - $found = Author::where('id', $author->id)->orderBy('id')->get(); - - $this->assertCount(1, $found); - Event::assertNotDispatched(QueryCacheHit::class); - Event::assertNotDispatched(QueryCacheMiss::class); - } - - public function test_fast_path_is_used_for_single_primary_key_lookup_with_raw_order_by(): void - { - $author = Author::create(['name' => 'Raw Order Author']); - - Event::fake([QueryBypassed::class, QueryCacheHit::class, QueryCacheMiss::class]); - - $found = Author::where('id', $author->id) - ->orderByRaw('CASE WHEN id = ? THEN 0 ELSE 1 END', [$author->id]) - ->get(); - - $this->assertCount(1, $found); - Event::assertNotDispatched(QueryBypassed::class); - Event::assertNotDispatched(QueryCacheHit::class); - Event::assertNotDispatched(QueryCacheMiss::class); - } - - public function test_fast_path_skips_where_in_with_order_by(): void - { - Author::create(['name' => 'Order A']); - Author::create(['name' => 'Order B']); - - Event::fake([QueryCacheHit::class, QueryCacheMiss::class]); - - Author::whereIn('id', [1, 2])->orderBy('id')->get(); - - Event::assertDispatched(QueryCacheMiss::class); - } - - public function test_belongs_to_remains_fast_path(): void - { - $author = Author::create(['name' => 'Alice']); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - // Eager load belongsTo - $p = Post::with('author')->first(); - $this->assertTrue($p->relationLoaded('author')); - - // Verify no query for author on second load - DB::enableQueryLog(); - Post::with('author')->get(); - $queries = DB::getQueryLog(); - DB::disableQueryLog(); - - // One query for posts, none for authors (because of fast path + cache) - $this->assertCount(1, $queries); - $this->assertStringContainsString('from "posts"', $queries[0]['query']); - } - - public function test_where_key_ignores_fast_path_when_extra_dependencies_exist(): void - { - $a1 = Author::create(['name' => 'Alice']); - - $builder = Author::whereKey($a1->id)->dependsOn([Post::class]); - $builder->get(); - - $this->assertNotEmpty($this->redisKeys('query:*')); - } -} diff --git a/tests/Integration/Cache/PivotCacheTest.php b/tests/Integration/Cache/PivotCacheTest.php deleted file mode 100644 index cf5d22a..0000000 --- a/tests/Integration/Cache/PivotCacheTest.php +++ /dev/null @@ -1,677 +0,0 @@ - 'Alice']); - $tag = Tag::create(['name' => 'Fiction']); - - $versionBefore = NormCache::currentTableVersion($author->getConnection()->getName(), 'author_tag'); - - $author->tags()->attach($tag->id); - - $this->assertGreaterThan($versionBefore, NormCache::currentTableVersion($author->getConnection()->getName(), 'author_tag')); - } - - public function test_belongs_to_many_detach_invalidates_pivot_table(): void - { - $author = Author::create(['name' => 'Alice']); - $tag = Tag::create(['name' => 'Fiction']); - $author->tags()->attach($tag->id); - - $versionBefore = NormCache::currentTableVersion($author->getConnection()->getName(), 'author_tag'); - - $author->tags()->detach($tag->id); - - $this->assertGreaterThan($versionBefore, NormCache::currentTableVersion($author->getConnection()->getName(), 'author_tag')); - } - - public function test_belongs_to_many_sync_invalidates_once(): void - { - $author = Author::create(['name' => 'Alice']); - $tag1 = Tag::create(['name' => 'Fiction']); - $tag2 = Tag::create(['name' => 'Drama']); - - $versionBefore = NormCache::currentTableVersion($author->getConnection()->getName(), 'author_tag'); - - $author->tags()->sync([$tag1->id, $tag2->id]); - - $this->assertSame( - $versionBefore + 1, - NormCache::currentTableVersion($author->getConnection()->getName(), 'author_tag') - ); - } - - public function test_belongs_to_many_detach_without_changes_does_not_invalidate(): void - { - $author = Author::create(['name' => 'Alice']); - $tag = Tag::create(['name' => 'Fiction']); - $versionBefore = NormCache::currentTableVersion($author->getConnection()->getName(), 'author_tag'); - - $author->tags()->detach($tag->id); - - $this->assertSame( - $versionBefore, - NormCache::currentTableVersion($author->getConnection()->getName(), 'author_tag'), - ); - } - - public function test_belongs_to_many_sync_without_changes_does_not_invalidate(): void - { - $author = Author::create(['name' => 'Alice']); - $tag = Tag::create(['name' => 'Fiction']); - $author->tags()->attach($tag->id); - $versionBefore = NormCache::currentTableVersion($author->getConnection()->getName(), 'author_tag'); - - $author->tags()->sync([$tag->id]); - - $this->assertSame( - $versionBefore, - NormCache::currentTableVersion($author->getConnection()->getName(), 'author_tag'), - ); - } - - public function test_belongs_to_many_update_existing_pivot_invalidates_cache(): void - { - $author = Author::create(['name' => 'Alice']); - $tag = Tag::create(['name' => 'Fiction']); - $author->tags()->attach($tag->id); - - $versionBefore = NormCache::currentTableVersion($author->getConnection()->getName(), 'author_tag'); - - $author->tags()->updateExistingPivot($tag->id, ['notes' => 'updated']); - - $this->assertGreaterThan($versionBefore, NormCache::currentTableVersion($author->getConnection()->getName(), 'author_tag')); - } - - public function test_morph_to_many_attach_invalidates_pivot_table(): void - { - $author = Author::create(['name' => 'Alice']); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id]); - $tag = Tag::create(['name' => 'Fiction']); - - $versionBefore = NormCache::currentTableVersion($post->getConnection()->getName(), 'taggables'); - - $post->tags()->attach($tag->id); - - $this->assertGreaterThan($versionBefore, NormCache::currentTableVersion($post->getConnection()->getName(), 'taggables')); - } - - public function test_eager_load_populates_pivot_cache(): void - { - $author = Author::create(['name' => 'Alice']); - $tag = Tag::create(['name' => 'Fiction']); - $author->tags()->attach($tag->id); - - Author::with('tags')->get(); - - $this->assertNotEmpty($this->redisKeys('pivot:*')); - } - - public function test_belongs_to_many_warm_hit_zero_sql(): void - { - $author = Author::create(['name' => 'Alice']); - $tag = Tag::create(['name' => 'Fiction']); - $author->tags()->attach($tag->id); - - Author::with('tags')->get(); - - DB::enableQueryLog(); - Author::with('tags')->get(); - $queries = DB::getQueryLog(); - DB::disableQueryLog(); - - $this->assertEmpty($queries); - } - - public function test_pivot_eager_load_falls_back_to_database_when_build_lock_is_held_elsewhere(): void - { - $author = Author::create(['name' => 'Alice']); - $tag = Tag::create(['name' => 'Fiction']); - $author->tags()->attach($tag->id); - - $constraintHash = $this->callConstraintHash($author->tags()); - $pivotTableKey = NormCache::keys()->tableKey($author->getConnection()->getName(), 'author_tag'); - - // Claim the same build lock another concurrent request would, before the eager load runs. - $claimed = NormCache::relationIndexes()->fetchPivot(Author::class, Tag::class, 'tags', [$author->id], $constraintHash, $pivotTableKey); - $this->assertNotNull($claimed->build->buildingKey); - - DB::enableQueryLog(); - $authors = Author::with('tags')->get(); - $queries = DB::getQueryLog(); - DB::disableQueryLog(); - - $pivotQueries = array_filter($queries, fn($q) => str_contains($q['query'], 'author_tag')); - $this->assertCount(1, $pivotQueries, 'Should fall back to a single direct DB query for the pivot relation while its build lock is held elsewhere'); - $this->assertSame(['Fiction'], $authors->first()->tags->pluck('name')->all()); - $this->assertSame( - $claimed->build->buildingToken, - NormCache::store()->getRaw($claimed->build->buildingKey), - 'The foreign build lock must remain untouched' - ); - } - - public function test_empty_relationship_is_cached(): void - { - Author::create(['name' => 'Alice']); - - Author::with('tags')->get(); - - $this->assertNotEmpty($this->redisKeys('pivot:*')); - - $authors = Author::with('tags')->get(); - - $this->assertCount(0, $authors->first()->tags); - } - - public function test_morph_to_many_warm_hit_zero_sql(): void - { - $author = Author::create(['name' => 'Alice']); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id]); - $tag = Tag::create(['name' => 'Fiction']); - $post->tags()->attach($tag->id); - - Post::with('tags')->get(); - - DB::enableQueryLog(); - Post::with('tags')->get(); - $queries = DB::getQueryLog(); - DB::disableQueryLog(); - - $this->assertEmpty($queries); - } - - public function test_pivot_attributes_are_preserved_on_warm_hit(): void - { - $author = Author::create(['name' => 'Alice']); - $tag = Tag::create(['name' => 'Fiction']); - $author->tags()->attach($tag->id); - - Author::with('tags')->get(); - $authors = Author::with('tags')->get(); - - $pivot = $authors->first()->tags->first()->pivot; - $this->assertSame($author->id, $pivot->author_id); - $this->assertSame($tag->id, $pivot->tag_id); - } - - public function test_pivot_fk_columns_are_not_stored_in_related_model_cache(): void - { - $author = Author::create(['name' => 'Alice']); - $tag = Tag::create(['name' => 'Fiction']); - $author->tags()->attach($tag->id); - - Author::with('tags')->get(); - - $cached = $this->modelCacheEntry(Tag::class, $tag->id); - - $this->assertIsArray($cached); - $this->assertArrayNotHasKey('pivot_author_id', $cached); - $this->assertArrayNotHasKey('pivot_tag_id', $cached); - } - - public function test_pivot_extra_columns_are_not_stored_in_related_model_cache(): void - { - $author = Author::create(['name' => 'Alice']); - $tag = Tag::create(['name' => 'Fiction']); - $author->tags()->attach($tag->id, ['notes' => 'important']); - - Author::with(['tags' => fn($q) => $q->withPivot('notes')])->get(); - - $cached = $this->modelCacheEntry(Tag::class, $tag->id); - - $this->assertIsArray($cached); - $this->assertArrayNotHasKey('pivot_notes', $cached); - } - - public function test_tag_served_from_model_cache_has_no_pivot_attributes(): void - { - $author = Author::create(['name' => 'Alice']); - $tag = Tag::create(['name' => 'Fiction']); - $author->tags()->attach($tag->id); - - Author::with('tags')->get(); - - $fetched = Tag::find($tag->id); - - $this->assertNotNull($fetched); - $this->assertArrayNotHasKey('pivot_author_id', $fetched->getRawOriginal()); - $this->assertArrayNotHasKey('pivot_tag_id', $fetched->getRawOriginal()); - } - - public function test_parent_version_bump_does_not_invalidate_pivot_membership_cache(): void - { - $author = Author::create(['name' => 'Alice']); - $tag = Tag::create(['name' => 'Fiction']); - $author->tags()->attach($tag->id); - - $author->tags()->get(); - - $keyCountAfterWarm = count($this->redisKeys('pivot:*')); - - $author->update(['name' => 'Alice Updated']); - - DB::enableQueryLog(); - $tags = $author->tags()->get(); - $queries = DB::getQueryLog(); - DB::disableQueryLog(); - - $this->assertEmpty($queries); - $this->assertSame($keyCountAfterWarm, count($this->redisKeys('pivot:*'))); - $this->assertSame(['Fiction'], $tags->pluck('name')->all()); - } - - public function test_pivot_warm_hit_runs_after_query_callbacks(): void - { - $author = Author::create(['name' => 'Alice']); - $tag = Tag::create(['name' => 'Fiction']); - $author->tags()->attach($tag->id); - - $count = 0; - - Author::with(['tags' => function ($query) use (&$count) { - $query->afterQuery(function () use (&$count) { - $count++; - }); - }])->get(); - - Author::with(['tags' => function ($query) use (&$count) { - $query->afterQuery(function () use (&$count) { - $count++; - }); - }])->get(); - - $this->assertSame(2, $count); - } - - public function test_pivot_warm_hit_replays_nested_eager_loads(): void - { - $author = Author::create(['name' => 'Alice']); - $tag = Tag::create(['name' => 'Fiction']); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id]); - $tag->posts()->attach($post->id); - $author->tags()->attach($tag->id); - - Author::with('tags.posts')->get(); - $authors = Author::with('tags.posts')->get(); - - $fetchedTag = $authors->first()->tags->first(); - - $this->assertTrue($fetchedTag->relationLoaded('posts')); - $this->assertSame([$post->id], $fetchedTag->posts->modelKeys()); - } - - public function test_pivot_cache_constrained_eager_load_does_not_collide_with_unconstrained(): void - { - $author = Author::create(['name' => 'Alice']); - $tag1 = Tag::create(['name' => 'Fiction']); - $tag2 = Tag::create(['name' => 'Drama']); - $author->tags()->attach($tag1->id, ['notes' => 'special']); - $author->tags()->attach($tag2->id); - - Author::with(['tags' => fn($q) => $q->wherePivot('notes', 'special')])->get(); - - $tags = Author::with('tags')->get()->first()->tags; - - $this->assertCount(2, $tags); - } - - public function test_pivot_relation_with_extra_join_delegates_to_eloquent(): void - { - $author = Author::create(['name' => 'Alice']); - $tag = Tag::create(['name' => 'Fiction']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - $author->tags()->attach($tag->id); - - $tags = $author->tags() - ->join('posts', 'posts.author_id', '=', 'author_tag.author_id') - ->get(); - - $this->assertSame([$tag->id], $tags->modelKeys()); - $this->assertEmpty($this->redisKeys('pivot:*')); - } - - public function test_pivot_cache_ordered_eager_loads_do_not_collide(): void - { - $author = Author::create(['name' => 'Alice']); - $fiction = Tag::create(['name' => 'Fiction']); - $drama = Tag::create(['name' => 'Drama']); - $author->tags()->attach([$fiction->id, $drama->id]); - - Author::with(['tags' => fn($q) => $q->orderBy('tags.name')])->get(); - - $tags = Author::with(['tags' => fn($q) => $q->orderByDesc('tags.name')]) - ->get() - ->first() - ->tags - ->pluck('name') - ->values() - ->all(); - - $this->assertSame(['Fiction', 'Drama'], $tags); - } - - public function test_constraint_hash_changes_when_join_distinct_or_lock_added(): void - { - $author = Author::create(['name' => 'Alice']); - - $base = $this->callConstraintHash($author->tags()); - - $distinct = $author->tags(); - $distinct->getQuery()->distinct(); - $this->assertNotSame($base, $this->callConstraintHash($distinct)); - - $lock = $author->tags(); - $lock->getQuery()->lockForUpdate(); - $this->assertNotSame($base, $this->callConstraintHash($lock)); - - $join = $author->tags(); - $join->getQuery()->join('author_tag as at2', 'at2.tag_id', '=', 'tags.id'); - $this->assertNotSame($base, $this->callConstraintHash($join)); - } - - public function test_constraint_hash_distinguishes_nested_closure_wheres(): void - { - $author = Author::create(['name' => 'Alice']); - - $fiction = $author->tags(); - $fiction->getQuery()->where(function ($query) { - $query->where('tags.name', 'Fiction'); - }); - - $drama = $author->tags(); - $drama->getQuery()->where(function ($query) { - $query->where('tags.name', 'Drama'); - }); - - $this->assertNotSame( - $this->callConstraintHash($fiction), - $this->callConstraintHash($drama) - ); - } - - public function test_pivot_eager_load_different_batch_sizes_reuse_same_parent_cache(): void - { - $alice = Author::create(['name' => 'Alice']); - $bob = Author::create(['name' => 'Bob']); - $fiction = Tag::create(['name' => 'Fiction']); - $alice->tags()->attach($fiction->id); - $bob->tags()->attach($fiction->id); - - // Warm pivot cache for batch [alice, bob] - Author::with('tags')->get(); - - // Second load for batch [alice] only should be a full cache hit (zero queries) - DB::enableQueryLog(); - Author::with('tags')->whereKey($alice->id)->get(); - $queries = DB::getQueryLog(); - DB::disableQueryLog(); - - $this->assertEmpty($queries); - } - - public function test_pivot_user_where_constraints_with_different_bindings_hash_differently(): void - { - $author = Author::create(['name' => 'Alice']); - - $phpRelation = $author->tags(); - $phpRelation->getQuery()->where('tags.name', 'php'); - - $laravelRelation = $author->tags(); - $laravelRelation->getQuery()->where('tags.name', 'laravel'); - - $this->assertNotSame( - $this->callConstraintHash($phpRelation), - $this->callConstraintHash($laravelRelation) - ); - } - - public function test_pivot_constraint_hash_normalizes_raw_order_group_and_having_shapes(): void - { - $author = Author::create(['name' => 'Alice']); - - $lowerRelation = $author->tags(); - $lowerRelation->getQuery() - ->select('tags.*') - ->groupBy('tags.id') - ->havingRaw('COUNT(*) > ?', [0]) - ->orderByRaw('LOWER(tags.name)'); - - $upperRelation = $author->tags(); - $upperRelation->getQuery() - ->select('tags.*') - ->groupBy('tags.id') - ->havingRaw('COUNT(*) > ?', [1]) - ->orderByRaw('UPPER(tags.name)'); - - $this->assertNotSame( - $this->callConstraintHash($lowerRelation), - $this->callConstraintHash($upperRelation) - ); - } - - public function test_pivot_constraint_hash_is_stable_across_eager_batch_sizes(): void - { - $author = Author::create(['name' => 'Alice']); - - // Simulate constraint hash as seen in batch [1, 2] vs batch [1] - $relationBatchTwo = $author->tags(); - $relationBatchTwo->addEagerConstraints([ - Author::create(['name' => 'Bob']), - $author, - ]); - - $relationBatchOne = $author->tags(); - $relationBatchOne->addEagerConstraints([$author]); - - $this->assertSame( - $this->callConstraintHash($relationBatchOne), - $this->callConstraintHash($relationBatchTwo) - ); - } - - public function test_pivot_constraint_hash_distinguishes_nested_where_bindings(): void - { - $author = Author::create(['name' => 'Alice']); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - $php = Tag::create(['name' => 'php']); - $laravel = Tag::create(['name' => 'laravel']); - - $post->tags()->attach([$php->id, $laravel->id]); - - $first = $post->tags() - ->where(fn($q) => $q->where('tags.name', 'php')) - ->get() - ->pluck('name') - ->all(); - - $second = $post->tags() - ->where(fn($q) => $q->where('tags.name', 'laravel')) - ->get() - ->pluck('name') - ->all(); - - $this->assertSame(['php'], $first); - $this->assertSame(['laravel'], $second); - } - - public function test_pivot_constraint_hash_distinguishes_raw_order_expressions(): void - { - $author = Author::create(['name' => 'Alice']); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - $alpha = Tag::create(['name' => 'alpha']); - $beta = Tag::create(['name' => 'beta']); - - $post->tags()->attach([$alpha->id, $beta->id]); - - $ascending = $post->tags() - ->orderByRaw('LOWER(tags.name) ASC') - ->get() - ->pluck('name') - ->all(); - - $descending = $post->tags() - ->orderByRaw('LOWER(tags.name) DESC') - ->get() - ->pluck('name') - ->all(); - - $this->assertSame(['alpha', 'beta'], $ascending); - $this->assertSame(['beta', 'alpha'], $descending); - } - - /** Hashes via the same public path CachesPivotRelation::get() uses, instead of reaching into a private method. */ - private function callConstraintHash(object $relation): string - { - $prepared = $relation->prepareScopedQuery(); - $prepared->applyBeforeCallbacks(); - - return QueryHasher::forRelationQuery($relation->getQualifiedForeignPivotKeyName(), $prepared->base); - } - - public function test_pivot_cache_used_when_projection_is_table_wildcard(): void - { - $author = Author::create(['name' => 'Alice']); - $tag = Tag::create(['name' => 'Fiction']); - $author->tags()->attach($tag->id); - - Author::with(['tags' => fn($q) => $q->select('tags.*')])->get(); - - DB::enableQueryLog(); - $tags = Author::with(['tags' => fn($q) => $q->select('tags.*')])->get()->first()->tags; - $queries = DB::getQueryLog(); - DB::disableQueryLog(); - - $this->assertSame([$tag->id], $tags->modelKeys()); - $this->assertNotEmpty($this->redisKeys('pivot:*'), 'pivot cache should be used for table.* projection'); - $this->assertEmpty(array_filter($queries, fn($q) => str_contains($q['query'], 'author_tag')), 'pivot query should be cached'); - } - - public function test_empty_parent_has_relation_loaded_on_warm_hit(): void - { - $alice = Author::create(['name' => 'Alice']); - $bob = Author::create(['name' => 'Bob']); - $tag = Tag::create(['name' => 'Fiction']); - $alice->tags()->attach($tag->id); - - // Warm: both authors loaded; Bob has no tags. - Author::with('tags')->get(); - - DB::enableQueryLog(); - $authors = Author::with('tags')->get(); - $queries = DB::getQueryLog(); - DB::disableQueryLog(); - - $this->assertEmpty($queries, 'warm hit should issue no SQL'); - - $bobLoaded = $authors->firstWhere('name', 'Bob'); - $this->assertTrue($bobLoaded->relationLoaded('tags'), 'empty-parent relation must be marked loaded'); - $this->assertCount(0, $bobLoaded->tags); - - // Accessing the relation must not trigger a lazy load. - DB::enableQueryLog(); - $_ = $bobLoaded->tags->all(); - $lazyQueries = DB::getQueryLog(); - DB::disableQueryLog(); - - $this->assertEmpty($lazyQueries, 'accessing loaded empty relation must not issue SQL'); - } - - public function test_belongs_to_many_remains_fast_path(): void - { - $author = Author::create(['name' => 'Alice']); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id]); - $tag = Tag::create(['name' => 'PHP']); - $post->tags()->attach($tag); - - // Eager load belongsToMany - first call warms cache - Post::with('tags')->get(); - - // Verify total cache hit on second load - DB::enableQueryLog(); - Post::with('tags')->get(); - $queries = DB::getQueryLog(); - DB::disableQueryLog(); - - $this->assertEmpty($queries, 'Should hit total query cache'); - } - - public function test_pivot_table_wildcard_plus_extra_column_does_not_pollute_model_cache(): void - { - $author = Author::create(['name' => 'Alice']); - $tag = Tag::create(['name' => 'Fiction']); - $author->tags()->attach($tag->id); - - // Query with table.* AND a computed column - $author->tags()->select('tags.*')->selectRaw('1 as polluted')->get(); - - // The model cache should NOT contain 'polluted' - $cached = NormCache::modelCache()->getModels([$tag->id], Tag::class); - $this->assertArrayNotHasKey('polluted', collect($cached)->first()->getRawOriginal()); - } - - public function test_corrupt_pivot_cache_entries_are_treated_as_miss(): void - { - $author = Author::create(['name' => 'Alice']); - $tag = Tag::create(['name' => 'Fiction']); - $author->tags()->attach($tag->id); - - Author::with('tags')->get(); // warm pivot cache - - $keys = $this->redisKeys('pivot:*'); - $this->assertNotEmpty($keys); - $pivotKey = $keys[0]; - - Redis::connection('normcache-test')->set($pivotKey, 'CORRUPT'); - - $authors = Author::with('tags')->get(); - - $this->assertCount(1, $authors); - $this->assertCount(1, $authors->first()->tags); - $this->assertSame('Fiction', $authors->first()->tags->first()->name); - } - - public function test_relation_cache_preserves_wildcard_plus_alias_projection(): void - { - $author = Author::create(['name' => 'Alice']); - $tag = Tag::create(['name' => 'php']); - $author->tags()->attach($tag->id); - - Author::with(['tags' => fn($q) => $q->select('tags.*', 'tags.name as tag_label')])->first(); - $result = Author::with(['tags' => fn($q) => $q->select('tags.*', 'tags.name as tag_label')])->first(); - - $this->assertSame('php', $result->tags->first()->name); - $this->assertSame('php', $result->tags->first()->tag_label); - } - - public function test_pivot_with_explicit_dependencies_bypasses_pivot_cache(): void - { - $author = Author::create(['name' => 'Alice']); - $tag = Tag::create(['name' => 'php']); - $author->tags()->attach($tag->id); - - $author->tags()->dependsOn([Post::class])->get(); - - $this->assertEmpty($this->redisKeys('pivot:*')); - } -} diff --git a/tests/Integration/Cache/PivotStampedeTest.php b/tests/Integration/Cache/PivotStampedeTest.php deleted file mode 100644 index 09e8b1f..0000000 --- a/tests/Integration/Cache/PivotStampedeTest.php +++ /dev/null @@ -1,73 +0,0 @@ -buildManager(buildingLockTtl: 5, stampedeWaitMs: 50); - $author = Author::create(['name' => 'Alice']); - Tag::create(['name' => 'Fiction']); - $pivotTableKey = $manager->keys()->tableKey($author->getConnection()->getName(), 'author_tag'); - - $first = $manager->relationIndexes()->fetchPivot(Author::class, Tag::class, 'tags', [$author->id], 'nc', $pivotTableKey); - $second = $manager->relationIndexes()->fetchPivot(Author::class, Tag::class, 'tags', [$author->id], 'nc', $pivotTableKey); - - $this->assertSame(CacheStatus::Miss, $first->status); - $this->assertNotNull($first->build->buildingKey); - $this->assertSame(CacheStatus::Building, $second->status); - } - - public function test_pivot_build_lock_is_released_after_store_and_visible_to_next_fetch(): void - { - $manager = $this->buildManager(buildingLockTtl: 5, stampedeWaitMs: 50); - $author = Author::create(['name' => 'Alice']); - $tag = Tag::create(['name' => 'Fiction']); - $pivotTableKey = $manager->keys()->tableKey($author->getConnection()->getName(), 'author_tag'); - - $miss = $manager->relationIndexes()->fetchPivot(Author::class, Tag::class, 'tags', [$author->id], 'nc', $pivotTableKey); - $this->assertSame(CacheStatus::Miss, $miss->status); - - $keys = $manager->keys(); - $pivotKey = $keys->pivotKey($keys->classKey(Author::class), $keys->classKey(Tag::class), 'tags', 'nc', $miss->seg, $author->id); - - $manager->relationIndexes()->storePivotEntries( - [$pivotKey => [['id' => $tag->id, 'pivot' => ['author_id' => $author->id, 'tag_id' => $tag->id]]]], - null, - $miss->build, - Tag::class, - ); - - $this->assertNull($manager->store()->getRaw($miss->build->buildingKey), 'Building lock must be released after store'); - - $hit = $manager->relationIndexes()->fetchPivot(Author::class, Tag::class, 'tags', [$author->id], 'nc', $pivotTableKey); - $this->assertSame(CacheStatus::Hit, $hit->status); - $this->assertSame([$tag->id], array_column($hit->data[$author->id], 'id')); - } - - public function test_pivot_falls_back_to_database_without_releasing_someone_elses_lock(): void - { - $manager = $this->buildManager(buildingLockTtl: 5, stampedeWaitMs: 50); - $author = Author::create(['name' => 'Bob']); - Tag::create(['name' => 'Fiction']); - $pivotTableKey = $manager->keys()->tableKey($author->getConnection()->getName(), 'author_tag'); - - $miss = $manager->relationIndexes()->fetchPivot(Author::class, Tag::class, 'tags', [$author->id], 'nc', $pivotTableKey); - $this->assertSame(CacheStatus::Miss, $miss->status); - $store = $manager->store(); - - $store->delete($miss->build->buildingKey); - $this->assertTrue($store->setNxEx($miss->build->buildingKey, 'other-token', 5)); - - $waited = $manager->relationIndexes()->waitForPivotBuild(Author::class, Tag::class, 'tags', [$author->id], 'nc', $pivotTableKey); - - $this->assertNull($waited); - $this->assertSame('other-token', $store->getRaw($miss->build->buildingKey)); - } -} diff --git a/tests/Integration/Cache/ProjectionBypassTest.php b/tests/Integration/Cache/ProjectionBypassTest.php deleted file mode 100644 index 64988c7..0000000 --- a/tests/Integration/Cache/ProjectionBypassTest.php +++ /dev/null @@ -1,189 +0,0 @@ - 'Alice']); - - Author::find($author->id); // warm full model cache - - $cached = Author::whereKey($author->id)->select('name')->first(); - $native = Author::withoutCache()->whereKey($author->id)->select('name')->first(); - - $this->assertSame(array_keys($native->getAttributes()), array_keys($cached->getAttributes())); - $this->assertArrayHasKey('name', $cached->getAttributes()); - $this->assertArrayNotHasKey('id', $cached->getAttributes()); - } - - public function test_direct_where_in_lookup_preserves_select_projection(): void - { - $a = Author::create(['name' => 'Alice']); - $b = Author::create(['name' => 'Bob']); - - Author::find($a->id); - Author::find($b->id); - - $cached = Author::whereIn('id', [$a->id, $b->id])->select('name')->get(); - $native = Author::withoutCache()->whereIn('id', [$a->id, $b->id])->select('name')->get(); - - $this->assertSame( - $native->map->getAttributes()->all(), - $cached->map->getAttributes()->all() - ); - } - - // ── BelongsTo ──────────────────────────────────────────────────────────── - - public function test_belongs_to_bypasses_when_owner_key_absent_from_projection(): void - { - $alice = Author::create(['name' => 'Alice']); - Post::create(['title' => 'P1', 'author_id' => $alice->id]); - - $native = Post::withoutCache()->with(['author' => fn($q) => $q->select('name')])->get() - ->map(fn($p) => $p->author?->name)->all(); - - $result = Post::with(['author' => fn($q) => $q->select('name')])->get() - ->map(fn($p) => $p->author?->name)->all(); - - $this->assertSame($native, $result); - $this->assertEmpty($this->redisKeys('query:testing:authors:*'), 'bypassed BelongsTo must not write query cache'); - } - - public function test_belongs_to_bypasses_when_owner_key_is_aliased(): void - { - $alice = Author::create(['name' => 'Alice']); - Post::create(['title' => 'P1', 'author_id' => $alice->id]); - - // 'authors.id as author_id' aliases the PK — normalizeProjection maps it to output key - // 'author_id', not 'id', so the owner-key check fails and the cache path is bypassed. - $native = Post::withoutCache() - ->with(['author' => fn($q) => $q->select('authors.id as author_id', 'name')]) - ->get()->map(fn($p) => $p->author?->name)->all(); - - $result = Post::with(['author' => fn($q) => $q->select('authors.id as author_id', 'name')]) - ->get()->map(fn($p) => $p->author?->name)->all(); - - $this->assertSame($native, $result); - $this->assertEmpty($this->redisKeys('query:testing:authors:*')); - } - - // ── BelongsToMany ───────────────────────────────────────────────────────── - - public function test_pivot_bypasses_when_related_pk_absent_from_projection(): void - { - $alice = Author::create(['name' => 'Alice']); - $php = Tag::create(['name' => 'php']); - $alice->tags()->attach($php->id); - - $native = Author::withoutCache() - ->with(['tags' => fn($q) => $q->select('name')])->get() - ->map(fn($a) => $a->tags->pluck('name')->all())->all(); - - $result = Author::with(['tags' => fn($q) => $q->select('name')])->get() - ->map(fn($a) => $a->tags->pluck('name')->all())->all(); - - $this->assertSame($native, $result); - $this->assertEmpty($this->redisKeys('pivot:*'), 'bypassed pivot must not write pivot cache'); - } - - public function test_pivot_uses_cache_when_qualified_related_pk_present(): void - { - $alice = Author::create(['name' => 'Alice']); - $php = Tag::create(['name' => 'php']); - $alice->tags()->attach($php->id); - - $native = Author::withoutCache() - ->with(['tags' => fn($q) => $q->select('tags.id', 'name')])->get() - ->map(fn($a) => $a->tags->pluck('name')->all())->all(); - - $cold = Author::with(['tags' => fn($q) => $q->select('tags.id', 'name')])->get() - ->map(fn($a) => $a->tags->pluck('name')->all())->all(); - - $this->assertNotEmpty($this->redisKeys('pivot:*'), 'qualified PK projection must populate pivot cache'); - $this->assertSame($native, $cold); - - $warm = Author::with(['tags' => fn($q) => $q->select('tags.id', 'name')])->get() - ->map(fn($a) => $a->tags->pluck('name')->all())->all(); - - $this->assertSame($cold, $warm); - } - - // ── HasManyThrough ─────────────────────────────────────────────────────── - - public function test_through_bypasses_when_related_pk_absent_from_projection(): void - { - $country = Country::create(['name' => 'UK']); - $alice = Author::create(['name' => 'Alice', 'country_id' => $country->id]); - Post::create(['title' => 'P1', 'author_id' => $alice->id]); - - $native = Country::withoutCache() - ->with(['posts' => fn($q) => $q->select('posts.title')])->get() - ->map(fn($c) => $c->posts->pluck('title')->all())->all(); - - $result = Country::with(['posts' => fn($q) => $q->select('posts.title')])->get() - ->map(fn($c) => $c->posts->pluck('title')->all())->all(); - - $this->assertSame($native, $result); - $this->assertEmpty($this->redisKeys('through:*'), 'bypassed through must not write through cache'); - } - - public function test_through_uses_cache_when_qualified_related_pk_present(): void - { - $country = Country::create(['name' => 'UK']); - $alice = Author::create(['name' => 'Alice', 'country_id' => $country->id]); - Post::create(['title' => 'P1', 'author_id' => $alice->id]); - - $native = Country::withoutCache() - ->with(['posts' => fn($q) => $q->select('posts.id', 'posts.title')])->get() - ->map(fn($c) => $c->posts->pluck('title')->sort()->values()->all())->all(); - - $cold = Country::with(['posts' => fn($q) => $q->select('posts.id', 'posts.title')])->get() - ->map(fn($c) => $c->posts->pluck('title')->sort()->values()->all())->all(); - - $this->assertNotEmpty($this->redisKeys('through:*'), 'qualified PK projection must populate through cache'); - $this->assertSame($native, $cold); - - $warm = Country::with(['posts' => fn($q) => $q->select('posts.id', 'posts.title')])->get() - ->map(fn($c) => $c->posts->pluck('title')->sort()->values()->all())->all(); - - $this->assertSame($cold, $warm); - } - - public function test_through_uses_cache_when_projection_is_table_wildcard(): void - { - $country = Country::create(['name' => 'UK']); - $alice = Author::create(['name' => 'Alice', 'country_id' => $country->id]); - Post::create(['title' => 'P1', 'author_id' => $alice->id]); - - $native = Country::withoutCache() - ->with(['posts' => fn($q) => $q->select('posts.*')])->get() - ->map(fn($c) => $c->posts->pluck('title')->sort()->values()->all())->all(); - - $cold = Country::with(['posts' => fn($q) => $q->select('posts.*')])->get() - ->map(fn($c) => $c->posts->pluck('title')->sort()->values()->all())->all(); - - $this->assertNotEmpty($this->redisKeys('through:*'), 'table.* projection must populate the through-relation cache'); - $this->assertSame($native, $cold); - - $warm = Country::with(['posts' => fn($q) => $q->select('posts.*')])->get() - ->map(fn($c) => $c->posts->pluck('title')->sort()->values()->all())->all(); - - $this->assertSame($cold, $warm); - } -} diff --git a/tests/Integration/Cache/ProjectionFallbackTest.php b/tests/Integration/Cache/ProjectionFallbackTest.php new file mode 100644 index 0000000..3e9374d --- /dev/null +++ b/tests/Integration/Cache/ProjectionFallbackTest.php @@ -0,0 +1,392 @@ +create(['name' => 'Author']); + $this->postId = (int) RawPost::query()->toBase()->insertGetId([ + 'title' => 'Canonical', + 'views' => 10, + 'published' => true, + 'author_id' => $author->getKey(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + public function test_falls_back_to_warm_row_cache_without_db_query(): void + { + RawPost::query()->toBase()->orderBy('id')->get(); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $row = RawPost::query()->toBase()->where('id', $this->postId)->select('title')->first(); + DB::disableQueryLog(); + + $this->assertSame('Canonical', $row->title); + $this->assertSame([], DB::getQueryLog()); + } + + public function test_falls_through_to_database_when_row_cache_is_cold(): void + { + DB::flushQueryLog(); + DB::enableQueryLog(); + $row = RawPost::query()->toBase()->where('id', $this->postId)->select('title')->first(); + DB::disableQueryLog(); + + $this->assertSame('Canonical', $row->title); + $this->assertNotEmpty(DB::getQueryLog()); + } + + public function test_fallback_respects_soft_delete_visibility(): void + { + Post::query()->whereKey($this->postId)->delete(); + Post::withTrashed()->findOrFail($this->postId); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $default = Post::query()->select('title')->find($this->postId); + $trashedOnly = Post::onlyTrashed()->select('title')->find($this->postId); + DB::disableQueryLog(); + + $this->assertNull($default); + $this->assertSame('Canonical', $trashedOnly?->title); + $this->assertSame([], DB::getQueryLog()); + } + + public function test_fallback_bypasses_on_extra_predicates(): void + { + RawPost::query()->toBase()->orderBy('id')->get(); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $row = RawPost::query()->toBase() + ->where('id', $this->postId) + ->where('published', false) + ->select('title') + ->first(); + DB::disableQueryLog(); + + $this->assertNull($row); + $this->assertCount(1, DB::getQueryLog()); + } + + public function test_fallback_reflects_precise_write(): void + { + RawPost::query()->toBase()->orderBy('id')->get(); + RawPost::query()->toBase()->where('id', $this->postId)->update(['title' => 'Updated']); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $row = RawPost::query()->toBase()->where('id', $this->postId)->select('title')->first(); + DB::disableQueryLog(); + + $this->assertSame('Updated', $row->title); + $this->assertCount(1, DB::getQueryLog()); + } + + public function test_fallback_serves_unrelated_rows_after_collateral_invalidation(): void + { + $authorId = RawPost::query()->toBase()->where('id', $this->postId)->value('author_id'); + $secondId = (int) RawPost::query()->toBase()->insertGetId([ + 'title' => 'Second', + 'views' => 0, + 'published' => true, + 'author_id' => $authorId, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + RawPost::query()->toBase()->orderBy('id')->get(); + RawPost::query()->toBase()->where('id', $secondId)->update(['title' => 'Second Updated']); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $row = RawPost::query()->toBase()->where('id', $this->postId)->select('title')->first(); + DB::disableQueryLog(); + + $this->assertSame('Canonical', $row->title); + $this->assertSame([], DB::getQueryLog()); + } + + public function test_fallback_misses_after_global_epoch_flush(): void + { + RawPost::query()->toBase()->orderBy('id')->get(); + NormCache::flushAll(); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $row = RawPost::query()->toBase()->where('id', $this->postId)->select('title')->first(); + DB::disableQueryLog(); + + $this->assertSame('Canonical', $row->title); + $this->assertCount(1, DB::getQueryLog()); + } + + public function test_fallback_declines_when_projected_column_is_missing(): void + { + RawPost::query()->toBase()->orderBy('id')->get(); + + $codec = $this->app->make(RawResultCodec::class); + $epoch = $this->cacheStore()->getRaw($this->cacheKeys()->epoch()) ?? '0'; + $rowKey = $this->cacheKeysMatching(':r:g')[0] ?? null; + $this->assertIsString($rowKey); + + $incomplete = (object) ['id' => $this->postId]; + $observed = $codec->encodeRow($incomplete, $epoch); + $this->cacheStore()->setRawForever($rowKey, $observed); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $row = RawPost::query()->toBase()->where('id', $this->postId)->select('title')->first(); + DB::disableQueryLog(); + + $this->assertSame('Canonical', $row->title); + $this->assertCount(1, DB::getQueryLog()); + $this->assertSame($observed, $this->cacheStore()->getRaw($rowKey)); + } + + public function test_aliased_source_does_not_accept_original_table_projection_qualifier(): void + { + RawPost::query()->toBase()->orderBy('id')->get(); + + $query = fn(bool $cached) => RawPost::query()->toBase()->from('posts as p') + ->when(!$cached, fn($builder) => $builder->withoutCache()) + ->where('p.id', $this->postId) + ->select('posts.title') + ->first(); + + $this->assertQueryFails(fn() => $query(false)); + $this->assertQueryFails(fn() => $query(true)); + } + + public function test_aliased_source_does_not_accept_original_table_wildcard(): void + { + RawPost::query()->toBase()->orderBy('id')->get(); + + $query = fn(bool $cached) => RawPost::query()->toBase()->from('posts as p') + ->when(!$cached, fn($builder) => $builder->withoutCache()) + ->where('p.id', $this->postId) + ->select('posts.*') + ->first(); + + $this->assertQueryFails(fn() => $query(false)); + $this->assertQueryFails(fn() => $query(true)); + } + + public function test_unrelated_primary_key_qualifier_does_not_use_row_fallback(): void + { + RawPost::query()->toBase()->orderBy('id')->get(); + + $query = fn(bool $cached) => RawPost::query()->toBase() + ->when(!$cached, fn($builder) => $builder->withoutCache()) + ->where('authors.id', $this->postId) + ->select('title') + ->first(); + + $this->assertQueryFails(fn() => $query(false)); + $this->assertQueryFails(fn() => $query(true)); + } + + public function test_contradictory_soft_delete_predicates_do_not_use_row_fallback(): void + { + Post::query()->whereKey($this->postId)->delete(); + Post::withTrashed()->findOrFail($this->postId); + + $native = Post::query() + ->whereNotNull('deleted_at') + ->select('title') + ->withoutCache() + ->find($this->postId); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $cached = Post::query() + ->whereNotNull('deleted_at') + ->select('title') + ->find($this->postId); + DB::disableQueryLog(); + + $this->assertNull($native); + $this->assertNull($cached); + $this->assertCount(1, DB::getQueryLog()); + } + + public function test_canonical_row_repair_is_reported_separately_when_this_process_queries_database(): void + { + RawPost::query()->toBase()->orderBy('id')->get(); + $this->deleteResultOverlays(); + $this->cacheStore()->delete($this->postRowKey()); + + Event::fake([QueryCacheHit::class, QueryCacheMiss::class, QueryCacheRepaired::class]); + DB::flushQueryLog(); + DB::enableQueryLog(); + $rows = RawPost::query()->toBase()->orderBy('id')->get(); + DB::disableQueryLog(); + + $this->assertSame('Canonical', $rows->firstWhere('id', $this->postId)->title); + $this->assertCount(1, DB::getQueryLog()); + Event::assertDispatched( + QueryCacheRepaired::class, + static fn(QueryCacheRepaired $event): bool => $event->reason === 'row_repair', + ); + Event::assertNotDispatched(QueryCacheHit::class); + Event::assertNotDispatched(QueryCacheMiss::class); + } + + public function test_corrupt_canonical_row_remains_when_projection_fallback_declines(): void + { + RawPost::query()->toBase()->orderBy('id')->get(); + $rowKey = $this->postRowKey(); + $this->cacheStore()->setRawForever($rowKey, 'not-a-valid-row-payload'); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $row = RawPost::query()->toBase()->where('id', $this->postId)->select('title')->first(); + DB::disableQueryLog(); + + $this->assertSame('Canonical', $row->title); + $this->assertCount(1, DB::getQueryLog()); + $this->assertSame('not-a-valid-row-payload', $this->cacheStore()->getRaw($rowKey)); + } + + public function test_retry_preserves_row_fallback_hit_reason(): void + { + if ( + !function_exists('pcntl_fork') + || !class_exists(\Redis::class) + || env('REDIS_CLUSTER') === true + || env('REDIS_CLUSTER') === 'true' + ) { + $this->markTestSkipped('Requires pcntl and standalone PhpRedis.'); + } + + $query = RawPost::query()->toBase()->where('id', $this->postId)->select('title')->limit(1); + $connection = $query->getConnection(); + $table = $this->app->make(TableIdentityResolver::class)->resolve($connection, $query->from); + $this->assertNotNull($table); + $analysis = $this->app->make(DependencyAnalyzer::class)->analyze($connection, $query); + $plan = $this->app->make(QueryPlanner::class)->plan( + $query, + $table, + $analysis->tables, + ); + $identity = $this->app->make(QueryIdentity::class); + $namespace = $identity->namespace($query->configuredTag()); + $queryHash = $identity->hash( + route: $plan->route, + rootHash: $table->hash, + dependencyHashes: array_map(static fn($dependency): string => $dependency->hash, $analysis->tables), + sql: $query->toSql(), + bindings: $connection->prepareBindings($query->getBindings()), + namespace: $namespace, + operation: 'select', + ); + $version = $this->cacheStore()->getRaw($this->cacheKeys()->version($table)) ?? '0'; + $generation = $this->cacheStore()->getRaw($this->cacheKeys()->generation($table)) ?? '0'; + $epoch = $this->cacheStore()->getRaw($this->cacheKeys()->epoch()) ?? '0'; + $token = str_repeat('a', 32); + $buildKey = $this->cacheKeys()->queryBuild($table, $version, $namespace, $queryHash); + $wakeKey = $this->cacheKeys()->wake($table, 'e', $queryHash, $token); + $rowKey = $this->cacheKeys()->row($table, $generation, 'i:' . $this->postId); + $payload = $this->app->make(RawResultCodec::class)->encodeRow( + (object) ['id' => $this->postId, 'title' => 'Canonical'], + $epoch, + ); + $this->assertTrue($this->cacheStore()->claimBuild($buildKey, $token, 5)[0]); + + $pid = pcntl_fork(); + $this->assertNotSame(-1, $pid); + + if ($pid === 0) { + usleep(50_000); + $redis = new \Redis; + $redis->connect((string) env('REDIS_HOST', '127.0.0.1'), (int) env('REDIS_PORT', 6379)); + $redis->select(15); + $redis->setex($rowKey, 3600, $payload); + $redis->lPush($wakeKey, '1'); + $redis->expire($wakeKey, 10); + $redis->del($buildKey); + $redis->close(); + exit(0); + } + + Event::fake([QueryCacheHit::class, QueryCacheMiss::class]); + DB::flushQueryLog(); + DB::enableQueryLog(); + $started = microtime(true); + $row = $query->first(); + $elapsed = microtime(true) - $started; + DB::disableQueryLog(); + pcntl_waitpid($pid, $status); + + $this->assertSame('Canonical', $row->title); + $this->assertSame([], DB::getQueryLog()); + $this->assertGreaterThan(0.02, $elapsed); + Event::assertDispatched( + QueryCacheHit::class, + static fn(QueryCacheHit $event): bool => $event->reason === 'row_cache_fallback', + ); + Event::assertDispatched(QueryCacheMiss::class); + } + + public function test_fallback_is_reported_as_cache_hit(): void + { + RawPost::query()->toBase()->orderBy('id')->get(); + + Event::fake([QueryCacheHit::class, QueryCacheMiss::class]); + + RawPost::query()->toBase()->where('id', $this->postId)->select('title')->first(); + + Event::assertDispatched( + QueryCacheHit::class, + static fn(QueryCacheHit $event): bool => $event->reason === 'row_cache_fallback', + ); + Event::assertNotDispatched(QueryCacheMiss::class); + } + + private function postRowKey(): string + { + foreach ($this->cacheKeysMatching(':r:g') as $key) { + if (str_ends_with($key, ':i:' . $this->postId)) { + return $key; + } + } + + throw new \RuntimeException('Post canonical row key was not found.'); + } + + private function assertQueryFails(callable $query): void + { + try { + $query(); + $this->fail('Expected the database query to fail.'); + } catch (QueryException) { + $this->addToAssertionCount(1); + } + } +} diff --git a/tests/Integration/Cache/PublicInvalidationTest.php b/tests/Integration/Cache/PublicInvalidationTest.php new file mode 100644 index 0000000..f5df7a2 --- /dev/null +++ b/tests/Integration/Cache/PublicInvalidationTest.php @@ -0,0 +1,237 @@ +getConnectionName() === 'manual_primary_alias' + ? 'primary_users' + : 'reporting_users'; + } +} + +final class PublicInvalidationTest extends TestCase +{ + private int $postId; + + protected function setUp(): void + { + parent::setUp(); + + $author = Author::query()->create(['name' => 'Author']); + $this->postId = (int) RawPost::query()->toBase()->insertGetId([ + 'title' => 'Public', + 'views' => 1, + 'published' => true, + 'author_id' => $author->getKey(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + public function test_flush_all_is_one_epoch_increment_and_invalidates_every_payload(): void + { + $read = fn() => RawPost::query()->toBase()->where('id', $this->postId)->first(); + $read(); + $read(); + + $before = (int) ($this->cacheStore()->getRaw( + $this->cacheKeys()->epoch(), + ) ?? '0'); + + $this->assertTrue(NormCache::flushAll()); + $this->assertSame( + $before + 1, + (int) $this->cacheStore()->getRaw($this->cacheKeys()->epoch()), + ); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $read(); + DB::disableQueryLog(); + + $this->assertCount(1, DB::getQueryLog()); + } + + public function test_flush_tag_invalidates_only_that_query_namespace(): void + { + $tagged = fn() => RawPost::query()->toBase()->where('id', $this->postId)->tag('homepage')->get(); + $untagged = fn() => RawPost::query()->toBase()->where('id', $this->postId)->get(); + + $tagged(); + $untagged(); + $tagged(); + $untagged(); + + $this->assertTrue(NormCache::flushTag('homepage')); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $tagged(); + $untagged(); + DB::disableQueryLog(); + + $this->assertCount(1, DB::getQueryLog()); + } + + public function test_table_invalidation_is_broad_and_boolean(): void + { + RawPost::query()->toBase()->get(); + $this->assertTrue(NormCache::invalidateTable('testing', 'posts')); + + DB::flushQueryLog(); + DB::enableQueryLog(); + RawPost::query()->toBase()->get(); + DB::disableQueryLog(); + + $this->assertCount(1, DB::getQueryLog()); + } + + public function test_invalidate_accepts_table_names_models_and_model_classes(): void + { + Post::query()->get(); + $identity = app(TableIdentityResolver::class) + ->resolve(DB::connection(), 'posts'); + $this->assertNotNull($identity); + $versionKey = $this->cacheKeys()->version($identity); + $generationKey = $this->cacheKeys()->generation($identity); + $version = (int) ($this->cacheStore()->getRaw($versionKey) ?? '0'); + $generation = (int) ($this->cacheStore()->getRaw($generationKey) ?? '0'); + + $this->assertTrue(NormCache::invalidate([ + 'posts', + Post::class, + new Post, + ])); + $this->assertSame($version + 1, (int) $this->cacheStore()->getRaw($versionKey)); + $this->assertSame($generation + 1, (int) $this->cacheStore()->getRaw($generationKey)); + + DB::flushQueryLog(); + DB::enableQueryLog(); + Post::query()->get(); + DB::disableQueryLog(); + + $this->assertCount(1, DB::getQueryLog()); + } + + /** + * @return list + */ + public static function scalarInvalidationTargets(): array + { + return [ + 'table name' => [fn() => 'posts'], + 'model class' => [fn() => Post::class], + 'model instance' => [fn() => new Post], + 'hydrated model' => [fn() => Post::query()->firstOrFail()], + ]; + } + + #[DataProvider('scalarInvalidationTargets')] + public function test_invalidate_accepts_a_single_unwrapped_target(callable $target): void + { + Post::query()->get(); + $identity = app(TableIdentityResolver::class) + ->resolve(DB::connection(), 'posts'); + $this->assertNotNull($identity); + $versionKey = $this->cacheKeys()->version($identity); + $generationKey = $this->cacheKeys()->generation($identity); + $version = (int) ($this->cacheStore()->getRaw($versionKey) ?? '0'); + $generation = (int) ($this->cacheStore()->getRaw($generationKey) ?? '0'); + + $this->assertTrue(NormCache::invalidate($target())); + $this->assertSame($version + 1, (int) $this->cacheStore()->getRaw($versionKey)); + $this->assertSame($generation + 1, (int) $this->cacheStore()->getRaw($generationKey)); + + DB::flushQueryLog(); + DB::enableQueryLog(); + Post::query()->get(); + DB::disableQueryLog(); + + $this->assertCount(1, DB::getQueryLog()); + } + + public function test_model_invalidation_applies_the_connection_before_resolving_the_table(): void + { + $database = tempnam(sys_get_temp_dir(), 'normcache-manual-invalidation-'); + $this->assertIsString($database); + + $connectionName = 'manual_primary_alias'; + $this->configureSqliteConnection($connectionName, $database, $connectionName); + + try { + $connection = DB::connection($connectionName); + $pdo = $connection->getPdo(); + $pdo->exec('create table primary_users (id integer primary key, name text not null)'); + $pdo->exec('create table reporting_users (id integer primary key, name text not null)'); + $pdo->exec("insert into primary_users (id, name) values (1, 'Before')"); + + $readPrimary = static fn() => PublicConnectionAwareInvalidationModel::on($connectionName) + ->toBase() + ->where('id', 1) + ->value('name'); + + $this->assertSame('Before', $readPrimary()); + $this->assertSame('Before', $readPrimary()); + + $pdo->exec("update primary_users set name = 'After class' where id = 1"); + + $this->assertTrue(NormCache::invalidate( + PublicConnectionAwareInvalidationModel::class, + $connectionName, + )); + $this->assertSame('After class', $readPrimary()); + $this->assertSame('After class', $readPrimary()); + + $pdo->exec("update primary_users set name = 'After instance' where id = 1"); + $model = new PublicConnectionAwareInvalidationModel; + + $this->assertSame('manual_reporting_alias', $model->getConnectionName()); + $this->assertTrue(NormCache::invalidate($model, $connectionName)); + $this->assertSame('manual_reporting_alias', $model->getConnectionName()); + $this->assertSame('After instance', $readPrimary()); + } finally { + DB::disconnect($connectionName); + DB::purge($connectionName); + + if (is_file($database)) { + unlink($database); + } + } + } + + private function configureSqliteConnection( + string $name, + string $database, + string $scope, + ): void { + config()->set("database.connections.{$name}", [ + 'driver' => 'sqlite', + 'database' => $database, + 'prefix' => '', + 'normcache_scope' => $scope, + ]); + DB::purge($name); + } +} diff --git a/tests/Integration/Cache/PublishRaceTest.php b/tests/Integration/Cache/PublishRaceTest.php new file mode 100644 index 0000000..ba3e973 --- /dev/null +++ b/tests/Integration/Cache/PublishRaceTest.php @@ -0,0 +1,127 @@ +authorId = (int) Author::query()->create(['name' => 'Author'])->getKey(); + + $rows = []; + + for ($index = 1; $index <= 20; $index++) { + $rows[] = [ + 'id' => $index, + 'title' => "Post {$index}", + 'views' => $index, + 'published' => true, + 'author_id' => $this->authorId, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + + RawPost::query()->toBase()->insert($rows); + } + + private function tableIdentity(string $table): TableIdentity + { + $identity = $this->app->make(TableIdentityResolver::class) + ->resolve(DB::connection(), $table); + + $this->assertNotNull($identity); + + return $identity; + } + + private function bumpVersionDuring(string $sqlNeedle, callable $callback): mixed + { + $bumped = false; + $versionKey = $this->cacheKeys()->version($this->tableIdentity('posts')); + + DB::listen(function ($query) use (&$bumped, $sqlNeedle, $versionKey): void { + if ($bumped || !str_contains($query->sql, $sqlNeedle)) { + return; + } + + $bumped = true; + $this->cacheStore()->increment($versionKey); + }); + + $result = $callback(); + + $this->assertTrue($bumped, 'expected the miss to reach the database'); + + return $result; + } + + public function test_a_version_bump_during_the_build_leaves_no_canonical_entry(): void + { + $rows = $this->bumpVersionDuring( + 'select * from "posts"', + fn() => RawPost::query()->toBase()->get(), + ); + + $this->assertCount(20, $rows, 'the caller still gets its rows'); + $this->assertSame( + [], + $this->cacheQueryKeysWithField('m'), + 'the membership must not be published against a version that already moved', + ); + } + + public function test_the_read_after_a_raced_build_is_a_miss_not_stale_data(): void + { + $this->bumpVersionDuring( + 'select * from "posts"', + fn() => RawPost::query()->toBase()->get(), + ); + + RawPost::query()->toBase()->where('id', 3)->update(['title' => 'changed']); + + $rows = collect(RawPost::query()->toBase()->get()); + + $this->assertSame('changed', $rows->firstWhere('id', 3)->title); + } + + public function test_a_version_bump_during_a_result_build_leaves_no_entry(): void + { + $count = $this->bumpVersionDuring( + 'select count(*)', + fn() => RawPost::query()->toBase()->count(), + ); + + $this->assertSame(20, $count); + $this->assertSame( + [], + $this->cacheQueryKeysWithField('r'), + 'the result payload must not be published against a moved version', + ); + } + + public function test_the_lease_is_released_when_the_publish_guard_rejects(): void + { + $this->bumpVersionDuring( + 'select * from "posts"', + fn() => RawPost::query()->toBase()->get(), + ); + + $this->assertSame( + [], + $this->cacheKeysMatching(':build:'), + 'a rejected publish must not leak the build lease', + ); + } +} diff --git a/tests/Integration/Cache/RedisStoreTest.php b/tests/Integration/Cache/RedisStoreTest.php new file mode 100644 index 0000000..edf9eb0 --- /dev/null +++ b/tests/Integration/Cache/RedisStoreTest.php @@ -0,0 +1,699 @@ +directMgetCalls++; + + $keys = $parameters[0] ?? []; + + if (!is_array($keys)) { + $keys = $parameters; + } + + return array_map( + static fn(string $key): string => "value:{$key}", + $keys, + ); + } + + if (strtolower((string) $method) === 'evalsha') { + $this->evalShaCalls++; + } + + return null; + } + + public function pipeline(...$arguments) + { + $this->pipelineCalls++; + + return []; + } +} + +final class RedisStoreTest extends TestCase +{ + public function test_predis_cluster_cross_slot_reads_use_one_pipeline(): void + { + $connection = new RecordingPredisClusterConnection(new Client); + $store = new RedisStore('unused'); + $property = new ReflectionProperty($store, 'connection'); + $property->setValue($store, $connection); + + $store->mget([ + '{slot-a}:version', + '{slot-b}:version', + ]); + + $this->assertSame([ + 'pipeline_calls' => 1, + 'direct_mget_calls' => 0, + ], [ + 'pipeline_calls' => $connection->pipelineCalls, + 'direct_mget_calls' => $connection->directMgetCalls, + ]); + } + + public function test_predis_cluster_same_slot_reads_use_direct_mget(): void + { + $connection = new RecordingPredisClusterConnection(new Client); + $store = new RedisStore('unused'); + $property = new ReflectionProperty($store, 'connection'); + $property->setValue($store, $connection); + + $keys = ['{slot-a}:version', '{slot-a}:generation']; + + $this->assertSame([ + '{slot-a}:version' => 'value:{slot-a}:version', + '{slot-a}:generation' => 'value:{slot-a}:generation', + ], $store->mget($keys)); + $this->assertSame([ + 'pipeline_calls' => 0, + 'direct_mget_calls' => 1, + ], [ + 'pipeline_calls' => $connection->pipelineCalls, + 'direct_mget_calls' => $connection->directMgetCalls, + ]); + } + + public function test_cluster_table_invalidations_remain_separate_scripts(): void + { + $connection = new RecordingPredisClusterConnection(new Client); + $store = new RedisStore('unused'); + $property = new ReflectionProperty($store, 'connection'); + $property->setValue($store, $connection); + + $store->invalidateTableStates([ + [ + 'versionKey' => '{table-a}:version', + 'generationKey' => '{table-a}:generation', + 'mode' => 'generation', + 'tokens' => [], + 'rowPrefix' => '{table-a}:rows:', + 'changePrefix' => '{table-a}:chg:', + 'changePayload' => '', + 'changeTtl' => 60, + ], + [ + 'versionKey' => '{table-b}:version', + 'generationKey' => '{table-b}:generation', + 'mode' => 'generation', + 'tokens' => [], + 'rowPrefix' => '{table-b}:rows:', + 'changePrefix' => '{table-b}:chg:', + 'changePayload' => '', + 'changeTtl' => 60, + ], + ]); + + $this->assertSame(2, $connection->evalShaCalls); + } + + public function test_corrupt_result_read_does_not_delete_the_observed_payload(): void + { + $store = app(RedisStore::class); + $key = 'test:{nc:x:corrupt-read}:payload'; + $store->setRawForever($key, 'corrupt'); + $state = new CacheState( + key: $key, + epoch: '0', + version: '0', + generation: '0', + versions: [], + tag: null, + tagKey: null, + ); + + $result = app(QueryEntryRepository::class)->readResult($state, 'corrupt'); + + $this->assertSame('corrupt_payload', $result->reason); + $this->assertSame('corrupt', $store->getRaw($key)); + } + + public function test_phpredis_raw_operations_preserve_the_shared_serializer(): void + { + $connection = Redis::connection('normcache-test'); + + if (!$connection instanceof PhpRedisConnection) { + $this->markTestSkipped('PhpRedis only.'); + } + + $client = $connection->client(); + $originalSerializer = $client->getOption(\Redis::OPT_SERIALIZER); + $sharedKey = 'test:shared-serialized-value'; + $rawKey = 'test:raw-normcache-value'; + + try { + $client->setOption(\Redis::OPT_SERIALIZER, \Redis::SERIALIZER_PHP); + $connection->set($sharedKey, ['value' => 123]); + + $store = app(RedisStore::class); + $store->setRawForever($rawKey, 'raw-value'); + + $this->assertSame('raw-value', $store->getRaw($rawKey)); + $this->assertSame(['value' => 123], $connection->get($sharedKey)); + $this->assertSame( + \Redis::SERIALIZER_PHP, + $client->getOption(\Redis::OPT_SERIALIZER), + ); + } finally { + $client->setOption(\Redis::OPT_SERIALIZER, \Redis::SERIALIZER_NONE); + $connection->del($sharedKey, $rawKey); + $client->setOption(\Redis::OPT_SERIALIZER, $originalSerializer); + } + } + + public function test_phpredis_lua_owner_comparison_supports_shared_serializer(): void + { + $connection = Redis::connection('normcache-test'); + + if (!$connection instanceof PhpRedisConnection) { + $this->markTestSkipped('PhpRedis only.'); + } + + $client = $connection->client(); + $originalSerializer = $client->getOption(\Redis::OPT_SERIALIZER); + $buildingKey = 'test:{nc:x:serializer}:build'; + $wakeKey = 'test:{nc:x:serializer}:wake'; + $token = str_repeat('a', 32); + + try { + $client->setOption(\Redis::OPT_SERIALIZER, \Redis::SERIALIZER_PHP); + $store = app(RedisStore::class); + + $this->assertTrue($store->claimBuild($buildingKey, $token, 60)[0]); + $this->assertTrue($store->releaseBuilding($buildingKey, $wakeKey, $token)); + $this->assertNull($store->getRaw($buildingKey)); + $this->assertSame( + \Redis::SERIALIZER_PHP, + $client->getOption(\Redis::OPT_SERIALIZER), + ); + } finally { + $client->setOption(\Redis::OPT_SERIALIZER, \Redis::SERIALIZER_NONE); + $connection->del($buildingKey, $wakeKey); + $client->setOption(\Redis::OPT_SERIALIZER, $originalSerializer); + } + } + + public function test_phpredis_unified_query_hash_protocol_supports_shared_serializer(): void + { + $connection = Redis::connection('normcache-test'); + + if (!$connection instanceof PhpRedisConnection) { + $this->markTestSkipped('PhpRedis only.'); + } + + $client = $connection->client(); + $originalSerializer = $client->getOption(\Redis::OPT_SERIALIZER); + $store = app(RedisStore::class); + $keys = app(CacheKeyBuilder::class); + $table = app(TableIdentityResolver::class) + ->resolve($this->app['db']->connection(), 'posts'); + $this->assertNotNull($table); + + $versionKey = $keys->version($table); + $generationKey = $keys->generation($table); + $entryKey = $keys->queryEntry($table, 'u', 'serializer-query'); + $buildKey = $keys->queryBuild($table, '0', 'u', 'serializer-query'); + $token = str_repeat('a', 32); + $wakeKey = $keys->wake($table, 'q', 'serializer-query', $token); + + try { + $client->setOption(\Redis::OPT_SERIALIZER, \Redis::SERIALIZER_PHP); + + $this->assertTrue($store->claimBuild($buildKey, $token, 30)[0]); + $this->assertTrue($store->publishCanonical( + versionKey: $versionKey, + generationKey: $generationKey, + membershipKey: $entryKey, + rowKeys: [], + rowPayloads: [], + expectedVersion: '0', + expectedGeneration: '0', + membershipPayload: 'membership-payload', + membershipTtl: 60, + rowTtl: 60, + buildingKey: $buildKey, + wakeKey: $wakeKey, + token: $token, + wakeTtl: 10, + resultPayload: 'result-payload', + )); + + $this->assertSame( + ['result', '0', 'result-payload', '0', 'membership-payload'], + $store->fetchResultOrCanonical( + $versionKey, + $generationKey, + $keys->tablePrefix($table), + 'u', + 'serializer-query', + 'serializer-query', + ), + ); + $this->assertSame('membership-payload', $store->readHashField($entryKey, 'm')); + $this->assertSame('result-payload', $store->readHashField($entryKey, 'r')); + $this->assertSame( + \Redis::SERIALIZER_PHP, + $client->getOption(\Redis::OPT_SERIALIZER), + ); + } finally { + $client->setOption(\Redis::OPT_SERIALIZER, \Redis::SERIALIZER_NONE); + $connection->del($entryKey, $buildKey, $wakeKey); + $client->setOption(\Redis::OPT_SERIALIZER, $originalSerializer); + } + } + + public function test_release_building_wakes_every_waiter_token(): void + { + $connection = Redis::connection('normcache-test'); + $store = new RedisStore('normcache-test'); + $buildingKey = 'test:{nc:x:wake-count}:build'; + $wakeKey = 'test:{nc:x:wake-count}:wake'; + $token = str_repeat('a', 32); + + try { + $this->assertTrue($store->claimBuild($buildingKey, $token, 60)[0]); + $this->assertTrue($store->releaseBuilding($buildingKey, $wakeKey, $token)); + $this->assertSame(64, $connection->llen($wakeKey)); + } finally { + $connection->del($buildingKey, $wakeKey); + } + } + + public function test_phpredis_raw_mode_is_applied_to_a_replacement_client(): void + { + $connection = Redis::connection('normcache-test'); + + if ( + !$connection instanceof PhpRedisConnection + || !$connection->client() instanceof \Redis + ) { + $this->markTestSkipped('Standalone PhpRedis only.'); + } + + $store = app(RedisStore::class); + $store->getRaw('test:warm-store-connection'); + + $property = new ReflectionProperty($connection, 'client'); + $originalClient = $connection->client(); + $replacement = new \Redis; + $replacement->connect( + (string) env('REDIS_HOST', '127.0.0.1'), + (int) env('REDIS_PORT', 6379), + ); + $replacement->select(15); + $replacement->setOption(\Redis::OPT_SERIALIZER, \Redis::SERIALIZER_PHP); + $key = 'test:replacement-client-value'; + + try { + $property->setValue($connection, $replacement); + + $store->setRawForever($key, 'replacement-raw-value'); + + $this->assertSame('replacement-raw-value', $store->getRaw($key)); + $this->assertSame( + \Redis::SERIALIZER_PHP, + $replacement->getOption(\Redis::OPT_SERIALIZER), + ); + } finally { + $replacement->setOption(\Redis::OPT_SERIALIZER, \Redis::SERIALIZER_NONE); + $replacement->del($key); + $property->setValue($connection, $originalClient); + $replacement->close(); + } + } + + public function test_canonical_fast_path_publishes_rows_and_membership_together(): void + { + $store = app(RedisStore::class); + $keys = app(CacheKeyBuilder::class); + $table = app(TableIdentityResolver::class) + ->resolve($this->app['db']->connection(), 'posts'); + $versionKey = $keys->version($table); + $generationKey = $keys->generation($table); + $membershipKey = $keys->queryEntry($table, 'u', 'query'); + $buildKey = $keys->queryBuild($table, '0', 'u', 'query'); + $token = str_repeat('a', 32); + $wakeKey = $keys->wake($table, 'q', 'query', $token); + $rowKey = $keys->row($table, '0', 'i:1'); + + $this->assertTrue($store->claimBuild($buildKey, $token, 5)[0]); + $this->assertTrue($store->publishCanonical( + versionKey: $versionKey, + generationKey: $generationKey, + membershipKey: $membershipKey, + rowKeys: [$rowKey], + rowPayloads: ['row-payload'], + expectedVersion: '0', + expectedGeneration: '0', + membershipPayload: '{"f":4,"ep":"0","g":"0","ids":["i:1"],"vec":[]}', + membershipTtl: 60, + rowTtl: 3600, + buildingKey: $buildKey, + wakeKey: $wakeKey, + token: $token, + wakeTtl: 11, + )); + + $result = $store->fetchCanonical( + versionKey: $versionKey, + generationKey: $generationKey, + tablePrefix: $keys->tablePrefix($table), + namespace: 'u', + queryHash: 'query', + ); + + $this->assertSame('hit', $result[0]); + $this->assertSame('0', $result[1]); + $this->assertSame('0', $result[2]); + + $missed = $store->fetchCanonical( + versionKey: $versionKey, + generationKey: $generationKey, + tablePrefix: $keys->tablePrefix($table), + namespace: 'u', + queryHash: 'never-written', + ); + + $this->assertSame('miss', $missed[0]); + $this->assertSame('0', $missed[1]); + $this->assertSame('0', $missed[2]); + + $store->increment($versionKey); + $bumped = $store->fetchCanonical( + $versionKey, + $generationKey, + $keys->tablePrefix($table), + 'u', + 'query', + ); + $this->assertSame('hit', $bumped[0]); + $this->assertSame('1', $bumped[1]); + } + + public function test_canonical_publication_carries_the_result_overlay_under_the_same_guard(): void + { + $store = app(RedisStore::class); + $keys = app(CacheKeyBuilder::class); + $table = app(TableIdentityResolver::class) + ->resolve($this->app['db']->connection(), 'posts'); + $versionKey = $keys->version($table); + $generationKey = $keys->generation($table); + $membershipKey = $keys->queryEntry($table, 'u', 'query'); + $buildKey = $keys->queryBuild($table, '0', 'u', 'query'); + $token = str_repeat('e', 32); + $wakeKey = $keys->wake($table, 'q', 'query', $token); + $rowKey = $keys->row($table, '0', 'i:1'); + + $this->assertTrue($store->claimBuild($buildKey, $token, 5)[0]); + $this->assertTrue($store->publishCanonical( + versionKey: $versionKey, + generationKey: $generationKey, + membershipKey: $membershipKey, + rowKeys: [$rowKey], + rowPayloads: ['row-payload'], + expectedVersion: '0', + expectedGeneration: '0', + membershipPayload: '{"f":4,"ep":"0","g":"0","ids":["i:1"],"vec":[]}', + membershipTtl: 60, + rowTtl: 3600, + buildingKey: $buildKey, + wakeKey: $wakeKey, + token: $token, + wakeTtl: 11, + resultPayload: 'overlay-payload', + )); + + $this->assertSame( + ['result', '0', 'overlay-payload'], + array_slice((array) $store->fetchResultOrCanonical( + versionKey: $versionKey, + generationKey: $generationKey, + tablePrefix: $keys->tablePrefix($table), + namespace: 'u', + resultQueryHash: 'query', + canonicalQueryHash: 'query', + ), 0, 3), + ); + $this->assertSame('hit', $store->fetchCanonical( + $versionKey, + $generationKey, + $keys->tablePrefix($table), + 'u', + 'query', + )[0]); + $this->assertSame('overlay-payload', $store->readHashField($membershipKey, 'r')); + $this->assertSame(60, Redis::connection('normcache-test')->ttl($membershipKey)); + } + + public function test_canonical_publication_rejects_the_result_overlay_after_state_changes(): void + { + $store = app(RedisStore::class); + $keys = app(CacheKeyBuilder::class); + $table = app(TableIdentityResolver::class) + ->resolve($this->app['db']->connection(), 'posts'); + $versionKey = $keys->version($table); + $generationKey = $keys->generation($table); + $membershipKey = $keys->queryEntry($table, 'u', 'guarded'); + $buildKey = $keys->queryBuild($table, '0', 'u', 'guarded'); + $token = str_repeat('f', 32); + $wakeKey = $keys->wake($table, 'q', 'guarded', $token); + $rowKey = $keys->row($table, '0', 'i:1'); + + $this->assertTrue($store->claimBuild($buildKey, $token, 5)[0]); + $store->increment($generationKey); + + $this->assertFalse($store->publishCanonical( + versionKey: $versionKey, + generationKey: $generationKey, + membershipKey: $membershipKey, + rowKeys: [$rowKey], + rowPayloads: ['row-payload'], + expectedVersion: '0', + expectedGeneration: '0', + membershipPayload: '{"f":4,"ep":"0","g":"0","ids":["i:1"],"vec":[]}', + membershipTtl: 60, + rowTtl: 3600, + buildingKey: $buildKey, + wakeKey: $wakeKey, + token: $token, + wakeTtl: 11, + resultPayload: 'overlay-payload', + )); + + $this->assertNull($store->readHashField($membershipKey, 'm')); + $this->assertNull($store->getRaw($rowKey)); + $this->assertNull($store->readHashField($membershipKey, 'r')); + } + + public function test_canonical_publication_rejects_membership_after_state_changes(): void + { + $store = app(RedisStore::class); + $keys = app(CacheKeyBuilder::class); + $table = app(TableIdentityResolver::class) + ->resolve($this->app['db']->connection(), 'posts'); + $versionKey = $keys->version($table); + $generationKey = $keys->generation($table); + $membershipKey = $keys->queryEntry($table, 'u', 'changed'); + $buildKey = $keys->queryBuild($table, '0', 'u', 'changed'); + $token = str_repeat('d', 32); + $wakeKey = $keys->wake($table, 'q', 'changed', $token); + $rowKey = $keys->row($table, '0', 'i:1'); + + $this->assertTrue($store->claimBuild($buildKey, $token, 5)[0]); + $store->increment($versionKey); + + $this->assertFalse($store->publishCanonical( + $versionKey, + $generationKey, + $membershipKey, + [$rowKey], + ['row-payload'], + '0', + '0', + '{"f":4,"ep":"0","g":"0","ids":["i:1"],"vec":[]}', + 60, + 3600, + $buildKey, + $wakeKey, + $token, + 11, + )); + $this->assertNull($store->readHashField($membershipKey, 'm')); + $this->assertNull($store->getRaw($rowKey)); + } + + public function test_result_or_canonical_head_prefers_result_then_returns_membership(): void + { + $store = app(RedisStore::class); + $keys = app(CacheKeyBuilder::class); + $table = app(TableIdentityResolver::class) + ->resolve($this->app['db']->connection(), 'posts'); + $versionKey = $keys->version($table); + $generationKey = $keys->generation($table); + $prefix = $keys->tablePrefix($table); + $resultKey = $keys->queryEntry($table, 'u', 'result-query'); + $membershipKey = $keys->queryEntry($table, 'u', 'canonical-query'); + $membership = '{"f":4,"ep":"0","g":"0","ids":["i:1"],"vec":[]}'; + + $store->writeHashField($membershipKey, 'm', $membership); + $store->writeHashField($resultKey, 'r', 'result-payload'); + + $result = $store->fetchResultOrCanonical( + $versionKey, + $generationKey, + $prefix, + 'u', + 'result-query', + 'canonical-query', + ); + + $this->assertSame(['result', '0', 'result-payload', '0', $membership], $result); + + $store->deleteHashField($resultKey, 'r'); + $canonical = $store->fetchResultOrCanonical( + $versionKey, + $generationKey, + $prefix, + 'u', + 'result-query', + 'canonical-query', + ); + + $this->assertSame('membership', $canonical[0]); + $this->assertSame('0', $canonical[1]); + $this->assertSame('0', $canonical[2]); + $this->assertSame($membership, $canonical[3]); + + $store->increment($versionKey); + $bumped = $store->fetchResultOrCanonical( + $versionKey, + $generationKey, + $prefix, + 'u', + 'result-query', + 'canonical-query', + ); + $this->assertSame('membership', $bumped[0]); + $this->assertSame('1', $bumped[1]); + $this->assertSame($membership, $bumped[3]); + } + + public function test_multiple_table_states_are_invalidated_together(): void + { + $store = app(RedisStore::class); + $keys = app(CacheKeyBuilder::class); + $tables = app(TableIdentityResolver::class); + $connection = $this->app['db']->connection(); + $posts = $tables->resolve($connection, 'posts'); + $authors = $tables->resolve($connection, 'authors'); + $authorRow = $keys->row($authors, '0', 'i:123'); + + $store->setRawForever($authorRow, 'cached-row'); + $store->invalidateTableStates([ + [ + 'versionKey' => $keys->version($posts), + 'generationKey' => $keys->generation($posts), + 'mode' => 'generation', + 'tokens' => ['i:123'], + 'rowPrefix' => $keys->tablePrefix($posts) . ':r:g', + 'changePrefix' => $keys->changeRecordPrefix($posts), + 'changePayload' => '', + 'changeTtl' => 60, + ], + [ + 'versionKey' => $keys->version($authors), + 'generationKey' => $keys->generation($authors), + 'mode' => 'precise', + 'tokens' => ['i:123'], + 'rowPrefix' => $keys->tablePrefix($authors) . ':r:g', + 'changePrefix' => $keys->changeRecordPrefix($authors), + 'changePayload' => '', + 'changeTtl' => 60, + ], + ]); + + $this->assertSame('1', $store->getRaw($keys->version($posts))); + $this->assertSame('1', $store->getRaw($keys->generation($posts))); + $this->assertSame('1', $store->getRaw($keys->version($authors))); + $this->assertNull($store->getRaw($authorRow)); + } + + public function test_expired_owner_cannot_publish_or_release_a_replacement_lease(): void + { + $store = app(RedisStore::class); + $key = 'test:{nc:x:lease}:result:u'; + $build = 'test:{nc:x:lease}:build'; + $wake = 'test:{nc:x:lease}:wake:' . str_repeat('a', 32); + + $store->setRawForever($build, str_repeat('b', 32)); + + $this->assertFalse($store->publishVersionedEntries( + [$key], + ['stale'], + 60, + [], + [], + $build, + $wake, + str_repeat('a', 32), + 11, + )); + $this->assertNull($store->getRaw($key)); + $this->assertSame(str_repeat('b', 32), $store->getRaw($build)); + } + + public function test_repair_publication_is_version_protected_without_a_lease(): void + { + $store = app(RedisStore::class); + $keys = app(CacheKeyBuilder::class); + $table = app(TableIdentityResolver::class) + ->resolve($this->app['db']->connection(), 'posts'); + $row = $keys->row($table, '0', 'i:1'); + + $this->assertTrue($store->publishVersionedEntries( + entryKeys: [$row], + entryPayloads: ['repaired'], + ttl: 3600, + versionKeys: [$keys->version($table), $keys->generation($table)], + expectedVersions: ['0', '0'], + )); + $this->assertSame('repaired', $store->getRaw($row)); + + $mismatchRow = $keys->row($table, '0', 'i:2'); + $store->increment($keys->version($table)); + + $this->assertFalse($store->publishVersionedEntries( + entryKeys: [$mismatchRow], + entryPayloads: ['stale'], + ttl: 3600, + versionKeys: [$keys->version($table), $keys->generation($table)], + expectedVersions: ['0', '0'], + )); + $this->assertNull($store->getRaw($mismatchRow)); + } +} diff --git a/tests/Integration/Cache/ResultCacheStrategyTest.php b/tests/Integration/Cache/ResultCacheStrategyTest.php new file mode 100644 index 0000000..9be5df5 --- /dev/null +++ b/tests/Integration/Cache/ResultCacheStrategyTest.php @@ -0,0 +1,514 @@ +create(['name' => 'Author']); + $this->authorId = (int) $author->getKey(); + + foreach (range(1, 6) as $index) { + RawPost::query()->toBase()->insert([ + 'title' => "Post {$index}", + 'views' => $index * 10, + 'published' => $index !== 5, + 'metadata' => json_encode(['index' => $index]), + 'author_id' => $author->getKey(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + + public function test_small_canonical_result_automatically_materializes_an_overlay(): void + { + $query = fn() => RawPost::query()->toBase() + ->where('published', true) + ->where(function ($query): void { + $query->whereBetween('views', [10, 60]) + ->where(function ($query): void { + $query->where('title', 'like', 'Post%') + ->orWhereNull('metadata'); + }); + }) + ->orderByDesc('views') + ->orderBy('id') + ->limit(4) + ->get(); + + $cold = $query(); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $warm = $query(); + DB::disableQueryLog(); + + $this->assertSame( + $cold->map(static fn(object $row): array => (array) $row)->all(), + $warm->map(static fn(object $row): array => (array) $row)->all(), + ); + $this->assertSame([], DB::getQueryLog()); + } + + public function test_unlimited_small_canonical_result_automatically_materializes_an_overlay(): void + { + $query = fn() => RawPost::query()->toBase() + ->where('published', true) + ->orderBy('id') + ->get(); + + $cold = $query(); + + $this->assertCount(5, $cold); + $this->assertCount(1, $this->cacheQueryKeysWithField('r')); + + Event::fake([QueryCacheHit::class]); + DB::flushQueryLog(); + DB::enableQueryLog(); + $warm = $query(); + DB::disableQueryLog(); + + $this->assertSame( + $cold->map(static fn(object $row): array => (array) $row)->all(), + $warm->map(static fn(object $row): array => (array) $row)->all(), + ); + $this->assertSame([], DB::getQueryLog()); + Event::assertDispatched( + QueryCacheHit::class, + static fn(QueryCacheHit $event): bool => $event->route === 'canonical' + && $event->reason === 'result_overlay', + ); + } + + public function test_one_row_allowance_applies_to_non_paginated_results(): void + { + foreach (range(1, 45) as $index) { + RawPost::query()->toBase()->insert([ + 'title' => "Extra {$index}", + 'views' => $index, + 'published' => true, + 'author_id' => $this->authorId, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + $query = fn() => RawPost::query()->toBase() + ->orderBy('id') + ->get(); + + $this->assertCount(51, $query()); + $this->assertCount(1, $this->cacheQueryKeysWithField('r')); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $this->assertCount(51, $query()); + DB::disableQueryLog(); + + $this->assertSame([], DB::getQueryLog()); + } + + public function test_query_builder_simple_pagination_uses_the_one_row_lookahead_allowance(): void + { + foreach (range(1, 45) as $index) { + RawPost::query()->toBase()->insert([ + 'title' => "Extra {$index}", + 'views' => $index, + 'published' => true, + 'author_id' => $this->authorId, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + $query = fn() => RawPost::query()->toBase() + ->orderBy('id') + ->simplePaginate(50); + + $cold = $query(); + + $this->assertCount(50, $cold->items()); + $this->assertTrue($cold->hasMorePages()); + $this->assertCount(1, $this->cacheQueryKeysWithField('r')); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $warm = $query(); + DB::disableQueryLog(); + + $this->assertCount(50, $warm->items()); + $this->assertTrue($warm->hasMorePages()); + $this->assertSame([], DB::getQueryLog()); + } + + public function test_pagination_lookahead_row_still_counts_toward_the_payload_size_limit(): void + { + foreach (range(1, 45) as $index) { + RawPost::query()->toBase()->insert([ + 'title' => "Extra {$index}", + 'views' => $index, + 'published' => true, + 'metadata' => $index === 45 + ? json_encode(['payload' => str_repeat('x', 192 * 1024)]) + : null, + 'author_id' => $this->authorId, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + $query = fn() => RawPost::query()->toBase() + ->orderBy('id') + ->simplePaginate(50); + + $cold = $query(); + + $this->assertCount(50, $cold->items()); + $this->assertTrue($cold->hasMorePages()); + $this->assertSame([], $this->cacheQueryKeysWithField('r')); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $warm = $query(); + DB::disableQueryLog(); + + $this->assertCount(50, $warm->items()); + $this->assertTrue($warm->hasMorePages()); + $this->assertSame([], DB::getQueryLog()); + $this->assertSame([], $this->cacheQueryKeysWithField('r')); + } + + public function test_eloquent_forwards_use_result_cache_to_the_query_builder(): void + { + $query = fn() => Post::query() + ->where('published', true) + ->orderByDesc('views') + ->limit(3) + ->get(); + + $cold = $query(); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $warm = $query(); + DB::disableQueryLog(); + + $this->assertSame($cold->modelKeys(), $warm->modelKeys()); + $this->assertSame([], DB::getQueryLog()); + } + + public function test_zero_row_limit_disables_automatic_result_overlays(): void + { + $originalConfig = $this->app->make(CacheConfig::class); + $config = (array) config('normcache'); + $config['auto_overlay_max_rows'] = 0; + $this->app->instance(CacheConfig::class, CacheConfig::fromArray($config)); + $this->app->forgetScopedInstances(); + + try { + $query = fn() => RawPost::query()->toBase() + ->where('published', true) + ->orderBy('id') + ->limit(4) + ->get(); + + $this->assertCount(4, $query()); + $this->assertSame([], $this->cacheQueryKeysWithField('r')); + $membershipKey = $this->cacheQueryKeysWithField('m')[0] ?? null; + $this->assertIsString($membershipKey); + $membership = $this->cacheStore()->readHashField($membershipKey, 'm'); + $this->assertIsString($membership); + $this->assertFalse(app(MembershipCodec::class)->decode($membership)->overlayRejected); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $this->assertCount(4, $query()); + DB::disableQueryLog(); + + $this->assertSame([], DB::getQueryLog()); + $this->assertSame([], $this->cacheQueryKeysWithField('r')); + } finally { + $this->app->instance(CacheConfig::class, $originalConfig); + $this->app->forgetScopedInstances(); + } + } + + public function test_zero_row_limit_disables_overlays_for_results_inside_the_lookahead_allowance(): void + { + $originalConfig = $this->app->make(CacheConfig::class); + $config = (array) config('normcache'); + $config['auto_overlay_max_rows'] = 0; + $this->app->instance(CacheConfig::class, CacheConfig::fromArray($config)); + $this->app->forgetScopedInstances(); + + try { + foreach ([1, 0] as $expected) { + Redis::connection('normcache-test')->flushdb(); + + $query = fn() => RawPost::query()->toBase() + ->where('published', true) + ->where('views', $expected === 1 ? '=' : '>', $expected === 1 ? 10 : 10_000) + ->orderBy('id') + ->get(); + + $this->assertCount($expected, $query()); + $this->assertSame([], $this->cacheQueryKeysWithField('r')); + + $this->assertCount($expected, $query()); + $this->assertSame([], $this->cacheQueryKeysWithField('r')); + } + } finally { + $this->app->instance(CacheConfig::class, $originalConfig); + $this->app->forgetScopedInstances(); + } + } + + public function test_result_larger_than_the_row_limit_plus_allowance_is_not_promoted(): void + { + $rows = $this->app->make(CacheConfig::class)->maxAutoOverlayRows + 2; + $existing = RawPost::query()->toBase()->where('published', true)->count(); + + foreach (range($existing + 1, $rows) as $index) { + RawPost::query()->toBase()->insert([ + 'title' => "Extra {$index}", + 'views' => $index, + 'published' => true, + 'author_id' => $this->authorId, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + // Remove the aggregate entry created while seeding. + Redis::connection('normcache-test')->flushdb(); + + $query = fn() => RawPost::query()->toBase() + ->where('published', true) + ->orderBy('id') + ->limit($rows) + ->get(); + + $this->assertCount($rows, $query()); + $this->assertSame([], $this->cacheQueryKeysWithField('r')); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $this->assertCount($rows, $query()); + DB::disableQueryLog(); + + $this->assertSame([], DB::getQueryLog()); + } + + public function test_result_larger_than_the_payload_limit_is_not_promoted(): void + { + RawPost::query()->toBase() + ->where('id', 1) + ->update([ + 'metadata' => json_encode([ + 'payload' => str_repeat('x', 192 * 1024), + ]), + ]); + + $query = fn() => RawPost::query()->toBase() + ->where('published', true) + ->orderBy('id') + ->limit(4) + ->get(); + + $this->assertCount(4, $query()); + $this->assertSame([], $this->cacheQueryKeysWithField('r')); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $this->assertCount(4, $query()); + DB::disableQueryLog(); + + $this->assertSame([], DB::getQueryLog()); + $this->assertSame([], $this->cacheQueryKeysWithField('r')); + } + + public function test_many_mid_sized_rows_under_the_payload_limit_are_still_promoted(): void + { + foreach (range(1, 45) as $index) { + RawPost::query()->toBase()->insert([ + 'title' => "Wide {$index}", + 'views' => $index, + 'published' => true, + 'metadata' => json_encode(['blob' => bin2hex(random_bytes(150))]), + 'author_id' => $this->authorId, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + $query = fn() => RawPost::query()->toBase() + ->where('published', true) + ->orderBy('id') + ->limit(45) + ->get(); + + $this->assertCount(45, $query()); + $this->assertCount(1, $this->cacheQueryKeysWithField('r')); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $warm = $query(); + DB::disableQueryLog(); + + $this->assertCount(45, $warm); + $this->assertSame([], DB::getQueryLog()); + } + + public function test_missing_result_overlay_falls_back_to_canonical_and_repromotes(): void + { + $query = fn() => RawPost::query()->toBase() + ->where('published', true) + ->orderBy('id') + ->limit(4) + ->get(); + + $expected = $query()->pluck('id')->all(); + $resultKey = $this->cacheQueryKeysWithField('r')[0]; + $this->cacheStore()->deleteHashField($resultKey, 'r'); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $actual = $query()->pluck('id')->all(); + DB::disableQueryLog(); + + $this->assertSame($expected, $actual); + $this->assertSame([], DB::getQueryLog()); + $this->assertCount(1, $this->cacheQueryKeysWithField('r')); + } + + public function test_corrupt_result_overlay_falls_back_to_canonical_and_self_heals(): void + { + $query = fn() => RawPost::query()->toBase() + ->where('published', true) + ->orderBy('id') + ->limit(4) + ->get(); + + $expected = $query()->pluck('id')->all(); + $resultKey = $this->cacheQueryKeysWithField('r')[0]; + $this->cacheStore()->writeHashField($resultKey, 'r', 'corrupt'); + Event::fake([QueryCacheRepaired::class]); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $actual = $query()->pluck('id')->all(); + DB::disableQueryLog(); + + $this->assertSame($expected, $actual); + $this->assertSame([], DB::getQueryLog()); + $payload = $this->cacheStore()->readHashField($resultKey, 'r'); + $this->assertIsString($payload); + $this->assertNotSame('corrupt', $payload); + Event::assertDispatched( + QueryCacheRepaired::class, + static fn(QueryCacheRepaired $event): bool => $event->reason === 'result_overlay_rebuilt', + ); + } + + public function test_write_invalidates_the_materialized_overlay(): void + { + $query = fn() => RawPost::query()->toBase() + ->where('published', true) + ->orderBy('id') + ->limit(4) + ->get(); + + $before = $query(); + $id = (int) $before->first()->id; + RawPost::query()->toBase()->where('id', $id)->update(['title' => 'Changed']); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $after = $query(); + DB::disableQueryLog(); + + $this->assertSame('Changed', $after->firstWhere('id', $id)->title); + $this->assertCount(1, DB::getQueryLog()); + } + + public function test_tag_flush_invalidates_the_materialized_overlay(): void + { + $query = fn() => RawPost::query()->toBase() + ->where('published', true) + ->orderBy('id') + ->limit(4) + ->tag('homepage') + ->get(); + + $query(); + $query(); + $this->assertTrue($this->cacheManager()->flushTag('homepage')); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $query(); + DB::disableQueryLog(); + + $this->assertCount(1, DB::getQueryLog()); + } + + public function test_dependency_version_invalidates_the_materialized_overlay(): void + { + $query = fn() => RawPost::query()->toBase() + ->dependsOn(['authors']) + ->where('published', true) + ->orderBy('id') + ->limit(4) + ->get(); + + $query(); + $query(); + Author::query()->toBase()->update(['name' => 'Changed']); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $query(); + DB::disableQueryLog(); + + $this->assertCount(1, DB::getQueryLog()); + } + + public function test_query_ttl_applies_to_membership_and_result_overlay(): void + { + RawPost::query()->toBase() + ->where('published', true) + ->orderBy('id') + ->limit(4) + ->ttl(30) + ->get(); + + $connection = Redis::connection('normcache-test'); + $membershipKey = $this->cacheQueryKeysWithField('m')[0]; + $resultKey = $this->cacheQueryKeysWithField('r')[0]; + $membershipTtl = (int) $connection->ttl($membershipKey); + $resultTtl = (int) $connection->ttl($resultKey); + + $this->assertGreaterThan(0, $membershipTtl); + $this->assertLessThanOrEqual(30, $membershipTtl); + $this->assertGreaterThan(0, $resultTtl); + $this->assertLessThanOrEqual(30, $resultTtl); + } +} diff --git a/tests/Integration/Cache/ResultOverlayStalenessTest.php b/tests/Integration/Cache/ResultOverlayStalenessTest.php new file mode 100644 index 0000000..9633589 --- /dev/null +++ b/tests/Integration/Cache/ResultOverlayStalenessTest.php @@ -0,0 +1,201 @@ +create(['name' => 'Author']); + + for ($i = 0; $i < 3; $i++) { + RawPost::query()->toBase()->insert([ + 'title' => 'Post ' . $i, + 'views' => $i, + 'published' => true, + 'author_id' => $author->getKey(), + 'created_at' => '2026-07-30 00:00:00', + 'updated_at' => '2026-07-30 00:00:00', + ]); + } + } + + public function test_a_single_cold_execution_publishes_the_overlay_alongside_the_membership(): void + { + $this->overlayQuery()(); + + $this->assertCount(1, $this->cacheQueryKeysWithField('m')); + $this->assertCount(1, $this->cacheQueryKeysWithField('r')); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $rows = $this->overlayQuery()(); + $queries = DB::getQueryLog(); + DB::disableQueryLog(); + + $this->assertSame([], $queries); + $this->assertCount(3, $rows); + } + + public function test_rejected_overlay_admission_is_recorded_in_the_membership(): void + { + RawPost::query()->toBase()->delete(); + $authorId = (int) Author::query()->toBase()->value('id'); + $rows = []; + + for ($index = 0; $index < 40; $index++) { + $rows[] = [ + 'title' => 'Post ' . $index, + 'views' => $index, + 'published' => true, + // Unique values prevent igbinary interning below the size limit. + 'metadata' => json_encode([ + 'blob' => $index < 2 ? 'small' : str_pad((string) $index, 4000, 'x'), + ], JSON_THROW_ON_ERROR), + 'author_id' => $authorId, + 'created_at' => '2026-07-30 00:00:00', + 'updated_at' => '2026-07-30 00:00:00', + ]; + } + + RawPost::query()->toBase()->insert($rows); + $query = static fn() => RawPost::query()->toBase()->orderBy('id')->get(); + $this->assertCount(40, $query()); + $membershipKey = $this->cacheQueryKeysWithField('m')[0] ?? null; + $postsPrefix = $this->cacheKeys()->tablePrefix($this->postsIdentity()); + $postResults = fn(): array => array_values(array_filter( + $this->cacheQueryKeysWithField('r'), + static fn(string $key): bool => str_starts_with($key, $postsPrefix), + )); + + $this->assertIsString($membershipKey); + $raw = $this->cacheStore()->readHashField($membershipKey, 'm'); + $this->assertIsString($raw); + $this->assertTrue(app(MembershipCodec::class)->decode($raw)->overlayRejected); + $this->assertSame([], $postResults()); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $this->assertCount(40, $query()); + DB::disableQueryLog(); + + $this->assertSame([], DB::getQueryLog()); + $this->assertSame([], $postResults()); + } + + public function test_an_overlay_is_not_served_after_its_root_table_is_invalidated(): void + { + $this->warmOverlay(); + $this->assertNotSame([], $this->cacheQueryKeysWithField('r')); + + NormCache::invalidate(['posts']); + + $this->assertServedFromDatabase(); + } + + public function test_an_overlay_is_not_served_after_a_dependency_is_invalidated(): void + { + $query = fn() => RawPost::query()->toBase() + ->join('authors', 'authors.id', '=', 'posts.author_id') + ->dependsOn(['posts', 'authors']) + ->select('posts.*') + ->orderBy('posts.id') + ->get(); + + $query(); + $query(); + + NormCache::invalidate(['authors']); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $query(); + $queries = DB::getQueryLog(); + DB::disableQueryLog(); + + $this->assertNotSame([], $queries); + } + + public function test_an_overlay_is_not_served_after_its_tag_is_flushed(): void + { + $query = fn() => RawPost::query()->toBase()->tag('homepage')->orderBy('id')->get(); + + $query(); + $query(); + + NormCache::flushTag('homepage'); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $query(); + $queries = DB::getQueryLog(); + DB::disableQueryLog(); + + $this->assertNotSame([], $queries); + } + + public function test_an_overlay_is_not_served_after_a_global_epoch_flush(): void + { + $this->warmOverlay(); + + NormCache::flushAll(); + + $this->assertServedFromDatabase(); + } + + public function test_an_overlay_written_against_a_superseded_version_is_never_served(): void + { + $this->warmOverlay(); + $overlayKey = $this->cacheQueryKeysWithField('r')[0] ?? null; + $this->assertIsString($overlayKey); + + $this->cacheStore()->increment($this->cacheKeys()->version($this->postsIdentity())); + + $this->assertServedFromDatabase(); + + // Invalidation guarantees unreachability, not physical deletion. + $this->assertContains($overlayKey, $this->cacheQueryKeysWithField('r')); + } + + private function warmOverlay(): void + { + $this->overlayQuery()(); + $this->overlayQuery()(); + } + + private function overlayQuery(): callable + { + return static fn() => RawPost::query()->toBase()->orderBy('id')->get(); + } + + private function assertServedFromDatabase(): void + { + DB::flushQueryLog(); + DB::enableQueryLog(); + $this->overlayQuery()(); + $queries = DB::getQueryLog(); + DB::disableQueryLog(); + + $this->assertNotSame([], $queries); + } + + private function postsIdentity(): TableIdentity + { + $identity = $this->app->make(TableIdentityResolver::class) + ->resolve(DB::connection(), 'posts'); + $this->assertNotNull($identity); + + return $identity; + } +} diff --git a/tests/Integration/Cache/RevalidationFuzzTest.php b/tests/Integration/Cache/RevalidationFuzzTest.php new file mode 100644 index 0000000..aebd74e --- /dev/null +++ b/tests/Integration/Cache/RevalidationFuzzTest.php @@ -0,0 +1,151 @@ +set('normcache.revalidation', true); + $this->app->forgetInstance(CacheConfig::class); + $this->app->forgetScopedInstances(); + + $this->authorId = (int) Author::query()->create(['name' => 'Author'])->getKey(); + $this->seedRows(); + } + + private function seedRows(): void + { + $rows = []; + + for ($index = 1; $index <= self::SEEDED_ROWS; $index++) { + $rows[] = $this->row($index); + } + + RawPost::query()->toBase()->insert($rows); + $this->nextId = self::SEEDED_ROWS + 1; + } + + /** @return array */ + private function row(int $index): array + { + return [ + 'title' => 'Post ' . $index, + 'views' => $index % 37, + 'published' => $index % 3 !== 0, + 'author_id' => $this->authorId, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + + private function cachedRead(): Collection + { + return RawPost::query()->toBase() + ->where('published', true) + ->orderBy('views') + ->orderBy('id') + ->get(); + } + + private function directRead(): Collection + { + return RawPost::query()->toBase() + ->where('published', true) + ->orderBy('views') + ->orderBy('id') + ->internal() + ->get(); + } + + private function existingId(): ?int + { + $row = RawPost::query()->toBase()->internal()->inRandomOrder()->first(); + + return $row === null ? null : (int) $row->id; + } + + public function test_the_cached_result_matches_an_uncached_read_under_churn(): void + { + mt_srand(20260811); + + $repaired = 0; + Event::listen( + QueryCacheRepaired::class, + static function () use (&$repaired): void { + $repaired++; + }, + ); + + $this->cachedRead(); + + for ($round = 1; $round <= self::ROUNDS; $round++) { + $action = mt_rand(1, 5); + $id = $this->existingId(); + + match (true) { + $action === 1 && $id !== null => $this->updateNonPredicate($id), + $action === 2 && $id !== null => $this->updatePredicate($id, $round), + $action === 3 => $this->insertRow(), + $action === 4 && $id !== null => $this->deleteRow($id), + default => null, + }; + + $this->assertEquals( + $this->directRead(), + $this->cachedRead(), + "cached result diverged from an uncached read at round {$round} (action {$action})", + ); + } + + $this->assertGreaterThan( + 0, + $repaired, + 'revalidation never engaged, so the comparison above proved nothing', + ); + } + + private function updateNonPredicate(int $id): void + { + RawPost::query()->toBase()->where('id', $id)->update([ + 'title' => 'changed ' . mt_rand(), + ]); + } + + private function updatePredicate(int $id, int $round): void + { + RawPost::query()->toBase()->where('id', $id)->update( + mt_rand(0, 1) === 0 + ? ['published' => mt_rand(0, 1) === 1] + : ['views' => $round % 37], + ); + } + + private function insertRow(): void + { + RawPost::query()->toBase()->insert($this->row($this->nextId++)); + } + + private function deleteRow(int $id): void + { + RawPost::query()->toBase()->where('id', $id)->delete(); + } +} diff --git a/tests/Integration/Cache/RevalidationIntrospectionTest.php b/tests/Integration/Cache/RevalidationIntrospectionTest.php new file mode 100644 index 0000000..71113aa --- /dev/null +++ b/tests/Integration/Cache/RevalidationIntrospectionTest.php @@ -0,0 +1,128 @@ +authorId = (int) Author::query()->create(['name' => 'Author'])->getKey(); + } + + public function test_a_precise_update_runs_only_the_update(): void + { + $this->configureRevalidation(true); + $this->seedPosts(1); + + $queries = $this->captureQueries( + fn() => RawPost::query()->toBase()->where('id', 1)->update(['title' => 'changed']), + ); + + $this->assertOnlyUpdateQuery($queries); + } + + public function test_a_model_declaring_volatile_columns_runs_only_the_update(): void + { + $this->configureRevalidation(true); + $this->seedPosts(1); + + $queries = $this->captureQueries( + fn() => VolatilePost::query()->toBase()->where('id', 1)->update(['title' => 'changed']), + ); + + $this->assertOnlyUpdateQuery($queries); + } + + public function test_a_broad_update_runs_only_the_update(): void + { + $this->configureRevalidation(true); + $this->seedPosts(1); + + $queries = $this->captureQueries( + fn() => RawPost::query()->toBase()->where('title', 'Post 1')->update(['views' => 2]), + ); + + $this->assertOnlyUpdateQuery($queries); + } + + public function test_a_transactional_update_runs_no_catalog_query(): void + { + $this->configureRevalidation(true); + $this->seedPosts(3); + + $catalog = []; + DB::listen(function (QueryExecuted $query) use (&$catalog): void { + foreach (['pragma_', 'sqlite_master', 'information_schema', 'pg_catalog', 'pg_trigger', '.sys.'] as $source) { + if (str_contains(strtolower($query->sql), $source)) { + $catalog[] = $query->sql; + } + } + }); + + DB::transaction(function (): void { + RawPost::query()->toBase()->where('id', 1)->update(['title' => 'first']); + RawPost::query()->toBase()->where('id', 2)->update(['title' => 'second']); + }); + + $this->assertSame([], $catalog, 'invalidation must never inspect the catalog'); + } + + private function configureRevalidation(bool $enabled): void + { + config()->set('normcache.revalidation', $enabled); + $this->app->forgetInstance(CacheConfig::class); + $this->app->forgetScopedInstances(); + } + + private function seedPosts(int $count): void + { + $rows = []; + + for ($index = 1; $index <= $count; $index++) { + $rows[] = [ + 'title' => 'Post ' . $index, + 'views' => $index, + 'published' => true, + 'author_id' => $this->authorId, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + + RawPost::query()->toBase()->insert($rows); + } + + /** @return list, time: float}> */ + private function captureQueries(callable $callback): array + { + DB::flushQueryLog(); + DB::enableQueryLog(); + + try { + $callback(); + } finally { + DB::disableQueryLog(); + } + + return DB::getQueryLog(); + } + + /** @param list, time: float}> $queries */ + private function assertOnlyUpdateQuery(array $queries): void + { + $this->assertCount(1, $queries, 'invalidation should not issue a query of its own'); + $this->assertStringStartsWith('update ', strtolower($queries[0]['query'])); + } +} diff --git a/tests/Integration/Cache/RuntimeKillSwitchTest.php b/tests/Integration/Cache/RuntimeKillSwitchTest.php new file mode 100644 index 0000000..20fb6cb --- /dev/null +++ b/tests/Integration/Cache/RuntimeKillSwitchTest.php @@ -0,0 +1,189 @@ +toBase()->insert(['id' => 1, 'name' => 'Author']); + RawPost::query()->toBase()->insert([ + 'id' => 1, + 'title' => 'Before', + 'views' => 0, + 'published' => true, + 'author_id' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + public function test_disabled_reads_bypass_to_the_database(): void + { + $read = fn() => RawPost::query()->toBase()->where('id', 1)->value('title'); + + $this->assertSame('Before', $read()); + $this->assertSame('Before', $read()); + + $this->assertTrue(NormCache::disableCache()); + $this->newScope(); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $this->assertSame('Before', $read()); + $this->assertSame('Before', $read()); + DB::disableQueryLog(); + + $this->assertCount(2, DB::getQueryLog()); + } + + public function test_disabled_writes_do_not_invalidate(): void + { + RawPost::query()->toBase()->where('id', 1)->first(); + $versionKey = $this->cacheKeys()->version($this->postsTable()); + $before = $this->cacheStore()->getRaw($versionKey) ?? '0'; + + $this->assertTrue(NormCache::disableCache()); + $this->newScope(); + + RawPost::query()->toBase()->where('id', 1)->update(['title' => 'After']); + + $this->assertSame($before, $this->cacheStore()->getRaw($versionKey) ?? '0'); + } + + public function test_enable_advances_the_epoch_and_clears_the_flag(): void + { + $epochKey = $this->cacheKeys()->epoch(); + RawPost::query()->toBase()->where('id', 1)->first(); + $before = (int) ($this->cacheStore()->getRaw($epochKey) ?? '0'); + + $this->assertTrue(NormCache::disableCache()); + $this->newScope(); + $this->assertTrue(NormCache::cacheDisabled()); + + $epoch = NormCache::enableCache(); + + $this->assertSame($before + 1, $epoch); + $this->assertSame((string) ($before + 1), $this->cacheStore()->getRaw($epochKey)); + $this->assertFalse(NormCache::cacheDisabled()); + } + + public function test_store_enable_returns_the_advanced_epoch_and_clears_the_flag(): void + { + $epochKey = $this->cacheKeys()->epoch(); + $disabledKey = $this->cacheKeys()->disabled(); + $this->assertTrue(NormCache::disableCache()); + $before = (int) ($this->cacheStore()->getRaw($epochKey) ?? '0'); + $this->assertNotNull($this->cacheStore()->getRaw($disabledKey)); + + $epoch = $this->cacheStore()->enableCache($epochKey, $disabledKey); + + $this->assertSame($before + 1, $epoch); + $this->assertSame((string) ($before + 1), $this->cacheStore()->getRaw($epochKey)); + $this->assertNull($this->cacheStore()->getRaw($disabledKey)); + } + + public function test_payloads_cached_before_a_disable_are_not_served_after_enable(): void + { + $read = fn() => RawPost::query()->toBase()->where('id', 1)->value('title'); + $this->assertSame('Before', $read()); + + $this->assertTrue(NormCache::disableCache()); + $this->newScope(); + + RawPost::query()->toBase()->where('id', 1)->update(['title' => 'Written while off']); + + $this->assertNotNull(NormCache::enableCache()); + $this->newScope(); + + $this->assertSame('Written while off', $read()); + } + + public function test_reads_resume_from_cache_after_enable(): void + { + $read = fn() => RawPost::query()->toBase()->where('id', 1)->value('title'); + + $this->assertTrue(NormCache::disableCache()); + $this->newScope(); + $this->assertNotNull(NormCache::enableCache()); + $this->newScope(); + + $this->assertSame('Before', $read()); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $this->assertSame('Before', $read()); + DB::disableQueryLog(); + + $this->assertSame([], DB::getQueryLog()); + } + + public function test_a_flag_set_by_another_process_is_observed_next_scope(): void + { + $read = fn() => RawPost::query()->toBase()->where('id', 1)->value('title'); + $this->assertSame('Before', $read()); + + // Simulate a remote disable outside this scope's memo. + $this->cacheStore()->setRawForever($this->cacheKeys()->disabled(), '1'); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $this->assertSame('Before', $read()); + DB::disableQueryLog(); + + $this->assertSame([], DB::getQueryLog()); + + $this->newScope(); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $this->assertSame('Before', $read()); + DB::disableQueryLog(); + + $this->assertCount(1, DB::getQueryLog()); + } + + public function test_a_flag_set_by_another_process_reaches_a_live_scope(): void + { + $read = fn() => RawPost::query()->toBase()->where('id', 1)->value('title'); + $this->assertSame('Before', $read()); + + // Simulate a remote disable outside this scope's memo. + $this->cacheStore()->setRawForever($this->cacheKeys()->disabled(), '1'); + $this->expireEpochMemo(); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $this->assertSame('Before', $read()); + DB::disableQueryLog(); + + $this->assertCount( + 1, + DB::getQueryLog(), + 'a kill switch must reach a live scope once the refresh interval lapses', + ); + } + + private function postsTable() + { + $table = $this->app->make(TableIdentityResolver::class) + ->resolve(DB::connection(), 'posts'); + $this->assertNotNull($table); + + return $table; + } + + private function newScope(): void + { + $this->app->forgetScopedInstances(); + } +} diff --git a/tests/Integration/Cache/ScalarCollisionTest.php b/tests/Integration/Cache/ScalarCollisionTest.php deleted file mode 100644 index 12c5054..0000000 --- a/tests/Integration/Cache/ScalarCollisionTest.php +++ /dev/null @@ -1,111 +0,0 @@ - 'Alice']); - $p1 = Post::create(['title' => 'P1', 'author_id' => $author->id, 'views' => 10]); - - $v1 = Post::sum('views'); - $this->assertEquals(10, $v1); - - $v2 = Post::sum('id'); - $this->assertEquals($p1->id, $v2); - $this->assertNotEquals(10, $v2); - } - - public function test_value_collision(): void - { - $author = Author::create(['name' => 'Alice']); - $p1 = Post::create(['title' => 'UniqueTitle', 'author_id' => $author->id, 'views' => 10]); - - $v1 = Post::value('title'); - $this->assertEquals('UniqueTitle', $v1); - - $v2 = Post::value('id'); - $this->assertEquals($p1->id, $v2); - $this->assertNotEquals('UniqueTitle', $v2); - } - - public function test_pluck_collision(): void - { - $author = Author::create(['name' => 'Alice']); - $p1 = Post::create(['title' => 'T1', 'author_id' => $author->id, 'views' => 10]); - - $v1 = Post::pluck('title')->toArray(); - $this->assertEquals(['T1'], $v1); - - $v2 = Post::pluck('id')->toArray(); - $this->assertEquals([$p1->id], $v2); - $this->assertNotEquals(['T1'], $v2); - } - - public function test_pluck_keyed_collision(): void - { - $author = Author::create(['name' => 'Alice']); - $p1 = Post::create(['title' => 'T1', 'author_id' => $author->id, 'views' => 10]); - - $v1 = Post::pluck('title', 'id')->toArray(); - $this->assertEquals([$p1->id => 'T1'], $v1); - - $v2 = Post::pluck('id', 'title')->toArray(); - $this->assertEquals(['T1' => $p1->id], $v2); - } - - public function test_raw_expression_scalar_bypasses_cache(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'P1', 'author_id' => $author->id, 'views' => 10]); - - $sum = Post::sum(DB::raw('views + 1')); - $this->assertEquals(11, $sum); - - $this->assertEmpty($this->redisKeys('scalar:*')); - } - - public function test_sum_and_avg_same_column_do_not_share_cache_entry(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'P1', 'author_id' => $author->id, 'views' => 10]); - Post::create(['title' => 'P2', 'author_id' => $author->id, 'views' => 20]); - - $this->assertSame(30, Post::sum('views')); - $this->assertSame(15.0, Post::avg('views')); - } - - public function test_min_and_max_same_column_do_not_share_cache_entry(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'P1', 'author_id' => $author->id, 'views' => 10]); - Post::create(['title' => 'P2', 'author_id' => $author->id, 'views' => 20]); - - $this->assertSame(10, Post::min('views')); - $this->assertSame(20, Post::max('views')); - } - - public function test_scalar_aggregate_alias_tracks_related_model_dependency(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'P1', 'author_id' => $author->id]); - - $this->assertSame(1, Author::withCount('posts')->value('posts_count')); - $this->assertSame(1, Author::withCount('posts')->value('posts_count')); - - Post::create(['title' => 'P2', 'author_id' => $author->id]); - - $this->assertSame(2, Author::withCount('posts')->value('posts_count')); - } -} diff --git a/tests/Integration/Cache/SpaceResolutionTest.php b/tests/Integration/Cache/SpaceResolutionTest.php deleted file mode 100644 index f1f77fd..0000000 --- a/tests/Integration/Cache/SpaceResolutionTest.php +++ /dev/null @@ -1,272 +0,0 @@ -space('content'); - - $this->assertSame('content', $builder->getSpace()); - } - - public function test_space_is_null_by_default(): void - { - $this->assertNull(Post::query()->getSpace()); - } - - public function test_cross_space_dependency_bypasses(): void - { - $plan = SpacedPost::query() - ->dependsOn([Author::class]) - ->cachePlan(SpacedPost::query()->toBase(), CachePlanContext::models()); - - $this->assertFalse($plan->isCacheable(), 'SpacedPost(content) depending on Author(default) must bypass'); - $this->assertSame(CacheOperation::Models, $plan->operation); - $this->assertTrue($plan->hasBypassReason('space')); - $this->assertFalse($plan->hasBypassReason('dependency')); - } - - public function test_same_space_dependency_still_caches(): void - { - // No declared spaces on Post/Author → both in default → no cross-space bypass. - $plan = Post::query() - ->dependsOn([Author::class]) - ->cachePlan(Post::query()->toBase(), CachePlanContext::models()); - - $this->assertTrue($plan->isCacheable(), 'default-space deps must not be downgraded'); - } - - public function test_named_space_query_can_cache_with_raw_table_dependency(): void - { - $author = SpacedAuthor::create(['name' => 'Alice']); - SpacedPost::create(['title' => 'Hello', 'author_id' => $author->id]); - - $query = fn() => SpacedPost::query() - ->join('authors', 'authors.id', '=', 'posts.author_id') - ->where('authors.name', 'Alice') - ->select('posts.*') - ->dependsOnTables(['authors']) - ->get(); - - $this->assertSame(['Hello'], $query()->pluck('title')->all()); - $this->assertNotEmpty( - $this->cacheManager()->store()->scanPattern('{nc:content}:test:result:*'), - 'named-space raw table dependencies should cache under the active space', - ); - - DB::table('authors')->where('id', $author->id)->update(['name' => 'Bob']); - $this->cacheManager()->invalidateTableVersion('testing', 'authors'); - - $this->assertSame([], $query()->pluck('title')->all()); - } - - public function test_cacheable_plan_carries_the_resolved_space(): void - { - $plan = SpacedPost::query()->cachePlan( - SpacedPost::query()->toBase(), - CachePlanContext::models(), - ); - - $this->assertTrue($plan->isCacheable()); - $this->assertSame('content', $plan->space?->name); - } - - public function test_default_model_plan_carries_default_space(): void - { - $plan = Post::query()->cachePlan( - Post::query()->toBase(), - CachePlanContext::models(), - ); - - $this->assertSame('default', $plan->space?->name); - } - - public function test_spaced_model_query_writes_keys_under_its_space_tag(): void - { - Post::create(['title' => 'Hello', 'author_id' => 1]); - - SpacedPost::query()->get(); - - $store = $this->cacheManager()->store(); - - $this->assertNotEmpty( - $store->scanPattern('{nc:content}:*'), - 'SpacedPost (content) must write keys under the {nc:content} hash tag', - ); - } - - public function test_spaced_model_write_invalidates_its_space_cache(): void - { - SpacedPost::create(['title' => 'First', 'author_id' => 1]); - - $this->assertSame('First', SpacedPost::query()->get()->first()->title); - - $post = SpacedPost::query()->get()->first(); - $post->update(['title' => 'Second']); - - $this->assertSame( - 'Second', - SpacedPost::query()->get()->first()->title, - 'content-space cache must invalidate when a content model is written', - ); - } - - public function test_current_version_reads_spaced_model_home_space(): void - { - $before = $this->cacheManager()->currentVersion(SpacedPost::class); - - $this->cacheManager()->forceFlushModel(SpacedPost::class); - - $this->assertGreaterThan($before, $this->cacheManager()->currentVersion(SpacedPost::class)); - } - - public function test_simple_through_relation_caches_under_related_space(): void - { - $country = ReportingCountry::create(['name' => 'Australia']); - $author = SpacedAuthor::create(['name' => 'Alice', 'country_id' => $country->id]); - SpacedPost::create(['title' => 'Hello', 'author_id' => $author->id]); - - $this->assertSame(['Hello'], $country->spacedPosts()->get()->pluck('title')->all()); - - $store = $this->cacheManager()->store(); - $this->assertNotEmpty($store->scanPattern('{nc:content}:test:through:*')); - $this->assertEmpty($store->scanPattern('{nc}:test:through:*')); - } - - public function test_non_simple_through_relation_bypasses_when_through_model_is_in_another_space(): void - { - $country = ReportingCountry::create(['name' => 'Australia']); - $author = ReportingAuthor::create(['name' => 'Alice', 'country_id' => $country->id]); - SpacedPost::create(['title' => 'Hello', 'author_id' => $author->id]); - - $posts = $country->crossSpacePosts() - ->dependsOn([SpacedPost::class]) - ->get(); - - $this->assertSame(['Hello'], $posts->pluck('title')->all()); - $this->assertEmpty($this->cacheManager()->store()->scanPattern('{nc:content}:test:through:*')); - } - - public function test_spaced_pivot_relation_invalidates_when_pivot_table_changes(): void - { - $post = SpacedPost::create(['title' => 'First', 'author_id' => 1]); - $firstTag = CatalogTag::create(['name' => 'First']); - $secondTag = CatalogTag::create(['name' => 'Second']); - $post->catalogTags()->attach($firstTag->id); - - $first = SpacedPost::query()->with('catalogTags')->get()->first()->catalogTags->pluck('name')->all(); - $this->assertSame(['First'], $first); - $this->assertNotEmpty($this->cacheManager()->store()->scanPattern('{nc:catalog}:test:pivot:*')); - - $post->catalogTags()->attach($secondTag->id); - - $second = SpacedPost::query()->with('catalogTags')->get()->first()->catalogTags->pluck('name')->sort()->values()->all(); - $this->assertSame(['First', 'Second'], $second); - } - - public function test_flush_all_removes_spaced_keys(): void - { - SpacedPost::create(['title' => 'First', 'author_id' => 1]); - - SpacedPost::query()->get(); - - $store = $this->cacheManager()->store(); - $this->assertNotEmpty($store->scanPattern('{nc:content}:test:*')); - - $this->cacheManager()->flushAll(); - - $this->assertEmpty($store->scanPattern('{nc:content}:test:*')); - } - - public function test_flush_tag_removes_spaced_tagged_keys(): void - { - SpacedPost::create(['title' => 'First', 'author_id' => 1]); - - SpacedPost::query()->tag('homepage')->get(); - - $store = $this->cacheManager()->store(); - $this->assertNotEmpty($store->scanPattern('{nc:content}:test:query:*:homepage:*')); - - $this->cacheManager()->flushTag(SpacedPost::class, 'homepage'); - - $this->assertEmpty($store->scanPattern('{nc:content}:test:query:*:homepage:*')); - } - - public function test_flush_tag_across_models_removes_spaced_tagged_keys(): void - { - SpacedPost::create(['title' => 'First', 'author_id' => 1]); - - SpacedPost::query()->tag('deploy')->get(); - - $store = $this->cacheManager()->store(); - $this->assertNotEmpty($store->scanPattern('{nc:content}:test:query:*:deploy:*')); - - $this->cacheManager()->flushTagAcrossModels('deploy'); - - $this->assertEmpty($store->scanPattern('{nc:content}:test:query:*:deploy:*')); - } - - public function test_explain_shows_the_resolved_space(): void - { - $this->assertStringContainsString('[space: content]', SpacedPost::query()->explain()); - $this->assertStringNotContainsString('[space:', Post::query()->explain()); - } - - public function test_explain_reports_cross_space_bypass(): void - { - $explain = SpacedPost::query()->dependsOn([Author::class])->explain(); - - $this->assertStringContainsString('cross-space', $explain); - } - - public function test_co_located_relation_eager_load_caches_under_the_space_tag(): void - { - $author = SpacedAuthor::create(['name' => 'Ann']); - SpacedPost::create(['title' => 'Hi', 'author_id' => $author->id]); - - $post = SpacedPost::query()->with('spacedAuthor')->get()->first(); - - $this->assertSame('Ann', $post->spacedAuthor->name); - - $store = $this->cacheManager()->store(); - $authorKeys = $store->scanPattern('{nc:content}:test:model:*authors*'); - - $this->assertNotEmpty( - $authorKeys, - 'co-located belongsTo (content) must cache the related model under {nc:content}', - ); - } - - public function test_pivot_invalidation_only_bumps_spaces_of_involved_models(): void - { - $registry = app(CacheSpaceRegistry::class); - $registry->space('content'); - $registry->space('catalog'); - $registry->space('reporting'); - - $store = $this->cacheManager()->store(); - - $post = SpacedPost::create(['title' => 'Draft', 'author_id' => 1]); - $tag = CatalogTag::create(['name' => 'PHP']); - $post->catalogTags()->attach($tag->id); - - $this->assertNotEmpty($store->scanPattern('{nc:content}:test:ver:*taggables*')); - $this->assertNotEmpty($store->scanPattern('{nc:catalog}:test:ver:*taggables*')); - $this->assertEmpty($store->scanPattern('{nc:reporting}:test:ver:*taggables*')); - } -} diff --git a/tests/Integration/Cache/StaleMembershipTest.php b/tests/Integration/Cache/StaleMembershipTest.php new file mode 100644 index 0000000..956e2a4 --- /dev/null +++ b/tests/Integration/Cache/StaleMembershipTest.php @@ -0,0 +1,127 @@ +seedPosts(3); + RawPost::query()->toBase()->orderBy('id')->get(); + RawPost::query()->toBase()->where('id', 1)->update(['title' => 'x']); + + $read = $this->readCanonicalDirectly(); + + $this->assertSame(ReadOutcome::MISS, $read->outcome); + $this->assertNotNull($read->staleMembership); + $this->assertCount(3, $read->staleMembership->ids); + $this->assertNotNull($read->staleMembershipRaw); + } + + public function test_a_corrupt_entry_yields_no_stale_membership(): void + { + $this->seedPosts(3); + RawPost::query()->toBase()->orderBy('id')->get(); + $this->corruptMembershipPayload(); + + $read = $this->readCanonicalDirectly(); + + $this->assertSame(ReadOutcome::MISS, $read->outcome); + $this->assertNull($read->staleMembership); + $this->assertNull($read->staleMembershipRaw); + } + + public function test_every_cache_read_wither_preserves_the_stale_membership_fields(): void + { + $state = new CacheState( + key: 'test:{nc:x:stale-membership-wither}:q:u', + epoch: '0', + version: '0', + generation: '0', + versions: [], + tag: null, + tagKey: null, + ); + $membership = new MembershipPayload(valid: true, ids: ['i:1'], rootVersion: '0'); + $read = new CacheRead( + $state, + ReadOutcome::MISS, + staleMembership: $membership, + staleMembershipRaw: 'raw-payload', + ); + + $viaWithRows = $read->withRows(['row']); + $viaWithReason = $read->withReason('some_reason'); + $viaAsRepaired = $read->asRepaired('repaired_reason'); + + foreach ([$viaWithRows, $viaWithReason, $viaAsRepaired] as $wither) { + $this->assertSame($membership, $wither->staleMembership); + $this->assertSame('raw-payload', $wither->staleMembershipRaw); + } + } + + private function seedPosts(int $count): void + { + $author = Author::query()->create(['name' => 'Author']); + + for ($index = 1; $index <= $count; $index++) { + RawPost::query()->toBase()->insertGetId([ + 'title' => "Post {$index}", + 'views' => $index, + 'published' => true, + 'author_id' => $author->getKey(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + + private function corruptMembershipPayload(): void + { + $key = $this->cacheQueryKeysWithField('m')[0] ?? null; + $this->assertIsString($key); + $this->cacheStore()->writeHashField($key, 'm', 'corrupt'); + } + + private function readCanonicalDirectly(): CacheRead + { + $query = RawPost::query()->toBase(); + $root = $this->app->make(TableIdentityResolver::class) + ->resolve($query->getConnection(), $query->from); + $this->assertNotNull($root); + + $plan = QueryPlan::canonical($root, [$root], $query->primaryKey()); + $namespace = $this->app->make(QueryIdentity::class)->namespace(null, null); + + $membershipKey = $this->cacheQueryKeysWithField('m')[0] ?? null; + $this->assertIsString($membershipKey); + $raw = $this->cacheStore()->readHashField($membershipKey, 'm'); + $this->assertIsString($raw); + + $version = $this->cacheStore()->getRaw($this->cacheKeys()->version($root)) ?? '0'; + $generation = $this->cacheStore()->getRaw($this->cacheKeys()->generation($root)) ?? '0'; + $head = [RedisProtocol::HIT, $version, $generation, $raw]; + + return $this->app->make(QueryEntryRepository::class)->readCanonical( + $plan, + $namespace, + 'stale-membership-test', + $head, + false, + fn(CacheState $state, array $tokens) => null, + ); + } +} diff --git a/tests/Integration/Cache/StaleOverlayTest.php b/tests/Integration/Cache/StaleOverlayTest.php new file mode 100644 index 0000000..9cd6794 --- /dev/null +++ b/tests/Integration/Cache/StaleOverlayTest.php @@ -0,0 +1,104 @@ +create(['name' => 'Author']); + + for ($i = 0; $i < 3; $i++) { + RawPost::query()->toBase()->insert([ + 'title' => 'Post ' . $i, + 'views' => $i, + 'published' => true, + 'author_id' => $author->getKey(), + 'created_at' => '2026-07-30 00:00:00', + 'updated_at' => '2026-07-30 00:00:00', + ]); + } + } + + public function test_a_stale_overlay_is_not_served_after_a_root_version_bump(): void + { + $query = static fn() => RawPost::query()->toBase()->orderBy('id')->get(); + + $query(); + $this->assertCount(1, $this->cacheQueryKeysWithField('m')); + $this->assertCount(1, $this->cacheQueryKeysWithField('r')); + + $this->cacheStore()->increment($this->cacheKeys()->version($this->postsIdentity())); + + $this->assertColdCacheMiss($query); + } + + public function test_a_membership_forged_with_a_foreign_root_version_is_not_served(): void + { + $query = static fn() => RawPost::query()->toBase()->orderBy('id')->get(); + $query(); + + $key = $this->cacheQueryKeysWithField('m')[0] ?? null; + $this->assertIsString($key); + + $codec = $this->app->make(MembershipCodec::class); + $membership = $codec->decode((string) $this->cacheStore()->readHashField($key, 'm')); + $this->assertTrue($membership->valid); + + $this->cacheStore()->writeHashField($key, 'm', $codec->encode( + epoch: $membership->epoch, + generation: $membership->generation, + ids: $membership->ids, + versions: $membership->versions, + tagVersion: $membership->tagVersion, + overlayRejected: $membership->overlayRejected, + rootVersion: '999', + )); + $this->deleteResultOverlays(); + + $this->assertColdCacheMiss($query); + } + + public function test_a_result_payload_forged_with_a_foreign_root_version_is_not_served(): void + { + $query = static fn() => RawPost::query()->toBase()->where('published', true)->count(); + $query(); + + $key = $this->cacheQueryKeysWithField('r')[0] ?? null; + $this->assertIsString($key); + + $codec = $this->app->make(RawResultCodec::class); + $result = $codec->decode((string) $this->cacheStore()->readHashField($key, 'r')); + $this->assertTrue($result->valid); + + $this->cacheStore()->writeHashField($key, 'r', $codec->encode( + rows: $result->rows, + epoch: $result->epoch, + versions: $result->versions, + tagVersion: $result->tagVersion, + rootVersion: '999', + )); + + $this->assertColdCacheMiss($query); + } + + private function postsIdentity(): TableIdentity + { + $identity = $this->app->make(TableIdentityResolver::class) + ->resolve(DB::connection(), 'posts'); + $this->assertNotNull($identity); + + return $identity; + } +} diff --git a/tests/Integration/Cache/StreamingOperationsTest.php b/tests/Integration/Cache/StreamingOperationsTest.php deleted file mode 100644 index b61c6b5..0000000 --- a/tests/Integration/Cache/StreamingOperationsTest.php +++ /dev/null @@ -1,123 +0,0 @@ - 'Alice']); - Author::create(['name' => 'Bob']); - - $firstPass = []; - Author::orderBy('id')->chunk(1, function ($batch) use (&$firstPass): void { - $firstPass = array_merge($firstPass, $batch->pluck('name')->all()); - }); - - $this->assertSame(['Alice', 'Bob'], $firstPass); - $this->assertNotEmpty($this->redisKeys('query:*')); - - DB::enableQueryLog(); - - $warmPass = []; - Author::orderBy('id')->chunk(1, function ($batch) use (&$warmPass): void { - $warmPass = array_merge($warmPass, $batch->pluck('name')->all()); - }); - - $this->assertSame(['Alice', 'Bob'], $warmPass); - $this->assertEmpty(DB::getQueryLog(), 'Warm chunk pages should hit cache'); - - DB::disableQueryLog(); - } - - public function test_chunk_sees_fresh_data_after_version_bump(): void - { - Author::create(['name' => 'Alice']); - Author::all(); // warm - Author::create(['name' => 'Bob']); - - $names = []; - Author::orderBy('name')->chunk(10, function ($batch) use (&$names) { - $names = array_merge($names, $batch->pluck('name')->all()); - }); - - $this->assertContains('Bob', $names); - } - - public function test_streaming_page_queries_do_not_persist_wrapped_eager_load_constraints(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - $query = Author::query() - ->with('posts') - ->orderBy('id'); - - $query->chunk(1, fn() => null); - $query->getQuery()->limit = null; - $query->getQuery()->offset = null; - $query->get(); - - $queries = []; - DB::listen(function ($query) use (&$queries): void { - $queries[] = $query->sql; - }); - - $warm = $query->get(); - - $this->assertCount(1, $warm); - $this->assertTrue($warm->first()->relationLoaded('posts')); - $this->assertSame('Hello', $warm->first()->posts->first()->title); - - $this->assertEmpty($queries, 'Cache should be hit, but these DB queries were executed: ' . implode(', ', $queries)); - } - - public function test_sole_throws_when_row_is_deleted_after_warm_hit(): void - { - $author = Author::create(['name' => 'Alice']); - Author::where('name', 'Alice')->sole(); - $author->delete(); - - $this->expectException(ModelNotFoundException::class); - Author::where('name', 'Alice')->sole(); - } - - public function test_sole_throws_when_second_row_is_inserted_after_warm_hit(): void - { - Author::create(['name' => 'Alice']); - Author::where('name', 'Alice')->sole(); - Author::create(['name' => 'Alice']); - - $this->expectException(MultipleRecordsFoundException::class); - Author::where('name', 'Alice')->sole(); - } - - public function test_sole_caches_and_reuses_the_query_result(): void - { - Author::create(['name' => 'Alice']); - $first = Author::where('name', 'Alice')->sole(); - - $this->assertSame('Alice', $first->name); - $this->assertNotEmpty($this->redisKeys('query:*')); - - DB::enableQueryLog(); - - $warm = Author::where('name', 'Alice')->sole(); - - $this->assertSame('Alice', $warm->name); - $this->assertEmpty(DB::getQueryLog(), 'Warm sole query should hit cache'); - - DB::disableQueryLog(); - } -} diff --git a/tests/Integration/Cache/SubqueryDependencyTest.php b/tests/Integration/Cache/SubqueryDependencyTest.php new file mode 100644 index 0000000..d1caf8d --- /dev/null +++ b/tests/Integration/Cache/SubqueryDependencyTest.php @@ -0,0 +1,224 @@ +author = Author::query()->create(['name' => 'Author']); + Post::query()->create([ + 'title' => 'First', + 'author_id' => $this->author->getKey(), + 'views' => 3, + ]); + } + + public function test_with_count_invalidates_on_related_insert(): void + { + $read = fn(): int => (int) Author::withCount('posts') + ->whereKey($this->author->getKey()) + ->firstOrFail() + ->posts_count; + + $this->assertSame(1, $read()); + $this->assertSame(1, $read()); + + Post::query()->create(['title' => 'Second', 'author_id' => $this->author->getKey()]); + + $this->assertSame(2, $read()); + } + + public function test_constrained_with_count_invalidates_on_related_update(): void + { + $read = fn(): int => (int) Author::withCount([ + 'posts' => fn($query) => $query->where('published', true), + ]) + ->whereKey($this->author->getKey()) + ->firstOrFail() + ->posts_count; + + $this->assertSame(0, $read()); + $this->assertSame(0, $read()); + + Post::query()->where('author_id', $this->author->getKey())->update(['published' => true]); + + $this->assertSame(1, $read()); + } + + public function test_with_sum_invalidates_on_related_update(): void + { + $read = fn(): int => (int) Author::withSum('posts', 'views') + ->whereKey($this->author->getKey()) + ->firstOrFail() + ->posts_sum_views; + + $this->assertSame(3, $read()); + $this->assertSame(3, $read()); + + Post::query()->where('author_id', $this->author->getKey())->update(['views' => 10]); + + $this->assertSame(10, $read()); + } + + public function test_pivot_write_invalidates_a_belongs_to_many_count(): void + { + $post = Post::query()->firstOrFail(); + $tag = Tag::query()->create(['name' => 'php']); + + $read = fn(): int => (int) Post::withCount('tags') + ->whereKey($post->getKey()) + ->firstOrFail() + ->tags_count; + + $this->assertSame(0, $read()); + $this->assertSame(0, $read()); + + $post->tags()->attach($tag->getKey()); + + $this->assertSame(1, $read()); + } + + public function test_morph_write_invalidates_a_morph_many_count(): void + { + $post = Post::query()->firstOrFail(); + + $read = fn(): int => (int) Post::withCount('comments') + ->whereKey($post->getKey()) + ->firstOrFail() + ->comments_count; + + $this->assertSame(0, $read()); + $this->assertSame(0, $read()); + + Comment::query()->create([ + 'body' => 'First', + 'commentable_type' => Post::class, + 'commentable_id' => $post->getKey(), + ]); + + $this->assertSame(1, $read()); + } + + public function test_paginated_aggregate_stays_correct(): void + { + $read = fn(): int => (int) Author::withCount('posts') + ->orderBy('id') + ->paginate(10) + ->first() + ->posts_count; + + $this->assertSame(1, $read()); + $this->assertSame(1, $read()); + + Post::query()->create(['title' => 'Second', 'author_id' => $this->author->getKey()]); + + $this->assertSame(2, $read()); + } + + public function test_a_nested_subquery_mutation_is_detected_at_any_depth(): void + { + $read = function (): int { + $inner = Author::query()->toBase(); + $subquery = Comment::query()->toBase()->selectRaw('count(*)')->whereExists($inner); + $query = RawPost::query()->toBase()->selectSub($subquery, 'comment_count'); + $inner->from('tags'); + + return (int) $query->first()->comment_count; + }; + + $this->assertSame(0, $read()); + $this->assertSame(0, $read()); + + Comment::query()->toBase()->insert([ + 'body' => 'First', + 'commentable_type' => 'post', + 'commentable_id' => Post::query()->value('id'), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->assertSame(1, $read()); + } + + public function test_a_subquery_mutated_after_capture_is_no_longer_trusted(): void + { + $subquery = Comment::query()->toBase()->selectRaw('count(*)'); + $query = RawPost::query()->toBase()->selectSub($subquery, 'comment_count'); + $expression = null; + + foreach ((array) $query->columns as $column) { + if (!is_string($column)) { + $expression = $column; + } + } + + $this->assertNotNull($expression); + $this->assertNotNull( + $query->capturedSubquery($expression), + 'an untouched capture must resolve to its builder', + ); + + $subquery->from('tags'); + + $this->assertNull( + $query->capturedSubquery($expression), + 'a capture whose builder no longer compiles to the copied SQL must be rejected', + ); + } + + public function test_a_raw_select_subquery_requires_declared_dependencies(): void + { + $build = fn(bool $declared = false) => Author::withCount('posts') + ->selectRaw('(select count(*) from comments) as comment_count') + ->when($declared, fn($query) => $query->dependsOn(['comments'])) + ->whereKey($this->author->getKey()); + + $this->bypassContract( + fn() => $build()->firstOrFail(), + fn() => $build()->firstOrFail(), + reason: 'unidentifiable_dependency', + ); + $this->contract( + fn() => $build(true)->firstOrFail(), + fn() => $build()->firstOrFail(), + mutate: fn() => Comment::query()->create([ + 'body' => 'New', + 'commentable_type' => Author::class, + 'commentable_id' => $this->author->getKey(), + ]), + ); + } + + public function test_a_volatile_subquery_projection_still_bypasses(): void + { + $build = fn() => Author::query() + ->addSelect([ + 'sampled' => Post::query() + ->selectRaw('random()') + ->whereColumn('author_id', 'authors.id') + ->limit(1), + ]) + ->whereKey($this->author->getKey()); + + $build()->get(); + DB::flushQueryLog(); + DB::enableQueryLog(); + $build()->get(); + DB::disableQueryLog(); + + $this->assertCount(1, DB::getQueryLog()); + } +} diff --git a/tests/Integration/Cache/TagsTest.php b/tests/Integration/Cache/TagsTest.php new file mode 100644 index 0000000..1182888 --- /dev/null +++ b/tests/Integration/Cache/TagsTest.php @@ -0,0 +1,64 @@ + 'Alice']); + Post::create(['title' => 'P1', 'author_id' => $alice->id]); + $query = fn() => Author::query()->tag('home')->withCount('posts')->get(); + + $query(); + $this->assertWarmCacheHit($query); + + DB::statement( + 'insert into posts (title, author_id, created_at, updated_at) values (?, ?, ?, ?)', + ['P2', $alice->id, now(), now()], + ); + + $stale = null; + $this->assertWarmCacheHit(function () use ($query, &$stale) { + return $stale = $query(); + }); + $this->assertSame(1, $stale->first()->posts_count); + + $this->cacheManager()->flushTag('home'); + + $fresh = null; + $this->assertColdCacheMiss(function () use ($query, &$fresh) { + return $fresh = $query(); + }); + $this->assertSame(2, $fresh->first()->posts_count, 'flushTag must clear tagged aggregate cache entries'); + $this->assertWarmCacheHit($query); + } + + public function test_flush_tag_allows_arbitrary_characters(): void + { + $this->assertTrue($this->cacheManager()->flushTag('tag:with:colons/and*stars')); + } + + public function test_flush_tag_rejects_empty_tag(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->cacheManager()->flushTag(''); + } + + public function test_flush_tag_rejects_invalid_utf8(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->cacheManager()->flushTag("\xB1\x31"); + } + + public function test_flush_tag_rejects_tag_over_128_bytes(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->cacheManager()->flushTag(str_repeat('a', 129)); + } +} diff --git a/tests/Integration/Cache/ThroughRelationTest.php b/tests/Integration/Cache/ThroughRelationTest.php deleted file mode 100644 index fe905c9..0000000 --- a/tests/Integration/Cache/ThroughRelationTest.php +++ /dev/null @@ -1,384 +0,0 @@ - 'Australia']); - $author = Author::create(['name' => 'Alice', 'country_id' => $country->id]); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - $country->posts()->get(); - - $cached = $this->modelCacheEntry(Post::class, $post->id); - - $this->assertNotNull($cached, 'Through loads should populate the per-id model cache'); - $this->assertArrayNotHasKey('laravel_through_key', $cached); - } - - public function test_post_found_after_through_load_has_no_spurious_through_key(): void - { - $country = Country::create(['name' => 'Australia']); - $author = Author::create(['name' => 'Alice', 'country_id' => $country->id]); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - $country->posts()->get(); - - $fetched = Post::find($post->id); - - $this->assertArrayNotHasKey('laravel_through_key', $fetched->getRawOriginal()); - $this->assertArrayNotHasKey('laravel_through_key', $fetched->toArray()); - } - - public function test_has_many_through_caches_results(): void - { - $country = Country::create(['name' => 'Australia']); - $author = Author::create(['name' => 'Alice', 'country_id' => $country->id]); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - $first = $country->posts()->get()->pluck('title'); - - $keyCountAfterFirst = count($this->redisKeys('*')); - - $second = $country->posts()->get()->pluck('title'); - - $this->assertEquals($first, $second); - $this->assertSame($keyCountAfterFirst, count($this->redisKeys('*'))); - } - - public function test_use_write_pdo_bypasses_simple_through_relation_cache(): void - { - $country = Country::create(['name' => 'Australia']); - $author = Author::create(['name' => 'Alice', 'country_id' => $country->id]); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - $this->assertSame( - ['Hello'], - $country->posts()->useWritePdo()->get()->pluck('title')->all(), - ); - $this->assertEmpty($this->redisKeys('through:*')); - - $queryCount = 0; - DB::listen(function () use (&$queryCount): void { - $queryCount++; - }); - - $this->assertSame( - ['Hello'], - $country->posts()->useWritePdo()->get()->pluck('title')->all(), - ); - $this->assertGreaterThan(0, $queryCount); - } - - public function test_simple_has_many_through_warm_hit_refetches_only_evicted_child_model(): void - { - $country = Country::create(['name' => 'Australia']); - $author = Author::create(['name' => 'Alice', 'country_id' => $country->id]); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - $first = $country->posts()->get(); - - $this->assertSame(['Hello'], $first->pluck('title')->all()); - $this->assertNotEmpty($this->redisKeys('through:*')); - - Redis::connection('normcache-test') - ->del($this->prefixedModelKey(Post::class, $post->id)); - - $queryCount = 0; - DB::listen(function () use (&$queryCount) { - $queryCount++; - }); - - $second = $country->posts()->get(); - - $this->assertSame(['Hello'], $second->pluck('title')->all()); - $this->assertSame( - 1, - $queryCount, - 'Expected normalized through cache to refetch only the evicted Post row, not the whole relation' - ); - } - - public function test_through_relation_corrupt_query_payload_degrades_to_miss_and_repairs(): void - { - $country = Country::create(['name' => 'Australia']); - $author = Author::create(['name' => 'Alice', 'country_id' => $country->id]); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - $country->posts()->get(); - - $queryKey = collect($this->redisKeys('through:*'))->first(); - $this->assertNotNull($queryKey); - - Redis::connection('normcache-test')->set($queryKey, 'NOT_JSON'); - - $queryCount = 0; - DB::listen(function () use (&$queryCount) { - $queryCount++; - }); - - $results = $country->posts()->get(); - - $this->assertGreaterThan(0, $queryCount, 'Corrupt through payload should fall through to a fresh DB query'); - $this->assertSame([$post->id], $results->pluck('id')->all()); - - $raw = Redis::connection('normcache-test')->get($queryKey); - $repaired = json_decode($raw, true); - $this->assertIsArray($repaired); - $this->assertSame([(string) $post->id], $repaired['i']); - } - - public function test_flush_tag_removes_tagged_through_relation_cache(): void - { - $country = Country::create(['name' => 'Australia']); - $author = Author::create(['name' => 'Alice', 'country_id' => $country->id]); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - $country->posts()->tag('homepage')->get(); - - $this->assertNotEmpty($this->redisKeys('through:*:homepage:*')); - - NormCache::flushTag(Post::class, 'homepage'); - - $this->assertEmpty($this->redisKeys('through:*:homepage:*')); - } - - public function test_has_many_through_cache_invalidated_when_post_version_changes(): void - { - $country = Country::create(['name' => 'Australia']); - $author = Author::create(['name' => 'Alice', 'country_id' => $country->id]); - Post::create(['title' => 'Post A', 'author_id' => $author->id]); - - $country->posts()->get(); - - Post::create(['title' => 'Post B', 'author_id' => $author->id]); - - $titles = $country->posts()->get()->pluck('title')->sort()->values(); - - $this->assertSame(['Post A', 'Post B'], $titles->all()); - } - - public function test_updating_related_model_reflected_after_through_cache_warm(): void - { - $country = Country::create(['name' => 'Australia']); - $author = Author::create(['name' => 'Alice', 'country_id' => $country->id]); - $post = Post::create(['title' => 'Original', 'author_id' => $author->id]); - - $country->posts()->get(); - - $post->update(['title' => 'Updated']); - - $title = $country->posts()->get()->first()->title; - - $this->assertSame('Updated', $title); - } - - public function test_through_relation_join_columns_do_not_contaminate_model_cache(): void - { - $country = Country::create(['name' => 'Australia']); - Author::create(['name' => 'Dummy']); - $author = Author::create(['name' => 'Alice', 'country_id' => $country->id]); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - $country->posts()->get(); - - Redis::connection('normcache-test') - ->del($this->prefixedModelKey(Post::class, $post->id)); - - $country->posts()->get(); - - $cached = $this->modelCacheEntry(Post::class, $post->id); - - $canonicalColumns = array_keys((array) DB::table('posts')->find($post->id)); - - $this->assertNotNull($cached, 'The evicted Post should be refetched and recached'); - $this->assertArrayNotHasKey('laravel_through_key', $cached); - $this->assertEmpty( - array_diff(array_keys($cached), $canonicalColumns), - 'No join columns should leak into the cached attributes' - ); - } - - public function test_through_relation_model_cache_miss_does_not_corrupt_post_id(): void - { - $country = Country::create(['name' => 'Australia']); - Author::create(['name' => 'Dummy']); - $author = Author::create(['name' => 'Alice', 'country_id' => $country->id]); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - $this->assertNotSame($post->id, $author->id); - - $country->posts()->get(); - - Redis::connection('normcache-test') - ->del($this->prefixedModelKey(Post::class, $post->id)); - - $results = $country->posts()->get(); - - $this->assertCount(1, $results); - $this->assertSame($post->id, $results->first()->getKey()); - } - - public function test_through_relation_with_extra_join_delegates_to_eloquent(): void - { - $country = Country::create(['name' => 'Australia']); - $author = Author::create(['name' => 'Alice', 'country_id' => $country->id]); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - $posts = $country->posts() - ->join('countries as c2', 'c2.id', '=', 'authors.country_id') - ->get(); - - $this->assertSame([$post->id], $posts->modelKeys()); - $this->assertEmpty($this->redisKeys('through:*')); - } - - public function test_outdated_through_cache_entry_can_remain_after_through_model_version_bump(): void - { - $country = Country::create(['name' => 'Australia']); - $author = Author::create(['name' => 'Alice', 'country_id' => $country->id]); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - $country->posts()->get(); - - $oldPostVersion = NormCache::currentVersion(Post::class); - $oldAuthorVersion = NormCache::currentVersion(Author::class); - - $author->update(['name' => 'Alice Updated']); - - $this->assertGreaterThan($oldAuthorVersion, NormCache::currentVersion(Author::class)); - - $orphanedKeys = $this->redisKeys("through:*:v{$oldPostVersion}:v{$oldAuthorVersion}:*"); - - $this->assertNotEmpty($orphanedKeys); - $this->assertSame(['Hello'], $country->posts()->get()->pluck('title')->all()); - } - - public function test_without_cache_bypasses_has_many_through_cache(): void - { - $country = Country::create(['name' => 'Australia']); - $author = Author::create(['name' => 'Alice', 'country_id' => $country->id]); - Post::create(['title' => 'Post A', 'author_id' => $author->id]); - - $country->posts()->get(); - - // The version bump here makes the through cache entry outdated, but withoutCache() must skip it regardless. - Post::create(['title' => 'Post B', 'author_id' => $author->id]); - - DB::enableQueryLog(); - $country->posts()->withoutCache()->get(); - $queries = DB::getQueryLog(); - DB::disableQueryLog(); - - $this->assertNotEmpty($queries, 'withoutCache() on HasManyThrough should issue a DB query'); - } - - public function test_through_relation_raw_where_bindings_affect_cache_identity(): void - { - $country = Country::create(['name' => 'Australia']); - $author = Author::create(['name' => 'Alice', 'country_id' => $country->id]); - Post::create(['title' => 'A', 'author_id' => $author->id]); - Post::create(['title' => 'B', 'author_id' => $author->id]); - - $first = $country->posts() - ->whereRaw('posts.title = ?', ['A']) - ->get(); - - $second = $country->posts() - ->whereRaw('posts.title = ?', ['B']) - ->get(); - - $this->assertSame(['A'], $first->pluck('title')->all()); - $this->assertSame(['B'], $second->pluck('title')->all()); - } - - public function test_through_relation_subquery_where_does_not_serve_outdated_data(): void - { - $country = Country::create(['name' => 'Australia']); - $author = Author::create(['name' => 'Alice', 'country_id' => $country->id]); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id]); - DB::table('comments')->insert([ - 'body' => 'c1', - 'commentable_type' => Post::class, - 'commentable_id' => $post->id, - ]); - - $warm = $country->posts() - ->whereExists(function ($q) { - $q->from('comments')->whereColumn('comments.commentable_id', 'posts.id'); - }) - ->get(); - $this->assertSame(['Hello'], $warm->pluck('title')->all()); - - // Remove the only comment: the whereExists no longer matches the post. - DB::table('comments')->where('commentable_id', $post->id)->delete(); - NormCache::invalidateTableVersion(DB::getDefaultConnection(), 'comments'); - - $after = $country->posts() - ->whereExists(function ($q) { - $q->from('comments')->whereColumn('comments.commentable_id', 'posts.id'); - }) - ->get(); - - $this->assertSame([], $after->pluck('title')->all()); - } - - public function test_through_wildcard_plus_extra_column_does_not_pollute_model_cache(): void - { - $country = Country::create(['name' => 'UK']); - $author = Author::create(['name' => 'Alice', 'country_id' => $country->id]); - $post = Post::create(['title' => 'P1', 'author_id' => $author->id]); - - $country->posts()->select('posts.*')->selectRaw('2 as polluted')->get(); - - $cached = NormCache::modelCache()->getModels([$post->id], Post::class); - $this->assertArrayNotHasKey('polluted', $cached[0]->getRawOriginal()); - } - - public function test_lazy_has_many_through_cache_is_parent_specific(): void - { - $au = Country::create(['name' => 'Australia']); - $ca = Country::create(['name' => 'Canada']); - - $auAuthor = Author::create(['name' => 'Alice', 'country_id' => $au->id]); - $caAuthor = Author::create(['name' => 'Bob', 'country_id' => $ca->id]); - - Post::create(['title' => 'AU Post', 'author_id' => $auAuthor->id]); - Post::create(['title' => 'CA Post', 'author_id' => $caAuthor->id]); - - $this->assertSame(['AU Post'], $au->posts()->get()->pluck('title')->all()); - $this->assertSame(['CA Post'], $ca->posts()->get()->pluck('title')->all()); - } - - public function test_eager_has_many_through_cache_is_parent_set_specific(): void - { - $au = Country::create(['name' => 'Australia']); - $ca = Country::create(['name' => 'Canada']); - - $auAuthor = Author::create(['name' => 'Alice', 'country_id' => $au->id]); - $caAuthor = Author::create(['name' => 'Bob', 'country_id' => $ca->id]); - - Post::create(['title' => 'AU Post', 'author_id' => $auAuthor->id]); - Post::create(['title' => 'CA Post', 'author_id' => $caAuthor->id]); - - $countries = Country::with('posts')->get()->keyBy('name'); - - $this->assertSame(['AU Post'], $countries['Australia']->posts->pluck('title')->all()); - $this->assertSame(['CA Post'], $countries['Canada']->posts->pluck('title')->all()); - } -} diff --git a/tests/Integration/Cache/UnavailableTest.php b/tests/Integration/Cache/UnavailableTest.php new file mode 100644 index 0000000..cab0cde --- /dev/null +++ b/tests/Integration/Cache/UnavailableTest.php @@ -0,0 +1,180 @@ +getPdo()->exec( + "insert into authors (id, name) values (1, 'Author')" + ); + DB::connection()->getPdo()->exec( + "insert into posts + (id, title, views, published, author_id, created_at, updated_at) + values + (1, 'Live database', 0, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)" + ); + $this->useMissingRedisConnection(); + + $first = RawPost::query()->toBase()->where('id', 1)->first(); + $second = RawPost::query()->toBase()->where('id', 1)->first(); + + $this->assertSame('Live database', $first?->title); + $this->assertSame('Live database', $second?->title); + } + + public function test_cache_disabled_status_fails_open_when_redis_is_unavailable(): void + { + $store = $this->app->make(RedisStore::class); + + try { + $this->app->instance( + RedisStore::class, + new RedisStore('missing-normcache-connection'), + ); + $this->app->forgetScopedInstances(); + + $this->assertFalse($this->cacheManager()->cacheDisabled()); + $this->assertFalse($this->app->make(CacheRuntime::class)->available()); + } finally { + $this->app->instance(RedisStore::class, $store); + $this->app->forgetScopedInstances(); + } + } + + public function test_reads_stop_entering_the_cache_path_after_a_failure(): void + { + $store = $this->app->make(RedisStore::class); + + try { + $this->app->instance( + RedisStore::class, + new RedisStore('missing-normcache-connection'), + ); + $this->app->forgetScopedInstances(); + + $runtime = $this->app->make(CacheRuntime::class); + + $this->assertFalse($runtime->readable()); + $this->assertFalse($this->app->make(CacheRuntime::class)->available()); + $this->assertFalse( + $runtime->readable(), + 'a scope that has already failed must not re-enter the cache path', + ); + } finally { + $this->app->instance(RedisStore::class, $store); + $this->app->forgetScopedInstances(); + } + } + + public function test_completed_migrations_do_not_fail_when_redis_is_unavailable(): void + { + $store = $this->app->make(RedisStore::class); + + try { + $this->app->instance( + RedisStore::class, + new RedisStore('missing-normcache-connection'), + ); + $this->app->forgetScopedInstances(); + + $this->app['events']->dispatch(new MigrationsEnded('up')); + + $this->assertFalse($this->app->make(CacheRuntime::class)->available()); + } finally { + $this->app->instance(RedisStore::class, $store); + $this->app->forgetScopedInstances(); + } + } + + public function test_first_write_fails_open_when_redis_is_not_configured(): void + { + $this->useMissingRedisConnection(); + + Author::query()->toBase()->insert(['name' => 'Still written']); + Author::query()->toBase()->where('name', 'Still written')->update([ + 'name' => 'Updated', + ]); + + $this->assertTrue( + Author::query()->toBase()->where('name', 'Updated')->exists(), + ); + } + + public function test_failed_invalidation_is_logged_as_critical(): void + { + $authorId = Author::query()->toBase()->insertGetId(['name' => 'Author']); + RawPost::query()->toBase()->insert([ + 'title' => 'Before', + 'author_id' => $authorId, + ]); + + $store = $this->app->make(RedisStore::class); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('log') + ->with(LogLevel::CRITICAL); + + try { + $this->app->instance(LoggerInterface::class, $logger); + $this->app->instance( + RedisStore::class, + new RedisStore('missing-normcache-connection'), + ); + $this->app->forgetScopedInstances(); + + RawPost::query()->toBase()->where('id', 1)->update(['title' => 'After']); + } finally { + $this->app->instance(RedisStore::class, $store); + $this->app->forgetScopedInstances(); + } + } + + public function test_disabled_cache_reads_do_not_suppress_write_invalidation(): void + { + Author::query()->toBase()->insert(['id' => 1, 'name' => 'Author']); + RawPost::query()->toBase()->insert([ + 'id' => 1, + 'title' => 'Before', + 'views' => 0, + 'published' => true, + 'author_id' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + RawPost::query()->toBase()->where('id', 1)->first(); + + $table = $this->app->make(TableIdentityResolver::class) + ->resolve(DB::connection(), 'posts'); + $this->assertNotNull($table); + $versionKey = $this->cacheKeys()->version($table); + $before = $this->cacheStore()->getRaw($versionKey) ?? '0'; + + $this->app->make(CacheRuntime::class)->disable(); + RawPost::query()->toBase()->where('id', 1)->update(['title' => 'After']); + + $this->assertSame((string) ((int) $before + 1), $this->cacheStore()->getRaw($versionKey)); + $this->app->forgetScopedInstances(); + $this->assertSame('After', RawPost::query()->toBase()->where('id', 1)->first()?->title); + } + + private function useMissingRedisConnection(): void + { + $config = (array) config('normcache'); + $config['connection'] = 'missing-normcache-connection'; + $this->app->instance(CacheConfig::class, CacheConfig::fromArray($config)); + } +} diff --git a/tests/Integration/Cache/UnifiedQueryEntryTest.php b/tests/Integration/Cache/UnifiedQueryEntryTest.php new file mode 100644 index 0000000..6b57fa2 --- /dev/null +++ b/tests/Integration/Cache/UnifiedQueryEntryTest.php @@ -0,0 +1,198 @@ +create(['name' => 'Author']); + + foreach (range(1, 4) as $index) { + RawPost::query()->toBase()->insert([ + 'title' => "Post {$index}", + 'views' => $index, + 'published' => true, + 'author_id' => $author->getKey(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + + public function test_a_canonical_publish_writes_the_membership_and_overlay_to_one_key(): void + { + RawPost::query()->toBase()->orderBy('id')->get(); + + $withMembership = $this->cacheQueryKeysWithField('m'); + + $this->assertCount(1, $withMembership); + $this->assertSame( + $withMembership, + $this->cacheQueryKeysWithField('r'), + 'the membership and its overlay must land on the same query entry key', + ); + } + + public function test_an_oversized_result_clears_a_stale_overlay_but_keeps_the_membership(): void + { + $query = fn() => RawPost::query()->toBase()->orderBy('id')->get(); + $query(); + + $entryKey = $this->cacheQueryKeysWithField('m')[0]; + $this->assertNotNull($this->cacheStore()->readHashField($entryKey, 'r')); + + $originalConfig = $this->app->make(CacheConfig::class); + $config = (array) config('normcache'); + $config['auto_overlay_max_rows'] = 1; + $this->app->instance(CacheConfig::class, CacheConfig::fromArray($config)); + $this->app->forgetScopedInstances(); + + try { + // Corrupt in place so the rebuild targets the same key. + $this->cacheStore()->writeHashField($entryKey, 'r', 'corrupt'); + $this->cacheStore()->deleteHashField($entryKey, 'm'); + + $query(); + + $this->assertSame([$entryKey], $this->cacheQueryKeysWithField('m')); + $this->assertNull( + $this->cacheStore()->readHashField($entryKey, 'r'), + 'a rejected overlay must be cleared from the entry it shares with the membership', + ); + } finally { + $this->app->instance(CacheConfig::class, $originalConfig); + $this->app->forgetScopedInstances(); + } + } + + public function test_a_query_group_read_costs_a_single_pipelined_round_trip(): void + { + $this->skipWhenCommandStatsAreSharded(); + + $query = fn() => RawPost::query()->toBase() + ->join('authors', 'authors.id', '=', 'posts.author_id') + ->select('posts.id') + ->orderBy('posts.id') + ->get(); + + $query(); + + $this->assertSame( + [], + $this->cacheQueryKeysWithField('m'), + 'a joined query must take the query-group route', + ); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $calls = $this->commandCallsDuring($query); + DB::disableQueryLog(); + + $this->assertSame([], DB::getQueryLog(), 'the read must still be served from cache'); + $this->assertSame(0, $calls['hget'] ?? 0, 'the entry must not cost a standalone HGET'); + $this->assertSame(0, $calls['mget'] ?? 0, 'the cache state must not cost a second round trip'); + } + + public function test_a_canonical_overlay_read_needs_nothing_beyond_its_script(): void + { + $this->skipWhenCommandStatsAreSharded(); + + $query = fn() => RawPost::query()->toBase()->orderBy('id')->get(); + + $query(); + + $calls = $this->commandCallsDuring($query); + + $this->assertSame(0, $calls['hget'] ?? 0); + $this->assertSame( + 0, + $calls['mget'] ?? 0, + 'the canonical route resolves state inside fetch_result_or_canonical.lua', + ); + } + + public function test_promoting_an_overlay_refreshes_the_shared_query_entry_ttl(): void + { + $query = fn() => RawPost::query()->toBase()->orderBy('id')->get(); + $query(); + + $entryKey = $this->cacheQueryKeysWithField('m')[0]; + $redis = Redis::connection('normcache-test'); + + $this->cacheStore()->deleteHashField($entryKey, 'r'); + $redis->expire($entryKey, 5); + $this->assertLessThanOrEqual(5, (int) $redis->ttl($entryKey)); + + $query(); + + $this->assertGreaterThan( + 5, + (int) $redis->ttl($entryKey), + 'promoting the overlay also extends the membership: both fields share one key TTL', + ); + $this->assertNotNull($this->cacheStore()->readHashField($entryKey, 'm')); + $this->assertNotNull($this->cacheStore()->readHashField($entryKey, 'r')); + } + + public function test_a_warm_overlay_hit_leaves_the_query_entry_ttl_alone(): void + { + $query = fn() => RawPost::query()->toBase()->orderBy('id')->get(); + $query(); + + $entryKey = $this->cacheQueryKeysWithField('m')[0]; + $redis = Redis::connection('normcache-test'); + $redis->expire($entryKey, 5); + + $query(); + + $this->assertLessThanOrEqual( + 5, + (int) $redis->ttl($entryKey), + 'only a publication may refresh the entry TTL', + ); + } + + private function skipWhenCommandStatsAreSharded(): void + { + if (env('REDIS_CLUSTER') === 'true' || env('REDIS_CLUSTER') === true) { + $this->markTestSkipped('A cluster MGET is split per hash tag, so the counts differ.'); + } + } + + /** + * Raw phpredis script calls remain uncounted, so this measures client round trips. + * + * @param callable(): mixed $callback + * @return array + */ + private function commandCallsDuring(callable $callback): array + { + $connection = Redis::connection('normcache-test'); + $connection->setEventDispatcher($this->app->make('events')); + $calls = []; + + $connection->listen(static function (CommandExecuted $event) use (&$calls): void { + $command = strtolower($event->command); + $calls[$command] = ($calls[$command] ?? 0) + 1; + }); + + try { + $callback(); + } finally { + $connection->unsetEventDispatcher(); + } + + return $calls; + } +} diff --git a/tests/Integration/Cache/UnionDependencyTest.php b/tests/Integration/Cache/UnionDependencyTest.php new file mode 100644 index 0000000..76f2915 --- /dev/null +++ b/tests/Integration/Cache/UnionDependencyTest.php @@ -0,0 +1,96 @@ + 'First']); + $second = Author::create(['name' => 'Second']); + $read = fn(): array => Author::query()->toBase() + ->select(['id', 'name']) + ->where('id', $first->getKey()) + ->union( + Author::query()->toBase() + ->select(['id', 'name']) + ->where('id', $second->getKey()), + ) + ->orderBy('id') + ->pluck('name') + ->all(); + + $this->assertSame(['First', 'Second'], $read()); + Event::fake([QueryCacheHit::class]); + $this->assertSame(['First', 'Second'], $read()); + Event::assertDispatched( + QueryCacheHit::class, + fn(QueryCacheHit $event): bool => $event->route === 'result', + ); + + Author::whereKey($second->getKey())->update(['name' => 'Changed']); + + $this->assertSame(['First', 'Changed'], $read()); + } + + public function test_cross_table_union_uses_query_group_and_tracks_both_tables(): void + { + Author::create(['name' => 'Author']); + Tag::create(['name' => 'Tag']); + $read = fn(): array => Author::query()->toBase() + ->select('name') + ->union(Tag::query()->toBase()->select('name')) + ->orderBy('name') + ->pluck('name') + ->all(); + + $this->assertSame(['Author', 'Tag'], $read()); + Event::fake([QueryCacheHit::class]); + $this->assertSame(['Author', 'Tag'], $read()); + Event::assertDispatched( + QueryCacheHit::class, + fn(QueryCacheHit $event): bool => $event->route === 'query_group', + ); + + Tag::create(['name' => 'Second Tag']); + + $this->assertSame(['Author', 'Second Tag', 'Tag'], $read()); + } + + public function test_nested_union_dependencies_invalidate_the_outer_query(): void + { + $author = Author::create(['name' => 'Author']); + Post::create(['title' => 'Post', 'author_id' => $author->getKey()]); + $read = function (): array { + $authorIds = Author::query()->toBase() + ->select('id') + ->where('name', 'Missing') + ->union( + Tag::query()->toBase() + ->select('id') + ->where('name', 'Included'), + ); + + return RawPost::query()->toBase() + ->whereIn('author_id', $authorIds) + ->orderBy('id') + ->pluck('title') + ->all(); + }; + + $this->assertSame([], $read()); + $this->assertSame([], $read()); + + Tag::create(['id' => $author->getKey(), 'name' => 'Included']); + + $this->assertSame(['Post'], $read()); + } +} diff --git a/tests/Integration/Cache/VersionedPayloadStoreTest.php b/tests/Integration/Cache/VersionedPayloadStoreTest.php deleted file mode 100644 index 554bfac..0000000 --- a/tests/Integration/Cache/VersionedPayloadStoreTest.php +++ /dev/null @@ -1,226 +0,0 @@ -payloadStore(); - - $miss = $store->getOrBuild( - adapter: new ModelIndexAdapter, - build: function () use (&$builds): array { - $builds++; - - return [3, 1]; - }, - modelClass: Author::class, - hash: 'lifecycle-hit', - tag: null, - depClasses: [], - depTableKeys: [], - kind: CacheKind::ModelIndex, - ); - $hit = $store->getOrBuild( - adapter: new ModelIndexAdapter, - build: function () use (&$builds): array { - $builds++; - - return [9]; - }, - modelClass: Author::class, - hash: 'lifecycle-hit', - tag: null, - depClasses: [], - depTableKeys: [], - kind: CacheKind::ModelIndex, - ); - - $this->assertSame(CacheStatus::Miss, $miss->status); - $this->assertSame(['3', '1'], $hit->payload); - $this->assertSame(CacheStatus::Hit, $hit->status); - $this->assertSame(1, $builds); - } - - public function test_empty_payload_has_distinct_empty_status(): void - { - $store = $this->payloadStore(); - - $store->getOrBuild( - adapter: new ModelIndexAdapter, - build: fn(): array => [], - modelClass: Author::class, - hash: 'lifecycle-empty', - tag: null, - depClasses: [], - depTableKeys: [], - kind: CacheKind::ModelIndex, - ); - $hit = $store->getOrBuild( - adapter: new ModelIndexAdapter, - build: fn(): array => [1], - modelClass: Author::class, - hash: 'lifecycle-empty', - tag: null, - depClasses: [], - depTableKeys: [], - kind: CacheKind::ModelIndex, - ); - - $this->assertSame([], $hit->payload); - $this->assertSame(CacheStatus::Empty, $hit->status); - } - - public function test_corrupt_payload_is_deleted_and_rebuilt(): void - { - $store = $this->payloadStore(); - $first = $store->getOrBuild( - adapter: new ModelIndexAdapter, - build: fn(): array => [1], - modelClass: Author::class, - hash: 'lifecycle-corrupt', - tag: null, - depClasses: [], - depTableKeys: [], - kind: CacheKind::ModelIndex, - ); - $this->cacheManager()->store()->setRaw($first->key, '{"invalid":true}', 60); - - $rebuilt = $store->getOrBuild( - adapter: new ModelIndexAdapter, - build: fn(): array => [2], - modelClass: Author::class, - hash: 'lifecycle-corrupt', - tag: null, - depClasses: [], - depTableKeys: [], - kind: CacheKind::ModelIndex, - ); - - $this->assertSame(CacheStatus::Miss, $rebuilt->status); - $this->assertSame([2], $rebuilt->payload); - } - - public function test_build_exception_releases_lock_for_next_builder(): void - { - $store = $this->payloadStore(); - - try { - $store->getOrBuild( - adapter: new ModelIndexAdapter, - build: fn(): never => throw new \RuntimeException('failed build'), - modelClass: Author::class, - hash: 'lifecycle-release', - tag: null, - depClasses: [], - depTableKeys: [], - kind: CacheKind::ModelIndex, - ); - $this->fail('Expected build exception.'); - } catch (\RuntimeException $exception) { - $this->assertSame('failed build', $exception->getMessage()); - } - - $retry = $store->getOrBuild( - adapter: new ModelIndexAdapter, - build: fn(): array => [4], - modelClass: Author::class, - hash: 'lifecycle-release', - tag: null, - depClasses: [], - depTableKeys: [], - kind: CacheKind::ModelIndex, - ); - - $this->assertSame(CacheStatus::Miss, $retry->status); - $this->assertSame([4], $retry->payload); - } - - public function test_version_change_during_build_rejects_stale_store(): void - { - $manager = $this->cacheManager(); - $store = $this->payloadStore(); - $builds = 0; - - $first = $store->getOrBuild( - adapter: new ModelIndexAdapter, - build: function () use ($manager, &$builds): array { - $builds++; - $manager->forceFlushModel(Author::class); - - return [1]; - }, - modelClass: Author::class, - hash: 'lifecycle-stale', - tag: null, - depClasses: [], - depTableKeys: [], - kind: CacheKind::ModelIndex, - ); - $second = $store->getOrBuild( - adapter: new ModelIndexAdapter, - build: function () use (&$builds): array { - $builds++; - - return [2]; - }, - modelClass: Author::class, - hash: 'lifecycle-stale', - tag: null, - depClasses: [], - depTableKeys: [], - kind: CacheKind::ModelIndex, - ); - - $this->assertSame(CacheStatus::Miss, $first->status); - $this->assertSame(CacheStatus::Miss, $second->status); - $this->assertSame([2], $second->payload); - $this->assertSame(2, $builds); - } - - public function test_custom_ttl_is_applied_to_stored_payload(): void - { - $store = $this->payloadStore(); - $outcome = $store->getOrBuild( - adapter: new ModelIndexAdapter, - build: fn(): array => [1], - modelClass: Author::class, - hash: 'lifecycle-ttl', - tag: null, - depClasses: [], - depTableKeys: [], - kind: CacheKind::ModelIndex, - ttl: 30, - ); - - $ttl = Redis::connection('normcache-test')->ttl($outcome->key); - - $this->assertGreaterThan(0, $ttl); - $this->assertLessThanOrEqual(30, $ttl); - } - - private function payloadStore(): VersionedPayloadStore - { - $manager = $this->cacheManager(); - - return new VersionedPayloadStore( - $manager->store(), - $manager->keys(), - $manager->versionStore(), - $manager->config(), - $manager->config()->queryTtl, - 5, - 0, - ); - } -} diff --git a/tests/Integration/Cache/VolatileExpressionRoutingTest.php b/tests/Integration/Cache/VolatileExpressionRoutingTest.php new file mode 100644 index 0000000..844b8df --- /dev/null +++ b/tests/Integration/Cache/VolatileExpressionRoutingTest.php @@ -0,0 +1,142 @@ +create(['name' => 'Author']); + $this->postId = (int) RawPost::query()->toBase()->insertGetId([ + 'title' => 'Before', + 'views' => 1, + 'published' => true, + 'author_id' => $author->getKey(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + /** @return array */ + public static function volatileConstructions(): array + { + return [ + 'whereRaw' => [fn() => RawPost::query()->toBase()->whereRaw('views < random()')], + 'selectRaw' => [fn() => RawPost::query()->toBase()->selectRaw('id, random() as r')], + 'orderByRaw' => [fn() => RawPost::query()->toBase()->orderByRaw('random()')], + 'havingRaw' => [fn() => RawPost::query()->toBase() + ->selectRaw('author_id') + ->groupBy('author_id') + ->havingRaw('max(views) > random()')], + 'groupByRaw' => [fn() => RawPost::query()->toBase() + ->selectRaw('count(*) as c') + ->groupByRaw('views + random()')], + 'DB::raw value' => [fn() => RawPost::query()->toBase() + ->where('views', '<', DB::raw('random()'))], + 'selectSub' => [fn() => RawPost::query()->toBase() + ->selectSub(fn($q) => $q->selectRaw('random()'), 'r')], + 'whereIn sub' => [fn() => RawPost::query()->toBase() + ->whereIn('views', fn($q) => $q->selectRaw('random()')->from('posts'))], + ]; + } + + #[DataProvider('volatileConstructions')] + public function test_every_raw_entry_point_bypasses_as_volatile(\Closure $build): void + { + $reasons = []; + Event::listen(QueryBypassed::class, function (QueryBypassed $event) use (&$reasons): void { + $reasons[] = $event->reason; + }); + + $build()->dependsOn(['posts'])->get(); + + $this->assertContains( + 'volatile_expression', + $reasons, + 'the volatile fragment must be found in the builder structure', + ); + } + + public function test_a_volatile_source_expression_bypasses_despite_declared_dependencies(): void + { + $reasons = []; + Event::listen(QueryBypassed::class, function (QueryBypassed $event) use (&$reasons): void { + $reasons[] = $event->reason; + }); + + RawPost::query()->toBase() + ->fromRaw('(select random() as r) x') + ->dependsOn(['posts']) + ->get(); + + $this->assertContains('volatile_expression', $reasons); + } + + /** @return array */ + public static function reservedColumnNames(): array + { + return [ + 'current_role' => ['current_role'], + 'current_user' => ['current_user'], + 'current_date' => ['current_date'], + 'localtime' => ['localtime'], + ]; + } + + #[DataProvider('reservedColumnNames')] + public function test_a_column_named_after_a_volatile_function_is_still_cached(string $column): void + { + Schema::create('reserved_name_records', function (Blueprint $table) use ($column): void { + $table->id(); + $table->string($column)->nullable(); + }); + + try { + ReservedNameRecord::query()->create(['id' => 1, $column => 'value']); + + $read = fn() => ReservedNameRecord::query()->where($column, 'value')->get(); + + $this->assertCount(1, $read()); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $this->assertCount(1, $read()); + DB::disableQueryLog(); + + $this->assertSame( + [], + DB::getQueryLog(), + "a column named {$column} must not disable caching", + ); + } finally { + Schema::dropIfExists('reserved_name_records'); + } + } +} diff --git a/tests/Integration/Cache/WhereHasCachingTest.php b/tests/Integration/Cache/WhereHasCachingTest.php deleted file mode 100644 index 16ef0e9..0000000 --- a/tests/Integration/Cache/WhereHasCachingTest.php +++ /dev/null @@ -1,191 +0,0 @@ -prepareCacheExecution(); - $plan = $prepared->builder->cachePlan($prepared->base, CachePlanContext::models()); - - $this->assertTrue($plan->dependencies->safe); - $this->assertContains('testing:posts', $plan->dependencies->tables); - } - - // HasMany — cache routing - - public function test_simple_wherehas_hasmany_uses_result_cache(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Author::whereHas('posts')->get(); - - $this->assertNotEmpty($this->redisKeys('result:*')); - } - - public function test_simple_wherehas_hasmany_invalidates_on_new_related_row(): void - { - $author = Author::create(['name' => 'Alice']); - Author::create(['name' => 'Bob']); - - $first = Author::whereHas('posts')->get(); - $this->assertSame([], $first->pluck('id')->all()); - - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - $second = Author::whereHas('posts')->get(); - $this->assertSame([$author->id], $second->pluck('id')->all()); - } - - // Constraint closures - - public function test_wherehas_with_safe_constraint_invalidates_on_dependency_change(): void - { - $author = Author::create(['name' => 'Alice']); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id, 'published' => true]); - - $first = Author::whereHas('posts', fn($q) => $q->where('published', true))->get(); - $this->assertSame([$author->id], $first->pluck('id')->all()); - - $post->update(['published' => false]); - - $second = Author::whereHas('posts', fn($q) => $q->where('published', true))->get(); - $this->assertSame([], $second->pluck('id')->all()); - } - - public function test_wherehas_with_unsafe_constraint_bypasses(): void - { - $result = Author::whereHas('posts', fn($q) => $q->whereRaw('views > 0'))->explain(); - - $this->assertStringStartsWith('not cached', $result); - $this->assertStringContainsString("can't infer cache dependency", $result); - } - - public function test_wherehas_relation_definition_with_lock_bypasses(): void - { - $result = Author::whereHas('lockedPosts')->explain(); - - $this->assertStringStartsWith('not cached', $result); - } - - public function test_wherehas_relation_definition_with_without_cache_remains_cacheable(): void - { - $result = Author::whereHas('cacheSkippedPosts')->explain(); - - $this->assertStringStartsWith('cached', $result); - } - - public function test_wherehas_relation_on_non_cacheable_model_bypasses(): void - { - $result = Author::whereHas('uncachedPosts')->explain(); - - $this->assertStringStartsWith('not cached', $result); - } - - // whereDoesntHave / orWhereHas / count thresholds - - public function test_wheredoesnthave_caches_and_invalidates_on_membership_change(): void - { - $author = Author::create(['name' => 'Alice']); - $other = Author::create(['name' => 'Bob']); - Post::create(['title' => 'Hello', 'author_id' => $other->id]); - - $first = Author::whereDoesntHave('posts')->get(); - $this->assertSame([$author->id], $first->pluck('id')->all()); - - Post::create(['title' => 'New', 'author_id' => $author->id]); - - $second = Author::whereDoesntHave('posts')->get(); - $this->assertSame([], $second->pluck('id')->all()); - } - - public function test_count_threshold_has_bypasses(): void - { - $result = Author::has('posts', '>', 1)->explain(); - - $this->assertStringStartsWith('not cached', $result); - } - - // BelongsToMany — pivot table dependency - - public function test_wherehas_belongstomany_caches_and_invalidates_on_pivot_change(): void - { - $author = Author::create(['name' => 'Alice']); - $php = Tag::create(['name' => 'php']); - Author::create(['name' => 'Bob']); - - $first = Author::whereHas('tags')->get(); - $this->assertSame([], $first->pluck('id')->all()); - - $author->tags()->attach($php->id); - - $second = Author::whereHas('tags')->get(); - $this->assertSame([$author->id], $second->pluck('id')->all()); - } - - // HasManyThrough — through-parent dependency - - public function test_wherehas_hasmanythrough_caches_and_invalidates_on_through_parent_change(): void - { - $uk = Country::create(['name' => 'UK']); - $us = Country::create(['name' => 'US']); - $author = Author::create(['name' => 'Alice', 'country_id' => $us->id]); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - $first = Country::whereHas('posts')->get(); - $this->assertSame([$us->id], $first->pluck('id')->all()); - - $author->update(['country_id' => $uk->id]); - - $second = Country::whereHas('posts')->get(); - $this->assertSame([$uk->id], $second->pluck('id')->all()); - } - - // MorphMany allowed, MorphTo bails - - public function test_wherehas_morphto_bails(): void - { - $author = Author::create(['name' => 'Alice']); - Comment::create(['body' => 'Hi', 'commentable_type' => Author::class, 'commentable_id' => $author->id]); - - $result = Comment::whereHas('commentable')->explain(); - - $this->assertStringStartsWith('not cached', $result); - $this->assertStringContainsString("can't infer cache dependency", $result); - } - - // Nested/dotted — bails - - public function test_wherehas_nested_dotted_bails(): void - { - $result = Author::whereHas('posts.comments')->explain(); - - $this->assertStringStartsWith('not cached', $result); - $this->assertStringContainsString("can't infer cache dependency", $result); - } - - // Mixed query — non-simple predicate forces whole-query bypass - - public function test_mixed_query_with_raw_where_bypasses_despite_simple_wherehas(): void - { - $result = Author::whereHas('posts')->whereRaw('1 = 1')->explain(); - - $this->assertStringStartsWith('not cached', $result); - } -} diff --git a/tests/Integration/Cache/WriteInvalidationTest.php b/tests/Integration/Cache/WriteInvalidationTest.php new file mode 100644 index 0000000..23b9033 --- /dev/null +++ b/tests/Integration/Cache/WriteInvalidationTest.php @@ -0,0 +1,798 @@ +throwAfterNextAffectingStatement) { + $this->throwAfterNextAffectingStatement = false; + + throw new \RuntimeException('The database applied the write but the response was lost.'); + } + + return $affected; + } +} + +final class CommitFailingPdo extends \PDO +{ + public function commit(): bool + { + throw new \PDOException('SQLSTATE[HY000]: server has gone away'); + } +} + +final class WriteInvalidationTest extends TestCase +{ + private int $postId; + + protected function setUp(): void + { + parent::setUp(); + + $author = Author::query()->create(['name' => 'Author']); + $this->postId = (int) RawPost::query()->toBase()->insertGetId([ + 'title' => 'Before', + 'views' => 0, + 'published' => true, + 'author_id' => $author->getKey(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + public function test_cacheable_base_builder_update_invalidates_warm_results(): void + { + $read = fn() => RawPost::query()->toBase()->where('id', $this->postId)->value('title'); + + $this->assertSame('Before', $read()); + $this->assertSame('Before', $read()); + + RawPost::query()->toBase()->where('id', $this->postId)->update(['title' => 'After']); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $this->assertSame('After', $read()); + $this->assertSame('After', $read()); + DB::disableQueryLog(); + + $this->assertCount(1, DB::getQueryLog()); + } + + public function test_db_table_write_remains_stale_until_explicit_invalidation(): void + { + $read = fn() => RawPost::query()->toBase()->where('id', $this->postId)->value('title'); + + $this->assertSame('Before', $read()); + $this->assertSame('Before', $read()); + + DB::table('posts') + ->where('id', $this->postId) + ->update(['title' => 'Manual']); + + $this->assertSame('Before', $read()); + $this->assertTrue(NormCache::invalidate(Post::class)); + $this->assertSame('Manual', $read()); + } + + public function test_manual_invalidation_is_immediate_outside_a_transaction(): void + { + RawPost::query()->toBase()->where('id', $this->postId)->get(); + $before = $this->tableVersion(); + + $this->assertTrue(NormCache::invalidate(Post::class)); + + $this->assertSame((string) ((int) $before + 1), $this->tableVersion()); + } + + public function test_manual_invalidation_inside_a_transaction_waits_for_commit(): void + { + $read = fn() => RawPost::query()->toBase()->where('id', $this->postId)->value('title'); + $this->assertSame('Before', $read()); + $before = $this->tableVersion(); + + DB::beginTransaction(); + DB::table('posts')->where('id', $this->postId)->update(['title' => 'Committed manual']); + $this->assertTrue(NormCache::invalidate(Post::class)); + $this->assertSame($before, $this->tableVersion()); + DB::commit(); + + $this->assertSame((string) ((int) $before + 1), $this->tableVersion()); + $this->assertSame('Committed manual', $read()); + } + + public function test_manual_invalidation_inside_a_rolled_back_transaction_is_discarded(): void + { + $read = fn() => RawPost::query()->toBase()->where('id', $this->postId)->value('title'); + $this->assertSame('Before', $read()); + $before = $this->tableVersion(); + + DB::beginTransaction(); + DB::table('posts')->where('id', $this->postId)->update(['title' => 'Rolled back manual']); + $this->assertTrue(NormCache::invalidate(Post::class)); + DB::rollBack(); + + $this->assertSame($before, $this->tableVersion()); + $this->assertSame('Before', $read()); + } + + public function test_applied_write_that_throws_invalidates_before_rethrowing(): void + { + $name = 'uncertain-write'; + $database = (string) DB::connection()->getDatabaseName(); + + config()->set("database.connections.{$name}", [ + 'driver' => 'sqlite', + 'database' => $database, + 'prefix' => '', + 'name' => $name, + 'normcache_scope' => $name, + ]); + DB::extend($name, static fn(array $config) => new AppliedThenThrowsConnection( + new \PDO('sqlite:' . $database), + $database, + '', + $config, + )); + DB::purge($name); + + try { + $connection = DB::connection($name); + $this->assertInstanceOf(AppliedThenThrowsConnection::class, $connection); + $postId = $this->postId; + $read = static fn() => RawPost::on($name) + ->toBase() + ->where('id', $postId) + ->value('title'); + + $this->assertSame('Before', $read()); + $this->assertSame('Before', $read()); + $connection->throwAfterNextAffectingStatement = true; + + try { + RawPost::on($name) + ->toBase() + ->where('id', $this->postId) + ->update(['title' => 'Applied then thrown']); + $this->fail('The simulated lost response was not thrown.'); + } catch (\RuntimeException $exception) { + $this->assertSame( + 'The database applied the write but the response was lost.', + $exception->getMessage(), + ); + } + + $this->assertSame('Applied then thrown', $read()); + $connection->throwAfterNextAffectingStatement = true; + + try { + RawPost::on($name) + ->toBase() + ->updateOrInsert( + ['id' => $postId], + ['title' => 'Nested applied then thrown'], + ); + $this->fail('The nested simulated lost response was not thrown.'); + } catch (\RuntimeException $exception) { + $this->assertSame( + 'The database applied the write but the response was lost.', + $exception->getMessage(), + ); + } + + $this->assertSame('Nested applied then thrown', $read()); + } finally { + DB::disconnect($name); + DB::purge($name); + DB::forgetExtension($name); + } + } + + public function test_sqlite_identifier_case_variants_share_invalidation_state(): void + { + $read = fn() => RawPost::query()->toBase() + ->where('id', $this->postId) + ->value('title'); + + $this->assertSame('Before', $read()); + $this->assertSame('Before', $read()); + + RawPost::query()->toBase()->from('POSTS')->where('id', $this->postId)->update(['title' => 'Case-safe']); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $actual = $read(); + DB::disableQueryLog(); + + $this->assertSame('Case-safe', $actual); + $this->assertCount(1, DB::getQueryLog()); + } + + public function test_traitless_eloquent_write_remains_stale_until_explicit_invalidation(): void + { + $this->assertSame('Before', Post::query()->findOrFail($this->postId)->title); + $this->assertSame('Before', Post::query()->findOrFail($this->postId)->title); + + UncachedPost::query()->whereKey($this->postId)->update(['title' => 'Traitless']); + + $this->assertSame('Before', Post::query()->findOrFail($this->postId)->title); + $this->assertTrue(NormCache::invalidate(Post::class)); + $this->assertSame('Traitless', Post::query()->findOrFail($this->postId)->title); + } + + public function test_raw_predicate_widening_a_write_evicts_the_rows_it_reached(): void + { + $otherId = (int) RawPost::query()->toBase()->insertGetId([ + 'title' => 'Before', + 'views' => 0, + 'published' => true, + 'author_id' => Author::query()->value('id'), + 'created_at' => now(), + 'updated_at' => now(), + ]); + // Exercise generation-scoped rows that precise invalidation evicts by token. + $read = static fn(int $id): string => RawPost::query() + ->toBase() + ->where('id', $id) + ->first() + ->title; + + $this->assertSame('Before', $read($this->postId)); + $this->assertSame('Before', $read($otherId)); + + // The ungrouped OR reaches both rows. + $affected = RawPost::query()->toBase() + ->where('id', $this->postId) + ->whereRaw("1 = 1 or id = {$otherId}") + ->update(['title' => 'After']); + + $this->assertSame(2, $affected); + $this->assertSame('After', $read($this->postId)); + $this->assertSame('After', $read($otherId)); + } + + public function test_transaction_writes_publish_no_invalidation_before_commit(): void + { + RawPost::query()->toBase()->where('id', $this->postId)->get(); + $before = $this->tableVersion(); + + DB::beginTransaction(); + RawPost::query()->toBase()->where('id', $this->postId)->update(['title' => 'Committed']); + $this->assertSame($before, $this->tableVersion()); + DB::commit(); + + $this->assertSame((string) ((int) $before + 1), $this->tableVersion()); + } + + public function test_a_commit_that_throws_has_its_queue_drained_by_the_next_transaction(): void + { + $read = fn(): ?string => RawPost::query()->toBase()->where('id', $this->postId)->value('title'); + $this->assertSame('Before', $read()); + $before = $this->tableVersion(); + + $database = (string) DB::connection()->getConfig('database'); + DB::connection()->setPdo(new CommitFailingPdo('sqlite:' . $database)); + + DB::beginTransaction(); + RawPost::query()->toBase()->where('id', $this->postId)->update(['title' => 'Ambiguous']); + + try { + DB::commit(); + $this->fail('The simulated commit failure was not thrown.'); + } catch (\PDOException) { + // Laravel emits no transaction completion event here. + } + + $this->assertSame($before, $this->tableVersion(), 'nothing can drain it yet'); + + DB::connection()->setPdo(new \PDO('sqlite:' . $database)); + DB::beginTransaction(); + DB::rollBack(); + + $this->assertNotSame( + $before, + $this->tableVersion(), + 'the abandoned queue must be drained when the next transaction opens', + ); + + // The failed commit left the row unchanged; the query log proves cache eviction. + DB::flushQueryLog(); + DB::enableQueryLog(); + $this->assertSame('Before', $read()); + DB::disableQueryLog(); + + $this->assertCount(1, DB::getQueryLog()); + } + + public function test_an_inner_rollback_does_not_discard_the_outer_transactions_invalidation(): void + { + $read = fn(): ?string => RawPost::query()->toBase()->where('id', $this->postId)->value('title'); + + $this->assertSame('Before', $read()); + $before = $this->tableVersion(); + + DB::beginTransaction(); + RawPost::query()->toBase()->where('id', $this->postId)->update(['title' => 'Outer']); + DB::beginTransaction(); + DB::rollBack(); + DB::commit(); + + $this->assertNotSame($before, $this->tableVersion()); + $this->assertSame('Outer', $read()); + } + + public function test_outer_transaction_rollback_discards_pending_invalidation(): void + { + RawPost::query()->toBase()->where('id', $this->postId)->get(); + $before = $this->tableVersion(); + + DB::beginTransaction(); + RawPost::query()->toBase()->where('id', $this->postId)->update(['title' => 'Rolled back']); + DB::rollBack(); + + $this->assertSame($before, $this->tableVersion()); + $this->assertSame( + 'Before', + RawPost::query()->toBase()->where('id', $this->postId)->value('title'), + ); + } + + public function test_transaction_invalidation_is_published_before_after_commit_callbacks(): void + { + $read = fn(): ?string => RawPost::query()->toBase()->where('id', $this->postId)->value('title'); + + $this->assertSame('Before', $read()); + $this->assertSame('Before', $read()); + $observed = null; + + DB::transaction(function () use (&$observed, $read): void { + RawPost::query()->toBase()->where('id', $this->postId)->update(['title' => 'Committed']); + DB::afterCommit(function () use (&$observed, $read): void { + $observed = $read(); + }); + }); + + $this->assertSame('Committed', $observed); + } + + public function test_transaction_invalidation_precedes_a_callback_registered_before_the_write(): void + { + $read = fn(): ?string => RawPost::query()->toBase()->where('id', $this->postId)->value('title'); + + $this->assertSame('Before', $read()); + $this->assertSame('Before', $read()); + $observed = null; + + DB::transaction(function () use (&$observed, $read): void { + DB::afterCommit(function () use (&$observed, $read): void { + $observed = $read(); + }); + RawPost::query()->toBase()->where('id', $this->postId)->update(['title' => 'Committed']); + }); + + $this->assertSame('Committed', $observed); + } + + public function test_transaction_invalidation_precedes_a_callback_registered_in_a_nested_transaction(): void + { + $read = fn(): ?string => RawPost::query()->toBase()->where('id', $this->postId)->value('title'); + + $this->assertSame('Before', $read()); + $this->assertSame('Before', $read()); + $observed = null; + + DB::transaction(function () use (&$observed, $read): void { + DB::transaction(function () use (&$observed, $read): void { + RawPost::query()->toBase() + ->where('id', $this->postId) + ->update(['title' => 'Committed']); + + DB::afterCommit(function () use (&$observed, $read): void { + $observed = $read(); + }); + }); + }); + + $this->assertSame('Committed', $observed); + } + + public function test_update_or_insert_internal_exists_is_live_and_invalidates_once(): void + { + RawPost::query()->toBase()->where('id', $this->postId)->exists(); + $before = (int) $this->tableVersion(); + + RawPost::query()->toBase()->updateOrInsert( + ['id' => $this->postId], + ['title' => 'Composite'], + ); + + $this->assertSame($before + 1, (int) $this->tableVersion()); + $this->assertSame('Composite', RawPost::query()->toBase()->where('id', $this->postId)->value('title')); + } + + public function test_update_or_insert_existing_row_without_values_does_not_invalidate(): void + { + RawPost::query()->toBase()->where('id', $this->postId)->get(); + $before = $this->tableVersion(); + + $this->assertTrue(RawPost::query()->toBase()->updateOrInsert(['id' => $this->postId])); + + $this->assertSame($before, $this->tableVersion()); + } + + public function test_update_or_insert_missing_row_without_values_still_invalidates(): void + { + RawPost::query()->toBase()->where('id', $this->postId)->get(); + $before = (int) $this->tableVersion(); + + $this->assertTrue(RawPost::query()->toBase()->updateOrInsert([ + 'id' => $this->postId + 1, + 'title' => 'Inserted', + 'author_id' => Author::query()->value('id'), + 'created_at' => now(), + 'updated_at' => now(), + ])); + + $this->assertSame($before + 1, (int) $this->tableVersion()); + } + + public function test_update_matching_no_rows_does_not_invalidate(): void + { + RawPost::query()->toBase()->where('id', $this->postId)->get(); + $before = $this->tableVersion(); + + $this->assertSame(0, RawPost::query()->toBase()->where('id', -1)->update(['title' => 'Nobody'])); + + $this->assertSame($before, $this->tableVersion()); + } + + public function test_update_or_insert_invalidates_exactly_when_its_nested_update_reports_rows(): void + { + $values = ['title' => 'Unchanged']; + RawPost::query()->toBase()->where('id', $this->postId)->update($values); + + // Drivers disagree whether value-preserving updates affect a row. + $affected = RawPost::query()->toBase()->where('id', $this->postId)->update($values); + + RawPost::query()->toBase()->where('id', $this->postId)->get(); + $before = (int) $this->tableVersion(); + + $this->assertSame( + $affected > 0, + RawPost::query()->toBase()->updateOrInsert(['id' => $this->postId], $values), + ); + $this->assertSame( + $before + ($affected > 0 ? 1 : 0), + (int) $this->tableVersion(), + 'updateOrInsert must invalidate on the same terms as the update it delegates to', + ); + } + + public function test_proven_primary_key_update_preserves_unrelated_canonical_rows(): void + { + $second = RawPost::query()->toBase()->insertGetId([ + 'title' => 'Second', + 'views' => 0, + 'published' => true, + 'author_id' => Author::query()->toBase()->value('id'), + 'created_at' => now(), + 'updated_at' => now(), + ]); + RawPost::query()->toBase()->orderBy('id')->get(); + + $identity = app(TableIdentityResolver::class) + ->resolve(DB::connection(), 'posts'); + $keys = $this->cacheKeys(); + $generationBefore = $this->cacheStore()->getRaw($keys->generation($identity)) ?? '0'; + $secondRowKey = $keys->row($identity, $generationBefore, 'i:' . $second); + + $this->assertNotNull($this->cacheStore()->getRaw($secondRowKey)); + + RawPost::query()->toBase()->where('id', $this->postId)->update(['title' => 'Precise']); + + $this->assertSame( + $generationBefore, + $this->cacheStore()->getRaw($keys->generation($identity)) ?? '0', + ); + $this->assertNotNull($this->cacheStore()->getRaw($secondRowKey)); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $row = RawPost::query()->toBase()->where('id', $second)->first(); + DB::disableQueryLog(); + + $this->assertSame('Second', $row->title); + $this->assertSame([], DB::getQueryLog()); + } + + public function test_eloquent_joined_update_on_related_primary_key_uses_broad_invalidation(): void + { + $second = Post::query()->create([ + 'title' => 'Second', + 'views' => 0, + 'published' => true, + 'author_id' => Author::query()->value('id'), + ]); + Post::query()->orderBy('id')->get(); + + $identity = app(TableIdentityResolver::class) + ->resolve(DB::connection(), 'posts'); + $keys = $this->cacheKeys(); + $generationBefore = $this->cacheStore()->getRaw($keys->generation($identity)) ?? '0'; + + Post::query() + ->join('authors', 'authors.id', '=', 'posts.author_id') + ->where('authors.id', Author::query()->value('id')) + ->update(['title' => 'Joined']); + + $this->assertSame( + (string) ((int) $generationBefore + 1), + $this->cacheStore()->getRaw($keys->generation($identity)), + ); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $reloaded = Post::query()->findOrFail($second->getKey()); + DB::disableQueryLog(); + + $this->assertSame('Joined', $reloaded->title); + $this->assertCount(1, DB::getQueryLog()); + } + + public function test_primary_key_mutation_deletes_the_cached_old_token_without_advancing_generation(): void + { + RawPost::query()->toBase()->orderBy('id')->get(); + $identity = app(TableIdentityResolver::class) + ->resolve(DB::connection(), 'posts'); + $keys = $this->cacheKeys(); + $generation = $this->cacheStore()->getRaw($keys->generation($identity)) ?? '0'; + $version = $this->cacheStore()->getRaw($keys->version($identity)) ?? '0'; + $oldRow = $keys->row($identity, $generation, 'i:' . $this->postId); + $newId = $this->postId + 1000; + + RawPost::query()->toBase() + ->where('id', $this->postId) + ->update(['id' => $newId]); + + $this->assertNull($this->cacheStore()->getRaw($oldRow)); + $this->assertSame( + (string) ((int) $version + 1), + $this->cacheStore()->getRaw($keys->version($identity)), + ); + $this->assertSame( + $generation, + $this->cacheStore()->getRaw($keys->generation($identity)) ?? '0', + ); + } + + public function test_unprovable_primary_key_mutation_promotes_to_generation_invalidation(): void + { + $identity = app(TableIdentityResolver::class) + ->resolve(DB::connection(), 'posts'); + $keys = $this->cacheKeys(); + $before = $this->cacheStore()->getRaw($keys->generation($identity)) ?? '0'; + + RawPost::query()->toBase() + ->where('id', $this->postId) + ->update(['id' => DB::raw('id + 1000')]); + + $this->assertSame( + (string) ((int) $before + 1), + $this->cacheStore()->getRaw($keys->generation($identity)), + ); + } + + public function test_string_primary_key_mutations_invalidate_broadly(): void + { + UuidItem::query()->create(['id' => 'abc', 'name' => 'Before']); + UuidItem::query()->get(); + $identity = app(TableIdentityResolver::class) + ->resolve(DB::connection(), 'uuid_items'); + $keys = $this->cacheKeys(); + $generation = $this->cacheStore()->getRaw($keys->generation($identity)) ?? '0'; + $version = $this->cacheStore()->getRaw($keys->version($identity)) ?? '0'; + $primaryKey = new PrimaryKeyMetadata('id', PrimaryKeyMetadata::STRING); + $token = $primaryKey->token('abc'); + + $this->assertNotNull($token); + $row = $keys->row($identity, $generation, $token); + $this->assertNotNull($this->cacheStore()->getRaw($row)); + + UuidItem::query()->whereKey('abc')->update(['name' => 'After']); + + $this->assertSame( + (string) ((int) $generation + 1), + $this->cacheStore()->getRaw($keys->generation($identity)), + 'string keys compare case-insensitively under common collations, so a row token is not proof of row identity', + ); + $this->assertSame( + (string) ((int) $version + 1), + $this->cacheStore()->getRaw($keys->version($identity)), + ); + } + + public function test_insert_get_id_preserves_string_processor_results(): void + { + $connection = DB::connection(); + $processor = new class extends Processor + { + public function processInsertGetId($query, $sql, $values, $sequence = null) + { + return 'generated-uuid'; + } + }; + $builder = new QueryBuilder( + $connection, + $connection->getQueryGrammar(), + $processor, + ); + $builder->enableCachingForModel(UuidItem::class, 'id', 'string') + ->from('uuid_items'); + + $this->assertSame( + 'generated-uuid', + $builder->insertGetId(['name' => 'Ignored by fake processor']), + ); + } + + public function test_precise_invalidation_deletes_the_atomically_resolved_current_generation(): void + { + $this->cacheManager()->invalidateTable('testing', 'posts'); + RawPost::query()->toBase()->orderBy('id')->get(); + $identity = app(TableIdentityResolver::class) + ->resolve(DB::connection(), 'posts'); + $keys = $this->cacheKeys(); + $generation = $this->cacheStore()->getRaw($keys->generation($identity)); + $rowKey = $keys->row($identity, (string) $generation, 'i:' . $this->postId); + + $this->assertNotNull($this->cacheStore()->getRaw($rowKey)); + RawPost::query()->toBase()->where('id', $this->postId)->update(['title' => 'Current generation']); + $this->assertNull($this->cacheStore()->getRaw($rowKey)); + } + + public function test_truncate_broadly_invalidates_all_rows_in_its_table(): void + { + $first = Tag::create(['name' => 'First']); + $second = Tag::create(['name' => 'Second']); + + $this->assertCount(2, Tag::orderBy('id')->get()); + $this->assertNotNull(Tag::find($first->getKey())); + $this->assertNotNull(Tag::find($second->getKey())); + $identity = app(TableIdentityResolver::class) + ->resolve(DB::connection(), 'tags'); + $generation = $this->cacheStore()->getRaw( + $this->cacheKeys()->generation($identity), + ) ?? '0'; + $epoch = (int) ($this->cacheStore()->getRaw($this->cacheKeys()->epoch()) ?? '0'); + + Tag::query()->toBase()->truncate(); + + $this->assertSame( + $epoch, + (int) ($this->cacheStore()->getRaw($this->cacheKeys()->epoch()) ?? '0'), + ); + $this->assertSame( + (string) ((int) $generation + 1), + $this->cacheStore()->getRaw($this->cacheKeys()->generation($identity)), + ); + $this->assertSame([], Tag::orderBy('id')->get()->all()); + $this->assertNull(Tag::find($first->getKey())); + $this->assertNull(Tag::find($second->getKey())); + } + + public function test_insert_using_invalidates_the_target_table(): void + { + $source = Author::create(['name' => 'Copied']); + $read = fn(): array => Tag::orderBy('name')->pluck('name')->all(); + + $this->assertSame([], $read()); + $this->assertSame([], $read()); + + $affected = Tag::query()->toBase()->insertUsing( + ['name', 'created_at', 'updated_at'], + Author::query()->toBase() + ->where('id', $source->getKey()) + ->select(['name', 'created_at', 'updated_at']), + ); + + $this->assertSame(1, $affected); + $this->assertSame(['Copied'], $read()); + } + + public function test_insert_or_ignore_using_invalidates_the_target_table(): void + { + $source = Author::create(['name' => 'Copied']); + $read = fn(): array => UuidItem::orderBy('id')->pluck('name', 'id')->all(); + + $this->assertSame([], $read()); + $this->assertSame([], $read()); + + $affected = UuidItem::query()->toBase()->insertOrIgnoreUsing( + ['id', 'name'], + Author::query()->toBase() + ->where('id', $source->getKey()) + ->select(['id', 'name']), + ); + + $this->assertSame(1, $affected); + $this->assertSame([$source->getKey() => 'Copied'], $read()); + } + + public function test_insert_or_ignore_returning_invalidates_the_target_table(): void + { + if (!method_exists(LaravelQueryBuilder::class, 'insertOrIgnoreReturning')) { + $this->markTestSkipped('insertOrIgnoreReturning requires this Laravel version.'); + } + + $read = fn(): array => Tag::orderBy('id')->pluck('name', 'id')->all(); + + $this->assertSame([], $read()); + $this->assertSame([], $read()); + + $returned = Tag::query()->toBase()->insertOrIgnoreReturning([ + 'id' => 10, + 'name' => 'Returned', + 'created_at' => now(), + 'updated_at' => now(), + ], ['id', 'name']); + + $this->assertCount(1, $returned); + $this->assertSame(10, $returned->first()->id); + $this->assertSame([10 => 'Returned'], $read()); + } + + public function test_update_from_invalidates_the_target_table(): void + { + $author = Author::create(['name' => 'Before']); + $read = fn(): string => Author::whereKey($author->getKey())->value('name'); + + $this->assertSame('Before', $read()); + $this->assertSame('Before', $read()); + + $connection = DB::connection(); + $builder = new QueryBuilder( + $connection, + new PostgresGrammar($connection), + $connection->getPostProcessor(), + ); + $builder->enableCachingForModel(Author::class, 'id', 'int') + ->from('authors') + ->where('id', $author->getKey()); + + $this->assertSame(1, $builder->updateFrom(['name' => 'After'])); + $this->assertSame('After', $read()); + } + + private function tableVersion(): string + { + $identity = app(TableIdentityResolver::class) + ->resolve(DB::connection(), 'posts'); + + return $this->cacheStore()->getRaw( + $this->cacheKeys()->version($identity), + ) ?? '0'; + } +} diff --git a/tests/Integration/Console/DisableCommandTest.php b/tests/Integration/Console/DisableCommandTest.php new file mode 100644 index 0000000..f526d2e --- /dev/null +++ b/tests/Integration/Console/DisableCommandTest.php @@ -0,0 +1,36 @@ +artisan('normcache:disable') + ->expectsOutputToContain('NormCache disabled.') + ->assertSuccessful(); + + // Verify through a scope without the command's memoized state. + $this->app->forgetScopedInstances(); + + $this->assertTrue(NormCache::cacheDisabled()); + } + + public function test_is_a_no_op_when_the_cache_is_already_off_by_configuration(): void + { + $config = (array) config('normcache'); + $config['enabled'] = false; + $this->app->instance(CacheConfig::class, CacheConfig::fromArray($config)); + $this->app->forgetScopedInstances(); + + $this->artisan('normcache:disable') + ->expectsOutputToContain('already disabled by configuration') + ->assertSuccessful(); + + $this->assertNull($this->cacheStore()->getRaw($this->cacheKeys()->disabled())); + } +} diff --git a/tests/Integration/Console/EnableCommandTest.php b/tests/Integration/Console/EnableCommandTest.php new file mode 100644 index 0000000..6b42b97 --- /dev/null +++ b/tests/Integration/Console/EnableCommandTest.php @@ -0,0 +1,37 @@ +cacheStore()->getRaw($this->cacheKeys()->epoch()) ?? '0'); + + $this->artisan('normcache:disable')->assertSuccessful(); + $this->app->forgetScopedInstances(); + + $this->artisan('normcache:enable') + ->expectsOutputToContain('epoch ' . ($before + 1)) + ->assertSuccessful(); + + $this->assertFalse(NormCache::cacheDisabled()); + $this->assertNull($this->cacheStore()->getRaw($this->cacheKeys()->disabled())); + } + + public function test_refuses_when_the_cache_is_off_by_configuration(): void + { + $config = (array) config('normcache'); + $config['enabled'] = false; + $this->app->instance(CacheConfig::class, CacheConfig::fromArray($config)); + $this->app->forgetScopedInstances(); + + $this->artisan('normcache:enable') + ->expectsOutputToContain('NORMCACHE_ENABLED=true') + ->assertFailed(); + } +} diff --git a/tests/Integration/Console/FlushCommandTest.php b/tests/Integration/Console/FlushCommandTest.php new file mode 100644 index 0000000..dd5508a --- /dev/null +++ b/tests/Integration/Console/FlushCommandTest.php @@ -0,0 +1,49 @@ + 'Alice']); + + $query = static fn() => Author::orderBy('id')->get(); + $query(); + $query(); + + $before = $this->cacheStore()->getRaw($this->cacheKeys()->epoch()); + + $this->artisan('normcache:flush') + ->expectsOutputToContain('NormCache global epoch advanced.') + ->assertSuccessful(); + + $this->assertNotSame( + $before, + $this->cacheStore()->getRaw($this->cacheKeys()->epoch()), + ); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $query(); + $queries = DB::getQueryLog(); + DB::disableQueryLog(); + + $this->assertNotSame([], $queries, 'a flushed cache must fall back to the database'); + } + + public function test_reports_failure_when_the_epoch_cannot_be_advanced(): void + { + $this->app->instance(RedisStore::class, new RedisStore('missing-normcache-connection')); + $this->app->forgetScopedInstances(); + + $this->artisan('normcache:flush') + ->expectsOutputToContain('NormCache global invalidation failed.') + ->assertFailed(); + } +} diff --git a/tests/Integration/Contract/AdvancedQueryShapeContractTest.php b/tests/Integration/Contract/AdvancedQueryShapeContractTest.php new file mode 100644 index 0000000..138609e --- /dev/null +++ b/tests/Integration/Contract/AdvancedQueryShapeContractTest.php @@ -0,0 +1,458 @@ +hasMany(Post::class, 'author_id'); + } +} + +final class AliasedPivotAuthor extends Author +{ + protected $table = 'authors'; + + public function memberships(): BelongsToMany + { + return $this->belongsToMany(Tag::class, 'author_tag', 'author_id', 'tag_id') + ->as('membership') + ->withPivot('notes'); + } +} + +final class OneOfManyAuthor extends Author +{ + protected $table = 'authors'; + + public function oldestPost(): HasOne + { + return $this->hasOne(Post::class, 'author_id')->oldestOfMany(); + } +} + +final class AdvancedQueryShapeContractTest extends TestCase +{ + private function fixtures(): array + { + $country = Country::create(['name' => 'UK']); + $alice = Author::create(['name' => 'Alice', 'country_id' => $country->id]); + $bob = Author::create(['name' => 'Bob', 'country_id' => $country->id]); + $carol = Author::create(['name' => 'Carol']); + + $a1 = Post::create([ + 'title' => 'A1', + 'author_id' => $alice->id, + 'views' => 10, + 'published' => true, + ]); + $a2 = Post::create([ + 'title' => 'A2', + 'author_id' => $alice->id, + 'views' => 20, + 'published' => false, + ]); + $b1 = Post::create([ + 'title' => 'B1', + 'author_id' => $bob->id, + 'views' => 30, + 'published' => true, + ]); + + $php = Tag::create(['name' => 'php']); + $laravel = Tag::create(['name' => 'laravel']); + $alice->tags()->attach($php->id, ['notes' => 'primary']); + $alice->tags()->attach($laravel->id, ['notes' => 'secondary']); + $bob->tags()->attach($php->id, ['notes' => 'secondary']); + + $authorComment = Comment::create([ + 'body' => 'Author comment', + 'commentable_type' => Author::class, + 'commentable_id' => $alice->id, + ]); + $postComment = Comment::create([ + 'body' => 'Post comment', + 'commentable_type' => Post::class, + 'commentable_id' => $a1->id, + ]); + + return compact( + 'country', + 'alice', + 'bob', + 'carol', + 'a1', + 'a2', + 'b1', + 'php', + 'laravel', + 'authorComment', + 'postComment', + ); + } + + public function test_select_sub_infers_dependencies_and_caches(): void + { + ['alice' => $alice] = $this->fixtures(); + $query = fn(bool $native) => ($native ? Author::withoutCache() : Author::query()) + ->addSelect([ + 'published_posts' => Post::query() + ->selectRaw('count(*)') + ->whereColumn('posts.author_id', 'authors.id') + ->where('published', true), + ]) + ->orderBy('authors.id') + ->get(); + + $this->contract( + fn() => $query(false), + fn() => $query(true), + mutate: fn() => Post::create([ + 'title' => 'A3', + 'author_id' => $alice->id, + 'views' => 40, + 'published' => true, + ]), + ); + } + + public function test_relation_subquery_uses_laravel_base_query_and_caches(): void + { + ['alice' => $alice] = $this->fixtures(); + $query = fn(bool $native) => ($native ? Author::withoutCache() : Author::query()) + ->select('authors.*') + ->selectSub( + $alice->posts()->selectRaw('max(views)'), + 'alice_max_views', + ) + ->orderBy('authors.id') + ->get(); + + $this->contract( + fn() => $query(false), + fn() => $query(true), + mutate: fn() => Post::create([ + 'title' => 'A3', + 'author_id' => $alice->id, + 'views' => 40, + 'published' => true, + ]), + ); + } + + public function test_opaque_from_sub_requires_declared_dependencies(): void + { + ['alice' => $alice] = $this->fixtures(); + $query = fn(bool $native, bool $declared = false) => ($native ? Author::withoutCache() : Author::query()) + ->fromSub( + Author::query()->select(['id', 'name', 'country_id', 'created_at', 'updated_at']), + 'derived_authors', + ) + ->when($declared, fn($query) => $query->dependsOn([Author::class])) + ->select('derived_authors.*') + ->orderBy('derived_authors.id') + ->get(); + + $this->bypassContract( + fn() => $query(false), + fn() => $query(true), + reason: 'unidentifiable_dependency', + ); + $this->contract( + fn() => $query(false, true), + fn() => $query(true), + mutate: fn() => Author::whereKey($alice->id)->update(['name' => 'Alice Updated']), + ); + } + + public function test_join_sub_and_left_join_sub_cache(): void + { + $this->fixtures(); + $publishedAuthors = fn() => Post::query() + ->select('author_id') + ->where('published', true) + ->groupBy('author_id'); + + $inner = fn(bool $native) => ($native ? Author::withoutCache() : Author::query()) + ->joinSub($publishedAuthors(), 'published_posts', 'published_posts.author_id', '=', 'authors.id') + ->select('authors.*') + ->orderBy('authors.id') + ->get(); + $left = fn(bool $native) => ($native ? Author::withoutCache() : Author::query()) + ->leftJoinSub($publishedAuthors(), 'published_posts', 'published_posts.author_id', '=', 'authors.id') + ->select('authors.*') + ->orderBy('authors.id') + ->get(); + + $this->contract(fn() => $inner(false), fn() => $inner(true)); + $this->contract(fn() => $left(false), fn() => $left(true)); + } + + public function test_cross_join_sub_requires_declared_dependencies(): void + { + ['alice' => $alice] = $this->fixtures(); + $query = fn(bool $native, bool $declared = false) => ($native ? Author::withoutCache() : Author::query()) + ->crossJoinSub(Post::query()->selectRaw('max(views) as max_views'), 'post_stats') + ->when($declared, fn($query) => $query->dependsOn([Post::class])) + ->select('authors.*') + ->addSelect('post_stats.max_views') + ->orderBy('authors.id') + ->get(); + + $this->bypassContract( + fn() => $query(false), + fn() => $query(true), + reason: 'unidentifiable_dependency', + ); + $this->contract( + fn() => $query(false, true), + fn() => $query(true), + mutate: fn() => Post::create([ + 'title' => 'A3', + 'author_id' => $alice->id, + 'views' => 40, + 'published' => true, + ]), + ); + } + + public function test_predicate_subqueries_cache(): void + { + $this->fixtures(); + $exists = fn(bool $native) => ($native ? Author::withoutCache() : Author::query()) + ->whereExists(function ($query): void { + $query->selectRaw('1') + ->from('posts') + ->whereColumn('posts.author_id', 'authors.id') + ->where('published', true); + }) + ->orderBy('authors.id') + ->get(); + $notExists = fn(bool $native) => ($native ? Author::withoutCache() : Author::query()) + ->whereNotExists(function ($query): void { + $query->selectRaw('1') + ->from('posts') + ->whereColumn('posts.author_id', 'authors.id'); + }) + ->orderBy('authors.id') + ->get(); + $whereIn = fn(bool $native) => ($native ? Author::withoutCache() : Author::query()) + ->whereIn( + 'authors.id', + Post::query()->select('author_id')->where('published', true), + ) + ->dependsOn([Post::class]) + ->orderBy('authors.id') + ->get(); + $scalar = fn(bool $native) => ($native ? Author::withoutCache() : Author::query()) + ->where( + 'authors.id', + '=', + Author::query()->select('id')->where('name', 'Alice')->limit(1), + ) + ->get(); + + $this->contract(fn() => $exists(false), fn() => $exists(true)); + $this->contract(fn() => $notExists(false), fn() => $notExists(true)); + $this->contract(fn() => $whereIn(false), fn() => $whereIn(true)); + $this->contract(fn() => $scalar(false), fn() => $scalar(true)); + } + + public function test_order_by_subquery_requires_declared_dependencies(): void + { + $this->fixtures(); + $query = fn(bool $native, bool $declared = false) => ($native ? Author::withoutCache() : Author::query()) + ->orderBy( + Post::query() + ->selectRaw('max(views)') + ->whereColumn('posts.author_id', 'authors.id'), + 'desc', + ) + ->when($declared, fn($query) => $query->dependsOn([Post::class])) + ->orderBy('authors.id') + ->get(); + + $this->bypassContract( + fn() => $query(false), + fn() => $query(true), + reason: 'unidentifiable_dependency', + ); + $this->contract( + fn() => $query(false, true), + fn() => $query(true), + mutate: fn() => Post::query()->orderBy('id')->firstOrFail()->update(['views' => 100]), + ); + } + + public function test_union_and_union_all_cache(): void + { + $this->fixtures(); + $union = fn(bool $native) => ($native ? Author::withoutCache() : Author::query()) + ->select('authors.*') + ->where('name', 'Alice') + ->union(Author::query()->select('authors.*')->where('name', 'Bob')) + ->orderBy('name') + ->get(); + $unionAll = fn(bool $native) => ($native ? Author::withoutCache() : Author::query()) + ->select('authors.*') + ->where('name', 'Alice') + ->unionAll(Author::query()->select('authors.*')->whereIn('name', ['Bob', 'Carol'])) + ->orderBy('name') + ->get(); + + $this->contract(fn() => $union(false), fn() => $union(true)); + $this->contract(fn() => $unionAll(false), fn() => $unionAll(true)); + } + + public function test_common_relationship_shorthand_queries_cache(): void + { + ['alice' => $alice, 'a1' => $a1, 'php' => $php] = $this->fixtures(); + + $withWhereHas = fn(bool $native) => ($native ? Author::withoutCache() : Author::query()) + ->withWhereHas('posts', fn($query) => $query->where('published', true)) + ->orderBy('authors.id') + ->get(); + $whereBelongsTo = fn(bool $native) => ($native ? Post::withoutCache() : Post::query()) + ->whereBelongsTo($alice) + ->orderBy('posts.id') + ->get(); + $whereAttachedTo = fn(bool $native) => ($native ? Author::withoutCache() : Author::query()) + ->whereAttachedTo($php, 'tags') + ->orderBy('authors.id') + ->get(); + $whereMorphedTo = fn(bool $native) => ($native ? Comment::withoutCache() : Comment::query()) + ->whereMorphedTo('commentable', $a1) + ->orderBy('comments.id') + ->get(); + $whereNotMorphedTo = fn(bool $native) => ($native ? Comment::withoutCache() : Comment::query()) + ->whereNotMorphedTo('commentable', $a1) + ->orderBy('comments.id') + ->get(); + + $this->contract(fn() => $withWhereHas(false), fn() => $withWhereHas(true)); + $this->contract(fn() => $whereBelongsTo(false), fn() => $whereBelongsTo(true)); + $this->contract(fn() => $whereAttachedTo(false), fn() => $whereAttachedTo(true)); + $this->contract(fn() => $whereMorphedTo(false), fn() => $whereMorphedTo(true)); + $this->contract(fn() => $whereNotMorphedTo(false), fn() => $whereNotMorphedTo(true)); + } + + public function test_default_eager_load_controls_and_load_morph_cache(): void + { + $this->fixtures(); + $withOnly = fn(bool $native) => ($native ? DefaultEagerAuthor::withoutCache() : DefaultEagerAuthor::query()) + ->withOnly('country') + ->orderBy('authors.id') + ->get(); + $loadMorph = fn(bool $native) => ($native ? Comment::withoutCache() : Comment::query()) + ->orderBy('comments.id') + ->get() + ->loadMorph('commentable', [ + Author::class => ['posts'], + Post::class => ['author'], + ]); + $loadMorphCount = fn(bool $native) => ($native ? Comment::withoutCache() : Comment::query()) + ->orderBy('comments.id') + ->get() + ->loadMorphCount('commentable', [ + Author::class => ['posts'], + Post::class => ['comments'], + ]); + + $this->contract(fn() => $withOnly(false), fn() => $withOnly(true)); + $this->contract(fn() => $loadMorph(false), fn() => $loadMorph(true)); + $this->contract(fn() => $loadMorphCount(false), fn() => $loadMorphCount(true)); + } + + public function test_pivot_alias_constraints_and_oldest_of_many_cache(): void + { + $this->fixtures(); + $pivot = fn(bool $native) => ($native ? AliasedPivotAuthor::withoutCache() : AliasedPivotAuthor::query()) + ->with([ + 'memberships' => fn($query) => $query + ->wherePivot('notes', 'primary') + ->orderByPivot('notes'), + ]) + ->orderBy('authors.id') + ->get(); + $oldest = fn(bool $native) => ($native ? OneOfManyAuthor::withoutCache() : OneOfManyAuthor::query()) + ->with('oldestPost') + ->orderBy('authors.id') + ->get(); + + $this->contract(fn() => $pivot(false), fn() => $pivot(true)); + $this->contract(fn() => $oldest(false), fn() => $oldest(true)); + } + + public function test_postgres_lateral_join_requires_declared_dependencies(): void + { + if (DB::connection()->getDriverName() !== 'pgsql') { + $this->markTestSkipped('Lateral join contract requires PostgreSQL.'); + } + + ['alice' => $alice] = $this->fixtures(); + $query = fn(bool $native, bool $declared = false) => ($native ? Author::withoutCache() : Author::query()) + ->leftJoinLateral( + Post::query() + ->select(['posts.author_id', 'posts.title']) + ->whereColumn('posts.author_id', 'authors.id') + ->orderByDesc('posts.id') + ->limit(1), + 'latest_post', + ) + ->when($declared, fn($query) => $query->dependsOn([Post::class])) + ->select('authors.*') + ->addSelect('latest_post.title as latest_title') + ->orderBy('authors.id') + ->get(); + + $this->bypassContract( + fn() => $query(false), + fn() => $query(true), + reason: 'unidentifiable_dependency', + ); + $this->contract( + fn() => $query(false, true), + fn() => $query(true), + mutate: fn() => Post::create([ + 'title' => 'Newest', + 'author_id' => $alice->id, + 'published' => true, + ]), + ); + } + + public function test_postgres_table_expression_bypasses_without_declared_dependencies(): void + { + if (DB::connection()->getDriverName() !== 'pgsql') { + $this->markTestSkipped('TABLE expression contract requires PostgreSQL.'); + } + + $this->fixtures(); + $query = fn() => Author::query() + ->whereRaw('exists (table comments)') + ->orderBy('authors.id') + ->get(); + $native = fn() => Author::withoutCache() + ->whereRaw('exists (table comments)') + ->orderBy('authors.id') + ->get(); + + $this->bypassContract($query, $native, reason: 'unidentifiable_dependency'); + } +} diff --git a/tests/Integration/Contract/ContractHarnessTest.php b/tests/Integration/Contract/ContractHarnessTest.php new file mode 100644 index 0000000..c3bfc0c --- /dev/null +++ b/tests/Integration/Contract/ContractHarnessTest.php @@ -0,0 +1,67 @@ + 'Alice']); + Post::create(['title' => 'Post', 'author_id' => $author->id]); + $native = fn() => Author::withoutCache()->with('posts')->get(); + DB::flushQueryLog(); + DB::enableQueryLog(); + + try { + $this->cacheManager()->withoutCache($native); + $this->cacheManager()->withoutCache($native); + $this->assertCount(4, DB::getQueryLog()); + } finally { + DB::disableQueryLog(); + } + + Event::fake([QueryCacheMiss::class, QueryCacheHit::class]); + Author::with('posts')->get(); + Event::assertDispatchedTimes(QueryCacheMiss::class, 2); + Event::assertNotDispatched(QueryCacheHit::class); + + Event::fake([QueryCacheMiss::class, QueryCacheHit::class]); + DB::flushQueryLog(); + DB::enableQueryLog(); + + try { + Author::with('posts')->get(); + $this->assertSame([], DB::getQueryLog()); + } finally { + DB::disableQueryLog(); + } + + Event::assertDispatchedTimes(QueryCacheHit::class, 2); + Event::assertNotDispatched(QueryCacheMiss::class); + } + + public function test_without_cache_callback_restores_cache_reads_after_an_exception(): void + { + Author::create(['name' => 'Alice']); + + try { + $this->cacheManager()->withoutCache( + static fn() => throw new \RuntimeException('Expected failure.'), + ); + } catch (\RuntimeException $exception) { + $this->assertSame('Expected failure.', $exception->getMessage()); + } + + $query = static fn() => Author::query()->get(); + $this->assertColdCacheMiss($query); + $this->assertWarmCacheHit($query); + } +} diff --git a/tests/Integration/Contract/CustomBehaviorContractTest.php b/tests/Integration/Contract/CustomBehaviorContractTest.php index b23084d..14d1c49 100644 --- a/tests/Integration/Contract/CustomBehaviorContractTest.php +++ b/tests/Integration/Contract/CustomBehaviorContractTest.php @@ -2,15 +2,17 @@ namespace NormCache\Tests\Integration\Contract; +use NormCache\Tests\Fixtures\Models\Author; use NormCache\Tests\Fixtures\Models\CustomPostCollection; use NormCache\Tests\Fixtures\Models\Post; use NormCache\Tests\TestCase; -class CustomBehaviorContractTest extends TestCase +final class CustomBehaviorContractTest extends TestCase { public function test_hidden_and_appends_visibility_is_respected(): void { - $post = Post::create(['title' => 'Secret', 'author_id' => 1]); + $author = Author::create(['name' => 'Author']); + $post = Post::create(['title' => 'Secret', 'author_id' => $author->id]); $this->contract( function () use ($post) { @@ -32,8 +34,9 @@ function () use ($post) { public function test_custom_collection_is_returned_from_cache(): void { - $post1 = Post::create(['title' => 'C1', 'author_id' => 1]); - $post2 = Post::create(['title' => 'C2', 'author_id' => 1]); + $author = Author::create(['name' => 'Author']); + $post1 = Post::create(['title' => 'C1', 'author_id' => $author->id]); + $post2 = Post::create(['title' => 'C2', 'author_id' => $author->id]); $this->contract( fn() => Post::whereIn('id', [$post1->id, $post2->id])->get(), diff --git a/tests/Integration/Contract/EloquentContractTest.php b/tests/Integration/Contract/EloquentContractTest.php index 76474f0..75fa0a0 100644 --- a/tests/Integration/Contract/EloquentContractTest.php +++ b/tests/Integration/Contract/EloquentContractTest.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\DB; +use NormCache\Planning\TableIdentityResolver; use NormCache\Tests\Fixtures\Models\Author; use NormCache\Tests\Fixtures\Models\Comment; use NormCache\Tests\Fixtures\Models\Country; @@ -13,23 +14,8 @@ use NormCache\Tests\TestCase; use ReflectionProperty; -/** - * Contract tests: every Eloquent operation NormCache intercepts must return an - * identical result on the native path (withoutCache), the cold-cache path - * (cache miss → DB), and the warm-cache path (cache hit). - * - * $native = withoutCache() ground truth - * $cold = first cached run (cache miss → DB → populates cache) - * $warm = second cached run (cache hit) - * - * A failure means NormCache's cached result diverges from native Eloquent. - */ -class EloquentContractTest extends TestCase +final class EloquentContractTest extends TestCase { - // Helpers - - // contract() and normalize() are inherited from TestCase - private function fixtures(): array { $country = Country::create(['name' => 'UK']); @@ -64,7 +50,42 @@ private function clearGlobalScope(string $modelClass, string $name): void $prop->setValue(null, $scopes); } - // get() — collection shapes + private function chunkedNames($query): array + { + $names = []; + + $query->chunk(2, function ($authors) use (&$names): void { + foreach ($authors as $author) { + $names[] = $author->name; + } + }); + + return $names; + } + + private function chunkedByIdNames($query): array + { + $names = []; + + $query->chunkById(2, function ($authors) use (&$names): void { + foreach ($authors as $author) { + $names[] = $author->name; + } + }); + + return $names; + } + + private function eachNames($query): array + { + $names = []; + + $query->each(function ($author) use (&$names): void { + $names[] = $author->name; + }, 2); + + return $names; + } public function test_get_all_models(): void { @@ -170,8 +191,6 @@ public function test_get_only_trashed(): void ); } - // Single model (first, find, sole, soleValue, firstWhere, findOrFail, firstOrFail, first/firstWhere on relation instance) - public function test_first_returns_first_model(): void { $this->fixtures(); @@ -219,7 +238,7 @@ public function test_find_multiple_ids(): void public function test_find_returns_null_for_missing_id(): void { - $this->contract( + $this->missContract( fn() => Author::find(99999), fn() => Author::withoutCache()->find(99999), ); @@ -288,14 +307,18 @@ public function test_first_where_on_relation_instance(): void ); } - // withAggregate - - public function test_aggregate_cache_does_not_leak_columns_across_projections(): void + public function test_aggregate_blob_key_includes_selected_columns(): void { ['alice' => $alice] = $this->fixtures(); + $idQuery = fn() => Author::select('id')->withCount('posts')->where('id', $alice->id)->get(); + $idNative = fn() => Author::withoutCache()->select('id')->withCount('posts')->where('id', $alice->id)->get(); + $nameQuery = fn() => Author::select('name')->withCount('posts')->where('id', $alice->id)->get(); + $nameNative = fn() => Author::withoutCache()->select('name')->withCount('posts')->where('id', $alice->id)->get(); - $idOnly = Author::select('id')->withCount('posts')->where('id', $alice->id)->get()->first(); - $nameOnly = Author::select('name')->withCount('posts')->where('id', $alice->id)->get()->first(); + $this->contract($idQuery, $idNative); + $idOnly = $idQuery()->first(); + $this->contract($nameQuery, $nameNative); + $nameOnly = $nameQuery()->first(); $this->assertArrayHasKey('id', $idOnly->getAttributes(), 'id-only projection must contain id'); $this->assertArrayNotHasKey('name', $idOnly->getAttributes(), 'id-only projection must not contain name from other query blob'); @@ -303,17 +326,17 @@ public function test_aggregate_cache_does_not_leak_columns_across_projections(): $this->assertArrayNotHasKey('id', $nameOnly->getAttributes(), 'name-only projection must not contain id from other query blob'); } - public function test_aggregate_cache_does_not_leak_across_relation_names(): void + public function test_aggregate_blob_key_includes_relation_name(): void { ['alice' => $alice] = $this->fixtures(); $this->contract( fn() => Author::withCount('posts')->where('id', $alice->id)->get(), - fn() => Author::withoutCache()->withoutAggregateCache()->withCount('posts')->where('id', $alice->id)->get(), + fn() => Author::withoutCache()->withCount('posts')->where('id', $alice->id)->get(), ); $this->contract( fn() => Author::withCount('firstPost')->where('id', $alice->id)->get(), - fn() => Author::withoutCache()->withoutAggregateCache()->withCount('firstPost')->where('id', $alice->id)->get(), + fn() => Author::withoutCache()->withCount('firstPost')->where('id', $alice->id)->get(), ); } @@ -322,28 +345,28 @@ public function test_aggregate_constraint_with_where_has_falls_back_to_native(): $this->fixtures(); $this->contract( fn() => Author::withCount(['posts' => fn($q) => $q->whereHas('tags')])->orderBy('name')->get(), - fn() => Author::withoutCache()->withoutAggregateCache()->withCount(['posts' => fn($q) => $q->whereHas('tags')])->orderBy('name')->get(), + fn() => Author::withoutCache()->withCount(['posts' => fn($q) => $q->whereHas('tags')])->orderBy('name')->get(), ); } - public function test_aggregate_cache_invalidates_on_explicit_depends_on_version_bump(): void + public function test_aggregate_blob_respects_explicit_depends_on(): void { $alice = Author::create(['name' => 'Alice']); Post::create(['title' => 'P1', 'author_id' => $alice->id]); + $query = fn() => Author::dependsOn([Tag::class]) + ->withCount('posts') + ->where('id', $alice->id) + ->get(); + $native = fn() => Author::withoutCache() + ->withCount('posts') + ->where('id', $alice->id) + ->get(); - Author::dependsOn([Tag::class])->withCount('posts')->where('id', $alice->id)->get(); // prime - - $tagVersionBefore = $this->cacheManager()->currentVersion(Tag::class); - Tag::create(['name' => 'dummy']); // bumps Tag version; inferred dep is Post only - $tagVersionAfter = $this->cacheManager()->currentVersion(Tag::class); - $this->assertGreaterThan($tagVersionBefore, $tagVersionAfter, 'Tag version must bump on create'); - - DB::enableQueryLog(); - Author::dependsOn([Tag::class])->withCount('posts')->where('id', $alice->id)->get(); - $log = DB::getQueryLog(); - DB::disableQueryLog(); - - $this->assertNotEmpty($log, 'blob must be invalidated when explicit dependsOn Tag version bumps'); + $this->contract( + $query, + $native, + mutate: fn() => Tag::create(['name' => 'dummy']), + ); } public function test_with_count_has_many(): void @@ -351,7 +374,7 @@ public function test_with_count_has_many(): void $this->fixtures(); $this->contract( fn() => Author::withCount('posts')->orderBy('name')->get(), - fn() => Author::withoutCache()->withoutAggregateCache()->withCount('posts')->orderBy('name')->get(), + fn() => Author::withoutCache()->withCount('posts')->orderBy('name')->get(), ); } @@ -360,7 +383,7 @@ public function test_with_count_aliased(): void $this->fixtures(); $this->contract( fn() => Author::withCount('posts as total_posts')->orderBy('name')->get(), - fn() => Author::withoutCache()->withoutAggregateCache()->withCount('posts as total_posts')->orderBy('name')->get(), + fn() => Author::withoutCache()->withCount('posts as total_posts')->orderBy('name')->get(), ); } @@ -369,7 +392,7 @@ public function test_with_count_multiple_aggregates(): void $this->fixtures(); $this->contract( fn() => Author::withCount(['posts', 'tags'])->orderBy('name')->get(), - fn() => Author::withoutCache()->withoutAggregateCache()->withCount(['posts', 'tags'])->orderBy('name')->get(), + fn() => Author::withoutCache()->withCount(['posts', 'tags'])->orderBy('name')->get(), ); } @@ -378,7 +401,7 @@ public function test_with_count_constrained(): void $this->fixtures(); $this->contract( fn() => Author::withCount(['posts' => fn($q) => $q->where('published', true)])->orderBy('name')->get(), - fn() => Author::withoutCache()->withoutAggregateCache()->withCount(['posts' => fn($q) => $q->where('published', true)])->orderBy('name')->get(), + fn() => Author::withoutCache()->withCount(['posts' => fn($q) => $q->where('published', true)])->orderBy('name')->get(), ); } @@ -387,7 +410,7 @@ public function test_with_count_zero_when_no_related(): void $this->fixtures(); $this->contract( fn() => Author::where('name', 'Carol')->withCount('posts')->first(), - fn() => Author::withoutCache()->withoutAggregateCache()->where('name', 'Carol')->withCount('posts')->first(), + fn() => Author::withoutCache()->where('name', 'Carol')->withCount('posts')->first(), ); } @@ -396,7 +419,7 @@ public function test_with_sum_has_many(): void $this->fixtures(); $this->contract( fn() => Author::withSum('posts', 'views')->orderBy('name')->get(), - fn() => Author::withoutCache()->withoutAggregateCache()->withSum('posts', 'views')->orderBy('name')->get(), + fn() => Author::withoutCache()->withSum('posts', 'views')->orderBy('name')->get(), ); } @@ -405,7 +428,7 @@ public function test_with_sum_custom_alias_matches_native(): void $this->fixtures(); $this->contract( fn() => Author::withSum('posts as total_views', 'views')->orderBy('name')->get(), - fn() => Author::withoutCache()->withoutAggregateCache()->withSum('posts as total_views', 'views')->orderBy('name')->get(), + fn() => Author::withoutCache()->withSum('posts as total_views', 'views')->orderBy('name')->get(), ); } @@ -414,7 +437,7 @@ public function test_with_avg_has_many(): void $this->fixtures(); $this->contract( fn() => Author::withAvg('posts', 'views')->orderBy('name')->get(), - fn() => Author::withoutCache()->withoutAggregateCache()->withAvg('posts', 'views')->orderBy('name')->get(), + fn() => Author::withoutCache()->withAvg('posts', 'views')->orderBy('name')->get(), ); } @@ -423,7 +446,7 @@ public function test_with_min_has_many(): void $this->fixtures(); $this->contract( fn() => Author::withMin('posts', 'views')->orderBy('name')->get(), - fn() => Author::withoutCache()->withoutAggregateCache()->withMin('posts', 'views')->orderBy('name')->get(), + fn() => Author::withoutCache()->withMin('posts', 'views')->orderBy('name')->get(), ); } @@ -432,7 +455,7 @@ public function test_with_max_has_many(): void $this->fixtures(); $this->contract( fn() => Author::withMax('posts', 'views')->orderBy('name')->get(), - fn() => Author::withoutCache()->withoutAggregateCache()->withMax('posts', 'views')->orderBy('name')->get(), + fn() => Author::withoutCache()->withMax('posts', 'views')->orderBy('name')->get(), ); } @@ -441,7 +464,7 @@ public function test_multiple_aggregate_functions_on_same_relation_match_native( $this->fixtures(); $this->contract( fn() => Author::withSum('posts', 'views')->withAvg('posts', 'views')->orderBy('name')->get(), - fn() => Author::withoutCache()->withoutAggregateCache()->withSum('posts', 'views')->withAvg('posts', 'views')->orderBy('name')->get(), + fn() => Author::withoutCache()->withSum('posts', 'views')->withAvg('posts', 'views')->orderBy('name')->get(), ); } @@ -450,7 +473,7 @@ public function test_aggregate_alias_collision_with_real_attribute_matches_nativ $this->fixtures(); $this->contract( fn() => Author::withCount('posts as name')->orderBy('id')->get(), - fn() => Author::withoutCache()->withoutAggregateCache()->withCount('posts as name')->orderBy('id')->get(), + fn() => Author::withoutCache()->withCount('posts as name')->orderBy('id')->get(), ); } @@ -459,7 +482,7 @@ public function test_with_count_belongs_to_many(): void $this->fixtures(); $this->contract( fn() => Author::withCount('tags')->orderBy('name')->get(), - fn() => Author::withoutCache()->withoutAggregateCache()->withCount('tags')->orderBy('name')->get(), + fn() => Author::withoutCache()->withCount('tags')->orderBy('name')->get(), ); } @@ -467,22 +490,30 @@ public function test_with_count_belongs_to_many_invalidates_when_pivot_changes() { ['carol' => $carol, 'php' => $php] = $this->fixtures(); - Author::withCount('tags')->orderBy('name')->get(); - - $carol->tags()->attach($php->id); - - $this->assertSame( - $this->normalize(Author::withoutCache()->withoutAggregateCache()->withCount('tags')->orderBy('name')->get()), - $this->normalize(Author::withCount('tags')->orderBy('name')->get()), + $this->contract( + fn() => Author::withCount('tags')->orderBy('name')->get(), + fn() => Author::withoutCache()->withCount('tags')->orderBy('name')->get(), + mutate: fn() => $carol->tags()->attach($php->id), ); } - public function test_with_exists_adds_correct_boolean_attribute(): void + public function test_with_exists_requires_declared_dependencies(): void { $this->fixtures(); + $query = fn(bool $native, bool $declared = false) => ($native ? Author::withoutCache() : Author::query()) + ->withExists('posts') + ->when($declared, fn($query) => $query->dependsOn([Post::class])) + ->orderBy('name') + ->get(); + + $this->bypassContract( + fn() => $query(false), + fn() => $query(true), + reason: 'unidentifiable_dependency', + ); $this->contract( - fn() => Author::withExists('posts')->orderBy('name')->get(), - fn() => Author::withoutCache()->withoutAggregateCache()->withExists('posts')->orderBy('name')->get(), + fn() => $query(false, true), + fn() => $query(true), ); } @@ -491,48 +522,46 @@ public function test_with_count_morph_many(): void $this->fixtures(); $this->contract( fn() => Author::withCount('comments')->orderBy('name')->get(), - fn() => Author::withoutCache()->withoutAggregateCache()->withCount('comments')->orderBy('name')->get(), + fn() => Author::withoutCache()->withCount('comments')->orderBy('name')->get(), ); } public function test_with_count_multiple_morph_relations_simultaneously(): void { - $this->fixtures(); // p1: 1 tag, 1 comment; p2/p3: 0 of each + $this->fixtures(); $this->contract( fn() => Post::withCount(['tags', 'comments'])->orderBy('title')->get(), - fn() => Post::withoutCache()->withoutAggregateCache()->withCount(['tags', 'comments'])->orderBy('title')->get(), + fn() => Post::withoutCache()->withCount(['tags', 'comments'])->orderBy('title')->get(), ); } public function test_with_aggregate_direct_call_produces_correct_attribute_and_value(): void { - $this->fixtures(); // Alice: A1(10), A2(20); Bob: B1(30); Carol: none + $this->fixtures(); $this->contract( fn() => Author::withAggregate('posts', 'views', 'avg')->orderBy('name')->get(), - fn() => Author::withoutCache()->withoutAggregateCache()->withAggregate('posts', 'views', 'avg')->orderBy('name')->get(), + fn() => Author::withoutCache()->withAggregate('posts', 'views', 'avg')->orderBy('name')->get(), ); } public function test_with_aggregate_expression_column_produces_same_attribute(): void { - // grammar->getValue() unwraps DB::raw so the alias matches native behavior (e.g. posts_sum_views). $this->fixtures(); $this->contract( fn() => Author::withSum('posts', DB::raw('views'))->orderBy('name')->get(), - fn() => Author::withoutCache()->withoutAggregateCache()->withSum('posts', DB::raw('views'))->orderBy('name')->get(), + fn() => Author::withoutCache()->withSum('posts', DB::raw('views'))->orderBy('name')->get(), ); } - public function test_with_count_non_cacheable_related_model_falls_through_to_eloquent(): void + public function test_with_count_non_cacheable_related_model_infers_table_dependency(): void { - // Models without Cacheable trait route to native Eloquent subselects (parent::withAggregate). $author = Author::create(['name' => 'Alice']); UncachedPost::create(['title' => 'P1', 'author_id' => $author->id]); UncachedPost::create(['title' => 'P2', 'author_id' => $author->id]); $this->contract( fn() => Author::withCount('uncachedPosts')->orderBy('name')->get(), - fn() => Author::withoutCache()->withoutAggregateCache()->withCount('uncachedPosts')->orderBy('name')->get(), + fn() => Author::withoutCache()->withCount('uncachedPosts')->orderBy('name')->get(), ); } @@ -548,23 +577,20 @@ public function test_with_count_mixed_cacheable_and_non_cacheable_in_one_call(): 'uncached_posts_count' => (int) $m->uncached_posts_count, ])->all(); - $native = $values(Author::withoutCache()->withoutAggregateCache()->withCount(['posts', 'uncachedPosts'])->orderBy('name')->get()); - $cold = $values(Author::withCount(['posts', 'uncachedPosts'])->orderBy('name')->get()); - $warm = $values(Author::withCount(['posts', 'uncachedPosts'])->orderBy('name')->get()); - - $this->assertSame($native, $cold, 'cold aggregate values differ from native'); - $this->assertSame($cold, $warm, 'warm aggregate values differ from cold'); + $this->contract( + fn() => $values(Author::withCount(['posts', 'uncachedPosts'])->orderBy('name')->get()), + fn() => $values(Author::withoutCache()->withCount(['posts', 'uncachedPosts'])->orderBy('name')->get()), + ); } public function test_with_count_having_raw_on_aggregate_alias_behaves_same_as_native(): void { - // havingRaw stores the SQL in a 'sql' key, not 'column' — the guard must check both. $this->fixtures(); $nativeException = null; $nativeResult = null; try { - $nativeResult = Author::withoutCache()->withoutAggregateCache()->withCount('posts')->havingRaw('posts_count > 0')->get(); + $nativeResult = Author::withoutCache()->withCount('posts')->havingRaw('posts_count > 0')->get(); } catch (\Throwable $e) { $nativeException = get_class($e); } @@ -594,7 +620,7 @@ public function test_with_count_having_on_aggregate_alias_behaves_same_as_native $nativeException = null; try { - Author::withoutCache()->withoutAggregateCache()->withCount('posts')->having('posts_count', '>', 1)->get(); + Author::withoutCache()->withCount('posts')->having('posts_count', '>', 1)->get(); } catch (\Throwable $e) { $nativeException = get_class($e); } @@ -609,42 +635,22 @@ public function test_with_count_having_on_aggregate_alias_behaves_same_as_native $this->assertSame($nativeException, $normcacheException, 'NormCache must fail identically to native Eloquent on HAVING aggregate alias'); } - public function test_with_count_order_by_raw_aggregate_alias_matches_native(): void + public function test_with_count_order_by_raw_aggregate_alias_is_cached(): void { - // orderByRaw referencing an aggregate alias falls back to native Eloquent subselects. $this->fixtures(); $this->contract( fn() => Author::withCount('posts')->orderByRaw('posts_count desc')->get(), - fn() => Author::withoutCache()->withoutAggregateCache()->withCount('posts')->orderByRaw('posts_count desc')->get(), - ); - } - - public function test_without_aggregate_cache_falls_through_entirely(): void - { - $this->fixtures(); - $this->contract( - fn() => Author::withoutAggregateCache()->withCount('posts')->withSum('posts', 'views')->orderBy('name')->get(), - fn() => Author::withoutCache()->withoutAggregateCache()->withCount('posts')->withSum('posts', 'views')->orderBy('name')->get(), - ); - } - - public function test_without_aggregate_cache_called_after_with_count_matches_native(): void - { - $this->fixtures(); - $this->contract( - fn() => Author::withCount('posts')->withSum('posts', 'views')->withoutAggregateCache()->orderBy('name')->get(), - fn() => Author::withoutCache()->withoutAggregateCache()->withCount('posts')->withSum('posts', 'views')->orderBy('name')->get(), + fn() => Author::withoutCache()->withCount('posts')->orderByRaw('posts_count desc')->get(), ); } public function test_with_count_alias_with_extra_whitespace_behaves_same_as_native(): void { - // Eloquent fails on multiple spaces in aliases; NormCache must fail identically. $this->fixtures(); $nativeException = null; try { - Author::withoutCache()->withoutAggregateCache()->withCount('posts as total_posts')->get(); + Author::withoutCache()->withCount('posts as total_posts')->get(); } catch (\Throwable $e) { $nativeException = get_class($e); } @@ -662,10 +668,9 @@ public function test_with_count_alias_with_extra_whitespace_behaves_same_as_nati public function test_with_count_nested_relation_name_throws_same_as_native(): void { $this->fixtures(); - // Native Eloquent does not support nested dot-notation in a single withCount; NormCache must throw the same exception. $nativeException = null; try { - Author::withoutCache()->withoutAggregateCache()->withCount('posts.comments')->get(); + Author::withoutCache()->withCount('posts.comments')->get(); } catch (\Throwable $e) { $nativeException = get_class($e); } @@ -681,8 +686,6 @@ public function test_with_count_nested_relation_name_throws_same_as_native(): vo $this->assertSame($nativeException, $normcacheException, 'NormCache must throw the same exception type as native Eloquent'); } - // dependsOn — result cache - public function test_depends_on_get(): void { $this->fixtures(); @@ -715,7 +718,10 @@ public function test_depends_on_count(): void public function test_result_depends_on_with_group_by_and_withcount_skips_aggregate_on_null_pk_models(): void { - // GROUP BY result dependsOn results lack primary keys — RelationAggregateLoader must skip aggregate loading. + if (DB::connection()->getDriverName() !== 'sqlite') { + $this->markTestSkipped('This null-primary-key grouped aggregate shape is only valid on SQLite.'); + } + $alice = Author::create(['name' => 'Alice']); $post = Post::create(['title' => 'P1', 'author_id' => $alice->id]); Comment::create(['body' => 'Hi', 'commentable_type' => Post::class, 'commentable_id' => $post->id]); @@ -725,17 +731,18 @@ public function test_result_depends_on_with_group_by_and_withcount_skips_aggrega ->dependsOn([Post::class]) ->withCount('comments') ->get(); + $native = fn() => Post::withoutCache() + ->select('author_id', DB::raw('COUNT(*) as post_count')) + ->groupBy('author_id') + ->withCount('comments') + ->get(); - $cold = $run(); - $warm = $run(); - - $this->assertCount(1, $cold); - $this->assertCount(1, $warm); + $this->contract($run, $native); + $this->assertCount(1, $run()); } public function test_depends_on_join_with_explicit_select_caches_and_matches_native(): void { - // JOIN + dependsOn + explicit select caches as result and matches native behavior. $this->fixtures(); $this->contract( fn() => Author::query() @@ -752,9 +759,7 @@ public function test_depends_on_join_with_explicit_select_caches_and_matches_nat ); } - // Complex paths (join, groupBy, lockForUpdate) - - public function test_join_without_depends_on_falls_through(): void + public function test_join_infers_dependencies_without_depends_on(): void { $this->fixtures(); $this->contract( @@ -770,7 +775,7 @@ public function test_join_without_depends_on_falls_through(): void ); } - public function test_group_by_falls_through(): void + public function test_group_by_result_is_cached(): void { $this->fixtures(); $this->contract( @@ -791,37 +796,208 @@ public function test_group_by_falls_through(): void public function test_lock_for_update_falls_through(): void { $this->fixtures(); - $this->contract( + $this->bypassContract( fn() => Author::lockForUpdate()->orderBy('name')->get(), fn() => Author::withoutCache()->orderBy('name')->get(), + reason: 'write_pdo', + ); + } + + public function test_chunk_returns_native_results(): void + { + $this->fixtures(); + + $this->contract( + fn(): array => $this->chunkedNames(Author::orderBy('id')), + fn(): array => $this->chunkedNames(Author::withoutCache()->orderBy('id')), + ); + } + + public function test_lazy_returns_native_results(): void + { + $this->fixtures(); + + $this->contract( + fn(): array => Author::orderBy('id')->lazy(2)->pluck('name')->all(), + fn(): array => Author::withoutCache()->orderBy('id')->lazy(2)->pluck('name')->all(), + ); + } + + public function test_chunk_by_id_returns_native_results(): void + { + $this->fixtures(); + + $this->contract( + fn(): array => $this->chunkedByIdNames(Author::query()), + fn(): array => $this->chunkedByIdNames(Author::withoutCache()), + ); + } + + public function test_each_returns_native_results(): void + { + $this->fixtures(); + + $this->contract( + fn(): array => $this->eachNames(Author::orderBy('id')), + fn(): array => $this->eachNames(Author::withoutCache()->orderBy('id')), + ); + } + + public function test_doesnt_have_returns_correct_models(): void + { + $this->fixtures(); + $this->contract( + fn() => Author::doesntHave('posts')->orderBy('name')->get(), + fn() => Author::withoutCache()->doesntHave('posts')->orderBy('name')->get(), + ); + } + + public function test_has_with_count_threshold_requires_declared_dependencies(): void + { + $this->fixtures(); + $query = fn(bool $native, bool $declared = false) => ($native ? Author::withoutCache() : Author::query()) + ->has('posts', '>=', 2) + ->when($declared, fn($query) => $query->dependsOn([Post::class])) + ->orderBy('name') + ->get(); + + $this->bypassContract( + fn() => $query(false), + fn() => $query(true), + reason: 'unidentifiable_dependency', + ); + $this->contract( + fn() => $query(false, true), + fn() => $query(true), + ); + } + + public function test_where_relation_returns_same_result_as_where_has(): void + { + $this->fixtures(); + $this->contract( + fn() => Author::whereRelation('posts', 'published', true)->orderBy('name')->get(), + fn() => Author::withoutCache()->whereRelation('posts', 'published', true)->orderBy('name')->get(), + ); + } + + public function test_or_where_relation_combines_conditions(): void + { + $this->fixtures(); + $this->contract( + fn() => Author::whereRelation('posts', 'title', 'A1') + ->orWhereRelation('posts', 'title', 'B1') + ->orderBy('name') + ->get(), + fn() => Author::withoutCache() + ->whereRelation('posts', 'title', 'A1') + ->orWhereRelation('posts', 'title', 'B1') + ->orderBy('name') + ->get(), ); } - public function test_simple_paginate_falls_through(): void + public function test_where_doesnt_have_relation_with_condition(): void { $this->fixtures(); - $native = Author::withoutCache()->orderBy('name')->simplePaginate(2); - $result = Author::orderBy('name')->simplePaginate(2); - $this->assertSame( - collect($native->items())->map->toArray()->values()->all(), - collect($result->items())->map->toArray()->values()->all(), + $this->contract( + fn() => Author::whereDoesntHaveRelation('posts', 'published', true)->orderBy('name')->get(), + fn() => Author::withoutCache()->whereDoesntHaveRelation('posts', 'published', true)->orderBy('name')->get(), ); } - public function test_cursor_paginate_falls_through_correctly(): void + public function test_or_where_doesnt_have_relation(): void { $this->fixtures(); + $this->contract( + fn() => Author::whereRelation('posts', 'title', 'A1') + ->orWhereDoesntHaveRelation('posts', 'published', false) + ->orderBy('name') + ->get(), + fn() => Author::withoutCache() + ->whereRelation('posts', 'title', 'A1') + ->orWhereDoesntHaveRelation('posts', 'published', false) + ->orderBy('name') + ->get(), + ); + } - $native = Author::withoutCache()->orderBy('name')->cursorPaginate(2); - $result = Author::orderBy('name')->cursorPaginate(2); + public function test_where_has_morph_filters_by_type_and_condition(): void + { + $this->fixtures(); + $this->contract( + fn() => Comment::whereHasMorph('commentable', [Author::class], fn($q) => $q->where('name', 'Alice'))->get(), + fn() => Comment::withoutCache()->whereHasMorph('commentable', [Author::class], fn($q) => $q->where('name', 'Alice'))->get(), + ); + } - $this->assertSame( - collect($native->items())->map->toArray()->values()->all(), - collect($result->items())->map->toArray()->values()->all(), + public function test_doesnt_have_morph_excludes_by_type(): void + { + $this->fixtures(); + $this->contract( + fn() => Comment::doesntHaveMorph('commentable', [Author::class])->orderBy('id')->get(), + fn() => Comment::withoutCache()->doesntHaveMorph('commentable', [Author::class])->orderBy('id')->get(), ); } - // Global scopes + public function test_where_has_morph_with_wildcard_type(): void + { + $this->fixtures(); + $this->contract( + fn() => Comment::whereHasMorph('commentable', '*')->orderBy('id')->get(), + fn() => Comment::withoutCache()->whereHasMorph('commentable', '*')->orderBy('id')->get(), + ); + } + + public function test_or_where_has_morph_combines_conditions(): void + { + $this->fixtures(); + $this->contract( + fn() => Comment::whereHasMorph('commentable', [Author::class]) + ->orWhereHasMorph('commentable', [Post::class]) + ->orderBy('id') + ->get(), + fn() => Comment::withoutCache() + ->whereHasMorph('commentable', [Author::class]) + ->orWhereHasMorph('commentable', [Post::class]) + ->orderBy('id') + ->get(), + ); + } + + public function test_where_morph_relation_shorthand(): void + { + $this->fixtures(); + $this->contract( + fn() => Comment::whereMorphRelation('commentable', [Author::class], 'name', 'Alice')->orderBy('id')->get(), + fn() => Comment::withoutCache()->whereMorphRelation('commentable', [Author::class], 'name', 'Alice')->orderBy('id')->get(), + ); + } + + public function test_or_where_morph_relation_shorthand(): void + { + $this->fixtures(); + $this->contract( + fn() => Comment::whereMorphRelation('commentable', [Author::class], 'name', 'Alice') + ->orWhereMorphRelation('commentable', [Post::class], 'title', 'A1') + ->orderBy('id') + ->get(), + fn() => Comment::withoutCache() + ->whereMorphRelation('commentable', [Author::class], 'name', 'Alice') + ->orWhereMorphRelation('commentable', [Post::class], 'title', 'A1') + ->orderBy('id') + ->get(), + ); + } + + public function test_where_not_closure_returns_correct_models(): void + { + $this->fixtures(); + $this->contract( + fn() => Author::whereNot(fn($q) => $q->where('name', 'Carol'))->orderBy('name')->get(), + fn() => Author::withoutCache()->whereNot(fn($q) => $q->where('name', 'Carol'))->orderBy('name')->get(), + ); + } public function test_global_scope_applies_consistently_cold_and_warm(): void { @@ -857,14 +1033,14 @@ public function test_without_global_scope_applies_consistently_cold_and_warm(): public function test_with_count_respects_global_scope_on_related_model(): void { - $this->fixtures(); // Alice: 2 posts (1 published), Bob: 1 post (1 published) + $this->fixtures(); Post::addGlobalScope('published_only', fn($q) => $q->where('published', true)); try { $this->contract( fn() => Author::withCount('posts')->orderBy('name')->get(), - fn() => Author::withoutCache()->withoutAggregateCache()->withCount('posts')->orderBy('name')->get(), + fn() => Author::withoutCache()->withCount('posts')->orderBy('name')->get(), ); } finally { $this->clearGlobalScope(Post::class, 'published_only'); @@ -873,7 +1049,7 @@ public function test_with_count_respects_global_scope_on_related_model(): void public function test_belongs_to_many_respects_global_scope_on_related(): void { - $this->fixtures(); // Alice has php+laravel tags, Bob has php + $this->fixtures(); Tag::addGlobalScope('no_laravel', fn($q) => $q->where('name', '!=', 'laravel')); try { @@ -894,7 +1070,7 @@ public function test_with_count_respects_global_scope_column_restriction_on_belo try { $this->contract( fn() => Author::withCount('tags')->orderBy('name')->get(), - fn() => Author::withoutCache()->withoutAggregateCache()->withCount('tags')->orderBy('name')->get(), + fn() => Author::withoutCache()->withCount('tags')->orderBy('name')->get(), ); } finally { $this->clearGlobalScope(Tag::class, 'no_laravel'); @@ -903,7 +1079,7 @@ public function test_with_count_respects_global_scope_column_restriction_on_belo public function test_has_many_through_respects_global_scope_on_through_model(): void { - $this->fixtures(); // UK country → posts through Alice+Bob; Carol has no country + $this->fixtures(); Post::addGlobalScope('published', fn($q) => $q->where('published', true)); try { @@ -919,7 +1095,7 @@ public function test_has_many_through_respects_global_scope_on_through_model(): public function test_morph_to_excludes_soft_deleted_related_by_default(): void { ['p1' => $p1] = $this->fixtures(); - $p1->delete(); // soft-delete the post that c2 points to + $p1->delete(); $this->contract( fn() => Comment::with('commentable')->orderBy('id')->get(), @@ -940,23 +1116,20 @@ public function test_morph_to_includes_soft_deleted_via_constraint(): void public function test_with_count_honours_removed_scope_on_parent_builder(): void { - // Aggregate miss-fetch must honour removed scopes so excluded rows receive 0 instead of null. Author::addGlobalScope('has_country', fn($q) => $q->whereNotNull('country_id')); try { - $this->fixtures(); // Alice, Bob have country_id=1; Carol has null + $this->fixtures(); $this->contract( fn() => Author::withoutGlobalScope('has_country')->withCount('posts')->orderBy('name')->get(), - fn() => Author::withoutCache()->withoutAggregateCache()->withoutGlobalScope('has_country')->withCount('posts')->orderBy('name')->get(), + fn() => Author::withoutCache()->withoutGlobalScope('has_country')->withCount('posts')->orderBy('name')->get(), ); } finally { $this->clearGlobalScope(Author::class, 'has_country'); } } - // Write operations (insert, update, delete, insertOrIgnore, upsert, forceDelete) - public function test_insert_returns_bool(): void { $native = Author::withoutCache()->insert(['name' => 'Test1', 'created_at' => now(), 'updated_at' => now()]); @@ -1021,6 +1194,26 @@ public function test_upsert_returns_affected_row_count(): void $this->assertSame($native, $cached); } + public function test_mutating_primary_key_evicts_old_model_cache_key(): void + { + $author = Author::create(['name' => 'Alice']); + $oldId = $author->id; + + Author::find($oldId); + + $connection = $this->app['db']->connection('testing'); + $table = $this->app->make(TableIdentityResolver::class)->resolve($connection, 'authors'); + $generation = $this->cacheStore()->getRaw($this->cacheKeys()->generation($table)) ?? '0'; + $oldRowKey = $this->cacheKeys()->row($table, $generation, 'i:' . $oldId); + + $this->assertNotNull($this->cacheStore()->getRaw($oldRowKey), 'expected the old PK canonical row to be cached'); + + $author->id = 9999; + $author->save(); + + $this->assertNull($this->cacheStore()->getRaw($oldRowKey), 'old canonical row must be evicted after PK mutation'); + } + public function test_force_delete_returns_affected_row_count(): void { $p1 = Post::create(['title' => 'FD1', 'author_id' => Author::create(['name' => 'X'])->id]); @@ -1036,7 +1229,7 @@ public function test_force_delete_returns_affected_row_count(): void public function test_query_id_cache_preserves_large_integer_primary_keys(): void { - $largeId = 9007199254740993; // 2^53 + 1 — first integer lossy in float64 / cjson + $largeId = 9007199254740993; // First integer not exactly representable by float64/cjson. DB::table('authors')->insert([ 'id' => $largeId, 'name' => 'BigId', @@ -1053,57 +1246,31 @@ public function test_query_id_cache_preserves_large_integer_primary_keys(): void public function test_with_count_pluck_aggregate_alias_matches_native(): void { $this->fixtures(); - - $native = Author::withoutCache()->withoutAggregateCache()->withCount('posts')->orderBy('name')->pluck('posts_count', 'name')->all(); - $cached = Author::withCount('posts')->orderBy('name')->pluck('posts_count', 'name')->all(); - - $this->assertSame($native, $cached, 'pluck on aggregate alias must match native Eloquent'); + $this->contract( + fn() => Author::withCount('posts')->orderBy('name')->pluck('posts_count', 'name')->all(), + fn() => Author::withoutCache()->withCount('posts')->orderBy('name')->pluck('posts_count', 'name')->all(), + ); } public function test_with_count_value_aggregate_alias_matches_native(): void { $this->fixtures(); - - $native = Author::withoutCache()->withoutAggregateCache()->withCount('posts')->orderBy('name')->value('posts_count'); - $cached = Author::withCount('posts')->orderBy('name')->value('posts_count'); - - $this->assertSame($native, $cached, 'value on aggregate alias must match native Eloquent'); + $this->contract( + fn() => Author::withCount('posts')->orderBy('name')->value('posts_count'), + fn() => Author::withoutCache()->withCount('posts')->orderBy('name')->value('posts_count'), + ); } public function test_with_count_cursor_aggregate_alias_matches_native(): void { $this->fixtures(); - - $native = Author::withoutCache()->withoutAggregateCache()->withCount('posts')->orderBy('name')->cursor()->map->posts_count->all(); - $cached = Author::withCount('posts')->orderBy('name')->cursor()->map->posts_count->all(); - - $this->assertSame($native, $cached, 'cursor on aggregate alias must match native Eloquent'); + $this->databaseContract( + fn() => Author::withCount('posts')->orderBy('name')->cursor()->map->posts_count->all(), + fn() => Author::withoutCache()->withCount('posts')->orderBy('name')->cursor()->map->posts_count->all(), + ); } - // flushTag validation - - public function test_flush_tag_clears_aggregate_cache_for_tagged_query(): void - { - $alice = Author::create(['name' => 'Alice']); - Post::create(['title' => 'P1', 'author_id' => $alice->id]); - - // Prime the tagged aggregate cache - Author::query()->tag('home')->withCount('posts')->get(); - - // External write that bypasses NormCache (versions unchanged, so aggregate is still "fresh") - DB::table('posts')->insert(['title' => 'P2', 'author_id' => $alice->id, 'created_at' => now(), 'updated_at' => now()]); - - // Explicit tag flush — must also clear the aggregate cache - $this->cacheManager()->flushTag(Author::class, 'home'); - - $result = Author::query()->tag('home')->withCount('posts')->get(); - - $this->assertSame(2, $result->first()->posts_count, 'flushTag must clear tagged aggregate cache entries'); - } - - // Scalar expression guard - - public function test_sum_with_raw_expression_bypasses_cache_and_returns_correct_result(): void + public function test_sum_with_raw_expression_is_cached_and_returns_correct_result(): void { $this->fixtures(); @@ -1113,7 +1280,7 @@ public function test_sum_with_raw_expression_bypasses_cache_and_returns_correct_ ); } - public function test_value_with_raw_expression_bypasses_cache_and_returns_correct_result(): void + public function test_value_with_raw_expression_is_cached_and_returns_correct_result(): void { $this->fixtures(); @@ -1123,8 +1290,6 @@ public function test_value_with_raw_expression_bypasses_cache_and_returns_correc ); } - // Expression primary key guard - public function test_where_id_with_expression_falls_through_to_normal_query_cache(): void { ['alice' => $alice] = $this->fixtures(); @@ -1135,25 +1300,35 @@ public function test_where_id_with_expression_falls_through_to_normal_query_cach ); } - // Pivot constraint hash — raw order bindings - public function test_pivot_orderby_raw_with_different_bindings_returns_distinct_results(): void { ['alice' => $alice] = $this->fixtures(); - - $phpFirst = $alice->tags() + $phpFirst = fn() => $alice->tags() ->orderByRaw('CASE WHEN tags.name = ? THEN 0 ELSE 1 END', ['php']) ->get() ->pluck('name') ->all(); - - $laravelFirst = $alice->tags() + $phpNative = fn() => $alice->tags() + ->withoutCache() + ->orderByRaw('CASE WHEN tags.name = ? THEN 0 ELSE 1 END', ['php']) + ->get() + ->pluck('name') + ->all(); + $laravelFirst = fn() => $alice->tags() + ->orderByRaw('CASE WHEN tags.name = ? THEN 0 ELSE 1 END', ['laravel']) + ->get() + ->pluck('name') + ->all(); + $laravelNative = fn() => $alice->tags() + ->withoutCache() ->orderByRaw('CASE WHEN tags.name = ? THEN 0 ELSE 1 END', ['laravel']) ->get() ->pluck('name') ->all(); - $this->assertSame(['php', 'laravel'], $phpFirst); - $this->assertSame(['laravel', 'php'], $laravelFirst); + $this->contract($phpFirst, $phpNative); + $this->contract($laravelFirst, $laravelNative); + $this->assertSame(['php', 'laravel'], $phpFirst()); + $this->assertSame(['laravel', 'php'], $laravelFirst()); } } diff --git a/tests/Integration/Contract/ModelHydrationContractTest.php b/tests/Integration/Contract/ModelHydrationContractTest.php index aff32d4..8beb2d7 100644 --- a/tests/Integration/Contract/ModelHydrationContractTest.php +++ b/tests/Integration/Contract/ModelHydrationContractTest.php @@ -2,36 +2,177 @@ namespace NormCache\Tests\Integration\Contract; +use Illuminate\Database\Eloquent\Model; use NormCache\Tests\Fixtures\Models\Author; use NormCache\Tests\Fixtures\Models\Comment; use NormCache\Tests\Fixtures\Models\Post; use NormCache\Tests\TestCase; +use NormCache\Traits\Cacheable; -/** - * Contract tests for model hydration after a cached query has been resolved. - */ -class ModelHydrationContractTest extends TestCase +final class CustomHydrationModel extends Model { + use Cacheable; + + public static int $newInstanceCalls = 0; + + public \stdClass $marker; + + public function __construct(array $attributes = []) + { + $this->marker = new \stdClass; + + parent::__construct($attributes); + } + + public function newInstance($attributes = [], $exists = false) + { + self::$newInstanceCalls++; + + return parent::newInstance($attributes, $exists); + } +} + +trait InitializesNestedHydrationState +{ + public static int $initializerCalls = 0; + + /** @var array{marker: \stdClass} */ + public array $nestedState; + + public function initializeInitializesNestedHydrationState(): void + { + self::$initializerCalls++; + $this->nestedState = ['marker' => new \stdClass]; + } +} + +final class TraitInitializedHydrationModel extends Model +{ + use Cacheable; + use InitializesNestedHydrationState; +} + +final class ModelHydrationContractTest extends TestCase +{ + public function test_custom_model_lifecycle_uses_fresh_laravel_instances(): void + { + $source = new CustomHydrationModel; + CustomHydrationModel::$newInstanceCalls = 0; + + $first = $source->newFromBuilder(['id' => 1]); + $second = $source->newFromBuilder(['id' => 2]); + + $this->assertSame(2, CustomHydrationModel::$newInstanceCalls); + $this->assertNotSame($first->marker, $second->marker); + } + + public function test_trait_initializers_run_for_each_hydrated_model(): void + { + $source = new TraitInitializedHydrationModel; + TraitInitializedHydrationModel::$initializerCalls = 0; + + $first = $source->newFromBuilder(['id' => 1]); + $second = $source->newFromBuilder(['id' => 2]); + + $this->assertSame(2, TraitInitializedHydrationModel::$initializerCalls); + $this->assertNotSame($first->nestedState['marker'], $second->nestedState['marker']); + } + + public function test_runtime_cast_changes_are_applied_to_later_hydrated_models(): void + { + $source = new Author; + $source->mergeCasts(['id' => 'integer']); + $this->assertSame(42, $source->newFromBuilder(['id' => 42])->id); + + $source->mergeCasts(['id' => 'string']); + + $this->assertSame('42', $source->newFromBuilder(['id' => 42])->id); + } + public function test_eager_loaded_models_match_native_eloquent(): void { $author = Author::create(['name' => 'Ivy']); $post = Post::create(['title' => 'WithComments', 'author_id' => $author->id]); Comment::create(['body' => 'Nice post', 'commentable_id' => $post->id, 'commentable_type' => Post::class]); - $this->evictModelCache(Post::class, $post->id); - $this->contract( cached: fn() => Post::with('comments')->whereKey($post->id)->get(), native: fn() => Post::withoutCache()->with('comments')->whereKey($post->id)->get(), ); } + public function test_retrieved_fires_once_per_model_on_cached_and_live_reads(): void + { + Author::create(['name' => 'Nia']); + Author::create(['name' => 'Omar']); + + $seen = []; + Author::retrieved(function (Author $author) use (&$seen): void { + $seen[] = $author->name; + }); + + Author::query()->orderBy('id')->get(); + $this->assertSame(['Nia', 'Omar'], $seen); + + $seen = []; + $this->assertWarmCacheHit(function () { + return Author::query()->orderBy('id')->get(); + }); + $this->assertSame(['Nia', 'Omar'], $seen, 'retrieved must fire on a cache hit'); + + $seen = []; + Author::withoutCache()->orderBy('id')->get(); + $this->assertSame(['Nia', 'Omar'], $seen, 'retrieved must fire on a bypassed read'); + } + + public function test_hydration_resolves_the_connection_exactly_as_eloquent_does(): void + { + $author = new Author; + $native = new class extends Model {}; + $author->setConnection('testing'); + $native->setConnection('testing'); + + foreach ([null, '', 'testing', 'other'] as $connection) { + $expected = $native->newFromBuilder(['id' => 1], $connection)->getConnectionName(); + $actual = $author->newFromBuilder(['id' => 1], $connection)->getConnectionName(); + + $this->assertSame($expected, $actual); + } + + $author->newFromBuilder(['id' => 1]); + $author->setConnection('drifted'); + $this->assertSame('drifted', $author->newFromBuilder(['id' => 1])->getConnectionName()); + + $author->setTable('relocated'); + $this->assertSame('relocated', $author->newFromBuilder(['id' => 1])->getTable()); + } + + public function test_hydrated_models_expose_the_same_state_as_native_eloquent(): void + { + $author = Author::create(['name' => 'Pia']); + + Author::query()->whereKey($author->id)->get(); + $cached = null; + $this->assertWarmCacheHit(function () use ($author, &$cached) { + return $cached = Author::query()->whereKey($author->id)->first(); + }); + $native = Author::withoutCache()->whereKey($author->id)->first(); + + $this->assertSame($native->getAttributes(), $cached->getAttributes()); + $this->assertSame($native->getRawOriginal(), $cached->getRawOriginal()); + // Carbon originals require value comparison. + $this->assertEquals($native->getOriginal(), $cached->getOriginal()); + $this->assertSame($native->exists, $cached->exists); + $this->assertSame($native->isDirty(), $cached->isDirty()); + $this->assertSame($native->getConnectionName(), $cached->getConnectionName()); + $this->assertSame($native->getTable(), $cached->getTable()); + $this->assertFalse($cached->wasRecentlyCreated); + } + public function test_joined_models_with_an_explicit_root_select_match_native_eloquent(): void { $author = Author::create(['name' => 'Lyle']); $post = Post::create(['title' => 'Joined', 'author_id' => $author->id]); - $this->evictModelCache(Post::class, $post->id); - $this->contract( cached: fn() => Post::query()->join('authors', 'authors.id', '=', 'posts.author_id') ->select('posts.*') diff --git a/tests/Integration/Contract/ResultCacheContractTest.php b/tests/Integration/Contract/ModelProjectionContractTest.php similarity index 76% rename from tests/Integration/Contract/ResultCacheContractTest.php rename to tests/Integration/Contract/ModelProjectionContractTest.php index da38321..c534dac 100644 --- a/tests/Integration/Contract/ResultCacheContractTest.php +++ b/tests/Integration/Contract/ModelProjectionContractTest.php @@ -7,18 +7,14 @@ use NormCache\Tests\Fixtures\Models\Post; use NormCache\Tests\TestCase; -/** - * Contract tests: result-cache mode (dependsOn) must apply casts and return - * identical values on cold and warm hits, with one documented exception. - */ -class ResultCacheContractTest extends TestCase +final class ModelProjectionContractTest extends TestCase { private function author(): Author { return Author::create(['name' => 'Alice']); } - public function test_class_defined_casts_are_preserved_on_result_cache_hits(): void + public function test_class_defined_casts_are_preserved_on_cached_model_reads(): void { $author = $this->author(); Post::create([ @@ -44,9 +40,8 @@ public function test_class_defined_casts_are_preserved_on_result_cache_hits(): v $this->assertInstanceOf(Carbon::class, $warm->created_at); } - public function test_with_casts_applied_on_result_cache_warm_hit(): void + public function test_runtime_casts_are_applied_on_cached_model_reads(): void { - // Passes the builder's model instance as a prototype to ensure stateful hydration of runtime casts. $author = $this->author(); Post::create(['title' => 'T', 'views' => 42, 'author_id' => $author->id]); @@ -62,7 +57,7 @@ public function test_with_casts_applied_on_result_cache_warm_hit(): void $this->assertSame('42', $warm->views); } - public function test_add_select_column_accessible_on_result_cache_hit(): void + public function test_add_select_column_is_accessible_on_cached_model_reads(): void { $author = $this->author(); Post::create(['title' => 'Hello', 'views' => 7, 'author_id' => $author->id]); @@ -79,14 +74,22 @@ public function test_add_select_column_accessible_on_result_cache_hit(): void $this->assertNull($warm->getRawOriginal('views')); } - public function test_select_raw_alias_accessible_on_result_cache_hit(): void + public function test_select_raw_alias_is_accessible_on_cached_model_reads(): void { $author = $this->author(); Post::create(['title' => 'T', 'views' => 10, 'author_id' => $author->id]); Post::create(['title' => 'T', 'views' => 20, 'author_id' => $author->id]); - - Post::selectRaw('MAX(views) as max_views')->dependsOn([Author::class])->get(); - $warm = Post::selectRaw('MAX(views) as max_views')->dependsOn([Author::class])->get()->first(); + $query = fn() => Post::selectRaw('MAX(views) as max_views') + ->dependsOn([Author::class]) + ->get() + ->first(); + $native = fn() => Post::withoutCache() + ->selectRaw('MAX(views) as max_views') + ->get() + ->first(); + + $this->contract($query, $native); + $warm = $query(); $this->assertSame(20, (int) $warm->max_views); } diff --git a/tests/Integration/Contract/PaginationContractTest.php b/tests/Integration/Contract/PaginationContractTest.php index 894641f..e7daddd 100644 --- a/tests/Integration/Contract/PaginationContractTest.php +++ b/tests/Integration/Contract/PaginationContractTest.php @@ -4,52 +4,33 @@ use Illuminate\Support\Facades\Event; use NormCache\Events\QueryCacheHit; -use NormCache\Events\QueryCacheMiss; use NormCache\Tests\Fixtures\Models\Author; use NormCache\Tests\Fixtures\Models\Post; use NormCache\Tests\TestCase; -/** - * Behavioral and contract tests: paginate(), simplePaginate(), and cursorPaginate() - * correctly utilize the result cache, handle multi-page navigation/cursors, - * and respect invalidation while maintaining exact parity with native Eloquent. - */ -class PaginationContractTest extends TestCase +final class PaginationContractTest extends TestCase { - // Standard paginate() - - public function test_paginate_contract(): void + public function test_paginate(): void { $this->createAuthors(5); - // Page 1 - Event::fake([QueryCacheMiss::class, QueryCacheHit::class]); $this->contract( fn() => Author::orderBy('id')->paginate(2), fn() => Author::withoutCache()->orderBy('id')->paginate(2), ); - Event::assertDispatched(QueryCacheMiss::class); - Event::assertDispatched(QueryCacheHit::class); - // Page 2 - Event::fake([QueryCacheMiss::class, QueryCacheHit::class]); $this->contract( fn() => Author::orderBy('id')->paginate(2, ['*'], 'page', 2), fn() => Author::withoutCache()->orderBy('id')->paginate(2, ['*'], 'page', 2), ); - Event::assertDispatched(QueryCacheMiss::class); - Event::assertDispatched(QueryCacheHit::class); } public function test_paginate_empty(): void { - Event::fake([QueryCacheMiss::class, QueryCacheHit::class]); $this->contract( fn() => Author::where('name', 'nobody')->paginate(10), fn() => Author::withoutCache()->where('name', 'nobody')->paginate(10), ); - Event::assertDispatched(QueryCacheMiss::class); - Event::assertDispatched(QueryCacheHit::class); } public function test_paginate_with_column_selection(): void @@ -70,60 +51,82 @@ public function test_paginate_with_distinct_returns_correct_total(): void ); } - // simplePaginate() - - public function test_simple_paginate_contract(): void + public function test_simple_paginate(): void { $this->createAuthors(5); - // Page 1 - Event::fake([QueryCacheMiss::class, QueryCacheHit::class]); $this->contract( fn() => Author::orderBy('id')->simplePaginate(2), fn() => Author::withoutCache()->orderBy('id')->simplePaginate(2), ); - Event::assertDispatched(QueryCacheMiss::class); - Event::assertDispatched(QueryCacheHit::class); - // Page 2 - Event::fake([QueryCacheMiss::class, QueryCacheHit::class]); $this->contract( fn() => Author::orderBy('id')->simplePaginate(2, ['*'], 'page', 2), fn() => Author::withoutCache()->orderBy('id')->simplePaginate(2, ['*'], 'page', 2), ); - Event::assertDispatched(QueryCacheMiss::class); - Event::assertDispatched(QueryCacheHit::class); } - // cursorPaginate() + public function test_simple_paginate_invalidates_on_change(): void + { + $this->createAuthors(3); + + Author::orderBy('id')->simplePaginate(2); // Prime cache. + + Event::fake([QueryCacheHit::class]); + Author::orderBy('id')->simplePaginate(2); + Event::assertDispatched(QueryCacheHit::class); + + Author::first()->update(['name' => 'Updated Name']); + + $this->assertSame( + 'Updated Name', + Author::orderBy('id')->simplePaginate(2)->items()[0]->name, + ); + $this->assertSame( + Author::withoutCache()->orderBy('id')->simplePaginate(2)->items()[0]->name, + Author::orderBy('id')->simplePaginate(2)->items()[0]->name, + ); + } - public function test_cursor_paginate_contract(): void + public function test_cursor_paginate(): void { $this->createAuthors(5); - // Page 1 - Event::fake([QueryCacheMiss::class, QueryCacheHit::class]); $this->contract( fn() => Author::orderBy('id')->cursorPaginate(2), fn() => Author::withoutCache()->orderBy('id')->cursorPaginate(2), ); - Event::assertDispatched(QueryCacheMiss::class); - Event::assertDispatched(QueryCacheHit::class); - // Next page via cursor $p1 = Author::withoutCache()->orderBy('id')->cursorPaginate(2); $cursor = $p1->nextCursor(); - Event::fake([QueryCacheMiss::class, QueryCacheHit::class]); $this->contract( fn() => Author::orderBy('id')->cursorPaginate(2, ['*'], 'cursor', $cursor), fn() => Author::withoutCache()->orderBy('id')->cursorPaginate(2, ['*'], 'cursor', $cursor), ); - Event::assertDispatched(QueryCacheMiss::class); - Event::assertDispatched(QueryCacheHit::class); } - // Complex / Dependencies + public function test_cursor_paginate_invalidates_on_change(): void + { + $this->createAuthors(3); + + Author::orderBy('id')->cursorPaginate(2); // Prime cache. + + Event::fake([QueryCacheHit::class]); + Author::orderBy('id')->cursorPaginate(2); + Event::assertDispatched(QueryCacheHit::class); + + Author::first()->update(['name' => 'Updated Name']); + + $this->assertSame( + 'Updated Name', + Author::orderBy('id')->cursorPaginate(2)->items()[0]->name, + ); + $this->assertSame( + Author::withoutCache()->orderBy('id')->cursorPaginate(2)->items()[0]->name, + Author::orderBy('id')->cursorPaginate(2)->items()[0]->name, + ); + } public function test_complex_simple_paginate_with_dependencies(): void { @@ -131,7 +134,6 @@ public function test_complex_simple_paginate_with_dependencies(): void $author->posts()->create(['title' => 'Post 1']); $author->posts()->create(['title' => 'Post 2']); - Event::fake([QueryCacheMiss::class, QueryCacheHit::class]); $this->contract( fn() => Author::join('posts', 'authors.id', '=', 'posts.author_id') ->select('authors.*') @@ -141,16 +143,11 @@ public function test_complex_simple_paginate_with_dependencies(): void ->select('authors.*') ->simplePaginate(1), ); - Event::assertDispatched(QueryCacheMiss::class); - Event::assertDispatched(QueryCacheHit::class); - Post::first()->update(['title' => 'Changed']); - Event::fake([QueryCacheMiss::class]); Author::join('posts', 'authors.id', '=', 'posts.author_id') ->select('authors.*') ->dependsOn([Post::class]) ->simplePaginate(1); - Event::assertDispatched(QueryCacheMiss::class); } public function test_complex_cursor_paginate_with_dependencies(): void @@ -159,7 +156,6 @@ public function test_complex_cursor_paginate_with_dependencies(): void $author->posts()->create(['title' => 'Post 1']); $author->posts()->create(['title' => 'Post 2']); - Event::fake([QueryCacheMiss::class, QueryCacheHit::class]); $this->contract( fn() => Author::join('posts', 'authors.id', '=', 'posts.author_id') ->select('authors.*') @@ -171,8 +167,6 @@ public function test_complex_cursor_paginate_with_dependencies(): void ->orderBy('authors.id') ->cursorPaginate(1), ); - Event::assertDispatched(QueryCacheMiss::class); - Event::assertDispatched(QueryCacheHit::class); } private function createAuthors(int $count): void diff --git a/tests/Integration/Contract/PivotHydrationContractTest.php b/tests/Integration/Contract/PivotHydrationContractTest.php index 074d610..1285222 100644 --- a/tests/Integration/Contract/PivotHydrationContractTest.php +++ b/tests/Integration/Contract/PivotHydrationContractTest.php @@ -7,14 +7,7 @@ use NormCache\Tests\Fixtures\Models\Tag; use NormCache\Tests\TestCase; -/** - * Contract tests for CachesPivotRelation::hydratePivotRelation(), which builds the - * first pivot model the normal way and clones it for every subsequent row instead of - * calling newExistingPivot() per row. These exercise batches large enough to hit the - * clone path (not just the first-row template) and guard against the clone leaking - * one row's pivot data into another. - */ -class PivotHydrationContractTest extends TestCase +final class PivotHydrationContractTest extends TestCase { public function test_belongs_to_many_pivot_hydration_matches_native_across_many_rows(): void { @@ -28,9 +21,8 @@ public function test_belongs_to_many_pivot_hydration_matches_native_across_many_ $query = fn() => Author::with(['tags' => fn($q) => $q->withPivot('notes')])->get(); $native = fn() => Author::withoutCache()->with(['tags' => fn($q) => $q->withPivot('notes')])->get(); - $this->contract($query, $native); + $this->contract($query, $native, expectNoStrayQueries: true); - // Guard against the clone-per-row optimization leaking row 1's pivot data into rows 2..N. $warm = $query()->first()->tags->sortBy('id')->values(); foreach ($warm as $i => $tag) { $this->assertSame("note-{$i}", $tag->pivot->notes); @@ -53,7 +45,7 @@ public function test_morph_to_many_pivot_hydration_matches_native_across_many_ro $query = fn() => Post::with('tags')->get(); $native = fn() => Post::withoutCache()->with('tags')->get(); - $this->contract($query, $native); + $this->contract($query, $native, expectNoStrayQueries: true); $warm = $query()->first()->tags->sortBy('id')->values(); $this->assertSame($tags->pluck('id')->sort()->values()->all(), $warm->pluck('id')->all()); @@ -72,8 +64,6 @@ public function test_pivot_hydration_matches_native_on_cache_miss_path(): void $author->tags()->attach($tag->id, ['notes' => "note-{$i}"]); } - // Relation calls with explicit dependencies bypass the pivot cache entirely, - // forcing every call through the live hydratePivotRelation() path (no cache hit). $query = fn() => $author->tags()->dependsOn([Post::class])->withPivot('notes')->get(); $native = fn() => $author->tags()->withoutCache()->withPivot('notes')->get(); diff --git a/tests/Integration/Contract/PrimaryKeyContractTest.php b/tests/Integration/Contract/PrimaryKeyContractTest.php index 138ca51..0ce5cb3 100644 --- a/tests/Integration/Contract/PrimaryKeyContractTest.php +++ b/tests/Integration/Contract/PrimaryKeyContractTest.php @@ -2,18 +2,28 @@ namespace NormCache\Tests\Integration\Contract; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; +use NormCache\Planning\TableIdentityResolver; use NormCache\Tests\Fixtures\Models\Author; use NormCache\Tests\Fixtures\Models\UuidItem; use NormCache\Tests\TestCase; +use NormCache\Traits\Cacheable; -/** - * Contract tests: primary-key lookups (find, whereKey, where id) must return identical - * results on the native path (withoutCache), cold-cache path, and warm-cache path, - * particularly regarding result ordering and fast-path bypass. - */ -class PrimaryKeyContractTest extends TestCase +final class UnsignedRecord extends Model { - public function test_where_in_primary_key_order_contract(): void + use Cacheable; + + public $timestamps = false; + + protected $table = 'unsigned_records'; +} + +final class PrimaryKeyContractTest extends TestCase +{ + public function test_where_in_primary_key_order(): void { Author::create(['name' => 'Alice']); Author::create(['name' => 'Bob']); @@ -30,7 +40,7 @@ public function test_where_in_primary_key_order_contract(): void ); } - public function test_where_in_primary_key_with_explicit_order_contract(): void + public function test_where_in_primary_key_with_explicit_order(): void { Author::create(['name' => 'Alice']); Author::create(['name' => 'Bob']); @@ -42,7 +52,7 @@ public function test_where_in_primary_key_with_explicit_order_contract(): void ); } - public function test_where_in_uuid_primary_key_order_contract(): void + public function test_where_in_uuid_primary_key_order(): void { UuidItem::create(['id' => 'b8f8702c-4734-45e0-a548-18e3c66f6f9c', 'name' => 'B']); UuidItem::create(['id' => 'a1f8702c-4734-45e0-a548-18e3c66f6f9c', 'name' => 'A']); @@ -59,4 +69,57 @@ public function test_where_in_uuid_primary_key_order_contract(): void fn() => UuidItem::withoutCache()->whereIn('id', $ids)->get(), ); } + + public function test_unsigned_bigint_values_above_php_int_max_use_canonical_rows(): void + { + if (!in_array(DB::connection()->getDriverName(), ['mysql', 'mariadb'], true)) { + $this->markTestSkipped('Requires MySQL or MariaDB unsigned BIGINT support.'); + } + + Schema::create('unsigned_records', function (Blueprint $table): void { + $table->unsignedBigInteger('id')->primary(); + $table->string('name'); + }); + + try { + $id = '18446744073709551615'; + DB::table('unsigned_records')->insert(['id' => $id, 'name' => 'Original']); + $direct = static fn() => UnsignedRecord::query()->toBase()->where('id', $id)->first(); + $canonical = static fn() => UnsignedRecord::query()->toBase()->orderBy('id')->get(); + + $this->assertSame('Original', $direct()?->name); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $this->assertSame('Original', $direct()?->name); + DB::disableQueryLog(); + $this->assertSame([], DB::getQueryLog()); + + $this->assertSame($id, (string) $canonical()->first()?->id); + $this->deleteResultOverlays(); + $identity = app(TableIdentityResolver::class) + ->resolve(DB::connection(), 'unsigned_records'); + $this->assertNotNull($identity); + $generation = $this->cacheStore()->getRaw( + $this->cacheKeys()->generation($identity), + ) ?? '0'; + $rowKey = $this->cacheKeys()->row($identity, $generation, 'i:' . $id); + $this->assertNotNull($this->cacheStore()->getRaw($rowKey)); + $this->cacheStore()->delete($rowKey); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $repaired = $canonical(); + DB::disableQueryLog(); + + $this->assertSame($id, (string) $repaired->first()?->id); + $this->assertCount(1, DB::getQueryLog()); + + UnsignedRecord::query()->toBase()->where('id', $id)->update(['name' => 'Updated']); + + $this->assertSame('Updated', $direct()?->name); + } finally { + Schema::dropIfExists('unsigned_records'); + } + } } diff --git a/tests/Integration/Contract/QueryCallbackContractTest.php b/tests/Integration/Contract/QueryCallbackContractTest.php index b20ff19..aac239d 100644 --- a/tests/Integration/Contract/QueryCallbackContractTest.php +++ b/tests/Integration/Contract/QueryCallbackContractTest.php @@ -11,9 +11,9 @@ use NormCache\Tests\TestCase; use ReflectionProperty; -class QueryCallbackContractTest extends TestCase +final class QueryCallbackContractTest extends TestCase { - public function test_before_query_callback_affects_normalized_cache_key_and_results(): void + public function test_before_query_callback_affects_graph_key_and_results(): void { $this->fixtures(); @@ -24,7 +24,12 @@ public function test_before_query_callback_affects_normalized_cache_key_and_resu ->get(); $this->assertSame(['Bob'], $query()->pluck('name')->all()); - $this->assertSame(['Bob'], $query()->pluck('name')->all()); + $this->assertWarmCacheHit(function () use ($query) { + $result = $query(); + $this->assertSame(['Bob'], $result->pluck('name')->all()); + + return $result; + }); } public function test_before_query_callback_runs_after_global_scopes_like_eloquent(): void @@ -34,21 +39,20 @@ public function test_before_query_callback_runs_after_global_scopes_like_eloquen Author::addGlobalScope('has_country', fn($builder) => $builder->whereNotNull('country_id')); try { - $cached = Author::orderBy('name') + $cached = fn() => Author::orderBy('name') ->beforeQuery(fn($base) => $base->orWhere('name', 'Carol')) ->get() ->pluck('name') ->all(); - - $native = Author::withoutCache() + $native = fn() => Author::withoutCache() ->orderBy('name') ->beforeQuery(fn($base) => $base->orWhere('name', 'Carol')) ->get() ->pluck('name') ->all(); - $this->assertSame($native, $cached); - $this->assertSame(['Alice', 'Bob', 'Carol'], $cached); + $this->contract($cached, $native); + $this->assertSame(['Alice', 'Bob', 'Carol'], $cached()); } finally { $this->clearGlobalScope(Author::class, 'has_country'); } @@ -68,14 +72,19 @@ public function test_global_scopes_are_applied_once_per_cache_execution(): void $query = fn() => Author::orderBy('name')->get(); $this->assertSame(['Alice', 'Bob'], $query()->pluck('name')->all()); - $this->assertSame(['Alice', 'Bob'], $query()->pluck('name')->all()); + $this->assertWarmCacheHit(function () use ($query) { + $result = $query(); + $this->assertSame(['Alice', 'Bob'], $result->pluck('name')->all()); + + return $result; + }); $this->assertSame(2, $calls); } finally { $this->clearGlobalScope(Author::class, 'counted'); } } - public function test_before_query_callback_affects_result_cache_key_and_results(): void + public function test_before_query_callback_affects_value_payload_key_and_results(): void { $this->fixtures(); @@ -87,10 +96,15 @@ public function test_before_query_callback_affects_result_cache_key_and_results( ->get(); $this->assertSame(['Bob'], $query()->pluck('name')->all()); - $this->assertSame(['Bob'], $query()->pluck('name')->all()); + $this->assertWarmCacheHit(function () use ($query) { + $result = $query(); + $this->assertSame(['Bob'], $result->pluck('name')->all()); + + return $result; + }); } - public function test_before_query_callback_affects_scalar_cache_key_and_results(): void + public function test_before_query_callback_affects_scalar_value_key_and_results(): void { $this->fixtures(); $calls = 0; @@ -106,7 +120,12 @@ public function test_before_query_callback_affects_scalar_cache_key_and_results( }; $this->assertSame(2, $query()); - $this->assertSame(2, $query()); + $this->assertWarmCacheHit(function () use ($query) { + $result = $query(); + $this->assertSame(2, $result); + + return $result; + }); $this->assertSame(2, $calls); } @@ -134,9 +153,19 @@ public function test_before_query_callback_runs_once_for_pluck_and_value(): void }; $this->assertSame(['Alice', 'Bob'], $pluck()->all()); - $this->assertSame(['Alice', 'Bob'], $pluck()->all()); - $this->assertSame('Bob', $value()); + $this->assertWarmCacheHit(function () use ($pluck) { + $result = $pluck(); + $this->assertSame(['Alice', 'Bob'], $result->all()); + + return $result; + }); $this->assertSame('Bob', $value()); + $this->assertWarmCacheHit(function () use ($value) { + $result = $value(); + $this->assertSame('Bob', $result); + + return $result; + }); $this->assertSame(4, $calls); } @@ -151,7 +180,10 @@ public function test_before_query_callback_affects_pagination_count_and_items(): ->paginate(10); $cold = $query(); - $warm = $query(); + $warm = null; + $this->assertWarmCacheHit(function () use ($query, &$warm) { + return $warm = $query(); + }); $this->assertSame(2, $cold->total()); $this->assertSame(['Alice', 'Bob'], $cold->pluck('name')->all()); @@ -177,7 +209,12 @@ public function test_before_query_callback_affects_belongs_to_eager_cache_path() }; $this->assertSame([null, null, 'Bob'], $query()->pluck('author.name')->all()); - $this->assertSame([null, null, 'Bob'], $query()->pluck('author.name')->all()); + $this->assertWarmCacheHit(function () use ($query) { + $result = $query(); + $this->assertSame([null, null, 'Bob'], $result->pluck('author.name')->all()); + + return $result; + }); $this->assertSame(2, $calls); } @@ -198,7 +235,12 @@ public function test_before_query_callback_affects_pivot_cache_hash_and_results( }; $this->assertSame(['php'], $query()->pluck('name')->all()); - $this->assertSame(['php'], $query()->pluck('name')->all()); + $this->assertWarmCacheHit(function () use ($query) { + $result = $query(); + $this->assertSame(['php'], $result->pluck('name')->all()); + + return $result; + }); $this->assertSame(2, $calls); } @@ -209,14 +251,13 @@ public function test_pivot_before_query_callback_runs_after_related_global_scope Tag::addGlobalScope('php_only', fn($builder) => $builder->where('tags.name', 'php')); try { - $cached = $alice->tags() + $cached = fn() => $alice->tags() ->orderBy('tags.name') ->beforeQuery(fn($base) => $base->orWhere('tags.name', 'laravel')) ->get() ->pluck('name') ->all(); - - $native = $alice->tags() + $native = fn() => $alice->tags() ->withoutCache() ->orderBy('tags.name') ->beforeQuery(fn($base) => $base->orWhere('tags.name', 'laravel')) @@ -224,8 +265,8 @@ public function test_pivot_before_query_callback_runs_after_related_global_scope ->pluck('name') ->all(); - $this->assertSame($native, $cached); - $this->assertSame(['laravel', 'php'], $cached); + $this->contract($cached, $native); + $this->assertSame(['laravel', 'php'], $cached()); } finally { $this->clearGlobalScope(Tag::class, 'php_only'); } @@ -248,7 +289,12 @@ public function test_before_query_callback_affects_through_cache_hash_and_result }; $this->assertSame(['B1'], $query()->pluck('title')->all()); - $this->assertSame(['B1'], $query()->pluck('title')->all()); + $this->assertWarmCacheHit(function () use ($query) { + $result = $query(); + $this->assertSame(['B1'], $result->pluck('title')->all()); + + return $result; + }); $this->assertSame(2, $calls); } @@ -268,7 +314,12 @@ public function test_after_query_callback_runs_on_normalized_cold_and_warm_resul }; $this->assertSame(['Alice', 'Carol'], $query()->pluck('name')->all()); - $this->assertSame(['Alice', 'Carol'], $query()->pluck('name')->all()); + $this->assertWarmCacheHit(function () use ($query) { + $result = $query(); + $this->assertSame(['Alice', 'Carol'], $result->pluck('name')->all()); + + return $result; + }); $this->assertSame(2, $calls); $this->assertSame(['Alice', 'Bob', 'Carol'], Author::orderBy('name')->get()->pluck('name')->all()); } @@ -290,7 +341,12 @@ public function test_after_query_callback_does_not_contaminate_aggregate_result_ }; $this->assertSame(['Alice', 'Carol'], $query()->pluck('name')->all()); - $this->assertSame(['Alice', 'Carol'], $query()->pluck('name')->all()); + $this->assertWarmCacheHit(function () use ($query) { + $result = $query(); + $this->assertSame(['Alice', 'Carol'], $result->pluck('name')->all()); + + return $result; + }); $this->assertSame(2, $calls); $this->assertSame( ['Alice', 'Bob', 'Carol'], @@ -315,9 +371,19 @@ public function test_after_query_callback_runs_for_value_and_pluck_without_cachi ->value('name'); $this->assertSame(['ALICE', 'BOB', 'CAROL'], $pluck()->all()); - $this->assertSame(['ALICE', 'BOB', 'CAROL'], $pluck()->all()); - $this->assertSame('CHANGED', $value()); + $this->assertWarmCacheHit(function () use ($pluck) { + $result = $pluck(); + $this->assertSame(['ALICE', 'BOB', 'CAROL'], $result->all()); + + return $result; + }); $this->assertSame('CHANGED', $value()); + $this->assertWarmCacheHit(function () use ($value) { + $result = $value(); + $this->assertSame('CHANGED', $result); + + return $result; + }); $this->assertSame(['Alice', 'Bob', 'Carol'], Author::orderBy('name')->pluck('name')->all()); } @@ -335,7 +401,12 @@ public function test_after_query_callback_does_not_run_for_aggregate_scalar_oper ->count(); $this->assertSame(3, $query()); - $this->assertSame(3, $query()); + $this->assertWarmCacheHit(function () use ($query) { + $result = $query(); + $this->assertSame(3, $result); + + return $result; + }); $this->assertSame(0, $calls); } @@ -349,8 +420,12 @@ public function test_after_query_callback_does_not_contaminate_pivot_cache(): vo ->get(); $this->assertSame(['laravel'], $filtered()->pluck('name')->all()); - $this->assertSame(['laravel'], $filtered()->pluck('name')->all()); - $this->assertNotEmpty($this->redisKeys('pivot:*')); + $this->assertWarmCacheHit(function () use ($filtered) { + $result = $filtered(); + $this->assertSame(['laravel'], $result->pluck('name')->all()); + + return $result; + }); $this->assertSame( ['laravel', 'php'], $alice->tags()->orderBy('tags.name')->get()->pluck('name')->all() @@ -367,8 +442,12 @@ public function test_after_query_callback_does_not_contaminate_through_cache(): ->get(); $this->assertSame(['A1', 'A2'], $filtered()->pluck('title')->all()); - $this->assertSame(['A1', 'A2'], $filtered()->pluck('title')->all()); - $this->assertNotEmpty($this->redisKeys('through:*')); + $this->assertWarmCacheHit(function () use ($filtered) { + $result = $filtered(); + $this->assertSame(['A1', 'A2'], $result->pluck('title')->all()); + + return $result; + }); $this->assertSame( ['A1', 'A2', 'B1'], $country->posts()->orderBy('posts.title')->get()->pluck('title')->all() diff --git a/tests/Integration/Contract/RelationContractTest.php b/tests/Integration/Contract/RelationContractTest.php index eb5b53a..b5f6e2b 100644 --- a/tests/Integration/Contract/RelationContractTest.php +++ b/tests/Integration/Contract/RelationContractTest.php @@ -11,11 +11,7 @@ use NormCache\Tests\Fixtures\Models\Tag; use NormCache\Tests\TestCase; -/** - * Contract tests: eager loading operations must return identical results on - * the native path (withoutCache), cold-cache path, and warm-cache path. - */ -class RelationContractTest extends TestCase +final class RelationContractTest extends TestCase { private function fixtures(): array { @@ -43,8 +39,6 @@ private function fixtures(): array return compact('country', 'alice', 'bob', 'carol', 'p1', 'p2', 'p3', 'php', 'laravel', 'c1', 'c2'); } - // Eager loading - public function test_with_has_many(): void { $this->fixtures(); @@ -61,6 +55,7 @@ public function test_with_has_one(): void $this->contract( fn() => Author::with('firstPost')->orderBy('name')->get(), fn() => Author::withoutCache()->with('firstPost')->orderBy('name')->get(), + expectNoStrayQueries: true, ); } @@ -70,6 +65,7 @@ public function test_with_belongs_to(): void $this->contract( fn() => Post::with('author')->orderBy('title')->get(), fn() => Post::withoutCache()->with('author')->orderBy('title')->get(), + expectNoStrayQueries: true, ); } @@ -83,6 +79,7 @@ public function test_with_belongs_to_computed_select_matches_native(): void fn() => Post::withoutCache()->with([ 'author' => fn($query) => $query->selectRaw('id, upper(name) as upper_name'), ])->orderBy('title')->get(), + expectNoStrayQueries: true, ); } @@ -92,6 +89,7 @@ public function test_with_belongs_to_many(): void $this->contract( fn() => Author::with('tags')->orderBy('name')->get(), fn() => Author::withoutCache()->with('tags')->orderBy('name')->get(), + expectNoStrayQueries: true, ); } @@ -101,6 +99,7 @@ public function test_with_morph_many(): void $this->contract( fn() => Author::with('comments')->orderBy('name')->get(), fn() => Author::withoutCache()->with('comments')->orderBy('name')->get(), + expectNoStrayQueries: true, ); } @@ -110,6 +109,7 @@ public function test_with_morph_one(): void $this->contract( fn() => Post::with('latestComment')->orderBy('title')->get(), fn() => Post::withoutCache()->with('latestComment')->orderBy('title')->get(), + expectNoStrayQueries: true, ); } @@ -119,6 +119,7 @@ public function test_with_morph_to_many(): void $this->contract( fn() => Post::with('tags')->orderBy('title')->get(), fn() => Post::withoutCache()->with('tags')->orderBy('title')->get(), + expectNoStrayQueries: true, ); } @@ -128,6 +129,7 @@ public function test_with_has_many_through(): void $this->contract( fn() => Country::with('posts')->first(), fn() => Country::withoutCache()->with('posts')->first(), + expectNoStrayQueries: true, ); } @@ -137,6 +139,7 @@ public function test_with_has_one_through(): void $this->contract( fn() => Country::with('firstPost')->first(), fn() => Country::withoutCache()->with('firstPost')->first(), + expectNoStrayQueries: true, ); } @@ -146,6 +149,7 @@ public function test_with_morph_to(): void $this->contract( fn() => Comment::with('commentable')->orderBy('id')->get(), fn() => Comment::withoutCache()->with('commentable')->orderBy('id')->get(), + expectNoStrayQueries: true, ); } @@ -162,6 +166,7 @@ public function test_with_morph_to_alias_and_fqcn_types_in_same_collection(): vo $this->contract( fn() => Comment::with('commentable')->orderBy('id')->get(), fn() => Comment::withoutCache()->with('commentable')->orderBy('id')->get(), + expectNoStrayQueries: true, ); } finally { Relation::morphMap([], false); @@ -170,10 +175,11 @@ public function test_with_morph_to_alias_and_fqcn_types_in_same_collection(): vo public function test_with_belongs_to_null_foreign_key(): void { - ['carol' => $carol] = $this->fixtures(); // Carol has no country_id + ['carol' => $carol] = $this->fixtures(); $this->contract( fn() => Author::where('id', $carol->id)->with('country')->first(), fn() => Author::withoutCache()->where('id', $carol->id)->with('country')->first(), + expectNoStrayQueries: true, ); } @@ -183,6 +189,7 @@ public function test_with_constrained_eager_load(): void $this->contract( fn() => Author::with(['posts' => fn($q) => $q->where('published', true)])->orderBy('name')->get(), fn() => Author::withoutCache()->with(['posts' => fn($q) => $q->where('published', true)])->orderBy('name')->get(), + expectNoStrayQueries: true, ); } @@ -202,6 +209,7 @@ public function test_with_multidimensional_array_eager_loading(): void $this->contract( fn() => Author::with(['posts' => ['comments']])->orderBy('name')->get(), fn() => Author::withoutCache()->with(['posts' => ['comments']])->orderBy('name')->get(), + expectNoStrayQueries: true, ); } @@ -211,44 +219,54 @@ public function test_with_colon_notation_column_selection(): void $this->contract( fn() => Author::with(['posts:id,title,author_id'])->orderBy('name')->get(), fn() => Author::withoutCache()->with(['posts:id,title,author_id'])->orderBy('name')->get(), + expectNoStrayQueries: true, ); } public function test_with_has_many_limit_constraint(): void { - $this->fixtures(); + ['p1' => $p1] = $this->fixtures(); $this->contract( fn() => Author::with(['posts' => fn($q) => $q->orderBy('title')->limit(1)])->orderBy('name')->get(), fn() => Author::withoutCache()->with(['posts' => fn($q) => $q->orderBy('title')->limit(1)])->orderBy('name')->get(), + expectNoStrayQueries: true, + ); + + $this->assertArrayNotHasKey( + 'laravel_row', + Post::query()->findOrFail($p1->getKey())->getAttributes(), ); } public function test_with_belongs_to_many_limit_in_eager_load(): void { - $this->fixtures(); // Alice has 2 tags (php, laravel), Bob has 1 + $this->fixtures(); $this->contract( fn() => Author::with(['tags' => fn($q) => $q->orderBy('name')->limit(1)])->orderBy('name')->get(), fn() => Author::withoutCache()->with(['tags' => fn($q) => $q->orderBy('name')->limit(1)])->orderBy('name')->get(), + expectNoStrayQueries: true, ); } public function test_with_has_many_through_limit_in_eager_load(): void { - $this->fixtures(); // Country UK has 3 posts + $this->fixtures(); $this->contract( fn() => Country::with(['posts' => fn($q) => $q->orderBy('title')->limit(2)])->first(), fn() => Country::withoutCache()->with(['posts' => fn($q) => $q->orderBy('title')->limit(2)])->first(), + expectNoStrayQueries: true, ); } public function test_with_has_many_with_trashed_constraint_includes_deleted(): void { ['p1' => $p1] = $this->fixtures(); - $p1->delete(); // soft-delete one of Alice's posts + $p1->delete(); $this->contract( fn() => Author::with(['posts' => fn($q) => $q->withTrashed()->orderBy('title')])->orderBy('name')->get(), fn() => Author::withoutCache()->with(['posts' => fn($q) => $q->withTrashed()->orderBy('title')])->orderBy('name')->get(), + expectNoStrayQueries: true, ); } @@ -257,13 +275,13 @@ public function test_with_constrained_load_with_count_inside_closure(): void $this->fixtures(); $this->contract( fn() => Author::with(['posts' => fn($q) => $q->withCount('comments')])->orderBy('name')->get(), - fn() => Author::withoutCache()->with(['posts' => fn($q) => $q->withoutAggregateCache()->withCount('comments')])->orderBy('name')->get(), + fn() => Author::withoutCache()->with(['posts' => fn($q) => $q->withCount('comments')])->orderBy('name')->get(), ); } public function test_with_has_one_of_many_latest(): void { - $this->fixtures(); // Alice: A1(views=10), A2(views=20); Bob: B1(views=30) + $this->fixtures(); $this->contract( fn() => Author::with('latestPost')->orderBy('name')->get(), @@ -272,9 +290,40 @@ public function test_with_has_one_of_many_latest(): void ); } + public function test_with_has_one_of_many_latest_invalidates_when_related_table_changes(): void + { + ['alice' => $alice] = $this->fixtures(); + $query = fn() => Author::with('latestPost')->findOrFail($alice->id); + + $this->assertSame('A2', $query()->latestPost?->title); + $this->assertSame('A2', $query()->latestPost?->title); + + Post::create([ + 'title' => 'A3', + 'author_id' => $alice->id, + 'views' => 40, + 'published' => true, + ]); + + DB::flushQueryLog(); + DB::enableQueryLog(); + + try { + $this->assertSame('A3', $query()->latestPost?->title); + $this->assertCount(1, DB::getQueryLog()); + + DB::flushQueryLog(); + + $this->assertSame('A3', $query()->latestPost?->title); + $this->assertSame([], DB::getQueryLog()); + } finally { + DB::disableQueryLog(); + } + } + public function test_with_has_one_through_of_many_latest(): void { - $this->fixtures(); // UK: latest post is B1 through Bob + $this->fixtures(); $this->contract( fn() => Country::with('latestPost')->orderBy('name')->get(), @@ -285,7 +334,7 @@ public function test_with_has_one_through_of_many_latest(): void public function test_with_has_one_of_many_aggregate_column(): void { - $this->fixtures(); // Alice: mostViewed=A2(20), Bob: mostViewed=B1(30), Carol: null + $this->fixtures(); $this->contract( fn() => Author::with('mostViewedPost')->orderBy('name')->get(), @@ -294,8 +343,6 @@ public function test_with_has_one_of_many_aggregate_column(): void ); } - // Collection loading (load, loadMissing, loadCount, loadSum, loadMax, loadMin) - public function test_load_on_collection_returns_same_relations(): void { $this->fixtures(); @@ -353,7 +400,7 @@ public function test_load_count_multiple_relations_simultaneously(): void public function test_load_count_excludes_soft_deleted_models(): void { ['p1' => $p1] = $this->fixtures(); - $p1->delete(); // soft-delete one of Alice's posts + $p1->delete(); $this->contract( fn() => tap(Author::orderBy('name')->get(), fn($c) => $c->loadCount('posts')), @@ -361,6 +408,15 @@ public function test_load_count_excludes_soft_deleted_models(): void ); } + public function test_load_sum_multiple_relations_simultaneously(): void + { + $this->fixtures(); + $this->contract( + fn() => tap(Author::orderBy('name')->get(), fn($c) => $c->loadSum('posts', 'views')), + fn() => tap(Author::withoutCache()->orderBy('name')->get(), fn($c) => $c->loadSum('posts', 'views')), + ); + } + public function test_load_max_on_collection(): void { $this->fixtures(); @@ -378,50 +434,4 @@ public function test_load_min_on_collection(): void fn() => tap(Author::withoutCache()->orderBy('name')->get(), fn($c) => $c->loadMin('posts', 'views')), ); } - - // Complex relation queries with raw expressions and custom selects - - private function rawExpressionFixtures(): void - { - $country = Country::create(['name' => 'USA']); - - $author = Author::create(['name' => 'John', 'country_id' => $country->id]); - - Post::create(['title' => 'P1', 'author_id' => $author->id, 'views' => 10]); - Post::create(['title' => 'P2', 'author_id' => $author->id, 'views' => 20]); - - $fiction = Tag::create(['name' => 'fiction']); - $science = Tag::create(['name' => 'science']); - - $author->tags()->attach([$fiction->id, $science->id]); - } - - public function test_belongs_to_many_with_where_raw(): void - { - $this->rawExpressionFixtures(); - - $query = fn() => Author::with(['tags' => fn($q) => $q->whereRaw('LOWER(name) = ?', ['fiction'])])->get(); - $nativeQuery = fn() => Author::withoutCache()->with(['tags' => fn($q) => $q->whereRaw('LOWER(name) = ?', ['fiction'])])->get(); - - $this->contract($query, $nativeQuery); - } - - public function test_has_many_through_with_custom_select_raw(): void - { - $this->rawExpressionFixtures(); - - $query = fn() => Country::with(['posts' => fn($q) => $q->select('posts.*', DB::raw('posts.id * 2 as doubled_id'))])->get(); - $nativeQuery = fn() => Country::withoutCache()->with(['posts' => fn($q) => $q->select('posts.*', DB::raw('posts.id * 2 as doubled_id'))])->get(); - - $this->contract($query, $nativeQuery); - - // Also verify the custom attribute is present and correct - $warm = $query(); - $this->assertCount(1, $warm); - $posts = $warm->first()->posts; - $this->assertCount(2, $posts); - foreach ($posts as $post) { - $this->assertEquals($post->id * 2, $post->doubled_id); - } - } } diff --git a/tests/Integration/Contract/RelationCorrectnessContractTest.php b/tests/Integration/Contract/RelationCorrectnessContractTest.php new file mode 100644 index 0000000..aa87655 --- /dev/null +++ b/tests/Integration/Contract/RelationCorrectnessContractTest.php @@ -0,0 +1,56 @@ + 'USA']); + + $author = Author::create(['name' => 'John', 'country_id' => $country->id]); + + $p1 = Post::create(['title' => 'P1', 'author_id' => $author->id, 'views' => 10]); + $p2 = Post::create(['title' => 'P2', 'author_id' => $author->id, 'views' => 20]); + + $fiction = Tag::create(['name' => 'fiction']); + $science = Tag::create(['name' => 'science']); + + $author->tags()->attach([$fiction->id, $science->id]); + } + + public function test_belongs_to_many_with_where_raw(): void + { + $this->fixtures(); + + $query = fn() => Author::with(['tags' => fn($q) => $q->whereRaw('LOWER(name) = ?', ['fiction'])])->get(); + $nativeQuery = fn() => Author::withoutCache()->with(['tags' => fn($q) => $q->whereRaw('LOWER(name) = ?', ['fiction'])])->get(); + + $this->contract($query, $nativeQuery); + } + + public function test_has_many_through_with_custom_select_raw(): void + { + $this->fixtures(); + + $query = fn() => Country::with(['posts' => fn($q) => $q->select('posts.*', DB::raw('posts.id * 2 as doubled_id'))])->get(); + $nativeQuery = fn() => Country::withoutCache()->with(['posts' => fn($q) => $q->select('posts.*', DB::raw('posts.id * 2 as doubled_id'))])->get(); + + $this->contract($query, $nativeQuery); + + $warm = $query(); + $this->assertCount(1, $warm); + $posts = $warm->first()->posts; + $this->assertCount(2, $posts); + foreach ($posts as $post) { + $this->assertEquals($post->id * 2, $post->doubled_id); + } + } +} diff --git a/tests/Integration/Contract/ScalarContractTest.php b/tests/Integration/Contract/ScalarContractTest.php index 5abe5df..004e5d2 100644 --- a/tests/Integration/Contract/ScalarContractTest.php +++ b/tests/Integration/Contract/ScalarContractTest.php @@ -9,12 +9,7 @@ use NormCache\Tests\Fixtures\Models\Tag; use NormCache\Tests\TestCase; -/** - * Contract tests: scalar aggregate operations (count, sum, avg, min, max, - * exists, doesntExist, value, pluck) must return identical results on the - * native path (withoutCache), cold-cache path, and warm-cache path. - */ -class ScalarContractTest extends TestCase +final class ScalarContractTest extends TestCase { private function fixtures(): array { @@ -42,8 +37,6 @@ private function fixtures(): array return compact('country', 'alice', 'bob', 'carol', 'p1', 'p2', 'p3', 'php', 'laravel', 'c1', 'c2'); } - // count - public function test_count_all(): void { $this->fixtures(); @@ -88,8 +81,6 @@ public function test_count_with_array_columns_bypasses_scalar_cache(): void ); } - // sum, avg, min, max - public function test_sum(): void { $this->fixtures(); @@ -135,8 +126,6 @@ public function test_max(): void ); } - // exists, doesntExist - public function test_exists_true(): void { $this->fixtures(); @@ -171,8 +160,6 @@ public function test_doesnt_exist_false(): void ); } - // value, pluck - public function test_value(): void { $this->fixtures(); @@ -185,7 +172,6 @@ public function test_value(): void public function test_value_with_alias(): void { $this->fixtures(); - // Native Eloquent returns null for an aliased value() projection $this->contract( fn() => Author::orderBy('name')->value('name as headline'), fn() => Author::withoutCache()->orderBy('name')->value('name as headline'), @@ -229,8 +215,6 @@ public function test_pluck_keyed(): void ); } - // Edge cases: empty sets and nullable columns - public function test_sum_on_empty_set_returns_consistent_type(): void { $this->contract( @@ -239,18 +223,6 @@ public function test_sum_on_empty_set_returns_consistent_type(): void ); } - public function test_avg_excludes_null_rows_consistently(): void - { - $author = Author::create(['name' => 'Avg']); - Post::create(['title' => 'P1', 'author_id' => $author->id, 'views' => 10]); - Post::create(['title' => 'P2', 'author_id' => $author->id, 'views' => 30]); - // 'views' is non-nullable in fixtures but avg on a filtered empty set returns null - $this->contract( - fn() => Post::where('author_id', $author->id)->avg('views'), - fn() => Post::withoutCache()->where('author_id', $author->id)->avg('views'), - ); - } - public function test_min_returns_null_on_empty_set(): void { $this->contract( @@ -277,9 +249,9 @@ public function test_avg_returns_null_on_empty_set(): void public function test_min_on_nullable_column_ignores_null_rows(): void { - $this->fixtures(); // Alice country_id=1, Bob country_id=1, Carol country_id=null + $this->fixtures(); $this->contract( - fn() => Author::min('country_id'), // null excluded → 1 + fn() => Author::min('country_id'), fn() => Author::withoutCache()->min('country_id'), ); } diff --git a/tests/Integration/Contract/SubquerySafetyContractTest.php b/tests/Integration/Contract/SubquerySafetyContractTest.php new file mode 100644 index 0000000..163f185 --- /dev/null +++ b/tests/Integration/Contract/SubquerySafetyContractTest.php @@ -0,0 +1,381 @@ + 'Real Author']); + Post::create([ + 'title' => 'Derived Post', + 'author_id' => $author->id, + 'views' => 10, + 'published' => true, + ]); + + $derived = static fn() => Author::query() + ->fromSub(Post::query(), 'derived') + ->dependsOn([Post::class]) + ->get(); + + $this->assertColdCacheMiss($derived); + $this->assertWarmCacheHit($derived); + + $cached = null; + $this->assertColdCacheMiss(function () use ($author, &$cached) { + return $cached = Author::find($author->id); + }); + + $this->assertSame('Real Author', $cached?->name); + $this->assertArrayNotHasKey('title', $cached?->getAttributes() ?? []); + $this->assertWarmCacheHit(static fn() => Author::find($author->id)); + } + + public function test_query_builder_from_sub_requires_a_declared_root(): void + { + $alice = Author::create(['name' => 'Alice']); + Author::create(['name' => 'Bob']); + + $query = static fn(bool $declared = false) => Author::query()->toBase() + ->fromSub( + Author::query()->toBase()->select([ + 'id', + 'name', + 'country_id', + 'created_at', + 'updated_at', + ]), + 'derived_authors', + ) + ->when($declared, fn($query) => $query->dependsOn(['authors'])) + ->orderBy('derived_authors.id') + ->get() + ->map(static fn(\stdClass $row): array => (array) $row) + ->values() + ->all(); + + $this->bypassContract( + fn() => $query(), + fn() => $query(), + reason: 'unidentifiable_dependency', + ); + $this->contract( + fn() => $query(true), + fn() => $query(), + mutate: fn() => Author::whereKey($alice->id)->update(['name' => 'Alice Updated']), + ); + } + + public function test_comma_join_raw_subquery_fails_open_until_dependencies_are_declared(): void + { + $author = Author::create(['name' => 'Alice']); + Comment::create([ + 'body' => 'First', + 'commentable_type' => Author::class, + 'commentable_id' => $author->id, + ]); + Tag::create(['name' => 'one']); + + $query = static fn() => Author::query() + ->selectRaw('(select count(*) from comments, tags) as dependency_count') + ->whereKey($author->id) + ->first(); + $native = static fn() => Author::withoutCache() + ->selectRaw('(select count(*) from comments, tags) as dependency_count') + ->whereKey($author->id) + ->first(); + + $this->bypassContract($query, $native, reason: 'unidentifiable_dependency'); + + $cached = static fn() => Author::query() + ->selectRaw('(select count(*) from comments, tags) as dependency_count') + ->dependsOn(['comments', 'tags']) + ->whereKey($author->id) + ->first(); + + $this->contract( + $cached, + $native, + mutate: static fn() => Tag::create(['name' => 'two']), + ); + } + + public function test_raw_projection_cannot_steal_a_structured_subquery_capture(): void + { + $author = Author::create(['name' => 'Alice']); + $post = Post::create([ + 'title' => 'Post', + 'author_id' => $author->id, + 'published' => true, + ]); + + $build = static function (bool $native, bool $declared = false) use ($author) { + $posts = Post::query() + ->selectRaw('count(*)') + ->whereColumn('posts.author_id', 'authors.id'); + $postsSql = $posts->toSql(); + $query = $native ? Author::withoutCache() : Author::query(); + + return $query + ->addSelect(['post_count' => $posts]) + ->selectRaw( + "(select count(*) from comments where exists ({$postsSql})) as comment_count" + ) + ->when($declared, fn($query) => $query->dependsOn([Comment::class])) + ->whereKey($author->id) + ->first(); + }; + + $this->bypassContract( + static fn() => $build(false), + static fn() => $build(true), + reason: 'unidentifiable_dependency', + ); + $this->contract( + static fn() => $build(false, true), + static fn() => $build(true), + mutate: static fn() => Comment::create([ + 'body' => 'New comment', + 'commentable_type' => Post::class, + 'commentable_id' => $post->id, + ]), + ); + } + + public function test_nested_view_requires_physical_base_table_dependencies(): void + { + if (DB::connection()->getDriverName() !== 'sqlite') { + $this->markTestSkipped('Portable view-safety contract currently uses SQLite syntax.'); + } + + $author = Author::create(['name' => 'Alice']); + Post::create(['title' => 'P1', 'author_id' => $author->id]); + DB::statement('create view post_titles as select id, title from posts'); + + try { + $query = static fn() => Author::query() + ->selectRaw('(select count(*) from post_titles) as post_count') + ->whereKey($author->id) + ->first(); + $native = static fn() => Author::withoutCache() + ->selectRaw('(select count(*) from post_titles) as post_count') + ->whereKey($author->id) + ->first(); + + $this->bypassContract($query, $native, reason: 'unidentifiable_dependency'); + + $physical = static fn() => Author::query() + ->selectRaw('(select count(*) from post_titles) as post_count') + ->dependsOn(['posts']) + ->whereKey($author->id) + ->first(); + + $this->contract( + $physical, + $native, + mutate: static fn() => Post::create([ + 'title' => 'P2', + 'author_id' => $author->id, + ]), + ); + } finally { + DB::statement('drop view if exists post_titles'); + } + } + + public function test_builder_backed_nested_view_supports_physical_base_table_dependencies(): void + { + if (DB::connection()->getDriverName() !== 'sqlite') { + $this->markTestSkipped('Portable view-safety contract currently uses SQLite syntax.'); + } + + $author = Author::create(['name' => 'Alice']); + Post::create(['title' => 'P1', 'author_id' => $author->id]); + DB::statement('create view post_titles as select id, title from posts'); + + try { + $native = static fn() => Author::withoutCache() + ->addSelect([ + 'post_count' => DB::table('post_titles')->selectRaw('count(*)'), + ]) + ->whereKey($author->id) + ->first(); + + $cached = static fn() => Author::query() + ->addSelect([ + 'post_count' => DB::table('post_titles')->selectRaw('count(*)'), + ]) + ->dependsOn(['posts']) + ->whereKey($author->id) + ->first(); + + $this->contract( + $cached, + $native, + mutate: static fn() => Post::create([ + 'title' => 'P2', + 'author_id' => $author->id, + ]), + ); + } finally { + DB::statement('drop view if exists post_titles'); + } + } + + #[DataProvider('unknownFunctionExpressions')] + public function test_unknown_database_function_is_cached_rather_than_bypassed( + string $expression, + ): void { + if (DB::connection()->getDriverName() !== 'sqlite') { + $this->markTestSkipped('The test function is registered through SQLite PDO.'); + } + + $author = Author::create(['name' => 'Alice']); + Post::create(['title' => 'P1', 'author_id' => $author->id]); + $database = (string) config('database.connections.testing.database'); + $reader = new \PDO("sqlite:{$database}"); + DB::connection()->getPdo()->sqliteCreateFunction( + 'normcache_post_count', + static fn(): int => (int) $reader + ->query('select count(*) from posts') + ->fetchColumn(), + 0, + ); + $query = static fn() => Author::query() + ->selectRaw("{$expression} as post_count") + ->whereKey($author->id) + ->first(); + $native = static fn() => Author::withoutCache() + ->selectRaw("{$expression} as post_count") + ->whereKey($author->id) + ->first(); + + $this->contract($query, $native); + } + + public static function unknownFunctionExpressions(): array + { + return [ + 'bare' => ['normcache_post_count()'], + 'quoted identifier' => ['"normcache_post_count"()'], + ]; + } + + public function test_a_commented_raw_projection_is_still_opaque(): void + { + $author = Author::create(['name' => 'Alice']); + $query = static fn() => Author::query() + ->selectRaw('1/**/ as post_count') + ->whereKey($author->id) + ->first(); + $native = static fn() => Author::withoutCache() + ->selectRaw('1/**/ as post_count') + ->whereKey($author->id) + ->first(); + + $this->bypassContract($query, $native, reason: 'unidentifiable_dependency'); + } + + public function test_raw_join_expression_requires_declared_dependencies(): void + { + $alice = Author::create(['name' => 'Alice']); + $bob = Author::create(['name' => 'Bob']); + Comment::create([ + 'body' => 'Alice comment', + 'commentable_type' => Author::class, + 'commentable_id' => $alice->id, + ]); + + $query = static fn(bool $native, bool $declared = false) => ($native ? Author::withoutCache() : Author::query()) + ->join(DB::raw('comments'), 'comments.commentable_id', '=', 'authors.id') + ->when($declared, fn($query) => $query->dependsOn([Comment::class])) + ->where('comments.commentable_type', Author::class) + ->select('authors.*') + ->orderBy('authors.id') + ->get(); + + $this->bypassContract( + static fn() => $query(false), + static fn() => $query(true), + reason: 'unidentifiable_dependency', + ); + $this->contract( + static fn() => $query(false, true), + static fn() => $query(true), + mutate: static fn() => Comment::create([ + 'body' => 'Bob comment', + 'commentable_type' => Author::class, + 'commentable_id' => $bob->id, + ]), + ); + } + + public function test_raw_from_expression_requires_declared_dependencies(): void + { + $author = Author::create(['name' => 'Alice']); + Post::create(['title' => 'P1', 'author_id' => $author->id]); + $query = static fn(bool $native, bool $declared = false): int => ($native ? Author::withoutCache() : Author::query()) + ->fromRaw('posts as raw_posts') + ->when($declared, fn($query) => $query->dependsOn([Post::class])) + ->count(); + + $this->bypassContract( + static fn() => $query(false), + static fn() => $query(true), + reason: 'unidentifiable_dependency', + ); + $this->contract( + static fn() => $query(false, true), + static fn() => $query(true), + mutate: static fn() => Post::create([ + 'title' => 'P2', + 'author_id' => $author->id, + ]), + ); + } + + public function test_opaque_derived_predicate_requires_declared_dependencies(): void + { + $author = Author::create(['name' => 'Alice']); + Post::create([ + 'title' => 'Published', + 'author_id' => $author->id, + 'published' => true, + ]); + $build = static function (bool $native, bool $declared = false) { + $publishedAuthors = DB::query() + ->fromSub( + Post::query() + ->select('author_id') + ->where('published', true), + 'published_posts', + ) + ->select('published_posts.author_id'); + + return ($native ? Author::withoutCache() : Author::query()) + ->whereIn('authors.id', $publishedAuthors) + ->when($declared, fn($query) => $query->dependsOn(['posts'])) + ->orderBy('authors.id') + ->get(); + }; + + $this->bypassContract( + static fn() => $build(false), + static fn() => $build(true), + reason: 'unidentifiable_dependency', + ); + $this->contract( + static fn() => $build(false, true), + static fn() => $build(true), + ); + } +} diff --git a/tests/Integration/Contract/WhereHasContractTest.php b/tests/Integration/Contract/WhereHasContractTest.php index 233630e..1d1512c 100644 --- a/tests/Integration/Contract/WhereHasContractTest.php +++ b/tests/Integration/Contract/WhereHasContractTest.php @@ -4,42 +4,11 @@ use NormCache\Tests\Fixtures\Models\Author; use NormCache\Tests\Fixtures\Models\Comment; -use NormCache\Tests\Fixtures\Models\Country; use NormCache\Tests\Fixtures\Models\Post; -use NormCache\Tests\Fixtures\Models\Tag; use NormCache\Tests\TestCase; -/** - * Contract tests for cacheable relationship-existence queries. - */ -class WhereHasContractTest extends TestCase +final class WhereHasContractTest extends TestCase { - private function fixtures(): array - { - $country = Country::create(['name' => 'UK']); - - $alice = Author::create(['name' => 'Alice', 'country_id' => $country->id]); - $bob = Author::create(['name' => 'Bob', 'country_id' => $country->id]); - $carol = Author::create(['name' => 'Carol']); - - $p1 = Post::create(['title' => 'A1', 'author_id' => $alice->id, 'views' => 10, 'published' => true]); - $p2 = Post::create(['title' => 'A2', 'author_id' => $alice->id, 'views' => 20, 'published' => false]); - $p3 = Post::create(['title' => 'B1', 'author_id' => $bob->id, 'views' => 30, 'published' => true]); - - $php = Tag::create(['name' => 'php']); - $laravel = Tag::create(['name' => 'laravel']); - - $alice->tags()->attach([$php->id, $laravel->id]); - $bob->tags()->attach($php->id); - - $p1->tags()->attach($php->id); - - $c1 = Comment::create(['body' => 'Great!', 'commentable_type' => Author::class, 'commentable_id' => $alice->id]); - $c2 = Comment::create(['body' => 'Nice post', 'commentable_type' => Post::class, 'commentable_id' => $p1->id]); - - return compact('country', 'alice', 'bob', 'carol', 'p1', 'p2', 'p3', 'php', 'laravel', 'c1', 'c2'); - } - public function test_simple_has_many_where_has_matches_native_eloquent(): void { $author = Author::create(['name' => 'Alice']); @@ -88,149 +57,4 @@ public function test_morph_many_where_has_matches_native_eloquent(): void fn() => Author::withoutCache()->whereHas('comments')->get(), ); } - - public function test_doesnt_have_returns_correct_models(): void - { - $this->fixtures(); // Carol has no posts - $this->contract( - fn() => Author::doesntHave('posts')->orderBy('name')->get(), - fn() => Author::withoutCache()->doesntHave('posts')->orderBy('name')->get(), - ); - } - - public function test_has_with_count_threshold_returns_correct_models(): void - { - $this->fixtures(); // Alice has 2 posts, Bob has 1, Carol has 0 - $this->contract( - fn() => Author::has('posts', '>=', 2)->orderBy('name')->get(), - fn() => Author::withoutCache()->has('posts', '>=', 2)->orderBy('name')->get(), - ); - } - - public function test_where_relation_returns_same_result_as_where_has(): void - { - $this->fixtures(); - $this->contract( - fn() => Author::whereRelation('posts', 'published', true)->orderBy('name')->get(), - fn() => Author::withoutCache()->whereRelation('posts', 'published', true)->orderBy('name')->get(), - ); - } - - public function test_or_where_relation_combines_conditions(): void - { - $this->fixtures(); - $this->contract( - fn() => Author::whereRelation('posts', 'title', 'A1') - ->orWhereRelation('posts', 'title', 'B1') - ->orderBy('name') - ->get(), - fn() => Author::withoutCache() - ->whereRelation('posts', 'title', 'A1') - ->orWhereRelation('posts', 'title', 'B1') - ->orderBy('name') - ->get(), - ); - } - - public function test_where_doesnt_have_relation_with_condition(): void - { - $this->fixtures(); // Carol has no posts; Alice/Bob have published posts - $this->contract( - fn() => Author::whereDoesntHaveRelation('posts', 'published', true)->orderBy('name')->get(), - fn() => Author::withoutCache()->whereDoesntHaveRelation('posts', 'published', true)->orderBy('name')->get(), - ); - } - - public function test_or_where_doesnt_have_relation(): void - { - $this->fixtures(); - $this->contract( - fn() => Author::whereRelation('posts', 'title', 'A1') - ->orWhereDoesntHaveRelation('posts', 'published', false) - ->orderBy('name') - ->get(), - fn() => Author::withoutCache() - ->whereRelation('posts', 'title', 'A1') - ->orWhereDoesntHaveRelation('posts', 'published', false) - ->orderBy('name') - ->get(), - ); - } - - public function test_where_has_morph_filters_by_type_and_condition(): void - { - $this->fixtures(); - $this->contract( - fn() => Comment::whereHasMorph('commentable', [Author::class], fn($q) => $q->where('name', 'Alice'))->get(), - fn() => Comment::withoutCache()->whereHasMorph('commentable', [Author::class], fn($q) => $q->where('name', 'Alice'))->get(), - ); - } - - public function test_doesnt_have_morph_excludes_by_type(): void - { - $this->fixtures(); - $this->contract( - fn() => Comment::doesntHaveMorph('commentable', [Author::class])->orderBy('id')->get(), - fn() => Comment::withoutCache()->doesntHaveMorph('commentable', [Author::class])->orderBy('id')->get(), - ); - } - - public function test_where_has_morph_with_wildcard_type(): void - { - $this->fixtures(); - $this->contract( - fn() => Comment::whereHasMorph('commentable', '*')->orderBy('id')->get(), - fn() => Comment::withoutCache()->whereHasMorph('commentable', '*')->orderBy('id')->get(), - ); - } - - public function test_or_where_has_morph_combines_conditions(): void - { - $this->fixtures(); - $this->contract( - fn() => Comment::whereHasMorph('commentable', [Author::class]) - ->orWhereHasMorph('commentable', [Post::class]) - ->orderBy('id') - ->get(), - fn() => Comment::withoutCache() - ->whereHasMorph('commentable', [Author::class]) - ->orWhereHasMorph('commentable', [Post::class]) - ->orderBy('id') - ->get(), - ); - } - - public function test_where_morph_relation_shorthand(): void - { - $this->fixtures(); - $this->contract( - fn() => Comment::whereMorphRelation('commentable', [Author::class], 'name', 'Alice')->orderBy('id')->get(), - fn() => Comment::withoutCache()->whereMorphRelation('commentable', [Author::class], 'name', 'Alice')->orderBy('id')->get(), - ); - } - - public function test_or_where_morph_relation_shorthand(): void - { - $this->fixtures(); - $this->contract( - fn() => Comment::whereMorphRelation('commentable', [Author::class], 'name', 'Alice') - ->orWhereMorphRelation('commentable', [Post::class], 'title', 'A1') - ->orderBy('id') - ->get(), - fn() => Comment::withoutCache() - ->whereMorphRelation('commentable', [Author::class], 'name', 'Alice') - ->orWhereMorphRelation('commentable', [Post::class], 'title', 'A1') - ->orderBy('id') - ->get(), - ); - } - - public function test_where_not_closure_returns_correct_models(): void - { - $this->fixtures(); - $this->contract( - fn() => Author::whereNot(fn($q) => $q->where('name', 'Carol'))->orderBy('name')->get(), - fn() => Author::withoutCache()->whereNot(fn($q) => $q->where('name', 'Carol'))->orderBy('name')->get(), - ); - } } diff --git a/tests/Integration/Database/ConnectionWiringTest.php b/tests/Integration/Database/ConnectionWiringTest.php new file mode 100644 index 0000000..8386500 --- /dev/null +++ b/tests/Integration/Database/ConnectionWiringTest.php @@ -0,0 +1,144 @@ +assertSame(Builder::class, DB::table('authors')::class); + $this->assertSame(Builder::class, DB::query()::class); + } + + public function test_connection_remains_laravels_configured_connection(): void + { + $this->assertSame(SQLiteConnection::class, DB::connection()::class); + } + + public function test_laravels_transactions_manager_is_left_in_place(): void + { + $this->assertSame( + DatabaseTransactionsManager::class, + $this->app->make('db.transactions')::class, + ); + } + + public function test_invalidation_still_publishes_under_a_replacement_transactions_manager(): void + { + $this->app->instance('db.transactions', new ForeignTransactionsManager); + DB::connection()->setTransactionManager($this->app->make('db.transactions')); + + $post = Post::query()->create([ + 'title' => 'Before', + 'author_id' => Author::query()->create(['name' => 'Author'])->getKey(), + ]); + $read = fn(): ?string => Post::query()->toBase()->where('id', $post->getKey())->value('title'); + + $this->assertSame('Before', $read()); + $this->assertSame('Before', $read()); + $observed = null; + + DB::transaction(function () use ($post, $read, &$observed): void { + Post::query()->toBase()->where('id', $post->getKey())->update(['title' => 'Committed']); + + DB::afterCommit(function () use ($read, &$observed): void { + $observed = $read(); + }); + }); + + $this->assertSame('Committed', $observed); + $this->assertSame('Committed', $read()); + } + + public function test_only_cacheable_models_receive_normcache_query_builders(): void + { + $this->assertInstanceOf(QueryBuilder::class, Post::query()->toBase()); + $this->assertSame(Builder::class, UncachedPost::query()->toBase()::class); + } + + public function test_cacheable_model_metadata_is_the_authoritative_primary_key_source(): void + { + $integer = Post::query()->toBase(); + $string = UuidItem::query()->toBase(); + + $this->assertSame(Post::class, $integer->modelClass()); + $this->assertSame('id', $integer->primaryKey()?->column); + $this->assertSame(PrimaryKeyMetadata::INTEGER, $integer->primaryKey()?->family); + $this->assertSame(PrimaryKeyMetadata::STRING, $string->primaryKey()?->family); + $this->assertSame('deleted_at', $integer->deletedAtColumn()); + } + + public function test_only_a_soft_deleting_model_reports_a_deleted_at_column(): void + { + $this->assertNull(Author::query()->toBase()->deletedAtColumn()); + $this->assertNull( + (new PseudoSoftDeletePost)->newQuery()->toBase()->deletedAtColumn(), + 'defining getDeletedAtColumn() without the SoftDeletes trait does not soft delete', + ); + } + + public function test_laravel_created_pivot_builder_does_not_reuse_root_model_metadata(): void + { + $pivot = Post::query()->toBase()->newQuery()->from('post_tag'); + + $this->assertInstanceOf(QueryBuilder::class, $pivot); + $this->assertNull($pivot->primaryKey()); + $this->assertNull($pivot->modelClass()); + } + + public function test_cacheable_model_works_with_a_user_provided_connection_subclass(): void + { + $name = 'user-provided'; + $database = (string) DB::connection()->getDatabaseName(); + + config()->set("database.connections.{$name}", [ + 'driver' => 'sqlite', + 'database' => $database, + 'prefix' => '', + 'name' => $name, + ]); + DB::extend($name, static fn(array $config) => new class(new \PDO('sqlite:' . $database), $database, '', $config) extends SQLiteConnection {}); + DB::purge($name); + + try { + $connection = DB::connection($name); + $builder = Post::on($name)->toBase(); + + $this->assertNotSame(SQLiteConnection::class, $connection::class); + $this->assertInstanceOf(QueryBuilder::class, $builder); + $this->assertSame($connection, $builder->getConnection()); + } finally { + DB::disconnect($name); + DB::purge($name); + DB::forgetExtension($name); + } + } +} diff --git a/tests/Integration/Database/InternalQueryTest.php b/tests/Integration/Database/InternalQueryTest.php new file mode 100644 index 0000000..ee80845 --- /dev/null +++ b/tests/Integration/Database/InternalQueryTest.php @@ -0,0 +1,51 @@ +insertGetId(['name' => 'Author']); + + for ($i = 0; $i < 3; $i++) { + DB::table('posts')->insertGetId([ + 'title' => "Post {$i}", + 'views' => $i, + 'published' => true, + 'author_id' => $authorId, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + + public function test_internal_queries_dispatch_no_cache_events(): void + { + Event::fake([QueryCacheHit::class, QueryCacheMiss::class, QueryBypassed::class]); + + $rows = Post::query()->toBase()->internal()->get(); + + $this->assertCount(3, $rows); + Event::assertNotDispatched(QueryCacheHit::class); + Event::assertNotDispatched(QueryCacheMiss::class); + Event::assertNotDispatched(QueryBypassed::class); + } + + public function test_internal_queries_write_nothing_to_the_cache(): void + { + Post::query()->toBase()->internal()->get(); + + $this->assertSame([], $this->cacheKeysMatching('')); + } +} diff --git a/tests/Integration/Database/LaravelBehaviorTest.php b/tests/Integration/Database/LaravelBehaviorTest.php new file mode 100644 index 0000000..9897a68 --- /dev/null +++ b/tests/Integration/Database/LaravelBehaviorTest.php @@ -0,0 +1,115 @@ +create(['name' => 'Author']); + $post = Post::query()->create([ + 'title' => 'Post', + 'views' => 12, + 'published' => true, + 'author_id' => $author->getKey(), + ]); + Comment::query()->create([ + 'body' => 'Comment', + 'commentable_type' => Post::class, + 'commentable_id' => $post->getKey(), + ]); + $tag = Tag::query()->create(['name' => 'Tag']); + $post->tags()->attach($tag); + + $load = fn() => Post::query() + ->with(['author', 'comments', 'tags']) + ->paginate(10); + $cold = $load(); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $warm = $load(); + DB::disableQueryLog(); + + $this->assertSame($cold->total(), $warm->total()); + $this->assertSame( + $cold->getCollection()->toArray(), + $warm->getCollection()->toArray(), + ); + $this->assertSame($post->getKey(), $warm[0]->tags[0]->pivot->taggable_id); + $this->assertSame([], DB::getQueryLog()); + } + + public function test_cached_rows_still_use_laravels_new_from_builder_hydration_path(): void + { + $author = Author::query()->create(['name' => 'Author']); + $post = NewFromBuilderOverridingPost::query()->create([ + 'title' => 'Hydrated', + 'views' => 0, + 'published' => true, + 'author_id' => $author->getKey(), + ]); + NewFromBuilderOverridingPost::$newFromBuilderCalls = 0; + + NewFromBuilderOverridingPost::query()->findOrFail($post->getKey()); + $afterCold = NewFromBuilderOverridingPost::$newFromBuilderCalls; + NewFromBuilderOverridingPost::query()->findOrFail($post->getKey()); + + $this->assertSame(1, $afterCold); + $this->assertSame(2, NewFromBuilderOverridingPost::$newFromBuilderCalls); + } + + public function test_mutating_a_connection_database_changes_its_table_identity(): void + { + $author = Author::query()->create(['name' => 'Author']); + $post = Post::query()->create([ + 'title' => 'Tenant one', + 'views' => 0, + 'published' => true, + 'author_id' => $author->getKey(), + ]); + $connection = DB::connection(); + $originalDatabase = (string) $connection->getDatabaseName(); + $originalPdo = $connection->getPdo(); + $resolver = $this->app->make(TableIdentityResolver::class); + $firstIdentity = $resolver->resolve($connection, 'posts'); + $this->assertSame( + 'Tenant one', + Post::query()->toBase()->where('id', $post->getKey())->first()?->title, + ); + + $database = sys_get_temp_dir() . '/normcache-tenant-' . getmypid() . '.sqlite'; + copy($originalDatabase, $database); + $tenantPdo = new \PDO('sqlite:' . $database); + $tenantPdo->exec("update posts set title = 'Tenant two' where id = {$post->getKey()}"); + + try { + $connection->setDatabaseName($database); + $connection->setPdo($tenantPdo); + $secondIdentity = $resolver->resolve($connection, 'posts'); + + $this->assertNotSame($firstIdentity?->hash, $secondIdentity?->hash); + $this->assertSame( + 'Tenant two', + Post::query()->toBase()->withoutCache()->where('id', $post->getKey())->first()?->title, + ); + $this->assertSame( + 'Tenant two', + Post::query()->toBase()->where('id', $post->getKey())->first()?->title, + ); + } finally { + $connection->setDatabaseName($originalDatabase); + $connection->setPdo($originalPdo); + @unlink($database); + } + } +} diff --git a/tests/Integration/Database/ReadInterceptionTest.php b/tests/Integration/Database/ReadInterceptionTest.php new file mode 100644 index 0000000..9cdd2b7 --- /dev/null +++ b/tests/Integration/Database/ReadInterceptionTest.php @@ -0,0 +1,190 @@ +create(['name' => 'Author']); + $this->postId = (int) DB::table('posts')->insertGetId([ + 'title' => 'Cached', + 'views' => 7, + 'published' => true, + 'author_id' => $author->getKey(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + public function test_db_table_reads_always_execute_sql_and_create_no_cache_entry(): void + { + $cold = DB::table('posts')->where('id', $this->postId)->get(); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $warm = DB::table('posts')->where('id', $this->postId)->get(); + DB::disableQueryLog(); + + $this->assertInstanceOf(\stdClass::class, $cold[0]); + $this->assertInstanceOf(\stdClass::class, $warm[0]); + $this->assertNotSame($cold[0], $warm[0]); + $this->assertSame((array) $cold[0], (array) $warm[0]); + $this->assertCount(1, DB::getQueryLog()); + $this->assertSame([], $this->cacheKeysMatching(':q:')); + } + + public function test_opted_in_eloquent_is_cached_but_traitless_and_unmarked_reads_are_live(): void + { + Post::query()->whereKey($this->postId)->firstOrFail(); + UncachedPost::query()->whereKey($this->postId)->firstOrFail(); + DB::query()->from('posts')->where('id', $this->postId)->first(); + + DB::flushQueryLog(); + DB::enableQueryLog(); + + Post::query()->whereKey($this->postId)->firstOrFail(); + $afterCached = count(DB::getQueryLog()); + UncachedPost::query()->whereKey($this->postId)->firstOrFail(); + DB::query()->from('posts')->where('id', $this->postId)->first(); + + DB::disableQueryLog(); + + $this->assertSame(0, $afterCached); + $this->assertCount(2, DB::getQueryLog()); + } + + public function test_unobserved_direct_primary_key_hit_does_not_compile_sql(): void + { + $connection = DB::connection(); + $originalGrammar = $connection->getQueryGrammar(); + $originalConfig = $this->app->make(CacheConfig::class); + $config = (array) config('normcache'); + $config['events'] = false; + $this->app->instance(CacheConfig::class, CacheConfig::fromArray($config)); + $this->app->forgetScopedInstances(); + $grammar = new class($connection) extends SQLiteGrammar + { + public int $postSelectCompilations = 0; + + public function compileSelect(Builder $query) + { + if ($query->from === 'posts') { + $this->postSelectCompilations++; + } + + return parent::compileSelect($query); + } + }; + $connection->setQueryGrammar($grammar); + + try { + Post::query()->toBase()->where('id', $this->postId)->first(); + $grammar->postSelectCompilations = 0; + + Post::query()->toBase()->where('id', $this->postId)->first(); + + $this->assertSame(0, $grammar->postSelectCompilations); + } finally { + $connection->setQueryGrammar($originalGrammar); + $this->app->instance(CacheConfig::class, $originalConfig); + $this->app->forgetScopedInstances(); + } + } + + public function test_before_callbacks_affect_identity_once_and_after_callbacks_see_hits(): void + { + $beforeCalls = 0; + $afterCalls = 0; + + $run = function () use (&$beforeCalls, &$afterCalls) { + return Post::query()->toBase() + ->beforeQuery(function ($query) use (&$beforeCalls) { + $beforeCalls++; + $query->where('views', 7); + }) + ->afterQuery(function ($rows) use (&$afterCalls) { + $afterCalls++; + + return $rows; + }) + ->where('id', $this->postId) + ->get(); + }; + + $run(); + $run(); + + $this->assertSame(2, $beforeCalls); + $this->assertSame(2, $afterCalls); + } + + public function test_exists_uses_the_cache_and_conditional_variants_delegate_to_it(): void + { + $query = fn() => Post::query()->toBase()->where('id', $this->postId); + + $this->assertTrue($query()->exists()); + + DB::flushQueryLog(); + DB::enableQueryLog(); + + $this->assertTrue($query()->exists()); + $this->assertFalse($query()->doesntExist()); + $this->assertTrue($query()->existsOr(fn() => false)); + + DB::disableQueryLog(); + + $this->assertSame([], DB::getQueryLog()); + } + + public function test_exists_cannot_poison_the_canonical_primary_key_row(): void + { + $query = fn() => Post::query()->toBase()->where('id', $this->postId); + + $this->assertTrue($query()->exists()); + $row = $query()->first(); + + $this->assertInstanceOf(\stdClass::class, $row); + $this->assertSame('Cached', $row->title); + } + + public function test_count_cannot_poison_the_canonical_primary_key_row(): void + { + $query = fn() => Post::query()->toBase()->where('id', $this->postId); + + $this->assertSame(1, $query()->count()); + $row = $query()->first(); + + $this->assertInstanceOf(\stdClass::class, $row); + $this->assertSame('Cached', $row->title); + } + + public function test_explicit_and_execution_safety_bypasses_remain_live(): void + { + Post::query()->toBase()->where('id', $this->postId)->get(); + + DB::flushQueryLog(); + DB::enableQueryLog(); + + Post::query()->toBase()->where('id', $this->postId)->withoutCache()->get(); + Post::query()->toBase()->where('id', $this->postId)->useWritePdo()->get(); + DB::transaction(fn() => Post::query()->toBase()->where('id', $this->postId)->get()); + + DB::disableQueryLog(); + + $this->assertCount(3, DB::getQueryLog()); + } +} diff --git a/tests/Integration/Infrastructure/CacheEventsTest.php b/tests/Integration/Infrastructure/CacheEventsTest.php deleted file mode 100644 index 4859d33..0000000 --- a/tests/Integration/Infrastructure/CacheEventsTest.php +++ /dev/null @@ -1,285 +0,0 @@ - 'Alice']); - Author::all(); - - Event::assertDispatched(QueryCacheMiss::class, function (QueryCacheMiss $e) { - return $e->modelClass === Author::class; - }); - } - - public function test_query_cache_events_include_observability_metadata(): void - { - Author::create(['name' => 'Alice']); - Event::fake([QueryCacheMiss::class]); - - Author::where('name', 'Alice')->get(); - - Event::assertDispatched(QueryCacheMiss::class, function (QueryCacheMiss $event): bool { - return ($event->meta['cache_kind'] ?? null) === CacheKind::ModelIndex->value - && ($event->meta['cache_status'] ?? null) === CacheStatus::Miss->value; - }); - - Event::fake([QueryCacheHit::class]); - Author::where('name', 'Alice')->get(); - - Event::assertDispatched(QueryCacheHit::class, function (QueryCacheHit $event): bool { - return ($event->meta['cache_kind'] ?? null) === CacheKind::ModelIndex->value - && ($event->meta['cache_status'] ?? null) === CacheStatus::Hit->value; - }); - } - - public function test_query_cache_hit_fired_on_subsequent_get(): void - { - Author::create(['name' => 'Alice']); - Author::all(); - - Event::fake([QueryCacheHit::class]); - - Author::all(); - - Event::assertDispatched(QueryCacheHit::class, function (QueryCacheHit $e) { - return $e->modelClass === Author::class; - }); - } - - public function test_model_cache_miss_fired_when_models_not_in_cache(): void - { - Event::fake([ModelCacheMiss::class]); - - $author = Author::create(['name' => 'Alice']); - Author::all(); - - Event::assertDispatched(ModelCacheMiss::class, function (ModelCacheMiss $e) use ($author) { - return $e->modelClass === Author::class - && in_array($author->id, $e->ids); - }); - } - - public function test_model_repair_metric_records_count(): void - { - $author = Author::create(['name' => 'Alice']); - $this->evictModelCache(Author::class, $author->id); - Event::fake([CacheMetricRecorded::class]); - - app('normcache')->modelCache()->getModels([$author->id], Author::class); - - Event::assertDispatched(CacheMetricRecorded::class, function (CacheMetricRecorded $event): bool { - return $event->metric === 'model_entry_repairs' - && $event->value === 1 - && $event->cacheKind === CacheKind::Model - && $event->status === CacheStatus::Miss; - }); - } - - public function test_model_cache_hit_fired_when_models_in_cache(): void - { - $author = Author::create(['name' => 'Alice']); - Author::all(); - - Event::fake([ModelCacheHit::class]); - - Author::all(); - - Event::assertDispatched(ModelCacheHit::class, function (ModelCacheHit $e) use ($author) { - return $e->modelClass === Author::class - && in_array($author->id, $e->ids); - }); - } - - public function test_partial_model_cache_miss_fires_miss_for_both_when_version_bumped(): void - { - $alice = Author::create(['name' => 'Alice']); - Author::all(); // warm alice's model key at version V - - Author::create(['name' => 'Bob']); // version bumps to V+1; alice's V key is no longer current - - Event::fake([ModelCacheHit::class, ModelCacheMiss::class]); - - // query cache is stale; version bump makes alice's model key unreachable too, so both miss - Author::all(); - - Event::assertNotDispatched(ModelCacheHit::class); - Event::assertDispatched(ModelCacheMiss::class); - } - - public function test_query_cache_hit_event_carries_correct_key(): void - { - Author::create(['name' => 'Alice']); - Author::all(); - - Event::fake([QueryCacheHit::class]); - - Author::all(); - - Event::assertDispatched(QueryCacheHit::class, function (QueryCacheHit $e) { - return str_starts_with($e->key, app('normcache')->keys()->prefixed('query:' . app('normcache')->keys()->classKey(Author::class) . ':v')); - }); - } - - public function test_no_events_fired_when_cache_bypassed_with_without_cache(): void - { - Event::fake([QueryCacheHit::class, QueryCacheMiss::class]); - - Author::create(['name' => 'Alice']); - Author::withoutCache()->get(); - - Event::assertNotDispatched(QueryCacheHit::class); - Event::assertNotDispatched(QueryCacheMiss::class); - } - - public function test_invalidation_event_records_dependency_type_and_space_fanout(): void - { - $author = Author::create(['name' => 'Alice']); - Event::fake([CacheInvalidated::class]); - - $author->update(['name' => 'Updated']); - - Event::assertDispatched(CacheInvalidated::class, function (CacheInvalidated $event): bool { - return $event->dependencyType === 'model' - && $event->target === Author::class - && $event->count >= 1 - && $event->spaces !== []; - }); - } - - public function test_result_depends_on_event_records_result_kind(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - Event::fake([QueryCacheMiss::class]); - - Author::whereHas('posts')->dependsOn([Post::class])->get(); - - Event::assertDispatched(QueryCacheMiss::class, function (QueryCacheMiss $event): bool { - return ($event->meta['cache_kind'] ?? null) === CacheKind::Result->value - && ($event->meta['result_kind'] ?? null) === ResultKind::Collection->value - && ($event->meta['cache_status'] ?? null) === CacheStatus::Miss->value; - }); - } - - public function test_empty_collection_result_reports_empty_status_not_hit_on_warm_read(): void - { - $query = fn() => Author::query() - ->join('posts', 'posts.author_id', '=', 'authors.id') - ->dependsOn([Post::class]) - ->where('authors.name', 'nobody') - ->select('authors.*') - ->get(); - - $query(); - - Event::fake([QueryCacheHit::class]); - - $warm = $query(); - - $this->assertCount(0, $warm); - Event::assertDispatched(QueryCacheHit::class, function (QueryCacheHit $event): bool { - return ($event->meta['cache_kind'] ?? null) === CacheKind::Result->value - && ($event->meta['cache_status'] ?? null) === CacheStatus::Empty->value; - }); - } - - public function test_result_depends_on_miss_fires_query_cache_miss(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Event::fake([QueryCacheMiss::class]); - - Author::whereHas('posts')->dependsOn([Post::class])->get(); - - Event::assertDispatched(QueryCacheMiss::class, function (QueryCacheMiss $e) { - return $e->modelClass === Author::class - && str_starts_with($e->key, app('normcache')->keys()->prefixed('result:' . app('normcache')->keys()->classKey(Author::class) . ':')); - }); - } - - public function test_result_depends_on_miss_does_not_fire_model_cache_hit(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Event::fake([ModelCacheHit::class]); - - Author::whereHas('posts')->dependsOn([Post::class])->get(); - - Event::assertNotDispatched(ModelCacheHit::class); - } - - public function test_through_relation_cache_fires_query_events(): void - { - $country = Country::create(['name' => 'Australia']); - $author = Author::create(['name' => 'Alice', 'country_id' => $country->id]); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Event::fake([QueryCacheMiss::class]); - - $country->posts()->get(); - - Event::assertDispatched(QueryCacheMiss::class, function (QueryCacheMiss $e) { - return $e->modelClass === Post::class - && str_starts_with($e->key, app('normcache')->keys()->prefixed('through:' . app('normcache')->keys()->classKey(Post::class) . ':')); - }); - - Event::fake([QueryCacheHit::class]); - - $country->posts()->get(); - - Event::assertDispatched(QueryCacheHit::class, function (QueryCacheHit $e) { - return $e->modelClass === Post::class - && str_starts_with($e->key, app('normcache')->keys()->prefixed('through:' . app('normcache')->keys()->classKey(Post::class) . ':')); - }); - } - - public function test_relation_aggregate_cache_fires_query_events(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Event::fake([QueryCacheMiss::class]); - - Author::withCount('posts')->get(); - - Event::assertDispatched(QueryCacheMiss::class, function (QueryCacheMiss $e) { - return $e->modelClass === Author::class - && str_starts_with($e->key, app('normcache')->keys()->prefixed('result:' . app('normcache')->keys()->classKey(Author::class) . ':')); - }); - - Event::fake([QueryCacheHit::class]); - - Author::withCount('posts')->get(); - - Event::assertDispatched(QueryCacheHit::class, function (QueryCacheHit $e) { - return $e->modelClass === Author::class - && str_starts_with($e->key, app('normcache')->keys()->prefixed('result:' . app('normcache')->keys()->classKey(Author::class) . ':')); - }); - } -} diff --git a/tests/Integration/Infrastructure/ClusterModeTest.php b/tests/Integration/Infrastructure/ClusterModeTest.php deleted file mode 100644 index bdb23c9..0000000 --- a/tests/Integration/Infrastructure/ClusterModeTest.php +++ /dev/null @@ -1,397 +0,0 @@ -setClusterMode(true); - } - - public function test_predis_cluster_bulk_reads_preserve_order_and_missing_values_across_spaces(): void - { - $this->requirePredisCluster(); - - $store = $this->cacheManager()->store(); - $defaultKeys = [ - '{nc}:test:bulk:1', - '{nc}:test:bulk:missing', - '{nc}:test:bulk:2', - ]; - $contentKeys = [ - '{nc:content}:test:bulk:1', - '{nc:content}:test:bulk:missing', - '{nc:content}:test:bulk:2', - ]; - - $store->set($defaultKeys[0], 'default-one', 60); - $store->set($defaultKeys[2], 'default-two', 60); - $store->set($contentKeys[0], 'content-one', 60); - $store->set($contentKeys[2], 'content-two', 60); - - $this->assertSame(['default-one', null, 'default-two'], $store->getMany($defaultKeys)); - $this->assertSame(['content-one', null, 'content-two'], $store->getMany($contentKeys)); - } - - public function test_predis_cluster_delete_groups_mixed_space_keys_without_crossslot_errors(): void - { - $this->requirePredisCluster(); - - $store = $this->cacheManager()->store(); - $keys = [ - '{nc}:test:delete:1', - '{nc}:test:delete:2', - '{nc:content}:test:delete:1', - '{nc:content}:test:delete:2', - ]; - - foreach ($keys as $key) { - $store->set($key, 'value', 60); - } - - $store->delete($keys); - - foreach ($keys as $key) { - $this->assertNull($store->get($key)); - } - } - - // dependsOn — multi-dependency normalized query stays normalized (no forced result cache) - - public function test_multi_dependency_normalized_query_stays_normalized_in_cluster_mode(): void - { - Author::create(['name' => 'Alice']); - - Author::query()->dependsOn([Post::class, Tag::class])->get(); - - $this->assertNotEmpty($this->redisKeys('query:*'), 'Multi-dependency normalized queries should use query keys in cluster mode'); - $this->assertEmpty($this->redisKeys('result:*'), 'Multi-dependency normalized queries should not fall back to result cache'); - } - - public function test_depends_on_returns_correct_results_in_cluster_mode(): void - { - $alice = Author::create(['name' => 'Alice']); - Post::create(['title' => 'P1', 'author_id' => $alice->id, 'published' => true]); - Author::create(['name' => 'Bob']); - - $native = Author::withoutCache() - ->whereHas('posts', fn($q) => $q->where('published', true)) - ->orderBy('name') - ->get() - ->pluck('name') - ->all(); - - $cold = Author::whereHas('posts', fn($q) => $q->where('published', true)) - ->dependsOn([Post::class]) - ->orderBy('name') - ->get() - ->pluck('name') - ->all(); - - $warm = Author::whereHas('posts', fn($q) => $q->where('published', true)) - ->dependsOn([Post::class]) - ->orderBy('name') - ->get() - ->pluck('name') - ->all(); - - $this->assertSame($native, $cold); - $this->assertSame($cold, $warm); - } - - public function test_depends_on_invalidates_on_dep_version_bump_in_cluster_mode(): void - { - $alice = Author::create(['name' => 'Alice']); - $post = Post::create(['title' => 'P1', 'author_id' => $alice->id, 'published' => true]); - - $first = Author::whereHas('posts', fn($q) => $q->where('published', true)) - ->dependsOn([Post::class]) - ->get(); - - $this->assertCount(1, $first); - - $post->update(['published' => false]); - - $second = Author::whereHas('posts', fn($q) => $q->where('published', true)) - ->dependsOn([Post::class]) - ->get(); - - $this->assertCount(0, $second); - } - - // Aggregate cache (withCount) - - public function test_with_count_returns_correct_results_in_cluster_mode(): void - { - $alice = Author::create(['name' => 'Alice']); - Post::create(['title' => 'P1', 'author_id' => $alice->id]); - Post::create(['title' => 'P2', 'author_id' => $alice->id]); - Author::create(['name' => 'Bob']); - - $native = Author::withoutCache()->withoutAggregateCache()->withCount('posts')->orderBy('name')->get() - ->map(fn($a) => [$a->name, $a->posts_count])->all(); - - $cold = Author::withCount('posts')->orderBy('name')->get() - ->map(fn($a) => [$a->name, $a->posts_count])->all(); - - $warm = Author::withCount('posts')->orderBy('name')->get() - ->map(fn($a) => [$a->name, $a->posts_count])->all(); - - $this->assertSame($native, $cold); - $this->assertSame($cold, $warm); - } - - public function test_with_count_invalidates_on_related_version_bump_in_cluster_mode(): void - { - $alice = Author::create(['name' => 'Alice']); - Post::create(['title' => 'P1', 'author_id' => $alice->id]); - - $first = Author::withCount('posts')->where('id', $alice->id)->get()->first(); - $this->assertSame(1, $first->posts_count); - - Post::create(['title' => 'P2', 'author_id' => $alice->id]); - - $second = Author::withCount('posts')->where('id', $alice->id)->get()->first(); - $this->assertSame(2, $second->posts_count); - } - - // Pivot cache (BelongsToMany) - - public function test_belongs_to_many_returns_correct_results_in_cluster_mode(): void - { - $alice = Author::create(['name' => 'Alice']); - $php = Tag::create(['name' => 'php']); - $laravel = Tag::create(['name' => 'laravel']); - $alice->tags()->attach([$php->id, $laravel->id]); - - $native = Author::withoutCache()->with('tags')->find($alice->id)->tags->pluck('name')->sort()->values()->all(); - - $cold = Author::with('tags')->find($alice->id)->tags->pluck('name')->sort()->values()->all(); - $warm = Author::with('tags')->find($alice->id)->tags->pluck('name')->sort()->values()->all(); - - $this->assertSame($native, $cold); - $this->assertSame($cold, $warm); - } - - public function test_belongs_to_many_invalidates_on_attach_in_cluster_mode(): void - { - $alice = Author::create(['name' => 'Alice']); - $php = Tag::create(['name' => 'php']); - - $before = Author::with('tags')->find($alice->id)->tags->count(); - $this->assertSame(0, $before); - - $alice->tags()->attach($php->id); - - $after = Author::with('tags')->find($alice->id)->tags->count(); - $this->assertSame(1, $after); - } - - // Through cache (HasManyThrough) - - public function test_has_many_through_returns_correct_results_in_cluster_mode(): void - { - $country = Country::create(['name' => 'UK']); - $alice = Author::create(['name' => 'Alice', 'country_id' => $country->id]); - Post::create(['title' => 'P1', 'author_id' => $alice->id]); - Post::create(['title' => 'P2', 'author_id' => $alice->id]); - - $native = Country::withoutCache()->with('posts')->find($country->id)->posts->pluck('title')->sort()->values()->all(); - $cold = Country::with('posts')->find($country->id)->posts->pluck('title')->sort()->values()->all(); - $warm = Country::with('posts')->find($country->id)->posts->pluck('title')->sort()->values()->all(); - - $this->assertSame($native, $cold); - $this->assertSame($cold, $warm); - } - - public function test_has_many_through_invalidates_on_post_write_in_cluster_mode(): void - { - $country = Country::create(['name' => 'UK']); - $alice = Author::create(['name' => 'Alice', 'country_id' => $country->id]); - Post::create(['title' => 'P1', 'author_id' => $alice->id]); - - $before = Country::with('posts')->find($country->id)->posts->count(); - $this->assertSame(1, $before); - - Post::create(['title' => 'P2', 'author_id' => $alice->id]); - - $after = Country::with('posts')->find($country->id)->posts->count(); - $this->assertSame(2, $after); - } - - // Flush operations — verify keys are cleared across all nodes - - public function test_flush_all_clears_all_cache_keys_in_cluster_mode(): void - { - Author::create(['name' => 'Alice']); - Author::create(['name' => 'Bob']); - - Author::get(); - Author::orderBy('name')->get(); - - $this->assertNotEmpty($this->redisKeys('*')); - - $this->cacheManager()->flushAll(); - - $this->assertEmpty($this->redisKeys('*')); - } - - public function test_flush_tag_clears_only_tagged_keys_in_cluster_mode(): void - { - Author::create(['name' => 'Alice']); - - Author::tag('homepage')->get(); - Author::get(); - - $taggedKeys = $this->redisKeys('query:*:homepage:*'); - $this->assertNotEmpty($taggedKeys, 'tagged query keys must exist before flush'); - - $this->cacheManager()->flushTag(Author::class, 'homepage'); - - $this->assertEmpty($this->redisKeys('query:*:homepage:*'), 'tagged keys must be gone after flushTag'); - $this->assertNotEmpty($this->redisKeys('query:*'), 'untagged query keys must survive flushTag'); - } - - public function test_flush_tag_across_models_clears_tagged_keys_for_all_models_in_cluster_mode(): void - { - Author::create(['name' => 'Alice']); - Post::create(['title' => 'P1', 'author_id' => 1]); - - Author::tag('homepage')->get(); - Post::tag('homepage')->get(); - Author::get(); - - $this->assertNotEmpty($this->redisKeys('query:*:homepage:*'), 'tagged keys must exist before flush'); - - $this->cacheManager()->flushTagAcrossModels('homepage'); - - $this->assertEmpty($this->redisKeys('query:*:homepage:*'), 'all tagged keys must be gone after flushTagAcrossModels'); - $this->assertNotEmpty($this->redisKeys('query:*'), 'untagged query keys must survive'); - } - - public function test_prefix_sensitive_scan_and_flush_operations_work_in_cluster_mode(): void - { - config()->set('database.redis.options.prefix', 'laravel:'); - Redis::purge('normcache-test'); - $this->app->forgetInstance(CacheManager::class); - $this->app->forgetInstance('normcache'); - - try { - Author::create(['name' => 'Alice']); - Post::create(['title' => 'P1', 'author_id' => 1]); - - Author::tag('homepage')->get(); - Post::tag('homepage')->get(); - Author::get(); - - $this->assertNotEmpty($this->redisKeys('query:*')); - foreach ($this->redisKeys('*') as $key) { - $this->assertStringNotContainsString('laravel:', $key); - } - - $this->cacheManager()->flushTag(Author::class, 'homepage'); - $this->assertEmpty($this->redisKeys('query:testing:authors:homepage:*'), 'author tagged keys must be gone'); - $this->assertNotEmpty($this->redisKeys('query:testing:posts:homepage:*'), 'post tagged keys must survive'); - - $this->cacheManager()->flushTagAcrossModels('homepage'); - $this->assertEmpty($this->redisKeys('query:*:homepage:*')); - $this->assertNotEmpty($this->redisKeys('query:*')); - - $removed = $this->cacheManager()->store()->flushByPatterns([$this->cacheManager()->keys()->prefixed('query:*')]); - $this->assertGreaterThan(0, $removed); - $this->assertEmpty($this->redisKeys('query:*')); - - Author::get(); - $this->assertNotEmpty($this->redisKeys('*')); - - $this->cacheManager()->flushAll(); - $this->assertEmpty($this->redisKeys('*')); - } finally { - Redis::purge('normcache-test'); - config()->set('database.redis.options.prefix', ''); - $this->app->forgetInstance(CacheManager::class); - $this->app->forgetInstance('normcache'); - } - } - - // Standard single-model query cache (should be unaffected by cluster flag) - - public function test_single_model_query_cache_still_works_in_cluster_mode(): void - { - Author::create(['name' => 'Alice']); - Author::create(['name' => 'Bob']); - - $native = Author::withoutCache()->orderBy('name')->get()->pluck('name')->all(); - $cold = Author::orderBy('name')->get()->pluck('name')->all(); - $warm = Author::orderBy('name')->get()->pluck('name')->all(); - - $this->assertSame($native, $cold); - $this->assertSame($cold, $warm); - } - - public function test_single_model_invalidation_still_works_in_cluster_mode(): void - { - Author::create(['name' => 'Alice']); - - $before = Author::get()->count(); - $this->assertSame(1, $before); - - Author::create(['name' => 'Bob']); - - $after = Author::get()->count(); - $this->assertSame(2, $after); - } - - // Version key TTL — incrementAndExpire cluster-safety (ensures hash tags route EVAL to owning node) - - public function test_version_key_has_ttl_in_cluster_mode(): void - { - Author::create(['name' => 'Alice']); - - $redis = Redis::connection('normcache-test'); - $classKey = $this->cacheManager()->keys()->classKey(Author::class); - $verKey = '{nc}:test:ver:' . $classKey . ':'; - - $ttl = $redis->ttl($verKey); - - $this->assertGreaterThan(0, $ttl, 'version key must carry a TTL in cluster mode'); - } - - // count() pagination total (getNamespacedCache) - - public function test_paginate_count_cache_works_in_cluster_mode(): void - { - Author::create(['name' => 'Alice']); - Author::create(['name' => 'Bob']); - Author::create(['name' => 'Carol']); - - $cold = Author::orderBy('name')->paginate(2); - $warm = Author::orderBy('name')->paginate(2); - - $this->assertSame(3, $cold->total()); - $this->assertSame(3, $warm->total()); - } - - private function requirePredisCluster(): void - { - $cluster = env('REDIS_CLUSTER') === 'true' || env('REDIS_CLUSTER') === true; - - if (!$cluster || env('REDIS_CLIENT') !== 'predis') { - $this->markTestSkipped('Requires a real Predis Redis Cluster connection.'); - } - } -} diff --git a/tests/Integration/Infrastructure/ClusterSpacesTest.php b/tests/Integration/Infrastructure/ClusterSpacesTest.php deleted file mode 100644 index 66a6bf8..0000000 --- a/tests/Integration/Infrastructure/ClusterSpacesTest.php +++ /dev/null @@ -1,324 +0,0 @@ -requiresRedisCluster(); - $this->setClusterMode(true); - } - - public function test_cluster_connection_is_real_cluster_backed(): void - { - $this->assertNotEmpty($this->clusterMasterNodes()); - } - - public function test_distinct_spaces_use_distinct_cluster_slots(): void - { - $default = $this->redisClusterSlot('nc'); - $content = $this->redisClusterSlot('nc:content'); - $catalog = $this->redisClusterSlot('nc:catalog'); - $reporting = $this->redisClusterSlot('nc:reporting'); - - $this->assertNotSame($default, $content); - $this->assertNotSame($default, $catalog); - $this->assertNotSame($default, $reporting); - $this->assertCount(4, array_unique([$default, $content, $catalog, $reporting])); - } - - public function test_each_space_full_cache_lifecycle_runs_without_crossslot(): void - { - $this->assertNoCrossSlot(function () { - $author = SpacedAuthor::create(['name' => 'Ann']); - SpacedPost::create(['title' => 'Article', 'author_id' => $author->id]); - CatalogTag::create(['name' => 'Widgets']); - ReportingCountry::create(['name' => 'Atlantis']); - - $this->assertSame('Article', SpacedPost::query()->get()->first()->title); - $this->assertSame('Article', SpacedPost::query()->get()->first()->title); - - $this->assertSame('Widgets', CatalogTag::query()->get()->first()->name); - $this->assertSame('Widgets', CatalogTag::query()->get()->first()->name); - - $this->assertSame('Atlantis', ReportingCountry::query()->get()->first()->name); - $this->assertSame('Atlantis', ReportingCountry::query()->get()->first()->name); - }); - - $this->assertAnyKeysForHashTag('nc:content', 'test:*'); - $this->assertAnyKeysForHashTag('nc:catalog', 'test:*'); - $this->assertAnyKeysForHashTag('nc:reporting', 'test:*'); - } - - public function test_explicit_space_query_with_allowed_dependencies_caches_in_that_space(): void - { - $author = SpacedAuthor::create(['name' => 'Ann']); - SpacedPost::create(['title' => 'First', 'author_id' => $author->id]); - - $this->assertNoCrossSlot(function () { - return SpacedPost::query() - ->space('content') - ->dependsOn([SpacedAuthor::class]) - ->get(); - }); - - $this->assertSame( - 'First', - SpacedPost::query()->space('content')->dependsOn([SpacedAuthor::class])->get()->first()->title, - ); - $this->assertAnyKeysForHashTag('nc:content', 'test:query:*'); - $this->assertNoKeysForHashTag('nc', 'test:query:*posts*'); - - $author->update(['name' => 'Annie']); - - $post = SpacedPost::query()->space('content')->with('spacedAuthor')->get()->first(); - $this->assertSame('Annie', $post->spacedAuthor->name); - } - - public function test_cross_space_dependency_bypasses_before_redis(): void - { - config(['normcache.spaces.cross_space_behavior' => 'bypass']); - - $author = SpacedAuthor::create(['name' => 'Ann']); - SpacedPost::create(['title' => 'First', 'author_id' => $author->id]); - CatalogTag::create(['name' => 'Widgets']); - - $result = $this->assertNoCrossSlot(fn() => SpacedPost::query() - ->space('content') - ->dependsOn([CatalogTag::class]) - ->get()); - - $this->assertSame('First', $result->first()->title); - $this->assertNoKeysForHashTag('nc:content', 'test:query:*'); - } - - public function test_cross_space_dependency_throw_mode_fails_before_redis(): void - { - config(['normcache.spaces.cross_space_behavior' => 'throw']); - - $author = SpacedAuthor::create(['name' => 'Ann']); - SpacedPost::create(['title' => 'First', 'author_id' => $author->id]); - CatalogTag::create(['name' => 'Widgets']); - - try { - SpacedPost::query() - ->space('content') - ->dependsOn([CatalogTag::class]) - ->get(); - - $this->fail('Expected a cross-space planning exception.'); - } catch (RuntimeException $e) { - $this->assertStringContainsString('cross-space', $e->getMessage()); - $this->assertStringNotContainsString('CROSSSLOT', $e->getMessage()); - } - } - - public function test_model_update_invalidates_all_declared_model_spaces(): void - { - $author = SpacedAuthor::create(['name' => 'Ann']); - $post = MultiSpacePost::create(['title' => 'First', 'author_id' => $author->id]); - - $this->assertSame('First', MultiSpacePost::query()->space('content')->get()->first()->title); - $this->assertSame('First', MultiSpacePost::query()->space('reporting')->get()->first()->title); - - $this->assertAnyKeysForHashTag('nc:content', 'test:query:*'); - $this->assertAnyKeysForHashTag('nc:reporting', 'test:query:*'); - - $post->update(['title' => 'Second']); - - $this->assertSame('Second', MultiSpacePost::query()->space('content')->get()->first()->title); - $this->assertSame('Second', MultiSpacePost::query()->space('reporting')->get()->first()->title); - } - - public function test_through_relation_cache_uses_related_model_space_on_cluster(): void - { - $country = ReportingCountry::create(['name' => 'Australia']); - $author = SpacedAuthor::create(['name' => 'Alice', 'country_id' => $country->id]); - $post = SpacedPost::create(['title' => 'Hello', 'author_id' => $author->id]); - - $this->assertSame(['Hello'], $this->assertNoCrossSlot(fn() => $country->spacedPosts()->get()->pluck('title')->all())); - $this->assertSame(['Hello'], $country->spacedPosts()->get()->pluck('title')->all()); - - $this->assertAnyKeysForHashTag('nc:content', 'test:through:*'); - $this->assertNoKeysForHashTag('nc', 'test:through:*'); - - $post->update(['title' => 'Updated']); - - $this->assertSame(['Updated'], $country->spacedPosts()->get()->pluck('title')->all()); - } - - public function test_pivot_cache_uses_related_model_space_on_cluster(): void - { - $author = SpacedAuthor::create(['name' => 'Ann']); - $post = SpacedPost::create(['title' => 'First', 'author_id' => $author->id]); - $firstTag = CatalogTag::create(['name' => 'First']); - $secondTag = CatalogTag::create(['name' => 'Second']); - - $post->catalogTags()->attach($firstTag->id); - - $first = $this->assertNoCrossSlot(fn() => SpacedPost::query() - ->with('catalogTags') - ->get() - ->first() - ->catalogTags - ->pluck('name') - ->all()); - - $this->assertSame(['First'], $first); - $this->assertAnyKeysForHashTag('nc:catalog', 'test:pivot:*'); - $this->assertNoKeysForHashTag('nc:content', 'test:pivot:*'); - - $post->catalogTags()->attach($secondTag->id); - - $second = SpacedPost::query() - ->with('catalogTags') - ->get() - ->first() - ->catalogTags - ->pluck('name') - ->sort() - ->values() - ->all(); - - $this->assertSame(['First', 'Second'], $second); - } - - public function test_flush_space_clears_only_that_space_on_cluster(): void - { - $author = SpacedAuthor::create(['name' => 'Ann']); - SpacedPost::create(['title' => 'Article', 'author_id' => $author->id]); - CatalogTag::create(['name' => 'Widgets']); - ReportingCountry::create(['name' => 'Atlantis']); - - SpacedPost::query()->get(); - CatalogTag::query()->get(); - ReportingCountry::query()->get(); - - $this->assertAnyKeysForHashTag('nc:content', 'test:*'); - $this->assertAnyKeysForHashTag('nc:catalog', 'test:*'); - $this->assertAnyKeysForHashTag('nc:reporting', 'test:*'); - - $this->assertNoCrossSlot(fn() => $this->cacheManager()->flushAll('content')); - - $this->assertNoKeysForHashTag('nc:content', 'test:*'); - $this->assertAnyKeysForHashTag('nc:catalog', 'test:*'); - $this->assertAnyKeysForHashTag('nc:reporting', 'test:*'); - } - - public function test_flush_all_clears_default_and_known_spaces_on_cluster(): void - { - Author::create(['name' => 'Default']); - $author = SpacedAuthor::create(['name' => 'Ann']); - SpacedPost::create(['title' => 'Article', 'author_id' => $author->id]); - CatalogTag::create(['name' => 'Widgets']); - ReportingCountry::create(['name' => 'Atlantis']); - - Author::query()->get(); - SpacedPost::query()->get(); - CatalogTag::query()->get(); - ReportingCountry::query()->get(); - - $this->assertAnyKeysForHashTag('nc', 'test:*'); - $this->assertAnyKeysForHashTag('nc:content', 'test:*'); - $this->assertAnyKeysForHashTag('nc:catalog', 'test:*'); - $this->assertAnyKeysForHashTag('nc:reporting', 'test:*'); - - $this->assertNoCrossSlot(fn() => $this->cacheManager()->flushAll()); - - $this->assertNoKeysForHashTag('nc', 'test:*'); - $this->assertNoKeysForHashTag('nc:content', 'test:*'); - $this->assertNoKeysForHashTag('nc:catalog', 'test:*'); - $this->assertNoKeysForHashTag('nc:reporting', 'test:*'); - } - - public function test_scheduled_invalidation_keys_are_scoped_to_each_affected_space(): void - { - $author = SpacedAuthor::create(['name' => 'Ann']); - $post = SpacedPost::create(['title' => 'First', 'author_id' => $author->id]); - - SpacedPost::query()->get(); - - $this->cacheManager()->config()->cooldown = 5; - - $this->assertNoCrossSlot(fn() => $post->update(['title' => 'Second'])); - - $classKey = $this->cacheManager()->keys()->classKey(SpacedPost::class); - $contentScheduledKey = $this->cacheManager()->keys()->scheduledKey( - $classKey, - $this->cacheManager()->spaceFor(SpacedPost::class), - ); - $defaultScheduledKey = $this->cacheManager()->keys()->scheduledKey($classKey); - - $scheduledKeys = $this->keysForHashTag('nc:content', 'test:scheduled:*'); - $this->assertNotEmpty($scheduledKeys, 'Cooldown must write a scheduled key under {nc:content}.'); - $this->assertAllKeysShareHashTag($scheduledKeys, 'nc:content'); - $this->assertContains($contentScheduledKey, $scheduledKeys); - - $defaultScheduledKeys = $this->keysForHashTag('nc', 'test:scheduled:*'); - $this->assertNotEmpty($defaultScheduledKeys, 'A same-table default-space cache must also be invalidated.'); - $this->assertAllKeysShareHashTag($defaultScheduledKeys, 'nc'); - $this->assertContains($defaultScheduledKey, $defaultScheduledKeys); - - $contentSlot = $this->redisClusterSlot('nc:content'); - foreach ($scheduledKeys as $key) { - preg_match('/^\{([^}]+)\}:/', $key, $m); - $this->assertSame($contentSlot, $this->redisClusterSlot($m[1] ?? '')); - } - - // The read Lua passes each version key with its scheduled key; mismatched slots throw CROSSSLOT. - $this->assertNoCrossSlot(fn() => SpacedPost::query()->get()); - } - - public function test_building_and_wake_keys_share_payload_slot_per_space(): void - { - $author = SpacedAuthor::create(['name' => 'Ann']); - $post = SpacedPost::create(['title' => 'Hello', 'author_id' => $author->id]); - $country = ReportingCountry::create(['name' => 'Oz']); - SpacedAuthor::where('id', $author->id)->update(['country_id' => $country->id]); - $tag = CatalogTag::create(['name' => 'Widget']); - $post->catalogTags()->attach($tag->id); - - // Building/wake keys are ephemeral (Lua sets them on miss, deletes on store); co-location is - // proven indirectly — Lua passes them with version keys as KEYS[], and Redis Cluster - // requires all KEYS[] to share a slot, so CROSSSLOT fires immediately on any mismatch. - $this->assertNoCrossSlot(fn() => SpacedPost::query()->get()); - $this->assertAnyKeysForHashTag('nc:content', 'test:query:*'); - $this->assertNoKeysForHashTag('nc', 'test:query:*spaced*'); - - $this->assertNoCrossSlot(fn() => SpacedPost::query()->count()); - $this->assertAnyKeysForHashTag('nc:content', 'test:count:*'); - $this->assertNoKeysForHashTag('nc', 'test:count:*spaced*'); - - $this->assertNoCrossSlot(fn() => ReportingCountry::first()->spacedPosts()->get()); - $this->assertAnyKeysForHashTag('nc:content', 'test:through:*'); - $this->assertNoKeysForHashTag('nc', 'test:through:*'); - - // Pivot storage issues batched multi-key Lua writes and is the highest CROSSSLOT risk. - $this->assertNoCrossSlot(fn() => SpacedPost::query()->with('catalogTags')->get()); - $this->assertAnyKeysForHashTag('nc:catalog', 'test:pivot:*'); - $this->assertNoKeysForHashTag('nc:content', 'test:pivot:*'); - $this->assertNoKeysForHashTag('nc', 'test:pivot:*'); - - $this->assertNoCrossSlot(fn() => SpacedPost::query()->get()); - $this->assertNoCrossSlot(fn() => SpacedPost::query()->count()); - $this->assertNoCrossSlot(fn() => ReportingCountry::first()->spacedPosts()->get()); - $this->assertNoCrossSlot(fn() => SpacedPost::query()->with('catalogTags')->get()); - } -} diff --git a/tests/Integration/Infrastructure/Concerns/InteractsWithClusterRedis.php b/tests/Integration/Infrastructure/Concerns/InteractsWithClusterRedis.php deleted file mode 100644 index 3bdf6d9..0000000 --- a/tests/Integration/Infrastructure/Concerns/InteractsWithClusterRedis.php +++ /dev/null @@ -1,141 +0,0 @@ -markTestSkipped('Requires a Redis Cluster (composer test:cluster).'); - } - - $client = RedisFacade::connection('normcache-test')->client(); - - if ($client instanceof RedisCluster || $client instanceof PredisClient) { - return; - } - - if (class_exists(Redis::class)) { - [$host, $port] = $this->clusterProbeNode(); - $probe = new Redis; - - try { - $probe->connect($host, $port); - $slots = $probe->rawCommand('CLUSTER', 'SLOTS'); - } finally { - $probe->close(); - } - - if (is_array($slots) && $slots !== []) { - return; - } - } - - $this->markTestSkipped('Redis connection is not cluster-backed.'); - } - - /** @return list */ - protected function clusterMasterNodes(): array - { - if (!class_exists(Redis::class)) { - $this->markTestSkipped('Physical cluster inspection requires the phpredis extension.'); - } - - [$host, $port] = $this->clusterProbeNode(); - $probe = new Redis; - - try { - $probe->connect($host, $port); - $slots = $probe->rawCommand('CLUSTER', 'SLOTS'); - } finally { - $probe->close(); - } - - $nodes = []; - foreach ((array) $slots as $range) { - if (!is_array($range) || !isset($range[2]) || !is_array($range[2])) { - continue; - } - - $masterHost = is_string($range[2][0] ?? null) ? $range[2][0] : $host; - $masterPort = (int) ($range[2][1] ?? 0); - - if ($masterPort > 0) { - $nodes["{$masterHost}:{$masterPort}"] = [$masterHost, $masterPort]; - } - } - - return array_values($nodes); - } - - protected function redisClusterSlot(string $hashTag): int - { - $crc = 0; - - foreach (str_split($hashTag) as $char) { - $crc ^= ord($char) << 8; - - for ($i = 0; $i < 8; $i++) { - $crc = ($crc & 0x8000) ? (($crc << 1) ^ 0x1021) & 0xFFFF : ($crc << 1) & 0xFFFF; - } - } - - return $crc % 16384; - } - - /** @return list */ - protected function keysForHashTag(string $hashTag, string $suffix = '*'): array - { - return array_values($this->cacheManager()->store()->scanPattern('{' . $hashTag . '}:' . $suffix)); - } - - protected function assertAnyKeysForHashTag(string $hashTag, string $suffix = '*'): void - { - $this->assertNotEmpty( - $this->keysForHashTag($hashTag, $suffix), - "Expected keys for {{$hashTag}}:{$suffix}.", - ); - } - - protected function assertNoKeysForHashTag(string $hashTag, string $suffix = '*'): void - { - $this->assertEmpty( - $this->keysForHashTag($hashTag, $suffix), - "Expected no keys for {{$hashTag}}:{$suffix}.", - ); - } - - /** @param list $keys */ - protected function assertAllKeysShareHashTag(array $keys, string $hashTag): void - { - $this->assertNotEmpty($keys, 'Expected at least one key to inspect.'); - - foreach ($keys as $key) { - $this->assertStringStartsWith('{' . $hashTag . '}:', $key); - } - } - - protected function assertNoCrossSlot(callable $callback): mixed - { - try { - return $callback(); - } catch (\Throwable $e) { - $this->assertStringNotContainsString('CROSSSLOT', $e->getMessage()); - throw $e; - } - } - - /** @return array{0: string, 1: int} */ - protected function clusterProbeNode(): array - { - $node = config('database.redis.clusters.normcache-test.0'); - - return [$node['host'], (int) $node['port']]; - } -} diff --git a/tests/Integration/Infrastructure/FetchBatchBuildStatusScriptTest.php b/tests/Integration/Infrastructure/FetchBatchBuildStatusScriptTest.php deleted file mode 100644 index a5065de..0000000 --- a/tests/Integration/Infrastructure/FetchBatchBuildStatusScriptTest.php +++ /dev/null @@ -1,128 +0,0 @@ -buildManager(); - $store = $manager->store(); - $keys = new CacheKeyBuilder; - $classKey = $keys->classKey(Author::class); - $lockKey = $keys->resultBuildingKey($classKey, 'model', 'test-lock'); - $modelKey = $keys->modelPrefix($classKey, 0) . 'missing-id'; - - $result = $this->fetchStatus([$modelKey], $lockKey, $keys->wakeKey($classKey, 'test-lock')); - - $this->assertSame('miss', $result[0]); - $this->assertSame('token', $result[1]); - $this->assertSame('token', $store->getRaw($lockKey)); - } - - public function test_reports_building_without_overwriting_an_existing_lock(): void - { - $manager = $this->buildManager(); - $store = $manager->store(); - $keys = new CacheKeyBuilder; - $classKey = $keys->classKey(Author::class); - $lockKey = $keys->resultBuildingKey($classKey, 'model', 'test-lock'); - $modelKey = $keys->modelPrefix($classKey, 0) . 'missing-id'; - $store->setNxEx($lockKey, 'other-token', 5); - - $result = $this->fetchStatus([$modelKey], $lockKey, $keys->wakeKey($classKey, 'test-lock')); - - $this->assertSame('building', $result[0]); - $this->assertFalse((bool) $result[1]); - $this->assertSame('other-token', $store->getRaw($lockKey)); - } - - public function test_reports_hit_without_claiming_lock_when_payload_is_present(): void - { - $manager = $this->buildManager(); - $store = $manager->store(); - $keys = new CacheKeyBuilder; - $classKey = $keys->classKey(Author::class); - $lockKey = $keys->resultBuildingKey($classKey, 'model', 'test-lock'); - $modelKey = $keys->modelPrefix($classKey, 0) . 'present-id'; - $store->set($modelKey, ['id' => 1, 'name' => 'Present'], 60); - - $result = $this->fetchStatus([$modelKey], $lockKey, $keys->wakeKey($classKey, 'test-lock')); - - $this->assertSame('hit', $result[0]); - $this->assertFalse((bool) $result[1]); - $this->assertNull($store->getRaw($lockKey)); - } - - public function test_all_hit_payloads_are_returned_across_mget_chunks(): void - { - $manager = $this->buildManager(); - $store = $manager->store(); - $keys = new CacheKeyBuilder; - $classKey = $keys->classKey(Author::class); - $lockKey = $keys->resultBuildingKey($classKey, 'model', 'test-lock'); - $modelKeys = []; - - for ($i = 0; $i < 1200; $i++) { - $modelKey = $keys->modelPrefix($classKey, 0) . "present-{$i}"; - $store->set($modelKey, ['id' => $i], 60); - $modelKeys[] = $modelKey; - } - - $result = $this->fetchStatus($modelKeys, $lockKey, $keys->wakeKey($classKey, 'test-lock')); - - $this->assertSame('hit', $result[0]); - $this->assertFalse((bool) $result[1]); - $this->assertCount(1200, $result[3]); - foreach ($result[3] as $raw) { - $this->assertNotNull($raw); - } - $this->assertNull($store->getRaw($lockKey)); - } - - public function test_partial_miss_is_preserved_across_mget_chunk_boundary(): void - { - $manager = $this->buildManager(); - $store = $manager->store(); - $keys = new CacheKeyBuilder; - $classKey = $keys->classKey(Author::class); - $lockKey = $keys->resultBuildingKey($classKey, 'model', 'test-lock'); - $missingIndex = 500; - $modelKeys = []; - - for ($i = 0; $i < 600; $i++) { - $modelKey = $keys->modelPrefix($classKey, 0) . "key-{$i}"; - if ($i !== $missingIndex) { - $store->set($modelKey, ['id' => $i], 60); - } - $modelKeys[] = $modelKey; - } - - $result = $this->fetchStatus($modelKeys, $lockKey, $keys->wakeKey($classKey, 'test-lock')); - - $this->assertSame('miss', $result[0]); - $this->assertSame('token', $result[1]); - $this->assertCount(600, $result[3]); - $this->assertFalse((bool) $result[3][$missingIndex]); - foreach ($result[3] as $index => $raw) { - if ($index !== $missingIndex) { - $this->assertNotNull($raw); - } - } - $this->assertSame('token', $store->getRaw($lockKey)); - } - - private function fetchStatus(array $payloadKeys, string $lockKey, string $wakeKey): array - { - return $this->cacheManager()->store()->script( - RedisScripts::get('fetch_batch_build_status'), - [...$payloadKeys, $lockKey, $wakeKey], - ['token', '5'], - ); - } -} diff --git a/tests/Integration/Infrastructure/LuaScriptBehaviorTest.php b/tests/Integration/Infrastructure/LuaScriptBehaviorTest.php deleted file mode 100644 index 06eb953..0000000 --- a/tests/Integration/Infrastructure/LuaScriptBehaviorTest.php +++ /dev/null @@ -1,145 +0,0 @@ -redis()->setex($prefixed, $ttl, $value) - : $this->redis()->set($prefixed, $value); - } - - private function getKey(string $key): mixed - { - return $this->redis()->get('{nc}:test:' . $key); - } - - private function authorQueryHash(): string - { - $query = Author::query(); - - return QueryHasher::forModelIndexQuery($query, $query->toBase()); - } - - public function test_corrupt_query_entry_is_deleted_and_treated_as_miss(): void - { - Author::create(['name' => 'Alice']); - - $ck = NormCache::keys()->classKey(Author::class); - $hash = $this->authorQueryHash(); - - Author::get(); - - $version = NormCache::currentVersion(Author::class); - $this->setKey("query:{$ck}:v{$version}:{$hash}", 'not-valid-json'); - - $queryCount = 0; - DB::listen(function () use (&$queryCount) { - $queryCount++; - }); - - $results = Author::get(); - - $this->assertGreaterThan(0, $queryCount); - $this->assertCount(1, $results); - $this->assertIsArray(json_decode($this->getKey("query:{$ck}:v{$version}:{$hash}"), true)); - } - - // luaFetchVersionedQuery — cooldown firing - - public function test_pending_cooldown_fires_version_bump_on_read(): void - { - Author::create(['name' => 'Alice']); - - $ck = NormCache::keys()->classKey(Author::class); - - Author::get(); - $version = NormCache::currentVersion(Author::class); - - // Reads only check scheduled keys while the cooldown toggle is active. - $this->cacheManager()->config()->cooldown = 1; - - // Place a past-due scheduled invalidation directly in Redis - $pastMs = (int) (microtime(true) * 1000) - 5000; - $this->setKey("scheduled:{$ck}:", (string) $pastMs); - - Author::get(); // read triggers the Lua cooldown check — past-due scheduled key fires the bump - - $this->assertSame($version + 1, (int) $this->getKey("ver:{$ck}:")); - $this->assertNull($this->getKey("scheduled:{$ck}:")); - } - - public function test_non_numeric_scheduled_key_is_cleaned_up_without_version_bump(): void - { - Author::create(['name' => 'Alice']); - - $ck = NormCache::keys()->classKey(Author::class); - - Author::get(); - $version = NormCache::currentVersion(Author::class); - - $this->cacheManager()->config()->cooldown = 1; - $this->setKey("scheduled:{$ck}:", 'garbage'); - - Author::get(); - - // Non-numeric value cannot be a valid timestamp — Lua cleans it without bumping the version. - $this->assertSame($version, (int) $this->getKey("ver:{$ck}:")); - $this->assertNull($this->getKey("scheduled:{$ck}:")); - } - - // The two tests above enter through a query read; these two call currentVersion() directly. - - public function test_cooldown_fires_version_bump_on_standalone_version_resolution(): void - { - $ck = NormCache::keys()->classKey(Author::class); - - $this->setKey("ver:{$ck}:", '3'); - $pastMs = (int) (microtime(true) * 1000) - 5000; - $this->setKey("scheduled:{$ck}:", (string) $pastMs); - - $this->cacheManager()->config()->cooldown = 1; - - $version = NormCache::currentVersion(Author::class); - - $this->assertSame(4, $version); - $this->assertNull($this->getKey("scheduled:{$ck}:")); - } - - public function test_non_numeric_scheduled_key_cleaned_on_standalone_version_resolution(): void - { - $ck = NormCache::keys()->classKey(Author::class); - - $this->setKey("ver:{$ck}:", '3'); - $this->setKey("scheduled:{$ck}:", 'garbage'); - - $this->cacheManager()->config()->cooldown = 1; - - $version = NormCache::currentVersion(Author::class); - - $this->assertSame(3, $version); - $this->assertNull($this->getKey("scheduled:{$ck}:")); - } -} diff --git a/tests/Integration/Infrastructure/LuaScriptConsistencyTest.php b/tests/Integration/Infrastructure/LuaScriptConsistencyTest.php deleted file mode 100644 index 75f5b0b..0000000 --- a/tests/Integration/Infrastructure/LuaScriptConsistencyTest.php +++ /dev/null @@ -1,231 +0,0 @@ -redis()->setex($prefixed, $ttl, $value) - : $this->redis()->set($prefixed, $value); - } - - private function getKey(string $key): mixed - { - return $this->redis()->get('{nc}:test:' . $key); - } - - private function bumpVersionInRedis(string $classKey, int $times = 1): void - { - for ($i = 0; $i < $times; $i++) { - $this->redis()->incr("{nc}:test:ver:{$classKey}:"); - } - } - - private function setCooldown(int $seconds): void - { - $this->cacheManager()->config()->cooldown = $seconds; - } - - private function authorQueryHash(): string - { - $query = Author::query(); - - return QueryHasher::forModelIndexQuery($query, $query->toBase()); - } - - // dependsOn blob — building key causes DB fallthrough - - public function test_building_key_in_deps_query_causes_db_fallthrough(): void - { - Author::create(['name' => 'Alice']); - - $ck = NormCache::keys()->classKey(Author::class); - $hash = $this->authorQueryHash(); - $authorVer = NormCache::currentVersion(Author::class); - $postVer = NormCache::currentVersion(Post::class); - - $this->setKey("building:{$ck}:v{$authorVer}:v{$postVer}:{$hash}", '1', 30); - - $queryCount = 0; - DB::listen(function () use (&$queryCount) { - $queryCount++; - }); - - $results = Author::query()->dependsOn([Post::class])->get(); - - $this->assertGreaterThan(0, $queryCount); - $this->assertCount(1, $results); - } - - // Cooldown invalidation across cache families - - public function test_cooldown_due_invalidation_applies_to_result_depends_on_cache(): void - { - $this->setCooldown(1); - - $author = Author::create(['name' => 'Alice']); - $post = Post::create(['title' => 'Visible', 'author_id' => $author->id, 'published' => true]); - - $query = fn() => Author::query() - ->whereHas('posts', fn($q) => $q->where('published', true)) - ->dependsOn([Post::class]) - ->get(); - - $this->assertCount(1, $query()); - $this->assertNotEmpty($this->redisKeys('result:*')); - - $post->update(['published' => false]); - - $postClassKey = NormCache::keys()->classKey(Post::class); - $pastMs = (int) floor(microtime(true) * 1000) - 5000; - $this->setKey("scheduled:{$postClassKey}:", (string) $pastMs); - - // Post version is still 0 (never bumped) — the scheduled key is what triggers the bump - $this->assertSame('0', (string) ($this->getKey("ver:{$postClassKey}:") ?? '0')); - - $this->assertCount(0, $query()); - $this->assertSame('1', (string) $this->getKey("ver:{$postClassKey}:")); - $this->assertNull($this->getKey("scheduled:{$postClassKey}:")); - } - - public function test_versioned_payload_write_is_skipped_when_dependency_version_changes_during_build(): void - { - $manager = $this->cacheManager(); - $store = $manager->store(); - $keys = $manager->keys(); - $classKey = $keys->classKey(Author::class); - $hash = 'manual-result-build'; - [$versionKeys, $scheduledKeys] = $keys->depKeyPairs($classKey, [Post::class], []); - $prefix = $keys->namespacedPrefix($keys::K_RESULT, $classKey, null); - $token = $manager->versionStore()->buildLockToken(); - $result = $store->fetchVersionedPayload( - $versionKeys, - $scheduledKeys, - $prefix, - $keys->buildingPrefix($classKey), - $keys->wakePrefix($classKey), - $hash, - $hash, - $token, - 5, - false, - ); - $segment = (string) $result[1]; - $key = $prefix . $segment . ':' . $hash; - $buildingKey = $keys->resultBuildingKey($classKey, $segment, $hash); - $wakeKey = $keys->wakeKey($classKey, $hash); - - $this->assertSame('miss', $result[0]); - $this->bumpVersionInRedis($keys->classKey(Post::class)); - - $stored = $store->storeVersionedPayload( - [$key => $store->serialize([['id' => 1, 'name' => 'Old']])], - 60, - $versionKeys, - $keys->versionsFromSegment($segment), - $buildingKey, - $wakeKey, - (string) ($result[2] ?? $token), - ); - - $this->assertFalse($stored); - $this->assertNull($store->get($key)); - $this->assertNull($store->getRaw($buildingKey)); - } - - public function test_cooldown_due_invalidation_applies_to_scalar_cache(): void - { - $this->setCooldown(1); - - $author = Author::create(['name' => 'Alice']); - $post = Post::create(['title' => 'Visible', 'author_id' => $author->id, 'published' => true]); - - $query = fn() => Author::query() - ->whereHas('posts', fn($q) => $q->where('published', true)) - ->dependsOn([Post::class]) - ->count(); - - $this->assertSame(1, $query()); - $this->assertNotEmpty($this->redisKeys('count:*')); - $this->assertEmpty($this->redisKeys('result:*')); - - $post->update(['published' => false]); - - $postClassKey = NormCache::keys()->classKey(Post::class); - $pastMs = (int) floor(microtime(true) * 1000) - 5000; - $this->setKey("scheduled:{$postClassKey}:", (string) $pastMs); - - $this->assertSame(0, $query()); - $this->assertSame('1', (string) $this->getKey("ver:{$postClassKey}:")); - $this->assertNull($this->getKey("scheduled:{$postClassKey}:")); - } - - public function test_cooldown_due_invalidation_applies_to_pivot_cache(): void - { - $this->setCooldown(1); - - $author = Author::create(['name' => 'Alice']); - $old = Tag::create(['name' => 'old']); - $new = Tag::create(['name' => 'new']); - - $author->tags()->attach($old->id); - - $this->assertSame(['old'], $author->tags()->get()->pluck('name')->all()); - $this->assertNotEmpty($this->redisKeys('pivot:*')); - - $author->tags()->detach($old->id); - $author->tags()->attach($new->id); - - $pivotTableKey = NormCache::keys()->tableKey($author->getConnection()->getName(), 'author_tag'); - $pastMs = (int) floor(microtime(true) * 1000) - 5000; - $this->setKey("scheduled:{$pivotTableKey}:", (string) $pastMs); - - $this->assertSame(['new'], $author->tags()->get()->pluck('name')->all()); - } - - public function test_cooldown_due_invalidation_applies_to_aggregate_cache(): void - { - $this->setCooldown(1); - - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Post 1', 'author_id' => $author->id]); - Post::create(['title' => 'Post 2', 'author_id' => $author->id]); - - $query = fn() => Author::withCount('posts')->find($author->id); - - $this->assertSame(2, $query()->posts_count); - - Post::create(['title' => 'Post 3', 'author_id' => $author->id]); - - $postClassKey = NormCache::keys()->classKey(Post::class); - $pastMs = (int) floor(microtime(true) * 1000) - 5000; - $this->setKey("scheduled:{$postClassKey}:", (string) $pastMs); - - $this->assertSame(3, $query()->posts_count); - $this->assertSame('1', (string) $this->getKey("ver:{$postClassKey}:")); - $this->assertNull($this->getKey("scheduled:{$postClassKey}:")); - } -} diff --git a/tests/Integration/Infrastructure/MultiSpaceClusterPlacementTest.php b/tests/Integration/Infrastructure/MultiSpaceClusterPlacementTest.php deleted file mode 100644 index 47203df..0000000 --- a/tests/Integration/Infrastructure/MultiSpaceClusterPlacementTest.php +++ /dev/null @@ -1,74 +0,0 @@ -requiresRedisCluster(); - $this->setClusterMode(true); - } - - public function test_three_spaces_run_and_land_on_distinct_master_nodes(): void - { - // 1. Each space's write + cached read succeeds only if its keys co-locate. - SpacedPost::create(['title' => 'Article', 'author_id' => 1]); - $this->assertSame('Article', SpacedPost::query()->get()->first()->title); - - CatalogTag::create(['name' => 'Widgets']); - $this->assertSame('Widgets', CatalogTag::query()->get()->first()->name); - - ReportingCountry::create(['name' => 'Atlantis']); - $this->assertSame('Atlantis', ReportingCountry::query()->get()->first()->name); - - // 2. Each space's keys physically reside on its own master node. - $contentNode = $this->masterHolding('nc:content'); - $catalogNode = $this->masterHolding('nc:catalog'); - $reportingNode = $this->masterHolding('nc:reporting'); - - $this->assertNotNull($contentNode, 'content keys must exist on a master node'); - $this->assertNotNull($catalogNode, 'catalog keys must exist on a master node'); - $this->assertNotNull($reportingNode, 'reporting keys must exist on a master node'); - - // 3. Distribution: the three spaces spread across three distinct shards. - $this->assertCount( - 3, - array_unique([$contentNode, $catalogNode, $reportingNode]), - 'content/catalog/reporting must each land on a different master node', - ); - } - - // Master node whose keyspace holds this space tag's keys, or null. - private function masterHolding(string $hashTag): ?string - { - foreach ($this->clusterMasterNodes() as [$host, $port]) { - $node = new Redis; - $node->connect($host, $port); - - // KEYS treats { } as literal; only the owning node returns this space's keys. - if (!empty($node->keys('{' . $hashTag . '}:*'))) { - $node->close(); - - return "{$host}:{$port}"; - } - - $node->close(); - } - - return null; - } -} diff --git a/tests/Integration/Infrastructure/StampedeProtectionTest.php b/tests/Integration/Infrastructure/StampedeProtectionTest.php deleted file mode 100644 index e58623d..0000000 --- a/tests/Integration/Infrastructure/StampedeProtectionTest.php +++ /dev/null @@ -1,117 +0,0 @@ -set('normcache.stampede_wait_ms', 200); - } - - private function redis() - { - return Redis::connection('normcache-test'); - } - - private function setKey(string $key, string $value, ?int $ttl = null): void - { - $prefixed = '{nc}:test:' . $key; - $ttl !== null - ? $this->redis()->setex($prefixed, $ttl, $value) - : $this->redis()->set($prefixed, $value); - } - - private function authorQueryHash(): string - { - $query = Author::query(); - - return QueryHasher::forModelIndexQuery($query, $query->toBase()); - } - - public function test_waiter_serves_from_cache_after_build_completes(): void - { - Author::create(['name' => 'Alice']); - - $ck = NormCache::keys()->classKey(Author::class); - $hash = $this->authorQueryHash(); - - Author::get(); - - $this->redis()->incr("{nc}:test:ver:{$ck}:"); - $newVersion = NormCache::currentVersion(Author::class); - - $this->redis()->set("{nc}:test:building:{$ck}:v{$newVersion}:{$hash}", '1'); - $this->redis()->lpush("{nc}:test:wake:{$ck}:{$hash}", '1'); - $this->setKey("query:{$ck}:v{$newVersion}:{$hash}", json_encode([(string) Author::first()->id], JSON_THROW_ON_ERROR), 60); - - $queryCount = 0; - DB::listen(function () use (&$queryCount) { - $queryCount++; - }); - - $results = Author::get(); - - $this->assertSame(0, $queryCount); - $this->assertCount(1, $results); - } - - public function test_budget_exhausted_falls_through_to_db(): void - { - Author::create(['name' => 'Alice']); - - $ck = NormCache::keys()->classKey(Author::class); - $hash = $this->authorQueryHash(); - - $this->redis()->incr("{nc}:test:ver:{$ck}:"); - $newVersion = NormCache::currentVersion(Author::class); - $this->redis()->set("{nc}:test:building:{$ck}:v{$newVersion}:{$hash}", '1'); - - $queryCount = 0; - DB::listen(function () use (&$queryCount) { - $queryCount++; - }); - - $results = Author::get(); - - $this->assertGreaterThan(0, $queryCount); - $this->assertCount(1, $results); - } - - public function test_first_miss_claims_building_lock_and_populates_cache(): void - { - Author::create(['name' => 'Alice']); - Author::create(['name' => 'Bob']); - - $ck = NormCache::keys()->classKey(Author::class); - $hash = $this->authorQueryHash(); - - $queryCount = 0; - DB::listen(function () use (&$queryCount) { - $queryCount++; - }); - - $results = Author::get(); - - $this->assertGreaterThan(0, $queryCount); - $this->assertCount(2, $results); - $this->assertGreaterThan(0, $this->redis()->llen("{nc}:test:wake:{$ck}:{$hash}")); - - $queryCount = 0; - Author::get(); - $this->assertSame(0, $queryCount); - } -} diff --git a/tests/Integration/Infrastructure/StringPrimaryKeyTest.php b/tests/Integration/Infrastructure/StringPrimaryKeyTest.php deleted file mode 100644 index b5efcc7..0000000 --- a/tests/Integration/Infrastructure/StringPrimaryKeyTest.php +++ /dev/null @@ -1,111 +0,0 @@ - $id, 'name' => 'Alpha']); - - // Warm the cache. - UuidItem::find($id); - - $this->assertNotNull($this->modelCacheEntry(UuidItem::class, $id)); - } - - public function test_get_with_string_pk_returns_correct_models(): void - { - $id1 = 'aaaaaaaa-0000-0000-0000-000000000001'; - $id2 = 'bbbbbbbb-0000-0000-0000-000000000002'; - UuidItem::create(['id' => $id1, 'name' => 'Alpha']); - UuidItem::create(['id' => $id2, 'name' => 'Beta']); - - $names = UuidItem::orderBy('name')->get()->pluck('name')->all(); - - $this->assertSame(['Alpha', 'Beta'], $names); - - // Second call hits the query and model caches. - $namesCached = UuidItem::orderBy('name')->get()->pluck('name')->all(); - - $this->assertSame($names, $namesCached); - } - - public function test_update_with_string_pk_evicts_model_cache(): void - { - $id = 'aaaaaaaa-0000-0000-0000-000000000001'; - $item = UuidItem::create(['id' => $id, 'name' => 'Alpha']); - - // Warm the model cache. - UuidItem::find($id); - $this->assertNotNull($this->modelCacheEntry(UuidItem::class, $id)); - - $item->update(['name' => 'AlphaUpdated']); - - $this->assertNull($this->modelCacheEntry(UuidItem::class, $id)); - } - - public function test_delete_with_string_pk_evicts_model_cache_and_bumps_version(): void - { - $id = 'aaaaaaaa-0000-0000-0000-000000000001'; - $item = UuidItem::create(['id' => $id, 'name' => 'Alpha']); - - UuidItem::find($id); - $this->assertNotNull($this->modelCacheEntry(UuidItem::class, $id)); - - $versionBefore = NormCache::currentVersion(UuidItem::class); - - $item->delete(); - - $this->assertNull($this->modelCacheEntry(UuidItem::class, $id)); - $this->assertGreaterThan($versionBefore, NormCache::currentVersion(UuidItem::class)); - } - - public function test_query_result_with_string_pks_is_fresh_after_insert(): void - { - $id1 = 'aaaaaaaa-0000-0000-0000-000000000001'; - UuidItem::create(['id' => $id1, 'name' => 'Alpha']); - - // Cache the result. - $first = UuidItem::orderBy('name')->get()->pluck('name')->all(); - $this->assertSame(['Alpha'], $first); - - // Insert a new item — version is bumped. - $id2 = 'bbbbbbbb-0000-0000-0000-000000000002'; - UuidItem::create(['id' => $id2, 'name' => 'Beta']); - - // Must reflect the new item. - $second = UuidItem::orderBy('name')->get()->pluck('name')->all(); - $this->assertSame(['Alpha', 'Beta'], $second); - } - - public function test_where_in_with_multiple_string_pks_returns_correct_models(): void - { - $id1 = 'aaaaaaaa-0000-0000-0000-000000000001'; - $id2 = 'bbbbbbbb-0000-0000-0000-000000000002'; - $id3 = 'cccccccc-0000-0000-0000-000000000003'; - - UuidItem::create(['id' => $id1, 'name' => 'Alpha']); - UuidItem::create(['id' => $id2, 'name' => 'Beta']); - UuidItem::create(['id' => $id3, 'name' => 'Gamma']); - - $names = UuidItem::whereIn('id', [$id1, $id3])->orderBy('name')->get()->pluck('name')->all(); - - $this->assertSame(['Alpha', 'Gamma'], $names); - - // Second call — from cache. - $namesCached = UuidItem::whereIn('id', [$id1, $id3])->orderBy('name')->get()->pluck('name')->all(); - - $this->assertSame($names, $namesCached); - } -} diff --git a/tests/Integration/Infrastructure/VersionKeyTtlTest.php b/tests/Integration/Infrastructure/VersionKeyTtlTest.php deleted file mode 100644 index 7c959da..0000000 --- a/tests/Integration/Infrastructure/VersionKeyTtlTest.php +++ /dev/null @@ -1,44 +0,0 @@ - 'aaaaaaaa-0000-0000-0000-000000000001', 'name' => 'Alpha']); - - $redis = Redis::connection('normcache-test'); - $classKey = $this->cacheManager()->keys()->classKey(UuidItem::class); - $verKey = '{nc}:test:ver:' . $classKey . ':'; - - $ttl = $redis->ttl($verKey); - - // Version key must have an explicit TTL (not -1 = no TTL, not -2 = does not exist). - $this->assertGreaterThan(0, $ttl, 'version key must have a positive TTL to survive Redis LRU eviction'); - } - - public function test_version_key_ttl_exceeds_longest_payload_ttl(): void - { - UuidItem::create(['id' => 'aaaaaaaa-0000-0000-0000-000000000001', 'name' => 'Alpha']); - - $redis = Redis::connection('normcache-test'); - $classKey = $this->cacheManager()->keys()->classKey(UuidItem::class); - $verKey = '{nc}:test:ver:' . $classKey . ':'; - - $verTtl = $redis->ttl($verKey); - $modelTtl = (int) config('normcache.ttl'); - $queryTtl = (int) config('normcache.query_ttl'); - - // Version TTL must exceed the longest payload TTL so version keys outlive their payloads. - $this->assertGreaterThanOrEqual(max($modelTtl, $queryTtl), $verTtl); - } -} diff --git a/tests/Integration/Invalidation/BuilderInvalidationTest.php b/tests/Integration/Invalidation/BuilderInvalidationTest.php deleted file mode 100644 index 292bc34..0000000 --- a/tests/Integration/Invalidation/BuilderInvalidationTest.php +++ /dev/null @@ -1,210 +0,0 @@ -firstOrCreate(['name' => 'Alice']); - - $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Author::class)); - } - - public function test_update_or_create_invalidates_existing_model_cache(): void - { - $author = Author::create(['name' => 'Alice']); - Author::all(); - - $this->assertNotNull($this->modelCacheEntry(Author::class, $author->id)); - - $versionBefore = NormCache::currentVersion(Author::class); - - Author::query()->updateOrCreate(['id' => $author->id], ['name' => 'Alicia']); - - $this->assertNull($this->modelCacheEntry(Author::class, $author->id)); - $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Author::class)); - } - - public function test_upsert_invalidates_query_cache(): void - { - $author = Author::create(['name' => 'Alice']); - Author::all(); - - $this->assertNotNull($this->modelCacheEntry(Author::class, $author->id)); - - $versionBefore = NormCache::currentVersion(Author::class); - - Author::query()->upsert( - [['id' => 1, 'name' => 'Alicia', 'created_at' => now(), 'updated_at' => now()]], - ['id'], - ['name', 'updated_at'] - ); - - $this->assertNull($this->modelCacheEntry(Author::class, $author->id)); - $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Author::class)); - $this->assertSame('Alicia', Author::first()->name); - } - - public function test_update_or_insert_invalidates_query_cache(): void - { - $author = Author::create(['name' => 'Alice']); - Author::all(); - - $this->assertNotNull($this->modelCacheEntry(Author::class, $author->id)); - - $versionBefore = NormCache::currentVersion(Author::class); - - Author::query()->updateOrInsert( - ['name' => 'Alice'], - ['name' => 'Alicia', 'updated_at' => now()] - ); - - $this->assertNull($this->modelCacheEntry(Author::class, $author->id)); - $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Author::class)); - $this->assertSame('Alicia', Author::first()->name); - } - - public function test_insert_or_ignore_invalidates_query_cache(): void - { - Author::all(); - $versionBefore = NormCache::currentVersion(Author::class); - - Author::query()->insertOrIgnore([ - 'name' => 'Alice', - 'created_at' => now(), - 'updated_at' => now(), - ]); - - $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Author::class)); - $this->assertSame(['Alice'], Author::all()->pluck('name')->all()); - } - - public function test_insert_or_ignore_returning_uses_the_query_result_and_skips_noop_conflicts(): void - { - if (!method_exists(Author::query()->toBase(), 'insertOrIgnoreReturning')) { - $this->markTestSkipped('insertOrIgnoreReturning is available in Laravel 13 and later.'); - } - - Author::all(); - $versionBeforeInsert = NormCache::currentVersion(Author::class); - - $inserted = Author::query()->insertOrIgnoreReturning([ - 'id' => 1, - 'name' => 'Alice', - 'created_at' => now(), - 'updated_at' => now(), - ], ['id', 'name'], ['id']); - - $this->assertInstanceOf(Collection::class, $inserted); - $this->assertSame([['id' => 1, 'name' => 'Alice']], $inserted->map(fn($row) => (array) $row)->all()); - $this->assertGreaterThan($versionBeforeInsert, NormCache::currentVersion(Author::class)); - - $versionBeforeConflict = NormCache::currentVersion(Author::class); - $ignored = Author::query()->insertOrIgnoreReturning([ - 'id' => 1, - 'name' => 'Ignored', - 'created_at' => now(), - 'updated_at' => now(), - ], ['id', 'name'], ['id']); - - $this->assertTrue($ignored->isEmpty()); - $this->assertSame($versionBeforeConflict, NormCache::currentVersion(Author::class)); - } - - public function test_insert_using_invalidates_query_cache(): void - { - Author::create(['name' => 'Alice']); - Author::all(); - - $versionBefore = NormCache::currentVersion(Author::class); - - Author::query()->insertUsing( - ['name', 'created_at', 'updated_at'], - Author::query()->select('name', 'created_at', 'updated_at')->where('name', 'Alice') - ); - - $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Author::class)); - $this->assertCount(2, Author::all()); - } - - public function test_touch_and_increment_each_flush_model_cache(): void - { - $author = Author::create(['name' => 'Alice']); - Author::all(); - - $this->assertNotNull($this->modelCacheEntry(Author::class, $author->id)); - - $versionBeforeTouch = NormCache::currentVersion(Author::class); - - $touched = Author::whereKey($author->id)->touch(); - - $this->assertSame(1, $touched); - $this->assertNull($this->modelCacheEntry(Author::class, $author->id)); - $this->assertGreaterThan($versionBeforeTouch, NormCache::currentVersion(Author::class)); - - Author::find($author->id); - $this->assertNotNull($this->modelCacheEntry(Author::class, $author->id)); - - $versionBeforeIncrement = NormCache::currentVersion(Author::class); - - $affected = Author::whereKey($author->id)->incrementEach(['id' => 0]); - - $this->assertSame(1, $affected); - $this->assertNull($this->modelCacheEntry(Author::class, $author->id)); - $this->assertGreaterThan($versionBeforeIncrement, NormCache::currentVersion(Author::class)); - } - - public function test_bulk_update_flushes_all_model_keys(): void - { - $alice = Author::create(['name' => 'Alice']); - $bob = Author::create(['name' => 'Bob']); - $carol = Author::create(['name' => 'Carol']); - Author::all(); - - Author::whereBetween('id', [$bob->id, $bob->id])->update(['name' => 'Bobby']); - - $this->assertNull($this->modelCacheEntry(Author::class, $alice->id)); - $this->assertNull($this->modelCacheEntry(Author::class, $bob->id)); - $this->assertNull($this->modelCacheEntry(Author::class, $carol->id)); - } - - public function test_grouped_where_update_flushes_model_cache(): void - { - $a1 = Author::create(['name' => 'Alice']); - $a2 = Author::create(['name' => 'Bob']); - Author::all(); - - $this->assertNotNull($this->modelCacheEntry(Author::class, $a2->id)); - - Author::where(fn($q) => $q->whereIn('id', [$a1->id]))->update(['name' => 'Alicia']); - - $this->assertNull($this->modelCacheEntry(Author::class, $a1->id)); - $this->assertNull($this->modelCacheEntry(Author::class, $a2->id)); - } - - public function test_direct_where_in_update_flushes_model_cache(): void - { - $a1 = Author::create(['name' => 'Alice']); - $a2 = Author::create(['name' => 'Bob']); - Author::all(); - - $this->assertNotNull($this->modelCacheEntry(Author::class, $a2->id)); - - Author::whereIn('id', [$a1->id])->update(['name' => 'Alicia']); - - $this->assertNull($this->modelCacheEntry(Author::class, $a2->id)); - } -} diff --git a/tests/Integration/Invalidation/ModelInvalidationTest.php b/tests/Integration/Invalidation/ModelInvalidationTest.php deleted file mode 100644 index 0c90238..0000000 --- a/tests/Integration/Invalidation/ModelInvalidationTest.php +++ /dev/null @@ -1,450 +0,0 @@ - 'Alice']); - - $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Author::class)); - } - - public function test_updating_model_flushes_model_key_and_increments_version(): void - { - $author = Author::create(['name' => 'Alice']); - Author::all(); - - $versionBefore = NormCache::currentVersion(Author::class); - - $this->assertNotNull($this->modelCacheEntry(Author::class, $author->id)); - - $author->update(['name' => 'Alicia']); - - $this->assertNull($this->modelCacheEntry(Author::class, $author->id)); - $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Author::class)); - } - - public function test_mutating_primary_key_evicts_old_model_cache_key(): void - { - $author = Author::create(['name' => 'Alice']); - $oldId = $author->id; - - // Warm the model cache for the old ID. - Author::find($oldId); - $this->assertNotNull($this->modelCacheEntry(Author::class, $oldId)); - - // Mutate the PK. - $author->id = 9999; - $author->save(); - - // Old model key must be evicted — not left as orphaned memory. - $this->assertNull($this->modelCacheEntry(Author::class, $oldId), 'old model cache key must be evicted after PK mutation'); - } - - public function test_save_invalidates_again_after_job_processed_reenables_cache(): void - { - $author = Author::create(['name' => 'Alice']); - $manager = $this->cacheManager(); - $versionBefore = $manager->currentVersion(Author::class); - - CacheFallback::fallback($manager->config(), new \RuntimeException('simulated Redis error')); - $this->app['events']->dispatch(new JobProcessed('testing', $this->createStub(Job::class))); - $author->update(['name' => 'Alicia']); - - $this->assertTrue($manager->isEnabled()); - $this->assertGreaterThan($versionBefore, $manager->currentVersion(Author::class)); - } - - public function test_deleting_model_flushes_model_key_and_increments_version(): void - { - $author = Author::create(['name' => 'Alice']); - Author::all(); - - $versionBefore = NormCache::currentVersion(Author::class); - - $this->assertNotNull($this->modelCacheEntry(Author::class, $author->id)); - - $author->delete(); - - $this->assertNull($this->modelCacheEntry(Author::class, $author->id)); - $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Author::class)); - } - - public function test_deleting_model_increments_version_by_exactly_one(): void - { - $author = Author::create(['name' => 'Alice']); - Author::all(); - - $versionBefore = NormCache::currentVersion(Author::class); - - $author->delete(); - - $this->assertSame($versionBefore + 1, NormCache::currentVersion(Author::class)); - } - - public function test_incrementing_model_increments_version_by_exactly_one(): void - { - $author = Author::create(['name' => 'Alice']); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id, 'views' => 0]); - Post::all(); - - $versionBefore = NormCache::currentVersion(Post::class); - - $post->increment('views'); - - $this->assertSame($versionBefore + 1, NormCache::currentVersion(Post::class)); - } - - public function test_quiet_instance_writes_still_invalidate_cache(): void - { - $author = Author::create(['name' => 'Alice']); - Author::all(); - - $this->assertNotNull($this->modelCacheEntry(Author::class, $author->id)); - - $versionBefore = NormCache::currentVersion(Author::class); - - $author->updateQuietly(['name' => 'Alicia']); - - $this->assertNull($this->modelCacheEntry(Author::class, $author->id)); - $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Author::class)); - } - - public function test_quiet_create_invalidates_query_cache(): void - { - Author::all(); - $versionBefore = NormCache::currentVersion(Author::class); - - Author::query()->createQuietly(['name' => 'Alice']); - - $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Author::class)); - $this->assertSame(['Alice'], Author::all()->pluck('name')->all()); - } - - public function test_quiet_delete_invalidates_model_cache(): void - { - $author = Author::create(['name' => 'Alice']); - Author::all(); - - $this->assertNotNull($this->modelCacheEntry(Author::class, $author->id)); - - $versionBefore = NormCache::currentVersion(Author::class); - - $author->deleteQuietly(); - - $this->assertNull($this->modelCacheEntry(Author::class, $author->id)); - $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Author::class)); - } - - public function test_model_save_inside_without_events_still_invalidates_cache(): void - { - $author = Author::create(['name' => 'Alice']); - Author::all(); - - $this->assertNotNull($this->modelCacheEntry(Author::class, $author->id)); - $versionBefore = NormCache::currentVersion(Author::class); - - Author::withoutEvents(function () use ($author) { - $author->name = 'Alicia'; - $author->save(); - }); - - $this->assertNull($this->modelCacheEntry(Author::class, $author->id)); - $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Author::class)); - } - - public function test_model_create_inside_without_events_still_bumps_version(): void - { - Author::all(); - $versionBefore = NormCache::currentVersion(Author::class); - - Author::withoutEvents(fn() => Author::create(['name' => 'Alice'])); - - $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Author::class)); - $this->assertSame(['Alice'], Author::all()->pluck('name')->all()); - } - - public function test_touching_belongs_to_relation_invalidates_related_model_cache(): void - { - $author = Author::create(['name' => 'Alice']); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Author::all(); - $this->assertNotNull($this->modelCacheEntry(Author::class, $author->id)); - $versionBefore = NormCache::currentVersion(Author::class); - - $post->author()->touch(); - - $this->assertNull($this->modelCacheEntry(Author::class, $author->id)); - $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Author::class)); - } - - public function test_instance_update_bumps_version_and_invalidates_all_model_keys(): void - { - $a1 = Author::create(['name' => 'Alice']); - $a2 = Author::create(['name' => 'Bob']); - Author::all(); - - $versionBefore = NormCache::currentVersion(Author::class); - - $a1->update(['name' => 'Alicia']); - - $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Author::class)); - $this->assertNull($this->modelCacheEntry(Author::class, $a1->id)); - $this->assertNull($this->modelCacheEntry(Author::class, $a2->id)); - } - - public function test_instance_increment_bumps_version_and_invalidates_all_model_keys(): void - { - $a1 = Author::create(['name' => 'Alice']); - $a2 = Author::create(['name' => 'Bob']); - Author::all(); - - $versionBefore = NormCache::currentVersion(Author::class); - - $a1->increment('id', 0); - - $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Author::class)); - $this->assertNull($this->modelCacheEntry(Author::class, $a1->id)); - $this->assertNull($this->modelCacheEntry(Author::class, $a2->id)); - } - - public function test_instance_decrement_bumps_version_and_invalidates_all_model_keys(): void - { - $a1 = Author::create(['name' => 'Alice']); - $a2 = Author::create(['name' => 'Bob']); - Author::all(); - - $versionBefore = NormCache::currentVersion(Author::class); - - $a1->decrement('id', 0); - - $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Author::class)); - $this->assertNull($this->modelCacheEntry(Author::class, $a1->id)); - $this->assertNull($this->modelCacheEntry(Author::class, $a2->id)); - } - - public function test_new_query_from_existing_instance_update_invalidates_cache(): void - { - $author = Author::create(['name' => 'Alice']); - Author::all(); - - $this->assertNotNull($this->modelCacheEntry(Author::class, $author->id)); - $versionBefore = NormCache::currentVersion(Author::class); - - // newQuery() on a live instance sets $this->model->exists = true, which takes the instance-flush path - $author->newQuery()->where('id', $author->id)->update(['name' => 'Alicia']); - - $this->assertNull($this->modelCacheEntry(Author::class, $author->id)); - $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Author::class)); - } - - public function test_builder_update_still_invalidates_once(): void - { - config()->set('normcache.cooldown', 0); - - $author = Author::create(['name' => 'Alice']); - - Post::create([ - 'title' => 'Original', - 'author_id' => $author->id, - 'published' => false, - ]); - - $before = NormCache::currentVersion(Post::class); - - Post::where('published', false)->update(['published' => true]); - - $after = NormCache::currentVersion(Post::class); - - $this->assertSame($before + 1, $after); - } - - public function test_model_create_invalidates_once(): void - { - config()->set('normcache.cooldown', 0); - - $author = Author::create(['name' => 'Alice']); - - $before = NormCache::currentVersion(Post::class); - - Post::create([ - 'title' => 'Created', - 'author_id' => $author->id, - 'published' => true, - ]); - - $after = NormCache::currentVersion(Post::class); - - $this->assertSame( - $before + 1, - $after, - 'Model create should invalidate the model version exactly once.' - ); - } - - public function test_dirty_existing_model_save_invalidates_before_and_after_write(): void - { - $author = Author::create(['name' => 'Alice']); - - $post = Post::create([ - 'title' => 'Original', - 'author_id' => $author->id, - 'published' => true, - ]); - - // Reset the version after setup so the create invalidation does not matter. - NormCache::flushModel(Post::class); - - $before = NormCache::currentVersion(Post::class); - - $post->title = 'Changed'; - $post->save(); - - $after = NormCache::currentVersion(Post::class); - - $this->assertSame( - $before + 2, - $after, - 'Dirty existing model save should invalidate both before and after the write.' - ); - } - - public function test_dirty_existing_model_save_quietly_only_invalidates_once_outside_transaction(): void - { - config()->set('normcache.cooldown', 0); - - $author = Author::create(['name' => 'Alice']); - - $post = Post::create([ - 'title' => 'Original', - 'author_id' => $author->id, - 'published' => true, - ]); - - $before = NormCache::currentVersion(Post::class); - - $post->title = 'Changed'; - $post->saveQuietly(); - - $after = NormCache::currentVersion(Post::class); - - $this->assertSame( - $before + 1, - $after, - 'Dirty existing model saveQuietly should invalidate the model version exactly once.' - ); - } - - public function test_clean_existing_model_save_does_not_invalidate(): void - { - config()->set('normcache.cooldown', 0); - - $author = Author::create(['name' => 'Alice']); - - $post = Post::create([ - 'title' => 'Original', - 'author_id' => $author->id, - 'published' => true, - ]); - - $before = NormCache::currentVersion(Post::class); - - $post->save(); - - $after = NormCache::currentVersion(Post::class); - - $this->assertSame($before, $after); - } - - public function test_model_restore_invalidates_once(): void - { - config()->set('normcache.cooldown', 0); - - $author = Author::create(['name' => 'Alice']); - - $post = Post::create([ - 'title' => 'Original', - 'author_id' => $author->id, - 'published' => true, - ]); - - $post->delete(); - - $before = NormCache::currentVersion(Post::class); - - $post->restore(); - - $after = NormCache::currentVersion(Post::class); - - $this->assertSame($before + 1, $after); - } - - public function test_updating_model_evicts_own_cache_key_even_when_version_bump_is_deferred(): void - { - $author = Author::create(['name' => 'Alice']); - Author::find($author->id); - $this->assertNotNull($this->modelCacheEntry(Author::class, $author->id)); - - $this->cacheManager()->config()->cooldown = 60; - - $author->update(['name' => 'Alicia']); - - $this->assertSame('Alicia', Author::find($author->id)->name); - } - - public function test_deleting_model_evicts_own_cache_key_even_when_version_bump_is_deferred(): void - { - $author = Author::create(['name' => 'Alice']); - Author::find($author->id); - $this->assertNotNull($this->modelCacheEntry(Author::class, $author->id)); - - $this->cacheManager()->config()->cooldown = 60; - - $author->delete(); - - $this->assertNull(Author::find($author->id)); - } - - public function test_save_invalidates_when_saving_listener_makes_a_clean_model_dirty(): void - { - $author = Author::create(['name' => 'Alice']); - Author::find($author->id); - - $this->assertNotNull($this->modelCacheEntry(Author::class, $author->id)); - - Author::saving(function (Author $model) { - if ($model->name === 'Alice') { - $model->name = 'Alicia'; - } - }); - - try { - $clean = Author::find($author->id); - - $this->assertTrue($clean->save()); - $this->assertSame('Alicia', Author::withoutCache()->find($author->id)->name); - $this->assertNull($this->modelCacheEntry(Author::class, $author->id)); - $this->assertSame('Alicia', Author::find($author->id)->name); - } finally { - Author::flushEventListeners(); - } - } -} diff --git a/tests/Integration/Invalidation/SoftDeleteInvalidationTest.php b/tests/Integration/Invalidation/SoftDeleteInvalidationTest.php deleted file mode 100644 index e7d22ea..0000000 --- a/tests/Integration/Invalidation/SoftDeleteInvalidationTest.php +++ /dev/null @@ -1,211 +0,0 @@ - 'Alice']); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Post::all(); - $post->delete(); - - $fromCache = Post::all()->pluck('id'); - $fromDb = UncachedPost::all()->pluck('id'); - - $this->assertEquals($fromDb, $fromCache); - } - - public function test_restoring_soft_deleted_model_invalidates_version(): void - { - $author = Author::create(['name' => 'Alice']); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id]); - $post->delete(); - - $versionBefore = NormCache::currentVersion(Post::class); - - $post->restore(); - - $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Post::class)); - } - - public function test_soft_deleting_model_increments_version_by_exactly_one(): void - { - $author = Author::create(['name' => 'Alice']); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id]); - Post::all(); - - $versionBefore = NormCache::currentVersion(Post::class); - - $post->delete(); - - $this->assertSame($versionBefore + 1, NormCache::currentVersion(Post::class)); - } - - public function test_force_deleting_soft_deletable_model_flushes_cache(): void - { - $author = Author::create(['name' => 'Alice']); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Post::all(); - - $versionBefore = NormCache::currentVersion(Post::class); - - $this->assertNotNull($this->modelCacheEntry(Post::class, $post->id)); - - $post->forceDelete(); - - $this->assertNull($this->modelCacheEntry(Post::class, $post->id)); - $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Post::class)); - } - - public function test_restoring_soft_deleted_model_serves_fresh_data(): void - { - $author = Author::create(['name' => 'Alice']); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Post::all(); - $post->delete(); - Post::all(); - - $post->restore(); - - $fromCache = Post::all()->pluck('id'); - $fromDb = UncachedPost::all()->pluck('id'); - - $this->assertEquals($fromDb, $fromCache); - $this->assertContains($post->id, $fromCache); - } - - public function test_soft_deleted_model_is_not_written_to_model_cache_on_miss(): void - { - $author = Author::create(['name' => 'Alice']); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Post::all(); - $this->assertNotNull($this->modelCacheEntry(Post::class, $post->id)); - - $post->delete(); - $this->assertNull($this->modelCacheEntry(Post::class, $post->id)); - - NormCache::modelCache()->getModels([$post->id], Post::class); - - $this->assertNull($this->modelCacheEntry(Post::class, $post->id)); - } - - public function test_soft_deleted_model_is_not_returned_from_model_cache_miss(): void - { - $author = Author::create(['name' => 'Alice']); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Post::all(); - $post->delete(); - - $this->assertSame([], NormCache::modelCache()->getModels([$post->id], Post::class)); - } - - public function test_with_trashed_query_can_return_soft_deleted_model_after_model_cache_miss(): void - { - $author = Author::create(['name' => 'Alice']); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Post::all(); - $post->delete(); - - $posts = Post::withTrashed()->whereKey($post->id)->get(); - - $this->assertCount(1, $posts); - $this->assertTrue($posts->first()->trashed()); - } - - public function test_soft_deleted_model_excluded_from_subsequent_cache_reads(): void - { - $author = Author::create(['name' => 'Alice']); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Post::all(); - $post->delete(); - - NormCache::modelCache()->getModels([$post->id], Post::class); - - $ids = Post::all()->pluck('id'); - $this->assertNotContains($post->id, $ids); - } - - public function test_soft_delete_flushes_model_key_correctly(): void - { - $author = Author::create(['name' => 'Alice']); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Post::all(); - $this->assertNotNull($this->modelCacheEntry(Post::class, $post->id)); - - $post->delete(); - - $this->assertNull($this->modelCacheEntry(Post::class, $post->id)); - } - - public function test_version_bumped_after_soft_delete_not_before(): void - { - $author = Author::create(['name' => 'Alice']); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - Post::all(); - $versionBefore = NormCache::currentVersion(Post::class); - - $post->delete(); - - $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Post::class)); - - $fromCache = Post::all()->pluck('id'); - $this->assertNotContains($post->id, $fromCache); - } - - public function test_quiet_restore_invalidates_query_cache(): void - { - $author = Author::create(['name' => 'Alice']); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id]); - $post->delete(); - Post::all(); - - $versionBefore = NormCache::currentVersion(Post::class); - - $post->restoreQuietly(); - - $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Post::class)); - $this->assertSame([$post->id], Post::all()->pluck('id')->all()); - } - - public function test_bulk_restore_and_force_delete_invalidate_cache(): void - { - $author = Author::create(['name' => 'Alice']); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id]); - $post->delete(); - Post::all(); - - $versionBeforeRestore = NormCache::currentVersion(Post::class); - - Post::onlyTrashed()->whereKey($post->id)->restore(); - - $this->assertGreaterThan($versionBeforeRestore, NormCache::currentVersion(Post::class)); - - $versionBeforeForceDelete = NormCache::currentVersion(Post::class); - - Post::whereKey($post->id)->forceDelete(); - - $this->assertGreaterThan($versionBeforeForceDelete, NormCache::currentVersion(Post::class)); - $this->assertCount(0, Post::withTrashed()->get()); - } -} diff --git a/tests/Integration/Invalidation/TransactionInvalidationTest.php b/tests/Integration/Invalidation/TransactionInvalidationTest.php deleted file mode 100644 index 360fe51..0000000 --- a/tests/Integration/Invalidation/TransactionInvalidationTest.php +++ /dev/null @@ -1,307 +0,0 @@ - 'Alice']); - $versionDuring = NormCache::currentVersion(Author::class); - }); - - $this->assertSame($versionBefore, $versionDuring); - $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Author::class)); - } - - public function test_committed_transaction_bumps_version(): void - { - $versionBefore = NormCache::currentVersion(Author::class); - - DB::transaction(function () { - Author::create(['name' => 'Alice']); - }); - - $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Author::class)); - } - - public function test_rolled_back_transaction_does_not_bump_version(): void - { - $versionBefore = NormCache::currentVersion(Author::class); - - try { - DB::transaction(function () { - Author::create(['name' => 'Alice']); - throw new \RuntimeException('force rollback'); - }); - } catch (\RuntimeException) { - } - - $this->assertSame($versionBefore, NormCache::currentVersion(Author::class)); - } - - public function test_model_key_deleted_after_transaction_commits(): void - { - $author = Author::create(['name' => 'Alice']); - Author::all(); - - $this->assertNotNull($this->modelCacheEntry(Author::class, $author->id)); - - DB::transaction(function () use ($author) { - $author->update(['name' => 'Alicia']); - }); - - $this->assertNull($this->modelCacheEntry(Author::class, $author->id)); - } - - public function test_transaction_commit_bumps_version_and_evicts_all_model_keys(): void - { - $alice = Author::create(['name' => 'Alice']); - $bob = Author::create(['name' => 'Bob']); - Author::all(); - - $this->assertNotNull($this->modelCacheEntry(Author::class, $alice->id)); - $this->assertNotNull($this->modelCacheEntry(Author::class, $bob->id)); - - $versionBefore = NormCache::currentVersion(Author::class); - - DB::transaction(function () use ($alice) { - $alice->update(['name' => 'Alicia']); - }); - - $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Author::class)); - $this->assertNull($this->modelCacheEntry(Author::class, $alice->id)); - $this->assertNull($this->modelCacheEntry(Author::class, $bob->id)); - } - - public function test_model_key_preserved_after_transaction_rollback(): void - { - $author = Author::create(['name' => 'Alice']); - Author::all(); - - $this->assertNotNull($this->modelCacheEntry(Author::class, $author->id)); - - try { - DB::transaction(function () use ($author) { - $author->update(['name' => 'Alicia']); - throw new \RuntimeException('force rollback'); - }); - } catch (\RuntimeException) { - } - - $this->assertNotNull($this->modelCacheEntry(Author::class, $author->id)); - } - - public function test_multiple_writes_to_same_model_in_transaction_produce_one_version_bump(): void - { - $versionBefore = NormCache::currentVersion(Author::class); - - DB::transaction(function () { - Author::create(['name' => 'Alice']); - Author::create(['name' => 'Bob']); - Author::create(['name' => 'Carol']); - }); - - $this->assertSame($versionBefore + 1, NormCache::currentVersion(Author::class)); - } - - public function test_bulk_update_version_is_deferred_inside_transaction(): void - { - Author::create(['name' => 'Alice']); - Author::all(); - - $versionBefore = NormCache::currentVersion(Author::class); - $versionDuringTx = null; - - DB::transaction(function () use (&$versionDuringTx) { - Author::where('name', 'Alice')->update(['name' => 'Alicia']); - $versionDuringTx = NormCache::currentVersion(Author::class); - }); - - $this->assertSame($versionBefore, $versionDuringTx); - } - - public function test_bulk_update_rollback_does_not_leave_orphaned_version(): void - { - Author::create(['name' => 'Alice']); - Author::all(); - - $versionBefore = NormCache::currentVersion(Author::class); - - try { - DB::transaction(function () { - Author::where('name', 'Alice')->update(['name' => 'Alicia']); - throw new \RuntimeException('force rollback'); - }); - } catch (\RuntimeException) { - } - - $this->assertSame($versionBefore, NormCache::currentVersion(Author::class)); - } - - public function test_bulk_delete_model_key_not_removed_mid_transaction(): void - { - $author = Author::create(['name' => 'Alice']); - Author::create(['name' => 'Bob']); - Author::all(); - - $this->assertNotNull($this->modelCacheEntry(Author::class, $author->id)); - - $keyExistedMidTx = null; - $authorId = $author->id; - - try { - DB::transaction(function () use ($author, $authorId, &$keyExistedMidTx) { - Author::where('id', $author->id)->delete(); - $keyExistedMidTx = $this->modelCacheEntry(Author::class, $authorId) !== null; - throw new \RuntimeException('force rollback'); - }); - } catch (\RuntimeException) { - } - - // Invalidation is deferred to commit; the key must survive both mid-transaction and rollback. - $this->assertTrue($keyExistedMidTx); - $this->assertNotNull($this->modelCacheEntry(Author::class, $author->id)); - } - - public function test_committed_transaction_invalidates_outdated_query_cache(): void - { - $author = Author::create(['name' => 'Alice']); - Author::all(); - - DB::transaction(function () use ($author) { - $author->update(['name' => 'Alicia']); - }); - - $result = Author::all()->firstWhere('id', $author->id); - - $this->assertSame('Alicia', $result->name); - } - - public function test_insert_in_transaction_bumps_version_on_commit(): void - { - $alice = Author::create(['name' => 'Alice']); - $bob = Author::create(['name' => 'Bob']); - Author::all(); - - $this->assertNotNull($this->modelCacheEntry(Author::class, $alice->id)); - $this->assertNotNull($this->modelCacheEntry(Author::class, $bob->id)); - - $versionBefore = NormCache::currentVersion(Author::class); - - DB::transaction(function () { - Author::create(['name' => 'Carol']); - }); - - $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Author::class)); - $this->assertNull($this->modelCacheEntry(Author::class, $alice->id)); - $this->assertNull($this->modelCacheEntry(Author::class, $bob->id)); - } - - public function test_insert_in_transaction_still_invalidates_query_cache_on_commit(): void - { - Author::create(['name' => 'Alice']); - Author::all(); - - DB::transaction(function () { - Author::create(['name' => 'Bob']); - }); - - $this->assertCount(2, Author::all()); - } - - public function test_rolled_back_transaction_leaves_cache_consistent(): void - { - $author = Author::create(['name' => 'Alice']); - Author::all(); - - try { - DB::transaction(function () use ($author) { - $author->update(['name' => 'Alicia']); - throw new \RuntimeException('force rollback'); - }); - } catch (\RuntimeException) { - } - - $result = Author::all()->firstWhere('id', $author->id); - - $this->assertSame('Alice', $result->name); - } - - public function test_single_model_update_in_transaction_bumps_version_once(): void - { - $author = Author::create(['name' => 'Alice']); - Author::query()->get(); // warm - - $before = NormCache::currentVersion(Author::class); - - DB::transaction(function () use ($author) { - $author->update(['name' => 'Alicia']); - }); - - $after = NormCache::currentVersion(Author::class); - - $this->assertSame($before + 1, $after); - } - - public function test_multiple_model_updates_in_transaction_bumps_version_once(): void - { - $a1 = Author::create(['name' => 'Alice']); - $a2 = Author::create(['name' => 'Bob']); - Author::query()->get(); // warm - - $before = NormCache::currentVersion(Author::class); - - DB::transaction(function () use ($a1, $a2) { - $a1->update(['name' => 'Alicia']); - $a2->update(['name' => 'Roberto']); - }); - - $after = NormCache::currentVersion(Author::class); - - $this->assertSame($before + 1, $after); - } - - public function test_flush_and_update_in_transaction_bumps_version_once(): void - { - $author = Author::create(['name' => 'Alice']); - Author::query()->get(); // warm - - $before = NormCache::currentVersion(Author::class); - - DB::transaction(function () use ($author) { - NormCache::flushModel(Author::class); - $author->update(['name' => 'Alicia']); - }); - - $after = NormCache::currentVersion(Author::class); - - $this->assertSame($before + 1, $after); - } - - public function test_model_and_table_invalidation_for_the_same_table_bump_once_on_commit(): void - { - $author = Author::create(['name' => 'Alice']); - $before = NormCache::currentVersion(Author::class); - - DB::transaction(function () use ($author) { - $author->update(['name' => 'Alicia']); - NormCache::invalidateTableVersion(DB::getDefaultConnection(), 'authors'); - }); - - $this->assertSame($before + 1, NormCache::currentVersion(Author::class)); - } -} diff --git a/tests/Integration/Planning/QueryDependencyPlanningTest.php b/tests/Integration/Planning/QueryDependencyPlanningTest.php deleted file mode 100644 index 7af5692..0000000 --- a/tests/Integration/Planning/QueryDependencyPlanningTest.php +++ /dev/null @@ -1,171 +0,0 @@ -modelsPlan( - Author::whereIn('id', fn($query) => $query->from('posts')->select('author_id')), - ); - - $this->assertTracksTableOrBypasses($plan, 'testing:posts'); - } - - public function test_or_where_in_query_builder_tracks_its_subquery_table_or_bypasses(): void - { - $plan = $this->modelsPlan( - Author::where('name', 'Alice')->orWhereIn('id', Post::select('author_id')), - ); - - $this->assertTracksTableOrBypasses($plan, 'testing:posts'); - } - - public function test_or_where_not_in_query_builder_tracks_its_subquery_table_or_bypasses(): void - { - $plan = $this->modelsPlan( - Author::where('name', 'Alice')->orWhereNotIn('id', Post::select('author_id')), - ); - - $this->assertTracksTableOrBypasses($plan, 'testing:posts'); - } - - public function test_raw_expression_subquery_predicate_bypasses(): void - { - $plan = $this->modelsPlan( - Author::where( - DB::raw('(select count(*) from posts where posts.author_id = authors.id)'), - '>', - 3, - ), - ); - - $this->assertSame(CacheStrategy::LiveQuery, $plan->strategy); - $this->assertNotEmpty($plan->bypassReasons['dependency'] ?? []); - } - - public function test_raw_expression_in_predicate_bypasses(): void - { - $plan = $this->modelsPlan( - Author::whereIn('id', [DB::raw('select author_id from banned_authors')]), - ); - - $this->assertSame(CacheStrategy::LiveQuery, $plan->strategy); - $this->assertNotEmpty($plan->bypassReasons['dependency'] ?? []); - } - - public function test_nested_where_preserves_captured_subquery_dependencies(): void - { - $plan = $this->modelsPlan( - Author::where(fn($query) => $query->whereIn('id', Post::select('author_id'))), - ); - - $this->assertSame(CacheStrategy::Result, $plan->strategy); - $this->assertContains('testing:posts', $plan->dependencies->tables); - } - - public function test_nested_where_preserves_captured_safety_reasons(): void - { - $plan = $this->modelsPlan( - Author::where(fn($query) => $query->whereHas('lockedPosts')), - ); - - $this->assertSame(CacheStrategy::LiveQuery, $plan->strategy); - $this->assertNotEmpty($plan->bypassReasons['safety'] ?? []); - } - - public function test_relation_callback_lock_bypasses_and_writes_no_result_cache(): void - { - $builder = Author::whereHas('posts', fn($query) => $query->lockForUpdate()); - - $this->assertSame(CacheStrategy::LiveQuery, $this->modelsPlan($builder)->strategy); - - $builder->get(); - - $this->assertEmpty($this->redisKeys('result:*')); - } - - public function test_subquery_dependency_uses_the_subquery_connection_namespace(): void - { - config()->set('database.connections.secondary_testing', [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'prefix' => '', - ]); - DB::purge('secondary_testing'); - - $plan = $this->modelsPlan( - Author::query()->whereIn('id', Author::on('secondary_testing')->select('id')), - ); - - $this->assertContains('secondary_testing:authors', $plan->dependencies->tables); - $this->assertNotContains('testing:authors', $plan->dependencies->tables); - } - - public function test_scalar_plan_honours_captured_dependency_reasons(): void - { - $plan = $this->scalarPlan($this->opaqueSelectSubqueryWithHaving()); - - $this->assertSame(CacheStrategy::LiveQuery, $plan->strategy); - $this->assertNotEmpty($plan->bypassReasons['dependency'] ?? []); - } - - public function test_pagination_count_plan_honours_captured_dependency_reasons(): void - { - $plan = $this->paginationPlan($this->opaqueSelectSubqueryWithHaving()); - - $this->assertSame(CacheStrategy::LiveQuery, $plan->strategy); - $this->assertNotEmpty($plan->bypassReasons['dependency'] ?? []); - } - - private function opaqueSelectSubqueryWithHaving(): CacheableBuilder - { - return Author::selectSub( - fn($query) => $query->from('posts') - ->selectRaw('count(*)') - ->whereColumn('posts.author_id', 'authors.id'), - 'x', - )->having('x', '>', 0); - } - - private function modelsPlan(CacheableBuilder $builder): CachePlan - { - return $this->plan($builder, CachePlanContext::models()); - } - - private function scalarPlan(CacheableBuilder $builder): CachePlan - { - return $this->plan($builder, CachePlanContext::scalar(['*'])); - } - - private function paginationPlan(CacheableBuilder $builder): CachePlan - { - return $this->plan($builder, CachePlanContext::paginationCount()); - } - - private function plan(CacheableBuilder $builder, CachePlanContext $context): CachePlan - { - $prepared = $builder->prepareCacheExecution(); - - return $prepared->builder->cachePlan($prepared->base, $context); - } - - private function assertTracksTableOrBypasses(CachePlan $plan, string $table): void - { - $this->assertTrue( - $plan->strategy === CacheStrategy::LiveQuery || in_array($table, $plan->dependencies->tables, true), - "Expected the plan to bypass or track [{$table}], got [{$plan->strategy->name}] with dependencies [" - . implode(', ', $plan->dependencies->tables) . '].', - ); - } -} diff --git a/tests/Integration/Planning/SilentBypassLogTest.php b/tests/Integration/Planning/SilentBypassLogTest.php deleted file mode 100644 index 387858a..0000000 --- a/tests/Integration/Planning/SilentBypassLogTest.php +++ /dev/null @@ -1,38 +0,0 @@ - true]); - - Log::shouldReceive('warning') - ->once() - ->withArgs(function ($msg) { - return str_contains($msg, 'unsafe dependency inference'); - }); - - Author::whereHas('posts', fn($q) => $q->whereRaw('1 = 1'))->get(); - } - - public function test_cross_space_bypass_logs_only_the_space_warning(): void - { - config(['app.debug' => true]); - - Log::shouldReceive('warning') - ->once() - ->withArgs(function ($msg) { - return str_contains($msg, 'not in that space') - && !str_contains($msg, 'unsafe dependency inference'); - }); - - SpacedPost::query()->dependsOn([Author::class])->get(); - } -} diff --git a/tests/Integration/Relations/RelationOverrideTest.php b/tests/Integration/Relations/RelationOverrideTest.php deleted file mode 100644 index da0e540..0000000 --- a/tests/Integration/Relations/RelationOverrideTest.php +++ /dev/null @@ -1,212 +0,0 @@ - 'Alice']); - Post::create(['title' => 'Post 1', 'author_id' => $author->id]); - - $first = Author::find($author->id); - $this->assertCount(1, $first->posts); - - $queryCount = 0; - DB::listen(function () use (&$queryCount) { - $queryCount++; - }); - $second = Author::find($author->id); - $this->assertCount(1, $second->posts); - $this->assertSame(0, $queryCount, 'Expected cache hit — no DB queries for lazy posts relation'); - - Post::create(['title' => 'Post 2', 'author_id' => $author->id]); - - $third = Author::find($author->id); - $this->assertCount(2, $third->posts); - } - - public function test_eager_has_many_relation_is_served_from_cache_and_invalidated(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Post 1', 'author_id' => $author->id]); - - $first = Author::with('posts')->find($author->id); - $this->assertCount(1, $first->posts); - - $queryCount = 0; - DB::listen(function () use (&$queryCount) { - $queryCount++; - }); - $second = Author::with('posts')->find($author->id); - $this->assertCount(1, $second->posts); - $this->assertSame(0, $queryCount, 'Expected cache hit — no DB queries for eager-loaded posts'); - - Post::create(['title' => 'Post 2', 'author_id' => $author->id]); - - $third = Author::with('posts')->find($author->id); - $this->assertCount(2, $third->posts); - } - - public function test_eager_has_many_relation_warm_hit_refetches_only_evicted_child_model(): void - { - $author = Author::create(['name' => 'Alice']); - $post = Post::create(['title' => 'Post 1', 'author_id' => $author->id]); - - $first = Author::with('posts')->find($author->id); - $this->assertCount(1, $first->posts); - - $this->evictModelCache(Post::class, $post->id); - - $queryCount = 0; - DB::listen(function () use (&$queryCount) { - $queryCount++; - }); - - $second = Author::with('posts')->find($author->id); - - $this->assertCount(1, $second->posts); - $this->assertSame( - 1, - $queryCount, - 'Expected the model-index cache to refetch only the evicted Post row, not the whole relation' - ); - } - - public function test_eager_has_many_relation_uses_normalized_query_and_model_cache(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Post 1', 'author_id' => $author->id]); - - $first = Author::with('posts')->find($author->id); - $this->assertCount(1, $first->posts); - - $this->assertNotEmpty( - $this->redisKeys('query:*:posts:*'), - 'Expected simple hasMany eager load to populate the normalized query-id cache' - ); - $this->assertNotEmpty( - $this->redisKeys('model:*:posts:*'), - 'Expected simple hasMany eager load to populate the per-id model cache' - ); - } - - public function test_has_many_calculated_projection_is_not_cached(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Post 1', 'author_id' => $author->id, 'views' => 7]); - - $first = $author->posts() - ->select('posts.*') - ->selectRaw('views * 2 as doubled_views') - ->get(); - - $this->assertSame(14, (int) $first->first()->doubled_views); - - $queryCount = 0; - DB::listen(function () use (&$queryCount) { - $queryCount++; - }); - - $second = $author->posts() - ->select('posts.*') - ->selectRaw('views * 2 as doubled_views') - ->get(); - - $this->assertSame(14, (int) $second->first()->doubled_views); - $this->assertSame( - 1, - $queryCount, - 'Calculated columns cannot be normalized into model keys, same as a top-level query — expected a live query' - ); - } - - public function test_eager_has_many_limit_constraint_warm_hit_uses_normalized_cache(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'B Post', 'author_id' => $author->id]); - Post::create(['title' => 'A Post', 'author_id' => $author->id]); - - $first = Author::with(['posts' => fn($query) => $query->orderBy('title')->limit(1)]) - ->find($author->id); - - $this->assertSame(['A Post'], $first->posts->pluck('title')->all()); - - $queryCount = 0; - DB::listen(function () use (&$queryCount) { - $queryCount++; - }); - - $second = Author::with(['posts' => fn($query) => $query->orderBy('title')->limit(1)]) - ->find($author->id); - - $this->assertSame(['A Post'], $second->posts->pluck('title')->all()); - $this->assertSame(0, $queryCount, 'Expected limited hasMany eager load to warm-hit the model-index cache'); - } - - public function test_has_many_result_payload_with_count_invalidates_when_counted_model_changes(): void - { - $author = Author::create(['name' => 'Alice']); - $post = Post::create(['title' => 'Post 1', 'author_id' => $author->id]); - $post->comments()->create(['body' => 'Comment 1']); - - $query = fn() => Author::with(['posts' => fn($builder) => $builder->withCount('comments')]) - ->find($author->id); - - $first = $query(); - $this->assertSame(1, $first->posts->first()->comments_count); - $this->assertNotEmpty($this->redisKeys('result:*')); - - $warm = $query(); - $this->assertSame(1, $warm->posts->first()->comments_count); - - $post->comments()->create(['body' => 'Comment 2']); - - $after = $query(); - $this->assertSame(2, $after->posts->first()->comments_count); - } - - public function test_has_many_subquery_constraint_uses_result_payload(): void - { - $author = Author::create(['name' => 'Alice']); - $post = Post::create(['title' => 'Post 1', 'author_id' => $author->id]); - $post->comments()->create(['body' => 'Comment 1']); - - $posts = $author->posts() - ->whereIn('posts.id', Comment::query() - ->select('commentable_id') - ->where('commentable_type', Post::class)) - ->get(); - - $this->assertSame(['Post 1'], $posts->pluck('title')->all()); - $this->assertNotEmpty($this->redisKeys('result:*')); - } - - public function test_eager_morph_many_relation_is_served_from_cache_and_invalidated(): void - { - $author = Author::create(['name' => 'Alice']); - $author->comments()->create(['body' => 'Comment 1']); - - $first = Author::with('comments')->find($author->id); - $this->assertCount(1, $first->comments); - - $queryCount = 0; - DB::listen(function () use (&$queryCount) { - $queryCount++; - }); - $second = Author::with('comments')->find($author->id); - $this->assertCount(1, $second->comments); - $this->assertSame(0, $queryCount, 'Expected cache hit — no DB queries for eager-loaded comments'); - - $author->comments()->create(['body' => 'Comment 2']); - - $third = Author::with('comments')->find($author->id); - $this->assertCount(2, $third->comments); - } -} diff --git a/tests/TestCase.php b/tests/TestCase.php index 1018935..5f6b29f 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,23 +2,21 @@ namespace NormCache\Tests; -use Illuminate\Contracts\Pagination\LengthAwarePaginator; -use Illuminate\Database\Eloquent\Collection as EloquentCollection; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Pagination\CursorPaginator; -use Illuminate\Pagination\Paginator; -use Illuminate\Support\Collection; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Redis; +use Illuminate\Support\Facades\Schema; +use NormCache\Cache\CacheRuntime; use NormCache\CacheManager; -use NormCache\CacheManagerFactory; use NormCache\CacheServiceProvider; use NormCache\Support\CacheKeyBuilder; +use NormCache\Support\RedisStore; +use NormCache\Tests\Concerns\CacheAssertions; use Orchestra\Testbench\TestCase as OrchestraTestCase; use Predis\Client; abstract class TestCase extends OrchestraTestCase { + use CacheAssertions; + protected function setUp(): void { parent::setUp(); @@ -26,13 +24,13 @@ protected function setUp(): void $redis = Redis::connection('normcache-test'); $client = $redis->client(); - if (env('REDIS_CLUSTER') === 'true' || env('REDIS_CLUSTER') === true) { + if ($this->isClusterRun()) { if (class_exists(Client::class) && $client instanceof Client) { foreach ($client as $node) { try { $node->flushdb(); - } catch (\Exception $e) { - // Ignore READONLY errors from replicas + } catch (\Exception) { + // Replicas reject FLUSHDB. } } } elseif ($client instanceof \RedisCluster) { @@ -44,7 +42,8 @@ protected function setUp(): void $redis->flushdb(); } - $this->resetClassKeyCache(); + // Migrations memoize cache state before Redis is flushed. + $this->app->forgetScopedInstances(); } protected function getPackageProviders($app): array @@ -54,33 +53,39 @@ protected function getPackageProviders($app): array protected function defineEnvironment($app): void { + $driver = (string) env('TEST_DB_DRIVER', 'sqlite'); + $app['config']->set('database.default', 'testing'); - $app['config']->set('database.connections.testing', [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'prefix' => '', - ]); + $app['config']->set( + 'database.connections.testing', + $driver === 'sqlite' + ? $this->sqliteDatabaseConfig() + : $this->serverDatabaseConfig($driver), + ); $client = env('REDIS_CLIENT', 'phpredis'); $app['config']->set('database.redis.client', $client); $app['config']->set('database.redis.options.prefix', ''); - if (env('REDIS_CLUSTER') === 'true' || env('REDIS_CLUSTER') === true) { + if ($this->isClusterRun()) { if ($client === 'predis') { $app['config']->set('database.redis.options.cluster', 'redis'); } $nodes = explode(',', env('REDIS_CLUSTER_NODES', '127.0.0.1:6379')); - $app['config']->set('database.redis.clusters.normcache-test', array_map(function ($node) { - [$host, $port] = explode(':', $node); - - return [ - 'host' => $host, - 'port' => $port, - 'database' => 0, - 'password' => env('REDIS_PASSWORD', null), - ]; - }, $nodes)); + $app['config']->set('database.redis.clusters.normcache-test', array_map( + static function (string $node): array { + [$host, $port] = explode(':', $node); + + return [ + 'host' => $host, + 'port' => $port, + 'database' => 0, + 'password' => env('REDIS_PASSWORD', null), + ]; + }, + $nodes, + )); } else { $app['config']->set('database.redis.normcache-test', [ 'host' => env('REDIS_HOST', '127.0.0.1'), @@ -94,164 +99,96 @@ protected function defineEnvironment($app): void $app['config']->set('normcache.enabled', true); $app['config']->set('normcache.events', true); $app['config']->set('normcache.key_prefix', 'test:'); - $app['config']->set('normcache.ttl', 3600); + $app['config']->set('normcache.row_ttl', 3600); $app['config']->set('normcache.query_ttl', 60); - $app['config']->set('normcache.cooldown', 0); } protected function defineDatabaseMigrations(): void { - $this->loadMigrationsFrom(__DIR__ . '/Fixtures/database'); - } - - protected function resetClassKeyCache(): void - { - CacheKeyBuilder::reset(); - } - - protected function modelCacheEntry(string $class, mixed $id): mixed - { - $manager = $this->cacheManager(); - - return $manager->store()->get($this->currentModelKey($manager, $class, $id)); - } + if (env('TEST_DB_DRIVER', 'sqlite') !== 'sqlite') { + Schema::dropAllTables(); + } - protected function evictModelCache(string $class, mixed $id): void - { - $manager = $this->cacheManager(); - $manager->store()->delete($this->currentModelKey($manager, $class, $id)); + $this->loadMigrationsFrom(__DIR__ . '/Fixtures/database'); } - protected function prefixedModelKey(string $class, mixed $id): string + /** @return array */ + private function sqliteDatabaseConfig(): array { - $manager = $this->cacheManager(); + $database = sys_get_temp_dir() . '/normcache-tests-' . getmypid() . '.sqlite'; - return $this->currentModelKey($manager, $class, $id); - } + if (is_file($database)) { + unlink($database); + } - private function currentModelKey(CacheManager $manager, string $class, mixed $id): string - { - $classKey = $manager->keys()->classKey($class); - $version = $manager->currentVersion($class); + touch($database); - return $manager->keys()->modelPrefix($classKey, $version) . $id; + return [ + 'driver' => 'sqlite', + 'database' => $database, + 'prefix' => '', + ]; } - protected function redisKeys(string $pattern = '*'): array + /** @return array */ + private function serverDatabaseConfig(string $driver): array { - $manager = $this->cacheManager(); - - return $manager->store()->scanPattern($manager->keys()->prefixed($pattern)); + $port = match ($driver) { + 'pgsql' => 5432, + 'sqlsrv' => 1433, + default => 3306, + }; + $username = match ($driver) { + 'pgsql' => 'postgres', + 'sqlsrv' => 'sa', + default => 'root', + }; + + return [ + 'driver' => $driver, + 'host' => env('TEST_DB_HOST', '127.0.0.1'), + 'port' => env('TEST_DB_PORT', $port), + 'database' => env('TEST_DB_DATABASE', 'normcache'), + 'username' => env('TEST_DB_USERNAME', $username), + 'password' => env('TEST_DB_PASSWORD', ''), + 'charset' => in_array($driver, ['mysql', 'mariadb'], true) ? 'utf8mb4' : 'utf8', + 'collation' => in_array($driver, ['mysql', 'mariadb'], true) + ? 'utf8mb4_unicode_ci' + : null, + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => 'prefer', + 'encrypt' => $driver === 'sqlsrv' ? 'no' : null, + 'trust_server_certificate' => $driver === 'sqlsrv', + ]; } protected function cacheManager(): CacheManager { - return $this->app->make('normcache'); + return $this->app->make(CacheManager::class); } - protected function setClusterMode(bool $enabled): void + protected function cacheStore(): RedisStore { - $this->app->forgetInstance(CacheManager::class); - $this->app->forgetInstance('normcache'); + return $this->app->make(RedisStore::class); } - /** - * Build a standalone CacheManager (not bound in the container) for tests - * that need specific construction parameters like cooldown. - */ - protected function buildManager( - string $connection = 'normcache-test', - ?int $ttl = null, - ?int $queryTtl = null, - string $keyPrefix = 'test:', - int $cooldown = 0, - bool $enabled = true, - bool $dispatchEvents = true, - bool $fallback = false, - bool $fireRetrieved = false, - int $buildingLockTtl = 5, - int $stampedeWaitMs = 200, - int $stampedeWakeTokens = 64, - ): CacheManager { - return $this->app->make(CacheManagerFactory::class)->make([ - 'connection' => $connection, - 'ttl' => $ttl ?? (int) config('normcache.ttl'), - 'query_ttl' => $queryTtl ?? (int) config('normcache.query_ttl'), - 'key_prefix' => $keyPrefix, - 'cooldown' => $cooldown, - 'enabled' => $enabled, - 'events' => $dispatchEvents, - 'fallback' => $fallback, - 'fire_retrieved' => $fireRetrieved, - 'building_lock_ttl' => $buildingLockTtl, - 'stampede_wait_ms' => $stampedeWaitMs, - 'stampede_wake_tokens' => $stampedeWakeTokens, - ]); - } - - /** Assert native == cold == warm for a given query. */ - protected function contract(callable $cached, callable $native, bool $expectNoStrayQueries = false): void + protected function cacheKeys(): CacheKeyBuilder { - $expected = $this->normalize($native()); - $cold = $this->normalize($cached()); - - DB::flushQueryLog(); - DB::enableQueryLog(); - - try { - $warm = $this->normalize($cached()); - $strayQueries = DB::getQueryLog(); - } finally { - DB::disableQueryLog(); - } - - $this->assertSame($expected, $cold, 'cold cache result differs from native Eloquent'); - $this->assertSame($cold, $warm, 'warm cache result differs from cold'); - - if ($expectNoStrayQueries) { - $this->assertSame([], $strayQueries, 'expected no SQL queries on the warm cache path'); - } + return $this->app->make(CacheKeyBuilder::class); } - protected function normalize(mixed $value): mixed + protected function expireEpochMemo(): void { - if ($value instanceof LengthAwarePaginator) { - return [ - 'data' => collect($value->items())->map->toArray()->values()->all(), - 'total' => $value->total(), - 'current_page' => $value->currentPage(), - 'has_more' => $value->hasMorePages(), - ]; - } - - if ($value instanceof Paginator) { - return [ - 'data' => collect($value->items())->map->toArray()->values()->all(), - 'current_page' => $value->currentPage(), - 'has_more' => $value->hasMorePages(), - ]; - } - - if ($value instanceof CursorPaginator) { - return [ - 'data' => collect($value->items())->map->toArray()->values()->all(), - 'has_more' => $value->hasMorePages(), - 'cursor' => $value->cursor()?->toArray(), - ]; - } + $runtime = $this->app->make(CacheRuntime::class); + $readAt = new \ReflectionProperty($runtime, 'epochReadAt'); - if ($value instanceof EloquentCollection) { - return $value->map->toArray()->values()->all(); - } - - if ($value instanceof Collection) { - return $value->all(); // preserve keys (e.g. keyed pluck) - } - - if ($value instanceof Model) { - return $value->toArray(); - } + $this->assertNotNull( + $readAt->getValue($runtime), + 'a read must record when it resolved the epoch, or nothing can expire it', + ); - return $value; + $readAt->setValue($runtime, microtime(true) - 3600); } } diff --git a/tests/Unit/BypassReasonsTest.php b/tests/Unit/BypassReasonsTest.php deleted file mode 100644 index d71c20d..0000000 --- a/tests/Unit/BypassReasonsTest.php +++ /dev/null @@ -1,78 +0,0 @@ -assertSame([], BypassReasons::forQuery($this->makeBaseQuery(null), 'authors')); - } - - public function test_raw_order_is_reported_as_dependency_bypass_reason(): void - { - $query = $this->makeBaseQuery(null); - $query->orders = [['type' => 'Raw', 'sql' => 'CASE WHEN active THEN 0 ELSE 1 END']]; - - $this->assertSame( - ['dependency' => ['raw ORDER expression']], - BypassReasons::forQuery($query, 'authors') - ); - } - - public function test_raw_where_is_reported_as_dependency_bypass_reason(): void - { - $query = $this->makeBaseQuery(null); - $query->wheres = [['type' => 'raw', 'sql' => 'LOWER(name) = ?', 'boolean' => 'and']]; - - $this->assertSame( - ['dependency' => ['raw WHERE expression']], - BypassReasons::forQuery($query, 'authors') - ); - } - - public function test_group_by_is_reported_as_normalization_bypass_reason(): void - { - $query = $this->makeBaseQuery(null); - $query->groups = ['name']; - - $this->assertSame( - ['normalization' => ['GROUP BY']], - BypassReasons::forQuery($query, 'authors') - ); - } - - public function test_calculated_columns_are_reported_as_normalization_bypass_reason(): void - { - $query = $this->makeBaseQuery(['id', '1 + 1 as computed']); - - $this->assertSame( - ['normalization' => ['calculated or raw SELECT expressions']], - BypassReasons::forQuery($query, 'authors', $query->columns) - ); - } - - /** - * @param array|null $columns - */ - private function makeBaseQuery(?array $columns, string $from = 'authors'): Builder - { - $query = new Builder( - connection: $this->createStub(ConnectionInterface::class), - grammar: $this->createStub(Grammar::class), - processor: $this->createStub(Processor::class), - ); - - $query->columns = $columns; - $query->from = $from; - - return $query; - } -} diff --git a/tests/Unit/CacheConfigTest.php b/tests/Unit/CacheConfigTest.php new file mode 100644 index 0000000..d88eb50 --- /dev/null +++ b/tests/Unit/CacheConfigTest.php @@ -0,0 +1,72 @@ +assertSame('cache', $config->connection); + $this->assertSame('', $config->keyPrefix); + $this->assertSame('auto', $config->serializer); + $this->assertSame(604_800, $config->rowTtl); + $this->assertSame(3_600, $config->queryTtl); + $this->assertSame(1000, $config->maxAutoOverlayRows); + $this->assertSame(5, $config->buildingLockTtl); + $this->assertSame(200, $config->stampedeWaitMs); + $this->assertTrue($config->enabled); + $this->assertTrue($config->revalidation); + $this->assertFalse($config->dispatchEvents); + $this->assertFalse($config->debugbar); + } + + public function test_rejects_invalid_serializer(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('serializer'); + + CacheConfig::fromArray(['serializer' => 'json']); + } + + public function test_rejects_hash_tag_characters_in_key_prefix(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('key_prefix'); + + CacheConfig::fromArray(['key_prefix' => 'tenant:{unsafe}:']); + } + + #[DataProvider('invalidSafetyValues')] + public function test_rejects_invalid_safety_values(string $key, int $value): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($key); + + CacheConfig::fromArray([$key => $value]); + } + + public static function invalidSafetyValues(): array + { + return [ + ['building_lock_ttl', 0], + ['row_ttl', 0], + ['query_ttl', 0], + ['auto_overlay_max_rows', -1], + ['stampede_wait_ms', 0], + ]; + } + + public function test_accepts_zero_as_the_automatic_overlay_disable_value(): void + { + $config = CacheConfig::fromArray(['auto_overlay_max_rows' => 0]); + + $this->assertSame(0, $config->maxAutoOverlayRows); + } +} diff --git a/tests/Unit/CacheKeyBuilderTest.php b/tests/Unit/CacheKeyBuilderTest.php new file mode 100644 index 0000000..e6ae412 --- /dev/null +++ b/tests/Unit/CacheKeyBuilderTest.php @@ -0,0 +1,56 @@ +hash . '}'; + + $this->assertSame('app:' . $expectedTag . ':ver', $keys->version($table)); + $this->assertSame('app:' . $expectedTag . ':gen', $keys->generation($table)); + $this->assertStringContainsString($expectedTag, $keys->queryEntry($table, 'u', 'abc')); + $this->assertStringContainsString($expectedTag, $keys->row($table, '4', 'i:42')); + $this->assertStringContainsString($expectedTag, $keys->repairBuild($table, '4', 'batch')); + $this->assertStringContainsString($expectedTag, $keys->repairWake($table, '4', 'batch', 'token')); + } + + public function test_global_tag_and_query_group_keys_have_independent_groups(): void + { + $keys = new CacheKeyBuilder('app:'); + + $this->assertSame('app:{ncm}:epoch', $keys->epoch()); + $this->assertSame('app:{nc:g:taghash}:ver', $keys->tagVersion('taghash')); + $this->assertSame( + 'app:{nc:x:queryhash}:q:gabc', + $keys->queryGroupEntry('queryhash', 'gabc'), + ); + } + + public function test_unique_tables_do_not_retain_process_lifetime_state(): void + { + $keys = new CacheKeyBuilder('app:'); + $before = memory_get_usage(false); + + for ($index = 0; $index < 50_000; $index++) { + $keys->tablePrefix(TableIdentity::fromParts( + 'mysql', + 'tenant', + "tenant_{$index}", + "tenant_{$index}", + '', + 'posts', + )); + } + + $this->assertLessThan(2 * 1_024 * 1_024, memory_get_usage(false) - $before); + } +} diff --git a/tests/Unit/CacheManagerTest.php b/tests/Unit/CacheManagerTest.php deleted file mode 100644 index c805429..0000000 --- a/tests/Unit/CacheManagerTest.php +++ /dev/null @@ -1,822 +0,0 @@ -manager = $this->cacheManager(); - } - - public function test_factory_builds_manager_with_explicit_overrides(): void - { - $manager = $this->app->make(CacheManagerFactory::class)->make([ - 'connection' => 'normcache-test', - 'ttl' => 123, - 'query_ttl' => 45, - 'key_prefix' => 'factory:', - 'cooldown' => 7, - 'enabled' => false, - 'events' => false, - 'fallback' => true, - 'fire_retrieved' => true, - 'building_lock_ttl' => 9, - 'stampede_wait_ms' => 11, - 'stampede_wake_tokens' => 3, - ]); - - $this->assertSame(123, $manager->config()->ttl); - $this->assertSame(45, $manager->config()->queryTtl); - $this->assertSame(7, $manager->config()->cooldown); - $this->assertFalse($manager->isEnabled()); - $this->assertFalse($manager->isEventsEnabled()); - $this->assertTrue($manager->isFallbackEnabled()); - - $classKey = $manager->keys()->classKey(Author::class); - $this->assertSame("{nc}:factory:ver:{$classKey}:", $manager->keys()->verKey($classKey)); - } - - public function test_container_scopes_cache_manager_to_the_current_lifecycle(): void - { - $first = $this->app->make(CacheManager::class); - $first->disable(); - - $this->app->forgetScopedInstances(); - - $second = $this->app->make(CacheManager::class); - - $this->assertNotSame($first, $second); - $this->assertTrue($second->isEnabled()); - } - - public function test_enable_reenables_cache_after_fallback(): void - { - CacheFallback::fallback($this->manager->config(), new \RuntimeException('Redis unavailable')); - - $this->assertFalse($this->manager->isEnabled()); - - $this->manager->enable(); - - $this->assertTrue($this->manager->isEnabled()); - } - - public function test_job_processed_listener_reenables_cache_and_discards_pending_invalidations(): void - { - $before = $this->manager->currentVersion(Author::class); - - DB::beginTransaction(); - - try { - $this->manager->invalidateVersion(new Author); - CacheFallback::fallback($this->manager->config(), new \RuntimeException('Redis unavailable')); - - $this->app['events']->dispatch(new JobProcessed('testing', $this->createStub(Job::class))); - - $this->assertTrue($this->manager->isEnabled()); - - DB::commit(); - } catch (\Throwable $e) { - DB::rollBack(); - - throw $e; - } - - $this->assertSame($before, $this->manager->currentVersion(Author::class)); - } - - public function test_lifecycle_listener_resets_static_cache_metadata(): void - { - $first = CacheKeyBuilder::prototype(Author::class); - - $this->app['events']->dispatch(new Looping('testing', 'default')); - - $second = CacheKeyBuilder::prototype(Author::class); - - $this->assertNotSame($first, $second); - } - - // ------------------------------------------------------------------------- - // RedisStore pass-through (basic I/O tests live in RedisStoreTest) - // ------------------------------------------------------------------------- - - public function test_store_get_many_returns_values_in_key_order(): void - { - $this->manager->store()->set('{nc}:a', 'alpha', 60); - $this->manager->store()->set('{nc}:c', 'gamma', 60); - - $result = $this->manager->store()->getMany(['{nc}:a', '{nc}:b', '{nc}:c']); - - $this->assertSame(['alpha', null, 'gamma'], $result); - } - - // ------------------------------------------------------------------------- - // Version tracking - // ------------------------------------------------------------------------- - - public function test_current_version_returns_zero_before_any_invalidation(): void - { - $this->assertSame(0, $this->manager->currentVersion(Post::class)); - } - - public function test_invalidate_version_increments_version(): void - { - $this->manager->invalidateVersion(new Author); - - $this->assertSame(1, $this->manager->currentVersion(Author::class)); - } - - public function test_invalidate_version_called_twice_increments_twice(): void - { - $this->manager->invalidateVersion(new Author); - $this->manager->invalidateVersion(new Author); - - $this->assertSame(2, $this->manager->currentVersion(Author::class)); - } - - public function test_invalidate_version_schedules_once_with_cooldown(): void - { - $manager = $this->buildManager(cooldown: 60); - $redis = Redis::connection('normcache-test'); - $classKey = $manager->keys()->classKey(Author::class); - $scheduledKey = "{nc}:test:scheduled:{$classKey}:"; - - $manager->invalidateVersion(new Author); - $firstDueAt = $redis->get($scheduledKey); - - $manager->invalidateVersion(new Author); - $secondDueAt = $redis->get($scheduledKey); - - $this->assertNotFalse($firstDueAt); - $this->assertSame($firstDueAt, $secondDueAt); - $this->assertSame(0, $manager->currentVersion(Author::class)); - } - - public function test_current_version_applies_due_scheduled_invalidation(): void - { - $manager = $this->buildManager(cooldown: 60); - - $classKey = $manager->keys()->classKey(Author::class); - Redis::connection('normcache-test')->set( - "{nc}:test:scheduled:{$classKey}:", - (string) ((int) floor(microtime(true) * 1000) - 1000) - ); - - $this->assertSame(1, $manager->currentVersion(Author::class)); - } - - public function test_current_version_always_reads_from_redis(): void - { - Redis::connection('normcache-test')->set('{nc}:test:ver:' . DB::getDefaultConnection() . ':posts:', 99); - - $this->assertSame(99, $this->manager->currentVersion(Post::class)); - } - - public function test_keys_are_prefixed_with_hash_tag_and_key_prefix(): void - { - $manager = $this->buildManager(); - - $classKey = $manager->keys()->classKey(Author::class); - $keys = $manager->keys(); - - $fullVerKey = $keys->verKey($classKey); - $this->assertSame("{nc}:test:ver:{$classKey}:", $fullVerKey); - - $manager->store()->set($fullVerKey, 7, 60); - - $this->assertSame('7', Redis::connection('normcache-test')->get("{nc}:test:ver:{$classKey}:")); - $this->assertSame(7, $manager->currentVersion(Author::class)); - } - - public function test_with_space_reuses_matching_active_space(): void - { - $content = $this->manager->spaceFor(SpacedPost::class); - - $seen = $this->manager->keys()->withSpace( - $content, - fn() => $this->manager->withSpace($content, fn() => $this->manager->keys()->activeSpace()?->name), - ); - - $this->assertSame('content', $seen); - } - - public function test_version_store_current_version_reads_the_space_scoped_version_key(): void - { - $keys = new CacheKeyBuilder('{nc}:', 'test:'); - $store = new RedisStore('normcache-test'); - $versions = new VersionStore($store, $keys); - - $content = new CacheSpace('content', 'nc:content'); - $classKey = $keys->classKey(Post::class); - - $store->setRaw($keys->verKey($classKey, $content), '7', 60); - - $this->assertSame(7, $versions->currentVersion(Post::class, $content)); - $this->assertSame(0, $versions->currentVersion(Post::class)); - } - - // ------------------------------------------------------------------------- - // Flush operations - // ------------------------------------------------------------------------- - - public function test_flush_model_bumps_version_and_clears_related_keys(): void - { - $store = $this->manager->store(); - $postsKey = DB::getDefaultConnection() . ':posts'; - $authorsKey = DB::getDefaultConnection() . ':authors'; - - $store->set("model:{{$postsKey}}:v0:1", ['id' => 1], 3600); - $store->set("model:{{$postsKey}}:v0:2", ['id' => 2], 3600); - $store->set("query:{{$postsKey}}:v1:abc", [1, 2], 3600); - $store->set("model:{{$authorsKey}}:v0:1", ['id' => 1], 3600); - - $versionBefore = $this->manager->currentVersion(Post::class); - - $this->manager->forceFlushModel(Post::class); - - $this->assertGreaterThan($versionBefore, $this->manager->currentVersion(Post::class)); - $this->assertNull($this->modelCacheEntry(Post::class, 1)); - $this->assertNull($this->modelCacheEntry(Post::class, 2)); - $this->assertNotNull($store->get("query:{{$postsKey}}:v1:abc")); - $this->assertNotNull($store->get("model:{{$authorsKey}}:v0:1")); - } - - public function test_flush_all_removes_all_package_keys_and_returns_count(): void - { - $store = $this->manager->store(); - $postsKey = DB::getDefaultConnection() . ':posts'; - - $store->set("{nc}:test:query:{{$postsKey}}:v1:abc", [1, 2], 3600); - $store->set("{nc}:test:model:{{$postsKey}}:v0:1", ['id' => 1], 3600); - $store->set("{nc}:test:ver:{{$postsKey}}:", 3, 3600); - $store->set("{nc}:test:through:{{$postsKey}}:author:v1:v1:abc", [1], 3600); - $store->set("{nc}:test:scheduled:{{$postsKey}}:", (string) ((int) floor(microtime(true) * 1000) + 1000), 3600); - $store->set("{nc}:test:building:query:{{$postsKey}}:v1:abc", 1, 3600); - - $deleted = $this->manager->flushAll(); - - $this->assertSame(6, $deleted); - $this->assertEmpty($this->redisKeys('*')); - } - - public function test_flush_all_returns_zero_when_cache_is_empty(): void - { - $this->assertSame(0, $this->manager->flushAll()); - } - - public function test_flush_all_uses_wildcard_hash_tag_patterns_on_standalone_after_fresh_registry_boot(): void - { - if (env('REDIS_CLUSTER') === 'true' || env('REDIS_CLUSTER') === true) { - $this->markTestSkipped('Standalone wildcard hash-tag flush path is not used in Redis Cluster mode.'); - } - - $this->app->forgetInstance(CacheSpaceRegistry::class); - - $store = $this->manager->store(); - $postsKey = DB::getDefaultConnection() . ':posts'; - - $store->set("{nc:content}:test:query:{{$postsKey}}:v1:abc", [1, 2], 3600); - - $deleted = $this->manager->flushAll(); - - $this->assertSame(1, $deleted); - $this->assertEmpty($store->scanPattern('{nc:content}:test:*')); - } - - public function test_table_space_registration_survives_a_fresh_registry_instance(): void - { - $tableKey = 'testing:authors'; - $registry = $this->app->make(CacheSpaceRegistry::class); - - $this->assertTrue($registry->registerTableDependencies($registry->space('content'), [$tableKey])); - - $this->app->forgetInstance(CacheSpaceRegistry::class); - $freshRegistry = $this->app->make(CacheSpaceRegistry::class); - - $this->assertContains( - 'content', - array_map(fn($space) => $space->name, $freshRegistry->spacesForTable($tableKey)), - ); - } - - public function test_model_invalidation_bumps_registered_table_spaces(): void - { - $tableKey = 'testing:authors'; - $registry = $this->app->make(CacheSpaceRegistry::class); - $content = $registry->space('content'); - - $this->assertTrue($registry->registerTableDependencies($content, [$tableKey])); - - $this->manager->invalidateVersion(new Author); - - $this->assertSame( - '1', - $this->manager->store()->getRaw($this->manager->keys()->verKey($tableKey, $content)), - ); - } - - public function test_immediate_model_invalidation_reuses_memoized_table_space_metadata(): void - { - $connection = new class extends PredisConnection - { - public int $lookups = 0; - - public function __construct() {} - - public function command($method, array $parameters = []) - { - if (strtolower($method) === 'smembers') { - $this->lookups++; - - return []; - } - - return null; - } - }; - $metadataStore = new RedisStore('normcache-test'); - (new ReflectionProperty(RedisStore::class, 'connection'))->setValue($metadataStore, $connection); - $registry = new CacheSpaceRegistry(metadataStore: $metadataStore, metadataKeyPrefix: 'test:'); - $manager = (new CacheManagerFactory($registry, new CacheSpaceResolver($registry)))->make(); - - $manager->invalidateVersion(new Author); - $manager->invalidateVersion(new Author); - - $this->assertSame(1, $connection->lookups); - } - - public function test_force_model_flush_refreshes_table_space_metadata(): void - { - $connection = new class extends PredisConnection - { - public int $lookups = 0; - - public function __construct() {} - - public function command($method, array $parameters = []) - { - if (strtolower($method) === 'smembers') { - return $this->lookups++ === 0 ? [] : ['content']; - } - - return null; - } - }; - $metadataStore = new RedisStore('normcache-test'); - (new ReflectionProperty(RedisStore::class, 'connection'))->setValue($metadataStore, $connection); - $registry = new CacheSpaceRegistry(metadataStore: $metadataStore, metadataKeyPrefix: 'test:'); - $manager = (new CacheManagerFactory($registry, new CacheSpaceResolver($registry)))->make(); - $tableKey = 'testing:authors'; - - $registry->spacesForTable($tableKey); - $manager->forceFlushModel(Author::class); - - $this->assertSame(2, $connection->lookups); - $this->assertSame( - '1', - $manager->store()->getRaw($manager->keys()->verKey($tableKey, $registry->space('content'))), - ); - } - - public function test_lifecycle_listener_refreshes_table_space_mappings_registered_by_another_registry(): void - { - $tableKey = 'testing:authors'; - $registry = $this->app->make(CacheSpaceRegistry::class); - - $this->assertSame( - ['default'], - array_map(fn($space) => $space->name, $registry->spacesForTable($tableKey)), - ); - - $otherRegistry = new CacheSpaceRegistry( - metadataStore: $this->manager->store(), - metadataKeyPrefix: 'test:', - ); - $this->assertTrue($otherRegistry->registerTableDependencies($otherRegistry->space('content'), [$tableKey])); - - $this->app['events']->dispatch(new Looping('testing', 'default')); - - $this->assertContains( - 'content', - array_map(fn($space) => $space->name, $registry->spacesForTable($tableKey)), - ); - } - - public function test_transaction_commit_resolves_table_spaces_registered_after_queueing(): void - { - $tableKey = 'testing:authors'; - $registry = $this->app->make(CacheSpaceRegistry::class); - $content = $registry->space('content'); - - $this->assertSame( - ['default'], - array_map(fn($space) => $space->name, $registry->spacesForTable($tableKey)), - ); - - DB::beginTransaction(); - - try { - $this->manager->invalidateTableVersion('testing', 'authors'); - - $otherRegistry = new CacheSpaceRegistry( - metadataStore: $this->manager->store(), - metadataKeyPrefix: 'test:', - ); - $this->assertTrue($otherRegistry->registerTableDependencies($content, [$tableKey])); - - DB::commit(); - } catch (\Throwable $e) { - DB::rollBack(); - - throw $e; - } - - $versionKey = $this->manager->keys()->verKey($tableKey, $content); - - $this->assertSame('1', $this->manager->store()->getRaw($versionKey)); - } - - public function test_transaction_commit_resolves_model_table_spaces_registered_after_queueing(): void - { - $tableKey = 'testing:authors'; - $registry = $this->app->make(CacheSpaceRegistry::class); - $content = $registry->space('content'); - - DB::beginTransaction(); - - try { - $this->manager->invalidateVersion(new Author); - - $otherRegistry = new CacheSpaceRegistry( - metadataStore: $this->manager->store(), - metadataKeyPrefix: 'test:', - ); - $this->assertTrue($otherRegistry->registerTableDependencies($content, [$tableKey])); - - DB::commit(); - } catch (\Throwable $e) { - DB::rollBack(); - throw $e; - } - - $this->assertSame( - '1', - $this->manager->store()->getRaw($this->manager->keys()->verKey($tableKey, $content)), - ); - } - - public function test_flush_all_can_target_one_space(): void - { - $store = $this->manager->store(); - $postsKey = DB::getDefaultConnection() . ':posts'; - - $store->set("{nc}:test:query:{{$postsKey}}:v1:abc", [1], 3600); - $store->set("{nc:content}:test:query:{{$postsKey}}:v1:def", [2], 3600); - - $deleted = $this->manager->flushAll('content'); - - $this->assertSame(1, $deleted); - $this->assertNotEmpty($store->scanPattern('{nc}:test:*')); - $this->assertEmpty($store->scanPattern('{nc:content}:test:*')); - } - - public function test_flush_all_removes_keys_when_redis_connection_prefix_is_enabled(): void - { - config()->set('database.redis.options.prefix', 'laravel:'); - Redis::purge('normcache-test'); - - $manager = $this->buildManager(); - - $store = $manager->store(); - $postsKey = DB::getDefaultConnection() . ':posts'; - - $store->set("{nc}:test:query:{{$postsKey}}:v1:abc", [1, 2], 3600); - $store->set("{nc}:test:model:{{$postsKey}}:1", ['id' => 1], 3600); - $store->set("{nc}:test:ver:{{$postsKey}}:", 3, 3600); - - $deleted = $manager->flushAll(); - - $this->assertSame(3, $deleted); - $this->assertSame([], $store->scanPattern('{nc}:test:*')); - - Redis::purge('normcache-test'); - config()->set('database.redis.options.prefix', ''); - } - - public function test_targeted_update_bumps_version_and_makes_model_cache_unreachable(): void - { - $author = Author::create(['name' => 'Alice']); - Author::all(); - - $this->assertNotNull($this->modelCacheEntry(Author::class, $author->id)); - - $versionBefore = $this->manager->currentVersion(Author::class); - - Author::whereKey($author->id)->update(['name' => 'Alicia']); - - $this->assertGreaterThan($versionBefore, $this->manager->currentVersion(Author::class)); - $this->assertNull($this->modelCacheEntry(Author::class, $author->id)); - } - - public function test_scheduled_invalidation_key_persists_until_processed(): void - { - $manager = $this->buildManager(cooldown: 1); - - $model = new Author; - $classKey = $manager->keys()->classKey(Author::class); - $scheduledKey = "{nc}:test:scheduled:{$classKey}:"; - $redis = Redis::connection('normcache-test'); - - $manager->invalidateVersion($model); - $firstDueAt = $redis->get($scheduledKey); - $manager->invalidateVersion($model); - - $this->assertSame(1, $redis->exists($scheduledKey)); - $this->assertSame($firstDueAt, $redis->get($scheduledKey)); - } - - public function test_get_models_applies_due_cooldown_before_reading_model_cache(): void - { - $author = Author::create(['name' => 'Fresh']); - $manager = $this->buildManager(cooldown: 60); - $classKey = $manager->keys()->classKey(Author::class); - $store = $manager->store(); - - $store->setRaw($manager->keys()->verKey($classKey), '0', 3600); - $store->set($manager->keys()->modelPrefix($classKey, 0) . $author->id, [ - 'id' => $author->id, - 'name' => 'Stale', - ], 3600); - - $store->setRaw( - $manager->keys()->scheduledKey($classKey), - (string) ((int) floor(microtime(true) * 1000) - 1000), - 3600, - ); - - $models = $manager->modelCache()->getModels([$author->id], Author::class, null, null, Author::query()); - - $this->assertSame('Fresh', $models[0]->name); - $this->assertSame(1, $manager->currentVersion(Author::class)); - $this->assertNull($store->getRaw($manager->keys()->scheduledKey($classKey))); - } - - public function test_store_through_ids_returns_false_on_version_mismatch(): void - { - $store = $this->cacheManager()->store(); - $classKey = $this->cacheManager()->keys()->classKey(Author::class); - - $versionKey = "ver:{{$classKey}}:"; - $throughKey = "through:{{$classKey}}:v5:through-hash"; - $buildingKey = "building:{{$classKey}}:v5:through-hash"; - $wakeKey = "wake:{{$classKey}}:through-hash"; - - $store->setRaw($versionKey, '5', 3600); - $store->setRaw($buildingKey, '1', 3600); - $store->increment($versionKey); - - $stored = $store->storeVersionedPayload( - [$throughKey => json_encode(['i' => ['1'], 't' => ['through-1']], JSON_THROW_ON_ERROR)], - 3600, - [$versionKey], - ['5'], - $buildingKey, - $wakeKey, - ); - - $this->assertFalse($stored); - $this->assertNull($store->getRaw($throughKey)); - $this->assertNull($store->getRaw($buildingKey)); - } - - public function test_store_model_attrs_for_versioned_result_uses_matching_version_key(): void - { - $manager = $this->cacheManager(); - $store = $manager->store(); - $authorKey = $manager->keys()->classKey(Author::class); - $postKey = $manager->keys()->classKey(Post::class); - - $store->setRaw($manager->keys()->verKey($authorKey), '9', 3600); - $store->setRaw($manager->keys()->verKey($postKey), '4', 3600); - - $manager->modelCache()->storeForBuild( - Author::class, - [1 => ['id' => 1, 'name' => 'Fresh']], - new BuildHandle( - versionKeys: [$manager->keys()->verKey($postKey), $manager->keys()->verKey($authorKey)], - expectedVersions: ['4', '9'], - ), - ); - - $this->assertSame( - ['id' => 1, 'name' => 'Fresh'], - $store->get($manager->keys()->modelPrefix($authorKey, 9) . '1'), - ); - } - - public function test_store_model_attrs_for_version_skips_stale_version_write(): void - { - $manager = $this->cacheManager(); - $store = $manager->store(); - $classKey = $manager->keys()->classKey(Author::class); - - $store->setRaw($manager->keys()->verKey($classKey), '5', 3600); - $store->increment($manager->keys()->verKey($classKey)); - - $manager->modelCache()->storeForVersion(Author::class, [1 => ['id' => 1, 'name' => 'Stale']], 5); - - $this->assertNull($store->get($manager->keys()->modelPrefix($classKey, 5) . '1')); - $this->assertNull($store->get($manager->keys()->modelPrefix($classKey, 6) . '1')); - } - - public function test_store_model_attrs_for_version_writes_when_version_matches(): void - { - $manager = $this->cacheManager(); - $store = $manager->store(); - $classKey = $manager->keys()->classKey(Author::class); - - $store->setRaw($manager->keys()->verKey($classKey), '5', 3600); - - $manager->modelCache()->storeForVersion(Author::class, [1 => ['id' => 1, 'name' => 'Fresh']], 5); - - $this->assertSame( - ['id' => 1, 'name' => 'Fresh'], - $store->get($manager->keys()->modelPrefix($classKey, 5) . '1') - ); - } - - public function test_store_query_ids_skips_write_on_version_mismatch(): void - { - $store = $this->cacheManager()->store(); - $classKey = $this->cacheManager()->keys()->classKey(Author::class); - - $versionKey = "ver:{{$classKey}}:"; - $queryKey = "query:{{$classKey}}:v5:abc123"; - $buildingKey = "building:{{$classKey}}:abc123"; - $wakeKey = "wake:{{$classKey}}:abc123"; - - $store->setRaw($versionKey, '5', 3600); - $store->setRaw($buildingKey, '1', 3600); - $store->increment($versionKey); // now 6 - - $store->storeVersionedPayload( - [$queryKey => json_encode(['1', '2', '3'], JSON_THROW_ON_ERROR)], - 3600, - [$versionKey], - ['5'], - $buildingKey, - $wakeKey, - ); - - $this->assertNull($store->getRaw($queryKey), 'CAS skips write when version has been bumped'); - $this->assertNull($store->getRaw($buildingKey), 'Building lock released even when write is skipped'); - } - - public function test_store_query_ids_writes_when_version_matches(): void - { - $store = $this->cacheManager()->store(); - $classKey = $this->cacheManager()->keys()->classKey(Author::class); - - $versionKey = "ver:{{$classKey}}:"; - $queryKey = "query:{{$classKey}}:v5:def456"; - $buildingKey = "building:{{$classKey}}:def456"; - $wakeKey = "wake:{{$classKey}}:def456"; - - $store->setRaw($versionKey, '5', 3600); - $store->setRaw($buildingKey, '1', 3600); - - $store->storeVersionedPayload( - [$queryKey => json_encode(['4', '5', '6'], JSON_THROW_ON_ERROR)], - 3600, - [$versionKey], - ['5'], - $buildingKey, - $wakeKey, - ); - - $this->assertNotNull($store->getRaw($queryKey), 'CAS writes when version still matches'); - $this->assertNull($store->getRaw($buildingKey), 'Building lock released after successful write'); - } - - // ------------------------------------------------------------------------- - // storeQueryIds — corrupt/default path - // ------------------------------------------------------------------------- - - public function test_store_query_ids_writes_normally_with_building_key_and_wake_key(): void - { - $store = $this->manager->store(); - $classKey = $this->manager->keys()->classKey(Author::class); - $buildingKey = "building:{{$classKey}}:write_with_building_key"; - $wakeKey = "wake:{{$classKey}}:write_with_building_key"; - $key = "query:{{$classKey}}:write_with_building_key"; - - $store->setRaw($buildingKey, 'token', 3600); - - $store->storeVersionedPayload( - [$key => json_encode(['1', '2'], JSON_THROW_ON_ERROR)], - 60, - [], - [], - $buildingKey, - $wakeKey, - 'token', - ); - - $this->assertNotNull($store->getRaw($key), 'Non-CAS write should proceed when buildingKey is set'); - } - - // ------------------------------------------------------------------------- - // invalidateMultipleVersions - // ------------------------------------------------------------------------- - - public function test_invalidate_multiple_versions_bumps_version_for_each_class(): void - { - $this->manager->invalidateMultipleVersions([Author::class, Post::class]); - - $this->assertSame(1, $this->manager->currentVersion(Author::class)); - $this->assertSame(1, $this->manager->currentVersion(Post::class)); - } - - public function test_invalidate_multiple_versions_does_nothing_when_disabled(): void - { - $this->manager->disable(); - $this->manager->invalidateMultipleVersions([Author::class]); - - $this->assertSame(0, $this->manager->currentVersion(Author::class)); - } - - public function test_invalidate_multiple_versions_inside_transaction_queues_version_bumps(): void - { - $author = Author::create(['name' => 'Alice']); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id]); - Author::all(); - Post::all(); - - $this->assertNotNull($this->modelCacheEntry(Author::class, $author->id)); - $this->assertNotNull($this->modelCacheEntry(Post::class, $post->id)); - - $versionBefore = $this->manager->currentVersion(Author::class); - - DB::transaction(function () { - $this->manager->invalidateMultipleVersions([Author::class, Post::class], 'testing'); - }); - - $this->assertGreaterThan($versionBefore, $this->manager->currentVersion(Author::class)); - $this->assertNull($this->modelCacheEntry(Author::class, $author->id)); - $this->assertNull($this->modelCacheEntry(Post::class, $post->id)); - } - - public function test_store_versioned_result_does_not_write_or_release_when_building_token_mismatches(): void - { - $store = $this->cacheManager()->store(); - $classKey = $this->cacheManager()->keys()->classKey(Author::class); - - $versionKey = "ver:{{$classKey}}:"; - $resultKey = "result:{{$classKey}}:v5:token-mismatch"; - $buildingKey = "building:{{$classKey}}:v5:token-mismatch"; - $wakeKey = "wake:{{$classKey}}:token-mismatch"; - - $store->setRaw($versionKey, '5', 3600); - $store->setRaw($buildingKey, 'new-owner', 3600); - - $written = $store->storeVersionedPayload( - [$resultKey => $store->serialize([['id' => 1, 'name' => 'Old']])], - 3600, - [$versionKey], - ['5'], - $buildingKey, - $wakeKey, - 'old-owner', - ); - - $this->assertFalse($written); - $this->assertNull($store->getRaw($resultKey), 'Outdated builder must not write when lock token changed'); - $this->assertSame('new-owner', $store->getRaw($buildingKey), 'Outdated builder must not release a newer lock'); - $this->assertNull($store->getRaw($wakeKey), 'Outdated builder must not wake waiters for a lock it no longer owns'); - } -} diff --git a/tests/Unit/CachePlanSpaceValidatorTest.php b/tests/Unit/CachePlanSpaceValidatorTest.php deleted file mode 100644 index 37ef497..0000000 --- a/tests/Unit/CachePlanSpaceValidatorTest.php +++ /dev/null @@ -1,92 +0,0 @@ -validate($plan, $builder, $builder->getModel()); - - $this->assertSame(CacheStrategy::LiveQuery, $validated->strategy); - $this->assertSame('content', $validated->space?->name); - $this->assertStringContainsString(CatalogTag::class, $validated->bypassReasons['space'][0]); - $this->assertArrayNotHasKey('dependency', $validated->bypassReasons); - } - - public function test_failed_table_space_registration_uses_space_bypass_category(): void - { - $connection = new class extends PredisConnection - { - public function __construct() {} - - public function command($method, array $parameters = []) - { - return match (strtolower($method)) { - 'smembers' => [], - 'sadd' => throw new RuntimeException('SADD denied'), - default => null, - }; - } - }; - $store = new RedisStore('normcache-test'); - (new \ReflectionProperty(RedisStore::class, 'connection'))->setValue($store, $connection); - - $registry = new CacheSpaceRegistry(metadataStore: $store); - $validator = new CachePlanSpaceValidator($registry, new CacheSpaceResolver($registry)); - $builder = SpacedPost::query(); - $plan = CachePlan::result( - CacheOperation::Models, - new DependencySet(models: [SpacedPost::class], tables: ['testing:authors']), - ); - - $validated = $validator->validate($plan, $builder, $builder->getModel()); - - $this->assertSame( - ['failed to register table-space dependencies'], - $validated->bypassReasons['space'], - ); - $this->assertArrayNotHasKey('dependency', $validated->bypassReasons); - } - - public function test_cross_space_dependencies_can_throw(): void - { - $registry = new CacheSpaceRegistry; - $validator = new CachePlanSpaceValidator( - $registry, - new CacheSpaceResolver($registry), - crossSpaceBehavior: 'throw', - ); - $builder = SpacedPost::query(); - $plan = CachePlan::result( - CacheOperation::Models, - new DependencySet(models: [SpacedPost::class, CatalogTag::class]), - ); - - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('cross-space dependencies for space [content]'); - - $validator->validate($plan, $builder, $builder->getModel()); - } -} diff --git a/tests/Unit/CachePlannerTest.php b/tests/Unit/CachePlannerTest.php deleted file mode 100644 index c84a44c..0000000 --- a/tests/Unit/CachePlannerTest.php +++ /dev/null @@ -1,134 +0,0 @@ -prepareCacheExecution(); - $plan = $this->planner()->plan($prepared->builder, $prepared->base, CachePlanContext::models()); - - $this->assertTrue($plan->usesModelCache()); - $this->assertSame([], $plan->bypassReasons); - } - - public function test_active_soft_delete_scope_allows_a_direct_primary_key_plan(): void - { - $prepared = Post::whereKey([3, 1, 2])->prepareCacheExecution(); - $plan = $this->planner()->plan($prepared->builder, $prepared->base, CachePlanContext::models()); - - $this->assertSame(CacheStrategy::DirectModels, $plan->strategy); - $this->assertSame([1, 2, 3], $plan->primaryKeys); - } - - public function test_removed_soft_delete_scope_does_not_ignore_a_manual_null_constraint(): void - { - $prepared = Post::withTrashed()->whereNull('posts.deleted_at')->whereKey([3, 1, 2])->prepareCacheExecution(); - $plan = $this->planner()->plan($prepared->builder, $prepared->base, CachePlanContext::models()); - - $this->assertSame(CacheStrategy::ModelIndex, $plan->strategy); - } - - public function test_raw_order_bypasses_with_human_readable_reason(): void - { - $prepared = Author::orderByRaw('CASE WHEN id = ? THEN 0 ELSE 1 END', [1])->prepareCacheExecution(); - $plan = $this->planner()->plan($prepared->builder, $prepared->base, CachePlanContext::models()); - - $this->assertSame(CacheStrategy::LiveQuery, $plan->strategy); - $this->assertContains('raw ORDER expression', $plan->bypassReasons['dependency']); - } - - public function test_global_opt_out_precedes_query_analysis(): void - { - $prepared = Author::withoutCache()->orderByRaw('id')->prepareCacheExecution(); - $plan = $this->planner()->plan($prepared->builder, $prepared->base, CachePlanContext::models()); - - $this->assertSame(['opted_out' => ['withoutCache() was called explicitly']], $plan->bypassReasons); - } - - public function test_simple_scalar_uses_result_strategy(): void - { - $prepared = Author::where('name', 'Alice')->prepareCacheExecution(); - $plan = $this->planner()->plan($prepared->builder, $prepared->base, CachePlanContext::scalar(['*'])); - - $this->assertSame(CacheStrategy::Result, $plan->strategy); - $this->assertSame([Author::class], $plan->dependencies->models); - } - - public function test_raw_scalar_dependency_clause_bypasses(): void - { - $prepared = Author::whereRaw('name = ?', ['Alice'])->prepareCacheExecution(); - $plan = $this->planner()->plan($prepared->builder, $prepared->base, CachePlanContext::scalar(['*'])); - - $this->assertSame(CacheStrategy::LiveQuery, $plan->strategy); - } - - public function test_scalar_context_dependency_reason_is_merged_into_the_inspection(): void - { - $prepared = Author::where('name', 'Alice')->prepareCacheExecution(); - $plan = $this->planner()->plan( - $prepared->builder, - $prepared->base, - CachePlanContext::scalar(['*'], ['dependency' => ['custom subquery could not be inferred']]), - ); - - $this->assertSame(CacheStrategy::LiveQuery, $plan->strategy); - $this->assertSame(['custom subquery could not be inferred'], $plan->bypassReasons['dependency']); - } - - public function test_grouped_scalar_preserves_result_cache_behavior(): void - { - $prepared = Author::groupBy('name')->having('name', '!=', '')->prepareCacheExecution(); - $plan = $this->planner()->plan($prepared->builder, $prepared->base, CachePlanContext::scalar(['name'])); - - $this->assertSame(CacheStrategy::Result, $plan->strategy); - } - - public function test_scalar_join_uses_inferred_table_dependency(): void - { - $prepared = Author::join('posts', 'posts.author_id', '=', 'authors.id')->prepareCacheExecution(); - $plan = $this->planner()->plan($prepared->builder, $prepared->base, CachePlanContext::scalar(['*'])); - - $this->assertSame(CacheStrategy::Result, $plan->strategy); - $this->assertContains('testing:posts', $plan->dependencies->tables); - } - - public function test_locked_scalar_query_bypasses(): void - { - $prepared = Author::lockForUpdate()->prepareCacheExecution(); - $plan = $this->planner()->plan($prepared->builder, $prepared->base, CachePlanContext::scalar(['*'])); - - $this->assertSame(CacheStrategy::LiveQuery, $plan->strategy); - } - - public function test_exists_query_uses_query_derived_dependency(): void - { - $prepared = Author::whereHas('posts')->prepareCacheExecution(); - $plan = $this->planner()->plan($prepared->builder, $prepared->base, CachePlanContext::models()); - - $this->assertSame(CacheStrategy::Result, $plan->strategy); - $this->assertContains('testing:posts', $plan->dependencies->tables); - } - - public function test_exists_with_nested_raw_where_bypasses(): void - { - $prepared = Author::whereHas('posts', fn($query) => $query->whereRaw('views > 0'))->prepareCacheExecution(); - $plan = $this->planner()->plan($prepared->builder, $prepared->base, CachePlanContext::models()); - - $this->assertSame(CacheStrategy::LiveQuery, $plan->strategy); - $this->assertContains('raw WHERE expression', $plan->bypassReasons['dependency']); - } - - private function planner(): CachePlanner - { - return $this->app->make(CachePlanner::class); - } -} diff --git a/tests/Unit/CacheableBuilderPlanTest.php b/tests/Unit/CacheableBuilderPlanTest.php deleted file mode 100644 index 5377284..0000000 --- a/tests/Unit/CacheableBuilderPlanTest.php +++ /dev/null @@ -1,45 +0,0 @@ -where('name', 'A'); - $prepared = $builder->prepareCacheExecution(); - $context = fn() => CachePlanContext::models( - ProjectionClassifier::resolve($prepared->base, ['*']), - selectAll: true, - ); - - $this->assertEquals( - $builder->cachePlan($prepared->base, $context()), - $builder->planPrepared($prepared, $context), - ); - } - - public function test_plan_prepared_infers_join_table_dependency(): void - { - $builder = Post::query() - ->join('authors', 'authors.id', '=', 'posts.author_id') - ->select('posts.*'); - $prepared = $builder->prepareCacheExecution(); - - $plan = $builder->planPrepared( - $prepared, - fn() => CachePlanContext::models( - ProjectionClassifier::resolve($prepared->base, ['*']), - selectAll: false, - ), - ); - - $this->assertContains('testing:authors', $plan->dependencies->tables); - } -} diff --git a/tests/Unit/ConnectionSourceResolverTest.php b/tests/Unit/ConnectionSourceResolverTest.php new file mode 100644 index 0000000..c42ca70 --- /dev/null +++ b/tests/Unit/ConnectionSourceResolverTest.php @@ -0,0 +1,52 @@ +createMock(Connection::class); + $connection->expects($this->once()) + ->method('getConfig') + ->with(null) + ->willReturn([ + 'name' => 'testing', + 'normcache_scope' => 'shared-source', + ]); + + $this->assertSame('shared-source', ConnectionSourceResolver::resolve($connection)); + } + + public function test_connection_name_is_the_default_scope(): void + { + $connection = $this->createMock(Connection::class); + $connection->expects($this->once()) + ->method('getConfig') + ->with(null) + ->willReturn(['name' => 'testing']); + $connection->expects($this->once()) + ->method('getName') + ->willReturn('testing'); + + $this->assertSame('testing', ConnectionSourceResolver::resolve($connection)); + } + + public function test_invalid_or_empty_scopes_are_unidentifiable(): void + { + $invalid = $this->createStub(Connection::class); + $invalid->method('getConfig')->willReturn([ + 'name' => 'testing', + 'normcache_scope' => [], + ]); + $unnamed = $this->createStub(Connection::class); + $unnamed->method('getConfig')->willReturn([]); + + $this->assertNull(ConnectionSourceResolver::resolve($invalid)); + $this->assertNull(ConnectionSourceResolver::resolve($unnamed)); + } +} diff --git a/tests/Unit/DeleteDependencyResolverTest.php b/tests/Unit/DeleteDependencyResolverTest.php new file mode 100644 index 0000000..6db272f --- /dev/null +++ b/tests/Unit/DeleteDependencyResolverTest.php @@ -0,0 +1,108 @@ + 'testing', + 'driver' => 'sqlite', + 'database' => $path, + ], + ); + $schema = $connection->getSchemaBuilder(); + $schema->create('parents', function (Blueprint $table): void { + $table->id(); + }); + $schema->create('children', function (Blueprint $table): void { + $table->id(); + $table->foreignId('parent_id')->constrained('parents')->cascadeOnDelete(); + }); + + try { + $tables = new TableIdentityResolver; + $resolver = new DeleteDependencyResolver($tables); + $parent = $tables->resolve($connection, 'parents'); + + $this->assertNotNull($parent); + $this->assertSame( + ['children'], + $this->affectedTableNames($resolver, $connection, $parent), + ); + + $schema->create('grandchildren', function (Blueprint $table): void { + $table->id(); + $table->foreignId('child_id')->constrained('children')->cascadeOnDelete(); + }); + + $this->assertSame( + ['children'], + $this->affectedTableNames($resolver, $connection, $parent), + ); + + $resolver->clear(); + + $this->assertSame( + ['children', 'grandchildren'], + $this->affectedTableNames($resolver, $connection, $parent), + ); + } finally { + unset($connection); + unlink($path); + } + } + + public function test_failed_introspection_returns_null_and_is_retried(): void + { + $connection = Mockery::mock(Connection::class); + $connection->shouldReceive('getConfig')->twice()->andReturn(['name' => 'testing']); + $connection->shouldReceive('getName')->andReturn('testing'); + $connection->shouldReceive('getDriverName')->twice()->andReturn('sqlite'); + $connection->shouldReceive('getDatabaseName')->twice()->andReturn('/tmp/database.sqlite'); + $connection->shouldReceive('getTablePrefix')->times(4)->andReturn(''); + $connection->shouldReceive('getSchemaBuilder')->twice()->andThrow( + new \RuntimeException('Schema metadata is unavailable.'), + ); + $parent = TableIdentity::fromParts( + driver: 'sqlite', + connection: 'testing', + database: '/tmp/database.sqlite', + schema: 'main', + prefix: '', + table: 'parents', + ); + $resolver = new DeleteDependencyResolver(new TableIdentityResolver); + + $this->assertNull($resolver->affectedByDelete($connection, $parent)); + $this->assertNull($resolver->affectedByDelete($connection, $parent)); + } + + /** @return list */ + private function affectedTableNames( + DeleteDependencyResolver $resolver, + Connection $connection, + TableIdentity $parent, + ): array { + return array_map( + static fn(TableIdentity $table): string => $table->table, + $resolver->affectedByDelete($connection, $parent) ?? [], + ); + } +} diff --git a/tests/Unit/DependencyAnalyzerTest.php b/tests/Unit/DependencyAnalyzerTest.php new file mode 100644 index 0000000..60099fe --- /dev/null +++ b/tests/Unit/DependencyAnalyzerTest.php @@ -0,0 +1,189 @@ +getConnectionName() === 'primary' + ? 'primary_users' + : 'reporting_users'; + } +} + +final class DependencyAnalyzerTest extends UnitTestCase +{ + private string $primaryDatabase = ''; + + private string $reportingDatabase = ''; + + protected function defineEnvironment($app): void + { + parent::defineEnvironment($app); + + $this->primaryDatabase = (string) tempnam(sys_get_temp_dir(), 'nc_primary_'); + $this->reportingDatabase = (string) tempnam(sys_get_temp_dir(), 'nc_reporting_'); + + foreach ( + ['primary' => $this->primaryDatabase, 'reporting' => $this->reportingDatabase] as $name => $database + ) { + $app['config']->set('database.connections.' . $name, [ + 'driver' => 'sqlite', + 'database' => $database, + 'prefix' => '', + ]); + } + } + + protected function tearDown(): void + { + parent::tearDown(); + + foreach ([$this->primaryDatabase, $this->reportingDatabase] as $database) { + if ($database !== '' && file_exists($database)) { + unlink($database); + } + } + } + + public function test_declared_model_dependency_uses_the_active_query_connection(): void + { + $this->createUsersTables(); + + $resolver = app(TableIdentityResolver::class); + $primary = $resolver->resolve(DB::connection('primary'), 'users'); + $reporting = $resolver->resolve(DB::connection('reporting'), 'users'); + + $this->assertNotSame( + $primary?->hash, + $reporting?->hash, + 'the two connections must resolve to distinct identities for this test to mean anything', + ); + + $query = ReportingUser::on('primary')->toBase() + ->from('users') + ->dependsOn([ReportingUser::class]); + $this->assertInstanceOf(QueryBuilder::class, $query); + $analysis = app(DependencyAnalyzer::class) + ->analyze(DB::connection('primary'), $query); + + $this->assertSame($primary?->hash, $analysis->root?->hash); + $this->assertSame( + [$primary?->hash], + array_map(static fn($table): string => $table->hash, $analysis->tables), + ); + } + + public function test_declared_model_dependency_without_a_connection_uses_the_querying_connection(): void + { + $this->createUsersTables(); + + $reporting = app(TableIdentityResolver::class) + ->resolve(DB::connection('reporting'), 'users'); + + $query = AmbientUser::on('reporting')->toBase() + ->from('users') + ->dependsOn([AmbientUser::class]); + $this->assertInstanceOf(QueryBuilder::class, $query); + $analysis = app(DependencyAnalyzer::class) + ->analyze(DB::connection('reporting'), $query); + + $this->assertSame($reporting?->hash, $analysis->root?->hash); + } + + public function test_cross_database_eloquent_subquery_captures_laravel_normalized_builder(): void + { + $outer = AmbientUser::on('primary')->toBase()->from('users'); + $this->assertInstanceOf(QueryBuilder::class, $outer); + + $outer->selectSub(ReportingUser::query(), 'reporting_user'); + $expression = end($outer->columns); + $this->assertInstanceOf(Expression::class, $expression); + + $captured = $outer->capturedSubquery($expression); + $this->assertNotNull($captured); + $capturedSql = $captured->getGrammar()->compileSelect($captured); + $expressionSql = (string) $expression->getValue($outer->getGrammar()); + + $this->assertStringContainsString($capturedSql, $expressionSql); + $this->assertNotSame('users', $captured->from); + } + + public function test_declared_model_observes_the_active_connection_when_resolving_its_table(): void + { + Schema::connection('primary')->create('primary_users', function ($table): void { + $table->id(); + }); + Schema::connection('reporting')->create('reporting_users', function ($table): void { + $table->id(); + }); + + $resolver = app(TableIdentityResolver::class); + $primary = $resolver->resolve(DB::connection('primary'), 'primary_users'); + $query = ConnectionAwareTableUser::on('primary')->toBase() + ->fromRaw('(select 1) as derived') + ->dependsOn([ConnectionAwareTableUser::class]); + $this->assertInstanceOf(QueryBuilder::class, $query); + $analysis = app(DependencyAnalyzer::class) + ->analyze(DB::connection('primary'), $query); + + $this->assertSame($primary?->hash, $analysis->root?->hash); + } + + public function test_raw_source_without_a_model_is_unidentifiable_rather_than_fatal(): void + { + $this->createUsersTables(); + + $derived = AmbientUser::on('primary')->toBase()->newQuery(); + $this->assertInstanceOf(QueryBuilder::class, $derived); + $this->assertNull($derived->modelClass()); + + $derived->fromRaw('(select 1) as derived'); + $analysis = app(DependencyAnalyzer::class) + ->analyze(DB::connection('primary'), $derived); + + $this->assertNull($analysis->root); + $this->assertSame('unidentifiable_dependency', $analysis->bypassReason); + } + + private function createUsersTables(): void + { + foreach (['primary', 'reporting'] as $connection) { + Schema::connection($connection)->create('users', function ($table): void { + $table->id(); + $table->string('name')->nullable(); + }); + } + } +} diff --git a/tests/Unit/DependencyResolverTest.php b/tests/Unit/DependencyResolverTest.php deleted file mode 100644 index 97dfc71..0000000 --- a/tests/Unit/DependencyResolverTest.php +++ /dev/null @@ -1,52 +0,0 @@ -resolve( - modelClass: Post::class, - context: new CachePlanContext(CacheOperation::Models), - inspection: $inspection, - explicitModels: [Author::class], - explicitTables: [], - hasExplicit: true, - ); - - $this->assertFalse( - $resolved->safe, - 'An explicit dependsOn() must not silently discard an unresolvable inferred dependency.' - ); - $this->assertContains('joined subquery dependency could not be inferred', $resolved->reasons); - } - - public function test_explicit_dependency_with_safe_inference_stays_safe(): void - { - $resolved = (new DependencyResolver)->resolve( - modelClass: Post::class, - context: new CachePlanContext(CacheOperation::Models), - inspection: new QueryInspection, - explicitModels: [Author::class], - explicitTables: [], - hasExplicit: true, - ); - - $this->assertTrue($resolved->safe); - $this->assertSame([Post::class, Author::class], $resolved->models); - } -} diff --git a/tests/Unit/FailureReporterTest.php b/tests/Unit/FailureReporterTest.php new file mode 100644 index 0000000..938df3d --- /dev/null +++ b/tests/Unit/FailureReporterTest.php @@ -0,0 +1,115 @@ +createMock(LoggerInterface::class); + $logger->expects($this->exactly(4))->method('log'); + $reporter = new FailureReporter($logger); + $exception = new \RuntimeException('redis is down'); + + $reporter->invalidationFailed($exception, $this->table('posts'), 'version', ['1']); + $reporter->invalidationFailed($exception, $this->table('posts'), 'version', ['1']); + $reporter->invalidationFailed($exception, $this->table('authors'), 'generation', ['2']); + $reporter->observationFailed($exception, 'hit'); + $reporter->observationFailed($exception, 'hit'); + $reporter->observationFailed($exception, 'miss'); + } + + public function test_invalidation_failure_has_stable_structured_context(): void + { + $table = $this->table('posts'); + $exception = new \RuntimeException('redis is down'); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('log') + ->with( + LogLevel::CRITICAL, + 'Invalidation failed; cached reads may be stale.', + [ + 'component' => 'normcache', + 'event' => 'invalidation_failed', + 'connection' => 'testing', + 'table' => 'main.posts', + 'table_hash' => $table->hash, + 'mode' => 'version', + 'token_count' => 2, + 'exception' => $exception, + ], + ); + + (new FailureReporter($logger))->invalidationFailed($exception, $table, 'version', ['1', '2']); + } + + private function table(string $name): TableIdentity + { + return TableIdentity::fromParts( + driver: 'sqlite', + connection: 'testing', + database: 'app', + schema: 'main', + prefix: '', + table: $name, + ); + } + + public function test_reporting_survives_an_unreachable_log_channel(): void + { + $reporter = new FailureReporter($this->throwingLogger()); + + $reporter->invalidationFailed( + new \RuntimeException('redis is down'), + $this->table('posts'), + 'version', + ['1'], + ); + + $this->assertTrue(true, 'reporting returned without propagating the logging failure'); + } + + public function test_cache_unavailable_survives_a_failing_exception_handler(): void + { + $this->app->bind(ExceptionHandler::class, static fn(): ExceptionHandler => new class implements ExceptionHandler + { + public function report(\Throwable $e): void + { + throw new \RuntimeException('The exception handler is broken.'); + } + + public function shouldReport(\Throwable $e): bool + { + return true; + } + + public function render($request, \Throwable $e) + { + return null; + } + + public function renderForConsole($output, \Throwable $e): void {} + }); + + (new FailureReporter($this->throwingLogger()))->cacheUnavailable(new \RuntimeException('redis is down')); + + $this->assertTrue(true, 'cacheUnavailable() returned without propagating the reporting failure'); + } + + private function throwingLogger(): LoggerInterface + { + $logger = $this->createStub(LoggerInterface::class); + $logger->method('log') + ->willThrowException(new \RuntimeException('The log channel is unreachable.')); + + return $logger; + } +} diff --git a/tests/Unit/IdentityStabilityTest.php b/tests/Unit/IdentityStabilityTest.php new file mode 100644 index 0000000..b103f93 --- /dev/null +++ b/tests/Unit/IdentityStabilityTest.php @@ -0,0 +1,58 @@ +assertNotSame( + TableIdentity::encodeFields(['a:b', 'c']), + TableIdentity::encodeFields(['a', 'b:c']), + ); + $this->assertSame('2:ab0:1:c', TableIdentity::encodeFields(['ab', '', 'c'])); + } + + public function test_query_hash_is_stable_per_route_for_prepared_bindings(): void + { + $identity = new QueryIdentity; + + $hash = static fn(string $route): string => $identity->hash( + route: $route, + rootHash: 'roothash', + dependencyHashes: ['deps-b', 'deps-a'], + sql: 'select * from "posts" where "id" = ?', + bindings: [42, 'x', null, 1, 1.5], + namespace: 'u', + operation: 'select', + ); + + $this->assertSame('548acc7208c045aac641546e36bdbffc', $hash(QueryPlan::CANONICAL)); + $this->assertSame('f982b6a4ee8b0a2c9854ce4dffe05908', $hash(QueryPlan::RESULT)); + $this->assertSame('09bf37953b8677842e6d58ac178f35eb', $hash(QueryPlan::QUERY_GROUP)); + $this->assertSame('1427a690b77f0404a4ecbc02f48a3dbc', $hash(QueryPlan::DIRECT_PK)); + } + + public function test_tag_and_table_digests_are_stable(): void + { + $identity = new QueryIdentity; + + $this->assertSame('70cf626fa4c84d4ae1d3931451bf301c', $identity->tagHash('homepage')); + $this->assertSame( + '06a3bba515102093afe9de576d942169', + TableIdentity::fromParts( + driver: 'mysql', + connection: 'conn', + database: 'db', + schema: 'sch', + prefix: 'pre', + table: 'posts', + )->hash, + ); + } +} diff --git a/tests/Unit/MutationKeyExtractorTest.php b/tests/Unit/MutationKeyExtractorTest.php new file mode 100644 index 0000000..4d4833a --- /dev/null +++ b/tests/Unit/MutationKeyExtractorTest.php @@ -0,0 +1,100 @@ +toBase()->whereIn('id', [3, 1, 2]); + + $tokens = (new MutationKeyExtractor)->extract( + $query, + new PrimaryKeyMetadata('id', PrimaryKeyMetadata::INTEGER), + ); + + $this->assertSame(['i:1', 'i:2', 'i:3'], $tokens); + } + + public function test_where_integer_in_raw_produces_a_proven_pk_set(): void + { + $query = RawPost::query()->toBase()->whereIntegerInRaw('id', [3, 1, 2]); + + $tokens = (new MutationKeyExtractor)->extract( + $query, + new PrimaryKeyMetadata('id', PrimaryKeyMetadata::INTEGER), + ); + + $this->assertSame(['i:1', 'i:2', 'i:3'], $tokens); + } + + public function test_raw_predicates_are_not_treated_as_precise(): void + { + $query = RawPost::query()->toBase() + ->where('id', 1) + ->whereRaw('1 = 1 or id = 2'); + + $tokens = (new MutationKeyExtractor)->extract( + $query, + new PrimaryKeyMetadata('id', PrimaryKeyMetadata::INTEGER), + ); + + $this->assertNull($tokens); + } + + public function test_joined_mutations_are_not_treated_as_precise(): void + { + $query = RawPost::query()->toBase() + ->join('authors', 'authors.id', '=', 'posts.author_id') + ->where('posts.id', 1); + + $tokens = (new MutationKeyExtractor)->extract( + $query, + new PrimaryKeyMetadata('id', PrimaryKeyMetadata::INTEGER), + ); + + $this->assertNull($tokens); + } + + public function test_string_primary_key_mutation_extracts_old_and_assigned_tokens(): void + { + $query = RawPost::query()->toBase()->where('uuid_items.id', 'old-id'); + $primaryKey = new PrimaryKeyMetadata('id', PrimaryKeyMetadata::STRING); + + $tokens = (new MutationKeyExtractor)->extractMutation( + $query, + $primaryKey, + ['id' => 'new-id'], + ); + $expected = [ + $primaryKey->token('old-id'), + $primaryKey->token('new-id'), + ]; + sort($expected, SORT_STRING); + + $this->assertSame($expected, $tokens); + } + + public function test_string_where_in_accepts_uuid_ulid_and_large_keys(): void + { + $values = [ + 'b8f8702c-4734-45e0-a548-18e3c66f6f9c', + '01J0QZ5J8Y5RWV2M1Y6N7P8Q9R', + str_repeat('large-key-', 128), + ]; + $query = RawPost::query()->toBase()->whereIn('uuid_items.id', $values); + $primaryKey = new PrimaryKeyMetadata('id', PrimaryKeyMetadata::STRING); + $expected = array_map($primaryKey->token(...), $values); + sort($expected, SORT_STRING); + + $this->assertSame( + $expected, + (new MutationKeyExtractor)->extract($query, $primaryKey), + ); + } +} diff --git a/tests/Unit/Payload/ThroughIndexAdapterTest.php b/tests/Unit/Payload/ThroughIndexAdapterTest.php deleted file mode 100644 index 0652f09..0000000 --- a/tests/Unit/Payload/ThroughIndexAdapterTest.php +++ /dev/null @@ -1,28 +0,0 @@ - [1, 2], 'throughKeys' => ['through-1', 'through-2']]; - - $this->assertSame('{"i":["1","2"],"t":["through-1","through-2"]}', $adapter->encode($payload)); - $this->assertSame([ - 'ids' => ['1', '2'], - 'throughKeys' => ['through-1', 'through-2'], - ], $adapter->decode($adapter->encode($payload))->payload); - } - - public function test_rejects_misaligned_ids_and_through_keys(): void - { - $adapter = new ThroughIndexAdapter; - - $this->assertFalse($adapter->decode('{"i":[1,2],"t":["through-1"]}')->valid); - } -} diff --git a/tests/Unit/PayloadCodecTest.php b/tests/Unit/PayloadCodecTest.php new file mode 100644 index 0000000..17fc545 --- /dev/null +++ b/tests/Unit/PayloadCodecTest.php @@ -0,0 +1,250 @@ +decode($codec->encode('update', ['views', 'title'], true)); + + $this->assertTrue($record->valid); + $this->assertSame('update', $record->mutation); + $this->assertSame(['title', 'views'], $record->columns); + $this->assertTrue($record->precise); + } + + public function test_change_record_codec_round_trips_an_empty_column_list(): void + { + $codec = new ChangeRecordCodec; + + $record = $codec->decode($codec->encode('update', [], true)); + + $this->assertTrue($record->valid); + $this->assertSame([], $record->columns); + } + + #[DataProvider('malformedChangeRecords')] + public function test_change_record_codec_rejects_malformed_payloads(string $payload): void + { + $this->assertFalse((new ChangeRecordCodec)->decode($payload)->valid); + } + + /** @return iterable */ + public static function malformedChangeRecords(): iterable + { + yield 'not json' => ['not-a-payload']; + yield 'not an object' => ['[1,2,3]']; + yield 'unknown format' => ['{"f":2,"m":"update","c":[],"p":true}']; + yield 'missing format' => ['{"m":"update","c":[],"p":true}']; + yield 'string format' => ['{"f":"1","m":"update","c":[],"p":true}']; + yield 'missing mutation' => ['{"f":1,"c":[],"p":true}']; + yield 'columns not a list' => ['{"f":1,"m":"update","c":"title","p":true}']; + yield 'non string column' => ['{"f":1,"m":"update","c":[7],"p":true}']; + yield 'precise not a bool' => ['{"f":1,"m":"update","c":[],"p":1}']; + yield 'missing precise' => ['{"f":1,"m":"update","c":[]}']; + } + + public function test_raw_result_codec_owns_native_row_conversion(): void + { + $row = new \stdClass; + $row->id = 7; + $row->numeric = '007'; + $row->binary = "\x00\xff"; + $row->nullable = null; + + $codec = new RawResultCodec(new CacheSerializer); + + $first = $codec->decode($codec->encode([$row], '7', '1'))->rows[0]; + $second = $codec->decode($codec->encode([$row], '7', '1'))->rows[0]; + + $this->assertSame(['id', 'numeric', 'binary', 'nullable'], array_keys((array) $first)); + $this->assertSame(7, $first->id); + $this->assertSame('007', $first->numeric); + $this->assertSame("\x00\xff", $first->binary); + $this->assertNull($first->nullable); + $this->assertNotSame($first, $second); + } + + public function test_the_serializer_only_decodes_marked_payloads(): void + { + $serializer = new CacheSerializer('php'); + + $this->assertSame(['a' => 1], $serializer->decode($serializer->encode(['a' => 1]))); + $this->assertNull($serializer->decode(serialize(['a' => 1]))); + $this->assertNull($serializer->decode('not a payload at all')); + $this->assertNull($serializer->decode('')); + } + + public function test_raw_result_codec_validates_envelopes(): void + { + $codec = new RawResultCodec(new CacheSerializer); + $row = (object) ['id' => 1, 'amount' => '01.20']; + + $encoded = $codec->encode([$row], '7', '1', ['dep' => '12'], '3'); + $decoded = $codec->decode($encoded); + + $this->assertTrue($decoded->valid); + $this->assertSame('7', $decoded->epoch); + $this->assertSame(['dep' => '12'], $decoded->versions); + $this->assertSame('3', $decoded->tagVersion); + $this->assertSame('01.20', $decoded->rows[0]->amount); + $this->assertFalse($codec->decode('not-a-payload')->valid); + } + + public function test_row_codec_returns_exactly_one_object_for_every_valid_payload(): void + { + $codec = new RawResultCodec(new CacheSerializer); + $row = (object) ['id' => 7, 'title' => 'Post']; + + $decoded = $codec->decodeRow($codec->encodeRow($row, '9')); + + $this->assertTrue($decoded->valid); + $this->assertCount(1, $decoded->rows); + $this->assertInstanceOf(\stdClass::class, $decoded->rows[0]); + $this->assertSame(7, $decoded->rows[0]->id); + } + + public function test_row_codec_rejects_a_non_canonical_primary_key_token(): void + { + $codec = new RawResultCodec(new CacheSerializer); + $metadata = new PrimaryKeyMetadata('id', PrimaryKeyMetadata::INTEGER); + $payload = $codec->encodeRow((object) ['id' => 7], '9'); + + $this->assertTrue($codec->decodeRow($payload, $metadata, 'i:7')->valid); + $this->assertFalse($codec->decodeRow($payload, $metadata, 's:7')->valid); + } + + public function test_membership_codec_preserves_order_duplicates_and_string_counters(): void + { + $codec = new MembershipCodec; + $encoded = $codec->encode( + epoch: '7', + generation: '4', + rootVersion: '1', + ids: ['i:42', 'i:7', 'i:42'], + versions: ['b' => '2', 'a' => '1'], + tagVersion: '3', + overlayRejected: true, + ); + $decoded = $codec->decode($encoded); + + $this->assertTrue($decoded->valid); + $this->assertSame(['i:42', 'i:7', 'i:42'], $decoded->ids); + $this->assertSame(['a' => '1', 'b' => '2'], $decoded->versions); + $this->assertSame('7', $decoded->epoch); + $this->assertSame('4', $decoded->generation); + $this->assertSame('3', $decoded->tagVersion); + $this->assertTrue($decoded->overlayRejected); + $this->assertFalse($codec->decode('{"f":3}')->valid); + } + + public function test_membership_codec_rejects_invalid_dependency_versions(): void + { + $codec = new MembershipCodec; + $integerVersion = json_encode([ + 'f' => 5, + 'ep' => '0', + 'g' => '0', + 'ids' => 'i:1', + 'vec' => ['dependency' => 1], + 'rv' => '0', + ], JSON_THROW_ON_ERROR); + + $this->assertFalse($codec->decode($integerVersion)->valid); + } + + public function test_membership_codec_round_trips_an_empty_membership(): void + { + $codec = new MembershipCodec; + + $decoded = $codec->decode($codec->encode('7', '4', '1', [])); + + $this->assertSame([], $decoded->ids); + $this->assertFalse($decoded->overlayRejected); + } + + public function test_membership_codec_rejects_the_previous_array_id_layout(): void + { + $codec = new MembershipCodec; + $previous = json_encode([ + 'f' => 5, + 'ep' => '7', + 'g' => '4', + 'ids' => ['i:42', 'i:7'], + 'vec' => [], + 'rv' => '0', + ], JSON_THROW_ON_ERROR); + + $this->assertFalse($codec->decode($previous)->valid); + } + + public function test_membership_round_trips_the_root_version(): void + { + $codec = new MembershipCodec; + + $encoded = $codec->encode( + epoch: '7', + generation: '2', + ids: ['i:1', 'i:2'], + versions: ['abc' => '3'], + tagVersion: null, + overlayRejected: false, + rootVersion: '11', + ); + + $this->assertSame('11', $codec->decode($encoded)->rootVersion); + } + + public function test_a_membership_without_a_root_version_is_rejected(): void + { + $legacy = json_encode([ + 'f' => 5, + 'ep' => '1', + 'g' => '0', + 'ids' => 'i:1', + 'vec' => [], + ], JSON_THROW_ON_ERROR); + + $this->assertFalse((new MembershipCodec)->decode($legacy)->valid); + } + + public function test_result_payload_round_trips_the_root_version(): void + { + $codec = new RawResultCodec(new CacheSerializer('auto')); + + $encoded = $codec->encode( + rows: [(object) ['id' => 1]], + epoch: '7', + versions: ['abc' => '3'], + tagVersion: null, + rootVersion: '11', + ); + + $this->assertSame('11', $codec->decode($encoded)->rootVersion); + } + + public function test_a_result_payload_without_a_root_version_is_rejected(): void + { + $serializer = new CacheSerializer('auto'); + $codec = new RawResultCodec($serializer); + $legacy = $serializer->encode([ + 'f' => 5, + 'ep' => '7', + 'vec' => [], + 'rows' => [['id' => 1]], + ]); + + $this->assertFalse($codec->decode($legacy)->valid); + } +} diff --git a/tests/Unit/Planning/PredicateColumnExtractorTest.php b/tests/Unit/Planning/PredicateColumnExtractorTest.php new file mode 100644 index 0000000..2b77eaa --- /dev/null +++ b/tests/Unit/Planning/PredicateColumnExtractorTest.php @@ -0,0 +1,217 @@ +extractor = new PredicateColumnExtractor; + } + + private function builder(): QueryBuilder + { + return RawPost::query()->toBase()->from('posts'); + } + + public function test_extracts_where_and_order_columns(): void + { + $query = $this->builder()->where('length', '>', 10)->orderBy('title'); + + $this->assertSame(['length', 'title'], $this->extractor->extract($query)); + } + + public function test_descends_into_nested_wheres(): void + { + $query = $this->builder()->where(function ($q) { + $q->where('album_id', 1)->orWhere('artist_id', 2); + }); + + $this->assertSame(['album_id', 'artist_id'], $this->extractor->extract($query)); + } + + public function test_strips_table_qualifiers(): void + { + $query = $this->builder()->where('posts.album_id', 1); + + $this->assertSame(['album_id'], $this->extractor->extract($query)); + } + + public function test_returns_null_for_a_raw_where(): void + { + $query = $this->builder()->whereRaw('length > ?', [10]); + + $this->assertNull($this->extractor->extract($query)); + } + + public function test_returns_null_for_a_subquery_where(): void + { + $query = $this->builder()->whereIn('album_id', function ($q) { + $q->select('id')->from('albums'); + }); + + $this->assertNull($this->extractor->extract($query)); + } + + public function test_extracts_integer_in_raw_columns(): void + { + $query = $this->builder()->whereIntegerInRaw('id', [1, 2, 3]); + + $this->assertSame(['id'], $this->extractor->extract($query)); + } + + public function test_extracts_integer_not_in_raw_columns(): void + { + $query = $this->builder()->whereIntegerNotInRaw('id', [1, 2, 3]); + + $this->assertSame(['id'], $this->extractor->extract($query)); + } + + public function test_returns_null_for_a_raw_order(): void + { + $query = $this->builder()->orderByRaw('rand()'); + + $this->assertNull($this->extractor->extract($query)); + } + + public function test_a_query_with_no_predicate_returns_an_empty_list(): void + { + $this->assertSame([], $this->extractor->extract($this->builder())); + } + + public function test_extracts_between_columns(): void + { + $query = $this->builder()->whereBetween('length', [1, 10]); + + $this->assertSame(['length'], $this->extractor->extract($query)); + } + + public function test_extracts_not_between_columns(): void + { + $query = $this->builder()->whereNotBetween('length', [1, 10]); + + $this->assertSame(['length'], $this->extractor->extract($query)); + } + + public function test_returns_null_for_a_column_comparison(): void + { + $query = $this->builder()->whereColumn('starts_at', '<', 'ends_at'); + + $this->assertNull($this->extractor->extract($query)); + } + + public function test_returns_null_for_an_exists_where(): void + { + $query = $this->builder()->whereExists(function ($q) { + $q->select('id')->from('albums'); + }); + + $this->assertNull($this->extractor->extract($query)); + } + + public function test_returns_null_for_between_columns(): void + { + $query = $this->builder()->whereBetweenColumns('length', ['min_length', 'max_length']); + + $this->assertNull($this->extractor->extract($query)); + } + + public function test_returns_null_for_a_json_boolean_where(): void + { + $query = $this->builder()->where('data->flag', true); + + $this->assertNull($this->extractor->extract($query)); + } + + public function test_returns_null_for_a_fulltext_where(): void + { + $query = $this->builder()->whereFullText(['title'], 'needle'); + + $this->assertNull($this->extractor->extract($query)); + } + + public function test_returns_null_when_nested_where_contains_a_raw_clause(): void + { + $query = $this->builder()->where(function ($q) { + $q->where('album_id', 1)->orWhereRaw('artist_id = ?', [2]); + }); + + $this->assertNull($this->extractor->extract($query)); + } + + public function test_returns_null_for_a_basic_where_comparing_to_another_column_via_expression(): void + { + $query = $this->builder()->where('updated_at', '>', DB::raw('created_at')); + + $this->assertNull($this->extractor->extract($query)); + } + + public function test_returns_null_for_a_between_where_with_expression_bounds(): void + { + $query = $this->builder()->whereBetween('length', [DB::raw('min_length'), DB::raw('max_length')]); + + $this->assertNull($this->extractor->extract($query)); + } + + public function test_returns_null_for_a_between_where_with_one_expression_bound(): void + { + $query = $this->builder()->whereBetween('length', [1, DB::raw('max_length')]); + + $this->assertNull($this->extractor->extract($query)); + } + + public function test_returns_null_for_a_json_path_where(): void + { + $query = $this->builder()->where('data->flag', 'x'); + + $this->assertNull($this->extractor->extract($query)); + } + + public function test_returns_null_for_a_json_path_in_where(): void + { + $query = $this->builder()->whereIn('data->flag', [1, 2]); + + $this->assertNull($this->extractor->extract($query)); + } + + public function test_returns_null_for_a_json_path_null_where(): void + { + $query = $this->builder()->whereNull('data->flag'); + + $this->assertNull($this->extractor->extract($query)); + } + + public function test_returns_null_for_a_json_path_order(): void + { + $query = $this->builder()->orderBy('data->flag'); + + $this->assertNull($this->extractor->extract($query)); + } + + public function test_returns_null_for_a_json_path_containing_a_dot(): void + { + $query = $this->builder()->where('data->a.b', 1); + + $this->assertNull($this->extractor->extract($query)); + } + + public function test_extracted_column_names_are_always_strings(): void + { + $query = $this->builder()->where('123', 1); + + $columns = $this->extractor->extract($query); + + $this->assertSame(['123'], $columns); + $this->assertIsString($columns[0]); + } +} diff --git a/tests/Unit/PrimaryKeyMetadataTest.php b/tests/Unit/PrimaryKeyMetadataTest.php new file mode 100644 index 0000000..fe5c242 --- /dev/null +++ b/tests/Unit/PrimaryKeyMetadataTest.php @@ -0,0 +1,85 @@ +assertSame('i:42', $metadata->token(42)); + $this->assertSame('i:-42', $metadata->token('-42')); + $this->assertNull($metadata->token('0042')); + $this->assertNull($metadata->token(4.2)); + $this->assertNull($metadata->token('4e2')); + $this->assertSame( + 'i:9223372036854775807', + $metadata->token('9223372036854775807'), + ); + $this->assertSame( + 9223372036854775807, + $metadata->valueFromToken('i:9223372036854775807'), + ); + $this->assertSame( + 'i:-9223372036854775808', + $metadata->token('-9223372036854775808'), + ); + $this->assertSame( + -9223372036854775807 - 1, + $metadata->valueFromToken('i:-9223372036854775808'), + ); + $this->assertSame( + 'i:9223372036854775808', + $metadata->token('9223372036854775808'), + ); + $this->assertSame( + '9223372036854775808', + $metadata->valueFromToken('i:9223372036854775808'), + ); + $this->assertSame( + 'i:18446744073709551615', + $metadata->token('18446744073709551615'), + ); + $this->assertSame( + '18446744073709551615', + $metadata->valueFromToken('i:18446744073709551615'), + ); + } + + public function test_string_tokens_preserve_exact_bytes_without_key_delimiters(): void + { + $metadata = new PrimaryKeyMetadata('uuid', PrimaryKeyMetadata::STRING); + + $this->assertSame('s:NDI', $metadata->token('42')); + $this->assertSame('s:AP9hOnt9', $metadata->token("\x00\xffa:{}")); + $this->assertSame('42', $metadata->valueFromToken('s:NDI')); + $this->assertSame("\x00\xffa:{}", $metadata->valueFromToken('s:AP9hOnt9')); + } + + public function test_tokens_must_match_the_canonical_value_representation(): void + { + $integer = new PrimaryKeyMetadata('id', PrimaryKeyMetadata::INTEGER); + $string = new PrimaryKeyMetadata('uuid', PrimaryKeyMetadata::STRING); + + $this->assertTrue($integer->matchesToken(42, 'i:42')); + $this->assertTrue($integer->matchesToken('42', 'i:42')); + $this->assertFalse($integer->matchesToken('42', 's:42')); + $this->assertFalse($integer->matchesToken('42', 'i:0042')); + $this->assertTrue($string->matchesToken('42', 's:NDI')); + $this->assertFalse($string->matchesToken('42', 'i:42')); + } + + public function test_malformed_string_tokens_are_rejected(): void + { + $metadata = new PrimaryKeyMetadata('uuid', PrimaryKeyMetadata::STRING); + + $this->assertNull($metadata->valueFromToken('i:42')); + $this->assertNull($metadata->valueFromToken('s:*')); + $this->assertNull($metadata->valueFromToken('s:NDI=')); + $this->assertNull($metadata->valueFromToken('s:ND')); + } +} diff --git a/tests/Unit/QueryAnalyzerTest.php b/tests/Unit/QueryAnalyzerTest.php deleted file mode 100644 index 65771da..0000000 --- a/tests/Unit/QueryAnalyzerTest.php +++ /dev/null @@ -1,291 +0,0 @@ -makeBaseQuery(); - $inspection = $analyzer->inspect($query, 'authors', null); - - $this->assertSame(0, $inspection->flags); - $this->assertSame(0, $analyzer->flags($query, 'authors', null)); - $this->assertTrue($inspection->dependencies->hasNoDependencies()); - } - - public function test_nested_wheres_are_scanned_once_for_raw_and_exists_flags(): void - { - $nested = $this->makeBaseQuery(); - $nested->wheres = [ - ['type' => 'raw', 'sql' => 'LOWER(name) = ?'], - ['type' => 'Exists', 'query' => $this->makeBaseQuery()], - ]; - - $query = $this->makeBaseQuery(); - $query->wheres = [['type' => 'Nested', 'query' => $nested]]; - - $inspection = (new QueryAnalyzer)->inspect($query, 'authors', null); - - $this->assertTrue($inspection->has(QueryInspection::RAW_WHERE)); - $this->assertTrue($inspection->has(QueryInspection::EXISTS_WHERE)); - $this->assertFalse($inspection->has(QueryInspection::SUBQUERY_WHERE)); - $this->assertTrue($inspection->hasDependencyBypass()); - } - - public function test_notexists_where_type_sets_exists_where_flag(): void - { - $query = $this->makeBaseQuery(); - $query->wheres = [['type' => 'NotExists', 'query' => $this->makeBaseQuery()]]; - - $inspection = (new QueryAnalyzer)->inspect($query, 'authors', null); - - $this->assertTrue($inspection->has(QueryInspection::EXISTS_WHERE)); - $this->assertFalse($inspection->has(QueryInspection::SUBQUERY_WHERE)); - $this->assertFalse($inspection->hasDependencyBypass()); - } - - public function test_sub_where_type_sets_subquery_where_flag_not_exists_where(): void - { - $query = $this->makeBaseQuery(); - $query->wheres = [['type' => 'Sub', 'query' => $this->makeBaseQuery()]]; - - $inspection = (new QueryAnalyzer)->inspect($query, 'authors', null); - - $this->assertTrue($inspection->has(QueryInspection::SUBQUERY_WHERE)); - $this->assertFalse($inspection->has(QueryInspection::EXISTS_WHERE)); - $this->assertFalse($inspection->hasDependencyBypass()); - } - - public function test_structural_flags_map_to_existing_reason_strings(): void - { - $query = $this->makeBaseQuery(['id', '1 + 1 as computed'], 'other_authors'); - $query->joins = [(object) ['table' => 'countries as c']]; - $query->groups = ['id']; - $query->havings = [['type' => 'Basic']]; - $query->unions = [['query' => $this->makeBaseQuery()]]; - $query->aggregate = ['function' => 'count', 'columns' => ['*']]; - $query->distinct = true; - $query->lock = true; - $query->orders = [['type' => 'Raw']]; - - $inspection = (new QueryAnalyzer)->inspect($query, 'authors', $query->columns); - - $this->assertSame( - [ - 'dependency' => ['raw ORDER expression'], - 'normalization' => [ - 'non-standard FROM (subquery or raw expression)', - 'JOIN clauses', - 'GROUP BY', - 'HAVING', - 'UNION', - 'aggregate function (count/sum/etc.)', - 'DISTINCT', - 'calculated or raw SELECT expressions', - ], - 'safety' => ['query lock (SELECT FOR UPDATE)'], - ], - BypassReasons::fromInspection($inspection), - ); - $this->assertSame(['authors', 'countries'], (new QueryAnalyzer)->extractTables($query, 'authors')); - } - - public function test_primary_keys_are_extracted_without_reason_generation(): void - { - $query = $this->makeBaseQuery(); - $query->wheres = [[ - 'type' => 'In', - 'column' => 'authors.id', - 'values' => [3, 1, 2], - ]]; - - $inspection = (new QueryAnalyzer)->inspect( - $query, - 'authors', - null, - ['id', 'authors.id'], - ); - - $this->assertSame([1, 2, 3], $inspection->primaryKeys); - } - - public function test_primary_keys_allow_the_model_soft_delete_constraint(): void - { - $query = $this->makeBaseQuery(from: 'posts'); - $query->wheres = [ - ['type' => 'Null', 'column' => 'posts.deleted_at', 'boolean' => 'and'], - ['type' => 'In', 'column' => 'posts.id', 'values' => [3, 1, 2]], - ]; - - $inspection = (new QueryAnalyzer)->inspect( - $query, - 'posts', - null, - ['id', 'posts.id'], - softDeleteScopeColumn: 'posts.deleted_at', - ); - - $this->assertSame([1, 2, 3], $inspection->primaryKeys); - } - - public function test_primary_keys_do_not_ignore_an_arbitrary_null_constraint(): void - { - $query = $this->makeBaseQuery(from: 'posts'); - $query->wheres = [ - ['type' => 'Null', 'column' => 'posts.published_at', 'boolean' => 'and'], - ['type' => 'In', 'column' => 'posts.id', 'values' => [3, 1, 2]], - ]; - - $inspection = (new QueryAnalyzer)->inspect( - $query, - 'posts', - null, - ['id', 'posts.id'], - softDeleteScopeColumn: 'posts.deleted_at', - ); - - $this->assertNull($inspection->primaryKeys); - } - - public function test_expression_values_are_subquery_bypasses(): void - { - $expression = $this->createStub(Expression::class); - $query = $this->makeBaseQuery(); - $query->wheres = [[ - 'type' => 'In', - 'column' => 'id', - 'values' => [$expression], - ]]; - - $inspection = (new QueryAnalyzer)->inspect($query, 'authors', null, ['id']); - - $this->assertTrue($inspection->has(QueryInspection::SUBQUERY_WHERE)); - $this->assertNull($inspection->primaryKeys); - } - - public function test_exists_and_subquery_flags_do_not_bypass_dependency_inference(): void - { - $exists = new QueryInspection(flags: QueryInspection::EXISTS_WHERE); - $subquery = new QueryInspection(flags: QueryInspection::SUBQUERY_WHERE); - - $this->assertFalse($exists->hasDependencyBypass()); - $this->assertFalse($subquery->hasDependencyBypass()); - $this->assertSame([], BypassReasons::fromInspection($exists)); - } - - public function test_raw_where_still_bypasses_when_combined_with_exists(): void - { - $inspection = new QueryInspection(flags: QueryInspection::EXISTS_WHERE | QueryInspection::RAW_WHERE); - - $this->assertTrue($inspection->hasDependencyBypass()); - $this->assertContains('raw WHERE expression', BypassReasons::fromInspection($inspection)['dependency']); - } - - public function test_direct_primary_key_inspection_allows_harmless_single_row_ordering(): void - { - $query = $this->makeBaseQuery(); - $query->wheres = [[ - 'type' => 'Basic', - 'column' => 'id', - 'operator' => '=', - 'value' => 1, - ]]; - $query->orders = [['type' => 'Raw', 'sql' => 'CASE WHEN id = 1 THEN 0 END']]; - - $inspection = (new QueryAnalyzer)->inspect( - $query, - 'authors', - null, - ['id', 'authors.id'], - connection: static fn(): string => throw new \RuntimeException('direct path must not resolve connection'), - allowPrimaryKeyFastPath: true, - ); - - $this->assertSame([1], $inspection->primaryKeys); - $this->assertTrue($inspection->has(QueryInspection::RAW_ORDER)); - $this->assertSame(0, $inspection->normalizationFlags()); - $this->assertFalse($inspection->hasSafetyBypass()); - } - - public function test_direct_primary_key_inspection_rejects_structural_query_shapes(): void - { - $query = $this->makeBaseQuery(); - $query->wheres = [[ - 'type' => 'Basic', - 'column' => 'id', - 'operator' => '=', - 'value' => 1, - ]]; - $query->groups = ['id']; - $connectionResolutions = 0; - - $inspection = (new QueryAnalyzer)->inspect( - $query, - 'authors', - null, - ['id', 'authors.id'], - connection: static function () use (&$connectionResolutions): string { - $connectionResolutions++; - - return 'testing'; - }, - allowPrimaryKeyFastPath: true, - ); - - $this->assertNotSame(0, $inspection->normalizationFlags()); - $this->assertSame(1, $connectionResolutions); - } - - public function test_query_dependencies_include_nested_where_and_union_tables(): void - { - $exists = $this->makeBaseQuery(from: 'posts as p'); - $union = $this->makeBaseQuery(from: 'archived_authors'); - $query = $this->makeBaseQuery(); - $query->wheres = [['type' => 'Exists', 'query' => $exists]]; - $query->unions = [['query' => $union]]; - - $dependencies = (new QueryAnalyzer)->inferQueryDependencies($query, 'testing', 'authors'); - - $this->assertTrue($dependencies->safe); - $this->assertSame(['testing:posts', 'testing:archived_authors'], $dependencies->tables); - } - - public function test_query_dependencies_reject_opaque_join_sources(): void - { - $query = $this->makeBaseQuery(); - $query->joins = [(object) ['table' => $this->createStub(Expression::class), 'wheres' => []]]; - - $dependencies = (new QueryAnalyzer)->inferQueryDependencies($query, 'testing', 'authors'); - - $this->assertFalse($dependencies->safe); - $this->assertSame(['joined subquery dependency could not be inferred'], $dependencies->reasons); - } - - private function makeBaseQuery(?array $columns = null, string $from = 'authors'): Builder - { - $connection = new SQLiteConnection(new PDO('sqlite::memory:')); - $query = new Builder( - connection: $connection, - grammar: new SQLiteGrammar($connection), - processor: new SQLiteProcessor, - ); - - $query->columns = $columns; - $query->from = $from; - - return $query; - } -} diff --git a/tests/Unit/QueryIdentityTest.php b/tests/Unit/QueryIdentityTest.php new file mode 100644 index 0000000..182a2e8 --- /dev/null +++ b/tests/Unit/QueryIdentityTest.php @@ -0,0 +1,107 @@ +hash( + route: 'result', + rootHash: 'root', + dependencyHashes: ['b', 'a', 'a'], + sql: 'select * from posts where id = ?', + bindings: [42], + namespace: 'u', + operation: 'select', + ); + $string = $identity->hash( + route: 'result', + rootHash: 'root', + dependencyHashes: ['a', 'b'], + sql: 'select * from posts where id = ?', + bindings: ['42'], + namespace: 'u', + operation: 'select', + ); + + $this->assertSame(32, strlen($integer)); + $this->assertNotSame($integer, $string); + $this->assertSame( + $integer, + $identity->hash('result', 'root', ['a', 'b'], 'select * from posts where id = ?', [42], 'u', 'select'), + ); + } + + public function test_tags_use_a_separate_domain_and_validate_input(): void + { + $identity = new QueryIdentity; + + $this->assertSame(32, strlen($identity->tagHash('homepage'))); + $this->assertSame('g' . $identity->tagHash('homepage'), $identity->namespace('homepage')); + $this->assertSame('u', $identity->namespace(null)); + + $this->expectException(\InvalidArgumentException::class); + $identity->tagHash(''); + } + + public function test_cache_contexts_partition_tagged_and_untagged_namespaces(): void + { + $identity = new QueryIdentity; + $context = 'c' . $identity->contextHash('tenant:42'); + + $this->assertSame($context, $identity->namespace(null, 'tenant:42')); + $this->assertSame( + 'g' . $identity->tagHash('homepage') . ':' . $context, + $identity->namespace('homepage', 'tenant:42'), + ); + $this->assertSame('u', $identity->namespace(null, null)); + $this->assertSame('g' . $identity->tagHash('homepage'), $identity->namespace('homepage')); + } + + public function test_cache_contexts_are_validated(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('NormCache cache context'); + + (new QueryIdentity)->contextHash(''); + } + + public function test_laravel_prepared_bindings_are_hashable_without_additional_normalization(): void + { + $connection = $this->app['db']->connection(); + $query = $connection->query() + ->from('posts') + ->where('created_at', new \DateTimeImmutable('2026-08-04 12:34:56+10:00')) + ->where('published', true); + $bindings = $connection->prepareBindings($query->getBindings()); + + $this->assertSame(['2026-08-04 12:34:56', 1], $bindings); + $this->assertSame(32, strlen((new QueryIdentity)->hash( + route: 'result', + rootHash: 'root', + dependencyHashes: ['dependency'], + sql: $query->toSql(), + bindings: $bindings, + namespace: 'u', + operation: 'select', + ))); + } + + public function test_unique_tags_do_not_retain_process_lifetime_state(): void + { + $identity = new QueryIdentity; + $before = memory_get_usage(false); + + for ($index = 0; $index < 50_000; $index++) { + $identity->namespace("tenant-{$index}"); + } + + $this->assertLessThan(2 * 1_024 * 1_024, memory_get_usage(false) - $before); + } +} diff --git a/tests/Unit/QueryObserverTest.php b/tests/Unit/QueryObserverTest.php new file mode 100644 index 0000000..39c1f6a --- /dev/null +++ b/tests/Unit/QueryObserverTest.php @@ -0,0 +1,176 @@ + true]), + null, + new FailureReporter(new NullLogger), + ); + $table = TableIdentity::fromParts('sqlite', 'testing', '/tmp/test.sqlite', '', '', 'posts'); + $plan = QueryPlan::queryGroup($table, [$table]); + $query = Post::query()->toBase(); + $sql = 'select * from posts where id = ?'; + $bindings = [42]; + $statement = new QueryStatement(fn(): array => [$sql, $bindings]); + + $observer->hit($query, $plan, 'hit-hash', $statement, 'row_cache_fallback'); + $observer->miss($query, $plan, 'miss-hash', $statement); + $observer->repaired($query, $plan, 'repair-hash', $statement, 'row_repair'); + + Event::assertDispatched( + QueryCacheHit::class, + fn(QueryCacheHit $event): bool => $event->route === 'query_group' + && $event->queryHash === 'hit-hash' + && $event->tableHash === $table->hash + && $event->sql === $sql + && $event->bindings === $bindings + && $event->reason === 'row_cache_fallback', + ); + Event::assertDispatched( + QueryCacheMiss::class, + fn(QueryCacheMiss $event): bool => $event->route === 'query_group' + && $event->queryHash === 'miss-hash' + && $event->tableHash === $table->hash + && $event->sql === $sql + && $event->bindings === $bindings + && $event->reason === null, + ); + Event::assertDispatched( + QueryCacheRepaired::class, + fn(QueryCacheRepaired $event): bool => $event->route === 'query_group' + && $event->queryHash === 'repair-hash' + && $event->tableHash === $table->hash + && $event->sql === $sql + && $event->bindings === $bindings + && $event->reason === 'row_repair', + ); + } + + public function test_corrupt_payload_diagnostic_is_reported_at_most_once_per_scope_per_key(): void + { + Event::fake([QueryCacheMiss::class]); + + $observer = new QueryObserver( + CacheConfig::fromArray([...config('normcache'), 'events' => true]), + null, + new FailureReporter(new NullLogger), + ); + $table = TableIdentity::fromParts('sqlite', 'testing', '/tmp/test.sqlite', '', '', 'posts'); + $plan = QueryPlan::result($table, []); + $query = Post::query()->toBase(); + $statement = new QueryStatement(static fn(): array => ['select * from posts', []]); + + $observer->miss($query, $plan, 'hash-a', $statement, 'corrupt_payload'); + $observer->miss($query, $plan, 'hash-a', $statement, 'corrupt_payload'); + + $observer->miss($query, $plan, 'hash-b', $statement, 'corrupt_payload'); + + $observer->miss($query, $plan, 'hash-c', $statement); + $observer->miss($query, $plan, 'hash-c', $statement); + + Event::assertDispatchedTimes(QueryCacheMiss::class, 4); + } + + public function test_batched_invalidation_reports_the_batch_span_for_every_table(): void + { + $collector = $this->collector(); + $observer = $this->collectingObserver($collector); + + $observer->begin(); + usleep(2_000); + $observer->invalidatedMany([ + ['table' => $this->table('posts'), 'mode' => 'version', 'tokens' => []], + ['table' => $this->table('authors'), 'mode' => 'version', 'tokens' => []], + ['table' => $this->table('comments'), 'mode' => 'version', 'tokens' => []], + ]); + + $durations = $this->durations($collector); + + $this->assertCount(3, $durations); + + foreach ($durations as $duration) { + $this->assertGreaterThan(0.0, $duration); + $this->assertSame($durations[0], $duration); + } + } + + public function test_a_nested_observation_does_not_consume_the_enclosing_span(): void + { + $collector = $this->collector(); + $observer = $this->collectingObserver($collector); + + $observer->begin(); + usleep(4_000); + + // A repair issuing its own bypassed query opens and closes a span inside the + // read that encloses it. + $observer->begin(); + $observer->invalidated($this->table('posts'), 'version', []); + + $observer->invalidated($this->table('authors'), 'version', []); + + [$nested, $enclosing] = $this->durations($collector); + + $this->assertGreaterThan(0.004, $enclosing); + $this->assertGreaterThan($nested, $enclosing); + } + + private function collector(): DebugBarCollector + { + if (!class_exists(TimeDataCollector::class)) { + $this->markTestSkipped('Timing records need the optional Debugbar collector.'); + } + + return new DebugBarCollector; + } + + private function collectingObserver(DebugBarCollector $collector): QueryObserver + { + return new QueryObserver( + CacheConfig::fromArray([...config('normcache'), 'events' => false]), + $collector, + new FailureReporter(new NullLogger), + ); + } + + private function table(string $name): TableIdentity + { + return TableIdentity::fromParts('sqlite', 'testing', '/tmp/test.sqlite', '', '', $name); + } + + /** @return list */ + private function durations(DebugBarCollector $collector): array + { + return array_map( + static fn(array $measure): float => (float) $measure['duration'], + array_values($collector->collect()['measures']), + ); + } +} diff --git a/tests/Unit/QueryPlanTest.php b/tests/Unit/QueryPlanTest.php new file mode 100644 index 0000000..e143653 --- /dev/null +++ b/tests/Unit/QueryPlanTest.php @@ -0,0 +1,92 @@ +table(); + $plan = QueryPlan::queryGroup($table, [$table]); + + $this->assertSame(QueryPlan::QUERY_GROUP, $plan->route); + $this->assertTrue($plan->isQueryGroup()); + $this->assertNull($plan->primaryKey); + $this->assertFalse($plan->usesGeneration()); + } + + public function test_direct_primary_key_is_a_normalized_strategy_with_a_token(): void + { + $table = $this->table(); + $plan = QueryPlan::directPrimaryKey( + $table, + [$table], + $this->primaryKey(), + 'i:1', + 'default', + 'deleted_at', + ); + + $this->assertSame(QueryPlan::DIRECT_PK, $plan->route); + $this->assertTrue($plan->isDirectPrimaryKey()); + $this->assertSame('i:1', $plan->primaryKeyToken); + $this->assertSame('default', $plan->softDeleteMode); + $this->assertSame('deleted_at', $plan->deletedAtColumn); + $this->assertTrue($plan->usesGeneration()); + } + + public function test_canonical_query_is_a_normalized_strategy_without_a_token(): void + { + $table = $this->table(); + $plan = QueryPlan::canonical($table, [$table], $this->primaryKey()); + + $this->assertSame(QueryPlan::CANONICAL, $plan->route); + $this->assertTrue($plan->isCanonical()); + $this->assertNull($plan->primaryKeyToken); + $this->assertNull($plan->projectedColumns); + $this->assertTrue($plan->usesGeneration()); + } + + public function test_result_capabilities_are_orthogonal_to_the_storage_strategy(): void + { + $table = $this->table(); + $primaryKey = $this->primaryKey(); + $projectedResult = QueryPlan::projectedResult( + $table, + [$table], + $primaryKey, + ['id'], + ); + $projectedRow = QueryPlan::projectedRow( + $table, + [$table], + $primaryKey, + 'i:1', + ['id'], + null, + null, + ); + + $this->assertSame(QueryPlan::RESULT, $projectedResult->route); + $this->assertTrue($projectedResult->supportsCanonicalProjectionFallback()); + $this->assertFalse($projectedResult->supportsRowFallback()); + $this->assertTrue($projectedRow->supportsRowFallback()); + $this->assertFalse($projectedRow->supportsCanonicalProjectionFallback()); + $this->assertFalse($projectedRow->usesGeneration()); + } + + private function table(): TableIdentity + { + return TableIdentity::fromParts('sqlite', 'testing', '/tmp/test.sqlite', '', '', 'posts'); + } + + private function primaryKey(): PrimaryKeyMetadata + { + return new PrimaryKeyMetadata('id', PrimaryKeyMetadata::INTEGER); + } +} diff --git a/tests/Unit/QueryPlannerTest.php b/tests/Unit/QueryPlannerTest.php new file mode 100644 index 0000000..7542f50 --- /dev/null +++ b/tests/Unit/QueryPlannerTest.php @@ -0,0 +1,605 @@ +planner = new QueryPlanner; + $this->posts = TableIdentity::fromParts('sqlite', 'testing', '/tmp/test.sqlite', '', '', 'posts'); + } + + public function test_root_wildcard_uses_canonical_rows_with_automatic_overlay_admission(): void + { + $primaryKey = new PrimaryKeyMetadata('id', PrimaryKeyMetadata::INTEGER); + $query = RawPost::query()->toBase()->from('posts'); + + $plan = $this->planner->plan( + $query, + $this->posts, + [$this->posts], + ); + + $this->assertSame(QueryPlan::CANONICAL, $plan->route); + $this->assertEquals($primaryKey, $plan->primaryKey); + $this->assertSame([], $plan->predicateColumns); + } + + public function test_canonical_plan_carries_predicate_and_order_columns(): void + { + $query = RawPost::query()->toBase()->from('posts') + ->where('published', true) + ->orderBy('title'); + + $plan = $this->planner->plan( + $query, + $this->posts, + [$this->posts], + ); + + $this->assertSame(QueryPlan::CANONICAL, $plan->route); + $this->assertSame(['published', 'title'], $plan->predicateColumns); + } + + public function test_canonical_plan_with_an_unparseable_predicate_carries_no_predicate_columns(): void + { + $query = RawPost::query()->toBase()->from('posts')->whereRaw('published = ?', [true]); + + $plan = $this->planner->plan( + $query, + $this->posts, + [$this->posts], + ); + + $this->assertSame(QueryPlan::CANONICAL, $plan->route); + $this->assertNull($plan->predicateColumns); + } + + public function test_limited_root_wildcard_uses_an_automatic_result_overlay(): void + { + $query = RawPost::query()->toBase()->from('posts')->limit(20); + + $plan = $this->planner->plan( + $query, + $this->posts, + [$this->posts], + ); + + $this->assertSame(QueryPlan::CANONICAL, $plan->route); + } + + public function test_bare_alias_wildcard_uses_canonical_rows(): void + { + $query = RawPost::query()->toBase()->from('posts p')->select('p.*'); + + $plan = $this->planner->plan( + $query, + $this->posts, + [$this->posts], + ); + + $this->assertSame(QueryPlan::CANONICAL, $plan->route); + } + + public function test_explicitly_dependency_backed_source_uses_vector_validated_canonical_storage(): void + { + $view = TableIdentity::fromParts( + 'sqlite', + 'testing', + '/tmp/test.sqlite', + '', + '', + 'post_titles', + ); + $query = RawPost::query()->toBase()->from('post_titles')->select('*'); + + $plan = $this->planner->plan( + $query, + $view, + [$view, $this->posts], + ); + + $this->assertSame(QueryPlan::CANONICAL, $plan->route); + } + + public function test_limited_wildcard_query_materializes_result_overlay(): void + { + $query = RawPost::query()->toBase()->from('posts')->limit(20); + + $plan = $this->planner->plan( + $query, + $this->posts, + [$this->posts], + ); + + $this->assertSame(QueryPlan::CANONICAL, $plan->route); + } + + public function test_primary_key_query_uses_direct_row_route(): void + { + $query = RawPost::query()->toBase() + ->from('posts') + ->where('id', 42) + ->limit(1); + + $plan = $this->planner->plan( + $query, + $this->posts, + [$this->posts], + ); + + $this->assertSame(QueryPlan::DIRECT_PK, $plan->route); + $this->assertSame('i:42', $plan->primaryKeyToken); + } + + public function test_cache_context_uses_full_result_storage_without_shared_rows(): void + { + $query = RawPost::query()->toBase() + ->from('posts') + ->where('id', 42) + ->limit(1) + ->cacheContext('tenant:42'); + + $plan = $this->planner->plan( + $query, + $this->posts, + [$this->posts], + ); + + $this->assertSame(QueryPlan::RESULT, $plan->route); + $this->assertNull($plan->primaryKey); + } + + public function test_narrow_projection_uses_result_route(): void + { + $query = RawPost::query()->toBase()->from('posts')->select(['id', 'title as heading']); + + $plan = $this->planner->plan( + $query, + $this->posts, + [$this->posts], + ); + + $this->assertSame(QueryPlan::RESULT, $plan->route); + } + + public function test_scalar_primary_key_wildcard_uses_direct_row(): void + { + $primaryKey = new PrimaryKeyMetadata('id', PrimaryKeyMetadata::INTEGER); + $query = RawPost::query()->toBase()->from('posts')->where('id', 42)->limit(1); + + $plan = $this->planner->plan( + $query, + $this->posts, + [$this->posts], + ); + + $this->assertSame(QueryPlan::DIRECT_PK, $plan->route); + $this->assertEquals($primaryKey, $plan->primaryKey); + $this->assertSame('i:42', $plan->primaryKeyToken); + } + + public function test_exists_never_uses_a_canonical_row_route(): void + { + $query = RawPost::query()->toBase()->from('posts')->where('id', 42); + + $plan = $this->planner->plan( + $query, + $this->posts, + [$this->posts], + operation: 'exists', + ); + + $this->assertSame(QueryPlan::RESULT, $plan->route); + } + + public function test_primary_key_aggregate_never_uses_a_canonical_row_route(): void + { + $query = RawPost::query()->toBase()->from('posts')->where('id', 42); + $query->aggregate = ['function' => 'count', 'columns' => ['*']]; + + $plan = $this->planner->plan( + $query, + $this->posts, + [$this->posts], + ); + + $this->assertSame(QueryPlan::RESULT, $plan->route); + } + + public function test_narrow_primary_key_projection_with_bare_columns_is_eligible_for_row_fallback(): void + { + $primaryKey = new PrimaryKeyMetadata('id', PrimaryKeyMetadata::INTEGER); + $query = RawPost::query()->toBase()->from('posts')->where('id', 42)->select(['id', 'title']); + + $plan = $this->planner->plan( + $query, + $this->posts, + [$this->posts], + ); + + $this->assertSame(QueryPlan::RESULT, $plan->route); + $this->assertEquals($primaryKey, $plan->primaryKey); + $this->assertSame('i:42', $plan->primaryKeyToken); + $this->assertSame(['id', 'title'], $plan->projectedColumns); + } + + public function test_narrow_primary_key_projection_with_root_qualified_columns_is_eligible(): void + { + $query = RawPost::query()->toBase()->from('posts')->where('posts.id', 42)->select(['posts.id', 'posts.title']); + + $plan = $this->planner->plan( + $query, + $this->posts, + [$this->posts], + ); + + $this->assertSame('i:42', $plan->primaryKeyToken); + $this->assertSame(['id', 'title'], $plan->projectedColumns); + } + + public function test_narrow_primary_key_projection_with_aliased_from_and_qualified_columns_is_eligible(): void + { + $query = RawPost::query()->toBase()->from('posts as p')->where('p.id', 42)->select(['p.id', 'p.title']); + + $plan = $this->planner->plan( + $query, + $this->posts, + [$this->posts], + ); + + $this->assertSame('i:42', $plan->primaryKeyToken); + $this->assertSame(['id', 'title'], $plan->projectedColumns); + } + + public function test_aliased_from_rejects_projection_qualified_by_original_table(): void + { + $query = RawPost::query()->toBase()->from('posts as p')->where('p.id', 42)->select(['posts.title']); + + $plan = $this->planner->plan( + $query, + $this->posts, + [$this->posts], + ); + + $this->assertNull($plan->primaryKeyToken); + $this->assertNull($plan->projectedColumns); + } + + public function test_aliased_from_rejects_wildcard_qualified_by_original_table(): void + { + $query = RawPost::query()->toBase()->from('posts as p')->where('p.id', 42)->select(['posts.*']); + + $plan = $this->planner->plan( + $query, + $this->posts, + [$this->posts], + ); + + $this->assertSame(QueryPlan::RESULT, $plan->route); + $this->assertNull($plan->primaryKeyToken); + } + + public function test_primary_key_predicate_with_unrelated_qualifier_rejects_direct_row_but_keeps_membership_fallback(): void + { + $query = RawPost::query()->toBase()->from('posts')->where('authors.id', 42)->select(['title']); + + $plan = $this->planner->plan( + $query, + $this->posts, + [$this->posts], + ); + + $this->assertNull($plan->primaryKeyToken); + $this->assertSame(['title'], $plan->projectedColumns); + } + + public function test_narrow_primary_key_projection_with_column_alias_keeps_result_route_without_token(): void + { + $query = RawPost::query()->toBase()->from('posts')->where('id', 42)->select(['id', 'title as heading']); + + $plan = $this->planner->plan( + $query, + $this->posts, + [$this->posts], + ); + + $this->assertSame(QueryPlan::RESULT, $plan->route); + $this->assertNull($plan->primaryKeyToken); + $this->assertNull($plan->projectedColumns); + } + + public function test_narrow_primary_key_projection_with_raw_expression_keeps_result_route_without_token(): void + { + $query = RawPost::query()->toBase()->from('posts')->where('id', 42)->select(['id', DB::raw('UPPER(title)')]); + + $plan = $this->planner->plan( + $query, + $this->posts, + [$this->posts], + ); + + $this->assertSame(QueryPlan::RESULT, $plan->route); + $this->assertNull($plan->primaryKeyToken); + $this->assertNull($plan->projectedColumns); + } + + public function test_extra_predicate_rejects_direct_row_but_keeps_membership_fallback(): void + { + $query = RawPost::query()->toBase()->from('posts')->where('id', 42)->where('published', true)->select(['title']); + + $plan = $this->planner->plan( + $query, + $this->posts, + [$this->posts], + ); + + $this->assertNull($plan->primaryKeyToken); + $this->assertSame(['title'], $plan->projectedColumns); + } + + public function test_incompatible_direct_limit_keeps_membership_fallback(): void + { + $query = RawPost::query()->toBase()->from('posts')->where('id', 42)->limit(5)->select(['title']); + + $plan = $this->planner->plan( + $query, + $this->posts, + [$this->posts], + ); + + $this->assertNull($plan->primaryKeyToken); + $this->assertSame(['title'], $plan->projectedColumns); + } + + public function test_multi_row_plain_projection_is_eligible_for_canonical_membership_fallback(): void + { + $primaryKey = new PrimaryKeyMetadata('id', PrimaryKeyMetadata::INTEGER); + $query = RawPost::query()->toBase() + ->from('posts') + ->where('published', true) + ->orderBy('id') + ->select(['id', 'title']); + + $plan = $this->planner->plan( + $query, + $this->posts, + [$this->posts], + ); + + $this->assertSame(QueryPlan::RESULT, $plan->route); + $this->assertEquals($primaryKey, $plan->primaryKey); + $this->assertNull($plan->primaryKeyToken); + $this->assertSame(['id', 'title'], $plan->projectedColumns); + } + + public function test_aliased_or_raw_projection_is_not_eligible_for_canonical_membership_fallback(): void + { + $query = RawPost::query()->toBase() + ->from('posts') + ->where('published', true) + ->selectRaw('id, upper(title) as heading'); + + $plan = $this->planner->plan( + $query, + $this->posts, + [$this->posts], + ); + + $this->assertSame(QueryPlan::RESULT, $plan->route); + $this->assertNull($plan->projectedColumns); + } + + public function test_group_limited_queries_do_not_publish_canonical_rows(): void + { + $query = RawPost::query()->toBase()->from('posts'); + $query->groupLimit = ['value' => 1, 'column' => 'author_id']; + + $plan = $this->planner->plan( + $query, + $this->posts, + [$this->posts], + ); + + $this->assertSame(QueryPlan::RESULT, $plan->route); + $this->assertNull($plan->projectedColumns); + } + + public function test_narrow_primary_key_projection_with_tag_override_uses_membership_not_direct_row_fallback(): void + { + $query = RawPost::query()->toBase()->from('posts')->where('id', 42)->select(['title'])->tag('reports'); + + $plan = $this->planner->plan( + $query, + $this->posts, + [$this->posts], + ); + + $this->assertNull($plan->primaryKeyToken); + $this->assertSame(['title'], $plan->projectedColumns); + } + + public function test_narrow_primary_key_projection_with_ttl_override_uses_membership_not_direct_row_fallback(): void + { + $query = RawPost::query()->toBase()->from('posts')->where('id', 42)->select(['title'])->ttl(60); + + $plan = $this->planner->plan( + $query, + $this->posts, + [$this->posts], + ); + + $this->assertNull($plan->primaryKeyToken); + $this->assertSame(['title'], $plan->projectedColumns); + } + + public function test_narrow_primary_key_projection_computes_soft_delete_mode(): void + { + $query = RawPost::query()->toBase()->from('posts')->where('id', 42)->select(['title']) + ->enableCachingForModel(Post::class, 'id', 'int', 'deleted_at'); + + $plan = $this->planner->plan( + $query, + $this->posts, + [$this->posts], + ); + + $this->assertSame('i:42', $plan->primaryKeyToken); + $this->assertSame('with', $plan->softDeleteMode); + $this->assertSame('deleted_at', $plan->deletedAtColumn); + } + + public function test_multiple_soft_delete_predicates_disable_direct_row_but_keep_membership_fallback(): void + { + $query = RawPost::query()->toBase()->from('posts') + ->where('id', 42) + ->whereNull('posts.deleted_at') + ->whereNotNull('posts.deleted_at') + ->select(['title']) + ->enableCachingForModel(Post::class, 'id', 'int', 'deleted_at'); + + $plan = $this->planner->plan( + $query, + $this->posts, + [$this->posts], + ); + + $this->assertNull($plan->primaryKeyToken); + $this->assertSame(['title'], $plan->projectedColumns); + } + + public function test_unsafe_soft_delete_direct_candidate_still_uses_canonical_membership(): void + { + $query = RawPost::query()->toBase()->from('posts') + ->where('id', 42) + ->whereNull('posts.deleted_at') + ->whereNotNull('posts.deleted_at') + ->enableCachingForModel(Post::class, 'id', 'int', 'deleted_at'); + + $plan = $this->planner->plan( + $query, + $this->posts, + [$this->posts], + ); + + $this->assertSame(QueryPlan::CANONICAL, $plan->route); + $this->assertNull($plan->primaryKeyToken); + } + + public function test_soft_delete_predicate_with_unrelated_qualifier_rejects_direct_row_but_keeps_membership_fallback(): void + { + $query = RawPost::query()->toBase()->from('posts') + ->where('id', 42) + ->whereNull('wrong.deleted_at') + ->enableCachingForModel(Post::class, 'id', 'int', 'deleted_at'); + + $plan = $this->planner->plan( + $query, + $this->posts, + [$this->posts], + ); + + $this->assertSame(QueryPlan::CANONICAL, $plan->route); + $this->assertNull($plan->primaryKeyToken); + } + + public function test_aliased_from_rejects_soft_delete_predicate_qualified_by_original_table(): void + { + $query = RawPost::query()->toBase()->from('posts as p') + ->where('p.id', 42) + ->whereNull('posts.deleted_at') + ->enableCachingForModel(Post::class, 'id', 'int', 'deleted_at'); + + $plan = $this->planner->plan( + $query, + $this->posts, + [$this->posts], + ); + + $this->assertSame(QueryPlan::CANONICAL, $plan->route); + $this->assertNull($plan->primaryKeyToken); + } + + public function test_root_qualified_soft_delete_predicate_uses_direct_row_route(): void + { + $query = RawPost::query()->toBase()->from('posts') + ->where('id', 42) + ->whereNull('posts.deleted_at') + ->enableCachingForModel(Post::class, 'id', 'int', 'deleted_at'); + + $plan = $this->planner->plan( + $query, + $this->posts, + [$this->posts], + ); + + $this->assertSame(QueryPlan::DIRECT_PK, $plan->route); + $this->assertSame('default', $plan->softDeleteMode); + } + + public function test_alias_qualified_soft_delete_predicate_uses_direct_row_route(): void + { + $query = RawPost::query()->toBase()->from('posts as p') + ->where('p.id', 42) + ->whereNull('p.deleted_at') + ->enableCachingForModel(Post::class, 'id', 'int', 'deleted_at'); + + $plan = $this->planner->plan( + $query, + $this->posts, + [$this->posts], + ); + + $this->assertSame(QueryPlan::DIRECT_PK, $plan->route); + $this->assertSame('default', $plan->softDeleteMode); + } + + public function test_join_query_group_does_not_collapse(): void + { + $comments = TableIdentity::fromParts('sqlite', 'testing', '/tmp/test.sqlite', '', '', 'comments'); + $query = RawPost::query()->toBase() + ->from('posts') + ->join('comments', 'comments.post_id', '=', 'posts.id'); + + $plan = $this->planner->plan( + $query, + $this->posts, + [$this->posts, $comments], + ); + + $this->assertSame(QueryPlan::QUERY_GROUP, $plan->route); + } + + public function test_any_sql_join_uses_query_group(): void + { + $comments = TableIdentity::fromParts('sqlite', 'testing', '/tmp/test.sqlite', '', '', 'comments'); + $query = RawPost::query()->toBase() + ->from('posts') + ->join('comments', 'comments.post_id', '=', 'posts.id') + ->select('posts.*'); + + $plan = $this->planner->plan( + $query, + $this->posts, + [$this->posts, $comments], + ); + + $this->assertSame(QueryPlan::QUERY_GROUP, $plan->route); + } +} diff --git a/tests/Unit/RedisProtocolTest.php b/tests/Unit/RedisProtocolTest.php new file mode 100644 index 0000000..2b1ac8b --- /dev/null +++ b/tests/Unit/RedisProtocolTest.php @@ -0,0 +1,33 @@ +assertSame(RedisProtocol::HIT, RedisProtocol::status($canonical)); + $this->assertSame('4', RedisProtocol::version($canonical)); + $this->assertSame('7', RedisProtocol::version($canonical, 2)); + $this->assertSame( + 'canonical-payload', + RedisProtocol::canonicalPayload($canonical), + ); + $this->assertSame('result-payload', RedisProtocol::resultPayload($result)); + } + + public function test_invalid_or_missing_reply_values_use_safe_defaults(): void + { + $reply = [false, 4]; + + $this->assertNull(RedisProtocol::status($reply)); + $this->assertSame('0', RedisProtocol::version($reply)); + $this->assertNull(RedisProtocol::value($reply, 9)); + } +} diff --git a/tests/Unit/RedisStoreRecoveryTest.php b/tests/Unit/RedisStoreRecoveryTest.php new file mode 100644 index 0000000..9a3c8dc --- /dev/null +++ b/tests/Unit/RedisStoreRecoveryTest.php @@ -0,0 +1,430 @@ +swapRedisManager([ + new \RuntimeException('Redis server went away'), + 'cached-value', + ]); + + $store = new RedisStore('normcache-test'); + + $this->assertSame('cached-value', $store->getRaw('key')); + $this->assertSame(2, $manager->built); + $this->assertSame(['normcache-test'], $manager->purged); + } + + public function test_rebuilds_the_connection_after_a_predis_connection_failure(): void + { + $manager = $this->swapRedisManager([ + new ConnectionException( + $this->createStub(NodeConnectionInterface::class), + 'Error while reading line from the server.', + ), + 'cached-value', + ]); + + $store = new RedisStore('normcache-test'); + + $this->assertSame('cached-value', $store->getRaw('key')); + $this->assertSame(2, $manager->built); + } + + public function test_rebuilds_the_connection_for_lua_scripts(): void + { + $manager = $this->swapRedisManager([ + new \RuntimeException('Connection lost'), + ['payload'], + ]); + + $store = new RedisStore('normcache-test'); + + $this->assertSame( + ['payload'], + $store->fetchResult('version-key', 'prefix', 'namespace', 'query-hash'), + ); + $this->assertSame(2, $manager->built); + } + + public function test_rebuilds_the_connection_for_increments(): void + { + $manager = $this->swapRedisManager([ + new \RuntimeException('Connection lost'), + 7, + ]); + + $store = new RedisStore('normcache-test'); + + $this->assertSame(7, $store->increment('key')); + $this->assertSame(2, $manager->built); + } + + public function test_claim_retry_recognizes_a_token_applied_before_connection_loss(): void + { + $original = $this->app->make('redis'); + $manager = new class + { + public int $built = 0; + + public ?string $owner = null; + + /** @var list */ + public array $purged = []; + + public function connection($name = null): Connection + { + $attempt = $this->built++; + $manager = $this; + $client = new class($manager, $attempt) + { + public function __construct( + private object $manager, + private int $attempt, + ) {} + + /** @param list $arguments */ + public function __call(string $method, array $arguments): mixed + { + if (strtolower($method) !== 'evalsha') { + return null; + } + + $token = (string) ($arguments[3] ?? ''); + $this->manager->owner ??= $token; + + if ($this->attempt === 0) { + throw new \RuntimeException('Connection lost after the lease was claimed.'); + } + + return [1, $this->manager->owner]; + } + }; + + return new class($client) extends Connection + { + public function __construct(mixed $client) + { + $this->client = $client; + } + + public function createSubscription($channels, \Closure $callback, $method = 'subscribe'): void {} + }; + } + + public function purge(string $name): void + { + $this->purged[] = $name; + } + }; + + try { + $this->app->instance('redis', $manager); + Redis::clearResolvedInstance('redis'); + $token = str_repeat('a', 32); + + $this->assertSame( + [true, $token], + (new RedisStore('normcache-test'))->claimBuild('build-key', $token, 30), + ); + $this->assertSame($token, $manager->owner); + $this->assertSame(2, $manager->built); + $this->assertSame(['normcache-test'], $manager->purged); + } finally { + $this->app->instance('redis', $original); + Redis::clearResolvedInstance('redis'); + } + } + + public function test_monotonic_increment_retry_may_advance_more_than_once(): void + { + $original = $this->app->make('redis'); + $manager = new class + { + public int $built = 0; + + public int $value = 0; + + /** @var list */ + public array $purged = []; + + public function connection($name = null): Connection + { + $attempt = $this->built++; + $manager = $this; + $client = new class($manager, $attempt) + { + public function __construct( + private object $manager, + private int $attempt, + ) {} + + /** @param list $arguments */ + public function __call(string $method, array $arguments): mixed + { + if (strtolower($method) !== 'incr') { + return null; + } + + $this->manager->value++; + + if ($this->attempt === 0) { + throw new \RuntimeException('Connection lost after Redis applied INCR.'); + } + + return $this->manager->value; + } + }; + + return new class($client) extends Connection + { + public function __construct(mixed $client) + { + $this->client = $client; + } + + public function createSubscription($channels, \Closure $callback, $method = 'subscribe'): void {} + }; + } + + public function purge(string $name): void + { + $this->purged[] = $name; + } + }; + + try { + $this->app->instance('redis', $manager); + Redis::clearResolvedInstance('redis'); + + $this->assertSame(2, (new RedisStore('normcache-test'))->increment('counter')); + $this->assertSame(2, $manager->value); + $this->assertSame(2, $manager->built); + $this->assertSame(['normcache-test'], $manager->purged); + } finally { + $this->app->instance('redis', $original); + Redis::clearResolvedInstance('redis'); + } + } + + public function test_rebuilds_the_connection_for_deletes(): void + { + $manager = $this->swapRedisManager([ + new \RuntimeException('Connection lost'), + 1, + ]); + + $store = new RedisStore('normcache-test'); + $store->delete(['key']); + + $this->assertSame(2, $manager->built); + } + + public function test_surfaces_server_errors_after_a_single_retry(): void + { + // Retry all failures rather than misclassify a lost socket. + $error = new \RuntimeException('WRONGTYPE Operation against a key holding the wrong kind of value'); + $manager = $this->swapRedisManager([$error, $error]); + + $store = new RedisStore('normcache-test'); + + $this->expectExceptionMessage('WRONGTYPE'); + + try { + $store->getRaw('key'); + } finally { + $this->assertSame(2, $manager->built); + } + } + + public function test_does_not_retry_or_purge_for_programming_errors(): void + { + $manager = $this->swapRedisManager([ + new \TypeError('Argument #1 ($key) must be of type string, array given'), + 'cached-value', + ]); + + $store = new RedisStore('normcache-test'); + + $this->expectException(\TypeError::class); + + try { + $store->getRaw('key'); + } finally { + $this->assertSame(1, $manager->built); + $this->assertSame([], $manager->purged); + } + } + + public function test_gives_up_when_the_rebuilt_connection_also_fails(): void + { + $manager = $this->swapRedisManager([ + new \RuntimeException('Redis server went away'), + new \RuntimeException('Connection refused'), + ]); + + $store = new RedisStore('normcache-test'); + + $this->expectExceptionMessage('Connection refused'); + + try { + $store->getRaw('key'); + } finally { + $this->assertSame(2, $manager->built); + } + } + + public function test_cluster_batch_failure_reports_the_failing_state_index(): void + { + $original = $this->app->make('redis'); + $manager = new class + { + public int $built = 0; + + /** @var list */ + public array $purged = []; + + public function connection($name = null): Connection + { + $this->built++; + + return new class(new Client) extends PredisClusterConnection + { + private int $evaluations = 0; + + public function command($method, array $parameters = []) + { + if (strtolower((string) $method) !== 'evalsha') { + return null; + } + + $this->evaluations++; + + if ($this->evaluations === 2) { + throw new \RuntimeException('Second table invalidation failed.'); + } + + return 1; + } + }; + } + + public function purge(string $name): void + { + $this->purged[] = $name; + } + }; + + try { + $this->app->instance('redis', $manager); + Redis::clearResolvedInstance('redis'); + + try { + (new RedisStore('normcache-test'))->invalidateTableStates([ + [ + 'versionKey' => '{first}:version', + 'generationKey' => '{first}:generation', + 'mode' => 'generation', + 'tokens' => [], + 'rowPrefix' => '{first}:rows:', + 'changePrefix' => '{first}:chg:', + 'changePayload' => '', + 'changeTtl' => 60, + ], + [ + 'versionKey' => '{second}:version', + 'generationKey' => '{second}:generation', + 'mode' => 'generation', + 'tokens' => [], + 'rowPrefix' => '{second}:rows:', + 'changePrefix' => '{second}:chg:', + 'changePayload' => '', + 'changeTtl' => 60, + ], + ]); + + $this->fail('Expected the clustered invalidation batch to fail.'); + } catch (TableInvalidationException $exception) { + $this->assertSame(1, $exception->stateIndex); + $this->assertSame( + 'Second table invalidation failed.', + $exception->getPrevious()?->getMessage(), + ); + } + + $this->assertSame(2, $manager->built); + $this->assertSame(['normcache-test'], $manager->purged); + } finally { + $this->app->instance('redis', $original); + Redis::clearResolvedInstance('redis'); + } + } + + /** @param list $responses */ + private function swapRedisManager(array $responses): object + { + $manager = new class($responses) + { + public int $built = 0; + + /** @var list */ + public array $purged = []; + + /** @param list $responses */ + public function __construct(private array $responses) {} + + public function connection($name = null): Connection + { + $response = $this->responses[$this->built] ?? null; + $this->built++; + + $client = new class($response) + { + public function __construct(private mixed $response) {} + + /** @param list $arguments */ + public function __call(string $method, array $arguments): mixed + { + if ($this->response instanceof \Throwable) { + throw $this->response; + } + + return $this->response; + } + }; + + return new class($client) extends Connection + { + public function __construct(mixed $client) + { + $this->client = $client; + } + + public function createSubscription($channels, \Closure $callback, $method = 'subscribe'): void {} + }; + } + + public function purge(string $name): void + { + $this->purged[] = $name; + } + }; + + $this->app->instance('redis', $manager); + + return $manager; + } +} diff --git a/tests/Unit/Relations/CachesRelationAggregatesAliasTest.php b/tests/Unit/Relations/CachesRelationAggregatesAliasTest.php deleted file mode 100644 index 8116fb3..0000000 --- a/tests/Unit/Relations/CachesRelationAggregatesAliasTest.php +++ /dev/null @@ -1,46 +0,0 @@ -setAccessible(true); - - return $method->invoke($builder, $column, $name, $function, $columnArg); - } - - public function test_reads_alias_from_quoted_sql_suffix(): void - { - $column = new Expression('(select count(*) from "posts") as "weird_custom_alias"'); - - $alias = $this->resolveAlias($column, 'posts', 'count', '*'); - - $this->assertSame('weird_custom_alias', $alias); - } - - public function test_reads_alias_from_backtick_quoted_sql_suffix(): void - { - $column = new Expression('(select count(*) from `posts`) as `weird_custom_alias`'); - - $alias = $this->resolveAlias($column, 'posts', 'count', '*'); - - $this->assertSame('weird_custom_alias', $alias); - } - - public function test_falls_back_to_prediction_when_sql_has_no_recognizable_alias(): void - { - $column = new Expression('(select count(*) from "posts")'); - - $alias = $this->resolveAlias($column, 'posts', 'count', '*'); - - $this->assertSame('posts_count', $alias); - } -} diff --git a/tests/Unit/Spaces/CacheSpaceRegistryTest.php b/tests/Unit/Spaces/CacheSpaceRegistryTest.php deleted file mode 100644 index bca05c3..0000000 --- a/tests/Unit/Spaces/CacheSpaceRegistryTest.php +++ /dev/null @@ -1,302 +0,0 @@ -setValue($store, $connection); - - return new CacheSpaceRegistry(metadataStore: $store, metadataKeyPrefix: 'test:'); - } - - public function test_default_space_maps_to_nc_hash_tag(): void - { - $default = $this->registry()->defaultSpace(); - - $this->assertSame('default', $default->name); - $this->assertSame('nc', $default->hashTag); - } - - public function test_named_space_derives_hash_tag_by_convention(): void - { - $space = $this->registry()->space('content'); - - $this->assertSame('content', $space->name); - $this->assertSame('nc:content', $space->hashTag); - } - - public function test_placement_config_overrides_the_hash_tag(): void - { - $registry = new CacheSpaceRegistry(16, ['catalog' => ['hash_tag' => 'shard7']]); - - $this->assertSame('shard7', $registry->space('catalog')->hashTag); - $this->assertSame('nc:content', $registry->space('content')->hashTag); - } - - public function test_known_spaces_include_default_and_materialized_spaces(): void - { - $registry = new CacheSpaceRegistry(16); - $registry->space('catalog'); - - $this->assertSame( - ['default', 'catalog'], - array_map(fn($s) => $s->name, $registry->knownSpaces()), - ); - } - - public function test_known_spaces_include_configured_placement_spaces(): void - { - $registry = new CacheSpaceRegistry(16, ['catalog' => ['hash_tag' => 'shard7']]); - - $this->assertSame( - ['default', 'catalog'], - array_map(fn($s) => $s->name, $registry->knownSpaces()), - ); - } - - public function test_invalid_space_name_throws(): void - { - $this->expectException(\InvalidArgumentException::class); - - $this->registry()->space('has space'); - } - - public function test_logical_spaces_cannot_share_the_same_hash_tag(): void - { - $registry = new CacheSpaceRegistry(16, [ - 'content' => ['hash_tag' => 'shared'], - 'reporting' => ['hash_tag' => 'shared'], - ]); - - $this->expectException(\InvalidArgumentException::class); - - $registry->knownSpaces(); - } - - public function test_model_without_declaration_falls_back_to_default(): void - { - $registry = $this->registry(); - - $spaces = $registry->spacesForModel(Author::class); - - $this->assertSame(['default'], array_map(fn($s) => $s->name, $spaces)); - $this->assertTrue($registry->modelAllowedInSpace(Author::class, 'default')); - $this->assertFalse($registry->modelAllowedInSpace(Author::class, 'content')); - } - - public function test_model_declaration_drives_membership(): void - { - $registry = $this->registry(); - - $spaces = $registry->spacesForModel(SpacedPost::class); - - $this->assertSame(['content'], array_map(fn($s) => $s->name, $spaces)); - $this->assertTrue($registry->modelAllowedInSpace(SpacedPost::class, 'content')); - $this->assertFalse($registry->modelAllowedInSpace(SpacedPost::class, 'default')); - } - - public function test_model_in_too_many_spaces_throws_on_resolution(): void - { - $model = new class extends Model - { - use Cacheable; - - protected static array $normCacheSpaces = ['a', 'b', 'c']; - }; - - $this->expectException(\InvalidArgumentException::class); - - $this->registry(maxPerModel: 2)->spacesForModel($model::class); - } - - public function test_tables_start_in_the_default_space(): void - { - $registry = $this->registry(); - - $this->assertSame(['default'], array_map(fn($s) => $s->name, $registry->spacesForTable('mysql:legacy_flags'))); - } - - public function test_validating_table_dependency_does_not_mutate_registry(): void - { - $registry = $this->registry(); - $content = $registry->space('content'); - - $result = $registry->validateDependencies($content, [], ['mysql:legacy_flags'], includeDependenciesBySpace: true); - - $this->assertTrue($result->isValid); - $this->assertContains('content', $result->dependenciesBySpace['mysql:legacy_flags']); - $this->assertSame( - ['default'], - array_map(fn($s) => $s->name, $registry->spacesForTable('mysql:legacy_flags')), - ); - } - - public function test_table_dependencies_can_be_registered_after_plan_acceptance(): void - { - $registry = $this->registry(); - $content = $registry->space('content'); - - $this->assertTrue($registry->registerTableDependencies($content, ['mysql:legacy_flags'])); - - $this->assertContains( - 'content', - array_map(fn($s) => $s->name, $registry->spacesForTable('mysql:legacy_flags')), - ); - } - - public function test_failed_table_space_registration_is_reported_and_not_memoized(): void - { - $connection = new class extends PredisConnection - { - public function __construct() {} - - public function command($method, array $parameters = []) - { - return match (strtolower($method)) { - 'smembers' => [], - 'sadd' => throw new RuntimeException('SADD denied'), - default => null, - }; - } - }; - $registry = $this->registryWithConnection($connection); - $content = $registry->space('content'); - - $this->assertFalse($registry->registerTableDependencies($content, ['mysql:legacy_flags'])); - $this->assertSame( - ['default'], - array_map(fn($s) => $s->name, $registry->spacesForTable('mysql:legacy_flags')), - ); - } - - public function test_failed_table_space_lookup_falls_back_without_memoizing_the_failure(): void - { - $connection = new class extends PredisConnection - { - private int $lookups = 0; - - public function __construct() {} - - public function command($method, array $parameters = []) - { - if (strtolower($method) !== 'smembers') { - return null; - } - - if ($this->lookups++ === 0) { - throw new RuntimeException('SMEMBERS denied'); - } - - return ['content']; - } - }; - $registry = $this->registryWithConnection($connection); - - $this->assertSame( - ['default'], - array_map(fn($s) => $s->name, $registry->spacesForTable('mysql:legacy_flags')), - ); - $this->assertSame( - ['default', 'content'], - array_map(fn($s) => $s->name, $registry->spacesForTable('mysql:legacy_flags')), - ); - } - - public function test_table_space_lookup_is_memoized_until_runtime_reset(): void - { - $connection = new class extends PredisConnection - { - public int $lookups = 0; - - public function __construct() {} - - public function command($method, array $parameters = []) - { - if (strtolower($method) === 'smembers') { - $this->lookups++; - - return ['content']; - } - - return null; - } - }; - $registry = $this->registryWithConnection($connection); - - $registry->spacesForTable('mysql:legacy_flags'); - $registry->spacesForTable('mysql:legacy_flags'); - - $this->assertSame(1, $connection->lookups); - - $registry->resetMetadataMemo(); - $registry->spacesForTable('mysql:legacy_flags'); - - $this->assertSame(2, $connection->lookups); - } - - public function test_single_base_model_dependencies_do_not_need_validation(): void - { - $registry = $this->registry(); - - $this->assertTrue($registry->dependenciesAreOnlyModel(Author::class, [Author::class], [])); - $this->assertFalse($registry->dependenciesAreOnlyModel(Author::class, [Author::class, SpacedPost::class], [])); - $this->assertFalse($registry->dependenciesAreOnlyModel(Author::class, [Author::class], ['mysql:legacy_flags'])); - } - - public function test_validate_dependencies_passes_when_all_allowed(): void - { - $registry = $this->registry(); - $content = $registry->space('content'); - - $result = $registry->validateDependencies($content, [SpacedPost::class], []); - - $this->assertTrue($result->isValid); - $this->assertSame([], $result->invalidModels); - $this->assertSame([], $result->dependenciesBySpace); - } - - public function test_validate_dependencies_can_build_map_for_explain(): void - { - $registry = $this->registry(); - $content = $registry->space('content'); - - $result = $registry->validateDependencies($content, [SpacedPost::class], [], includeDependenciesBySpace: true); - - $this->assertTrue($result->isValid); - $this->assertSame(['content'], $result->dependenciesBySpace[SpacedPost::class]); - } - - public function test_validate_dependencies_reports_cross_space_members(): void - { - $registry = $this->registry(); - $content = $registry->space('content'); - - // Author is default-only; raw table dependencies are valid in the active space. - $result = $registry->validateDependencies($content, [SpacedPost::class, Author::class], ['mysql:legacy_flags']); - - $this->assertFalse($result->isValid); - $this->assertSame([Author::class], $result->invalidModels); - $this->assertSame(['default'], $result->dependenciesBySpace[Author::class]); - $this->assertSame(['content'], $result->dependenciesBySpace[SpacedPost::class]); - $this->assertContains('content', $result->dependenciesBySpace['mysql:legacy_flags']); - } -} diff --git a/tests/Unit/Spaces/CacheSpaceResolverTest.php b/tests/Unit/Spaces/CacheSpaceResolverTest.php deleted file mode 100644 index ece148e..0000000 --- a/tests/Unit/Spaces/CacheSpaceResolverTest.php +++ /dev/null @@ -1,40 +0,0 @@ -assertSame('default', $this->resolver()->resolve(Author::class, null)->name); - } - - public function test_declared_model_resolves_to_its_first_space(): void - { - // SpacedPost declares ['content']. - $this->assertSame('content', $this->resolver()->resolve(SpacedPost::class, null)->name); - } - - public function test_explicit_member_space_is_used(): void - { - $this->assertSame('content', $this->resolver()->resolve(SpacedPost::class, 'content')->name); - } - - public function test_explicit_non_member_space_throws(): void - { - $this->expectException(\InvalidArgumentException::class); - - $this->resolver()->resolve(SpacedPost::class, 'reporting'); - } -} diff --git a/tests/Unit/Spaces/CacheableSpacesDeclarationTest.php b/tests/Unit/Spaces/CacheableSpacesDeclarationTest.php deleted file mode 100644 index fce5b9e..0000000 --- a/tests/Unit/Spaces/CacheableSpacesDeclarationTest.php +++ /dev/null @@ -1,20 +0,0 @@ -assertSame([], Author::normCacheSpaces()); - } - - public function test_model_with_declaration_returns_its_spaces(): void - { - $this->assertSame(['content'], SpacedPost::normCacheSpaces()); - } -} diff --git a/tests/Unit/SqlVolatilityScannerTest.php b/tests/Unit/SqlVolatilityScannerTest.php new file mode 100644 index 0000000..2ffa407 --- /dev/null +++ b/tests/Unit/SqlVolatilityScannerTest.php @@ -0,0 +1,90 @@ +assertTrue((new SqlVolatilityScanner)->isVolatile($sql)); + } + + public static function volatileExpressions(): array + { + return [ + 'MySQL advisory lock' => ['GET_LOCK(\'normcache\', 0)'], + 'MySQL advisory unlock' => ['RELEASE_LOCK(\'normcache\')'], + 'PostgreSQL advisory lock' => ['pg_try_advisory_xact_lock(42)'], + 'PostgreSQL advisory unlock' => ['pg_advisory_unlock_all()'], + 'SQL Server advisory lock' => ['sp_getapplock(\'normcache\', \'Exclusive\')'], + 'schema-qualified volatile function' => ['pg_catalog.random()'], + 'volatile keyword' => ['CURRENT_TIMESTAMP'], + 'sequence syntax' => ['NEXT VALUE FOR dbo.order_seq'], + 'multiline sequence syntax' => ["NEXT\nVALUE FOR dbo.order_seq"], + 'sequence function in projection' => ['select nextval(\'order_seq\') as id'], + 'sequence function in aggregate' => ['select max(nextval(\'order_seq\'))'], + 'sequence function in ordering' => ['select id from posts order by nextval(\'order_seq\')'], + 'sequence function in predicate' => ['select id from posts where id < nextval(\'order_seq\')'], + 'SQL Server UTC clock' => ['GETUTCDATE()'], + 'SQL Server offset clock' => ['SYSDATETIMEOFFSET()'], + 'MySQL epoch clock' => ['UNIX_TIMESTAMP()'], + 'MySQL epoch conversion' => ['UNIX_TIMESTAMP(posts.created_at)'], + 'MySQL session variable' => ['@tenant_id = 42'], + 'MySQL connection state' => ['CONNECTION_ID()'], + 'PostgreSQL connection state' => ['current_setting(\'application_name\')'], + 'SQL Server connection state' => ['SESSION_CONTEXT(N\'tenant\')'], + 'argument-dependent function' => ['datetime(\'now\', \'+1 day\')'], + 'literal ending in a backslash' => ["select * from t where path = 'a\\' and r = random()"], + 'windows path before a lock call' => ["select * from t where path = 'c:\\' and x = get_lock('nc', 1)"], + 'unterminated literal' => ["select * from t where note = 'oops and r = random()"], + ]; + } + + #[DataProvider('undetectedExpressions')] + public function test_it_does_not_attempt_to_detect_unrecognised_calls(string $sql): void + { + $this->assertFalse((new SqlVolatilityScanner)->isVolatile($sql)); + } + + public static function undetectedExpressions(): array + { + return [ + 'unknown function' => ['vendor_schema.custom_score(users.id)'], + 'quoted unknown function' => ['"custom_score"(users.id)'], + 'unknown nested function' => ['coalesce(custom_score(id), 0)'], + 'commented unknown function' => ['custom_score/**/(id)'], + 'SQL comment' => ['select count(*) /* stable but opaque */ from posts'], + 'quoted SQL keyword function' => ['"select"(id)'], + ]; + } + + #[DataProvider('deterministicExpressions')] + public function test_it_accepts_ordinary_sql_and_known_deterministic_functions( + string $sql, + ): void { + $this->assertFalse((new SqlVolatilityScanner)->isVolatile($sql)); + } + + public static function deterministicExpressions(): array + { + return [ + 'deterministic functions' => ['ROUND(AVG(order_items.price), 2)'], + 'fixed date argument' => ['date(\'2026-08-04\')'], + 'ordinary query' => ['select * from "posts" where "published" = ? order by "created_at" asc'], + 'MySQL parenthesized union all' => [ + '(select * from `authors` where `name` = ?) union all (select * from `authors` where `name` = ?)', + ], + 'PostgreSQL lateral join' => [ + 'select "authors".* from "authors" left join lateral (select "posts".* from "posts") as "latest" on true', + ], + 'literal at-sign address' => ['select * from users where email like \'%@gmail.com\''], + 'literal at-sign handle' => ['select * from users where handle = \'@laravelphp\''], + 'volatile name inside a doubled-quote literal' => ['select * from t where note = \'it\'\'s random()\''], + ]; + } +} diff --git a/tests/Unit/Support/CacheKeyBuilderTest.php b/tests/Unit/Support/CacheKeyBuilderTest.php deleted file mode 100644 index b60ce0f..0000000 --- a/tests/Unit/Support/CacheKeyBuilderTest.php +++ /dev/null @@ -1,147 +0,0 @@ -assertSame('{nc:content}:test:ver:mysql:posts:', $keys->verKey('mysql:posts', $content)); - $this->assertSame('{nc:content}:test:model:mysql:posts:v3:', $keys->modelPrefix('mysql:posts', 3, $content)); - $this->assertSame('{nc:content}:test:query:mysql:posts:', $keys->queryPrefix('mysql:posts', null, $content)); - $this->assertSame('{nc:content}:test:query:*', $keys->prefixed('query:*', $content)); - } - - public function test_version_key_pair_uses_the_active_space(): void - { - $keys = new CacheKeyBuilder('{nc}:', 'test:'); - $content = new CacheSpace('content', 'nc:content'); - - $this->assertSame([ - '{nc:content}:test:ver:mysql:posts:', - '{nc:content}:test:scheduled:mysql:posts:', - ], $keys->versionKeyPair('mysql:posts', $content)); - } - - public function test_null_space_keeps_the_default_tag(): void - { - $keys = new CacheKeyBuilder('{nc}:', 'test:'); - - $this->assertSame('{nc}:test:ver:mysql:posts:', $keys->verKey('mysql:posts')); - } - - public function test_class_key_can_be_scoped_to_an_effective_connection(): void - { - $keys = new CacheKeyBuilder; - - $this->assertSame('testing:authors', $keys->classKey(Author::class)); - $this->assertSame('secondary_testing:authors', $keys->classKey(Author::class, 'secondary_testing')); - } - - public function test_table_key_strips_an_explicit_sql_alias(): void - { - $keys = new CacheKeyBuilder; - - $this->assertSame('testing:authors', $keys->tableKey('testing', 'authors as a')); - $this->assertSame('authors', CacheKeyBuilder::stripTableAlias('authors as a')); - } - - public function test_class_key_rejects_connection_name_containing_colon(): void - { - $model = new class extends Model - { - protected $connection = 'tenant:7'; - }; - - $keys = new CacheKeyBuilder; - - $this->expectException(\InvalidArgumentException::class); - - $keys->classKey($model::class); - } - - public function test_version_keys_are_brace_free(): void - { - $keys = new CacheKeyBuilder('', ''); - - $this->assertSame('ver:mysql:posts:', $keys->verKey('mysql:posts')); - $this->assertSame('scheduled:mysql:posts:', $keys->scheduledKey('mysql:posts')); - $this->assertSame('building:mysql:posts:', $keys->buildingPrefix('mysql:posts')); - $this->assertSame('wake:mysql:posts:', $keys->wakePrefix('mysql:posts')); - $this->assertSame('model:mysql:posts:v3:', $keys->modelPrefix('mysql:posts', 3)); - } - - public function test_dep_key_pairs_respects_active_space_in_static_cache(): void - { - $keys = new CacheKeyBuilder('{nc}:', 'test:'); - $content = new CacheSpace('content', 'nc:content'); - - [$defaultVer] = $keys->depKeyPairs('mysql:posts', []); - [$contentVer] = $keys->withSpace($content, fn() => $keys->depKeyPairs('mysql:posts', [])); - - $this->assertSame('{nc}:test:ver:mysql:posts:', $defaultVer[0]); - $this->assertSame('{nc:content}:test:ver:mysql:posts:', $contentVer[0]); - $this->assertNotSame($defaultVer[0], $contentVer[0], 'same classKey under two spaces must produce distinct version keys'); - } - - public function test_dep_key_pairs_resolves_each_dependency_classes_own_connection(): void - { - $keys = new CacheKeyBuilder; - $secondaryDep = new class extends Model - { - protected $connection = 'secondary_testing'; - - protected $table = 'secondary_dep_models'; - }; - - $expectedDepKey = $keys->classKey($secondaryDep::class); - $this->assertSame('secondary_testing:secondary_dep_models', $expectedDepKey); - - [$versionKeys] = $keys->depKeyPairs('testing:authors', [$secondaryDep::class]); - - $this->assertContains($keys->verKey($expectedDepKey), $versionKeys); - $this->assertContains($keys->verKey('testing:authors'), $versionKeys); - } - - public function test_active_space_is_exposed_and_restored(): void - { - $keys = new CacheKeyBuilder('{nc}:', 'test:'); - $content = new CacheSpace('content', 'nc:content'); - - $seen = $keys->withSpace($content, fn() => $keys->activeSpace()); - - $this->assertSame($content, $seen); - $this->assertNull($keys->activeSpace()); - } - - public function test_key_methods_emit_full_keys_with_hash_tag_and_prefix(): void - { - $keys = new CacheKeyBuilder('{nc}:', 'test:'); - - $this->assertSame('{nc}:test:ver:mysql:posts:', $keys->verKey('mysql:posts')); - $this->assertSame('{nc}:test:scheduled:mysql:posts:', $keys->scheduledKey('mysql:posts')); - $this->assertSame('{nc}:test:building:mysql:posts:', $keys->buildingPrefix('mysql:posts')); - $this->assertSame('{nc}:test:wake:mysql:posts:', $keys->wakePrefix('mysql:posts')); - $this->assertSame('{nc}:test:model:mysql:posts:v3:', $keys->modelPrefix('mysql:posts', 3)); - $this->assertSame('{nc}:test:query:mysql:posts:', $keys->queryPrefix('mysql:posts')); - $this->assertSame('{nc}:test:query:*', $keys->prefixed('query:*')); - } - - public function test_result_build_identity_uses_xxh128(): void - { - $keys = new CacheKeyBuilder; - $hash = $keys->resultBuildIdentityHash('scalar', 'report', 'query-hash'); - - $this->assertSame(32, strlen($hash)); - $this->assertSame(hash('xxh128', 'scalar:report:query-hash'), $hash); - } -} diff --git a/tests/Unit/Support/CacheSerializerTest.php b/tests/Unit/Support/CacheSerializerTest.php deleted file mode 100644 index cfbffa6..0000000 --- a/tests/Unit/Support/CacheSerializerTest.php +++ /dev/null @@ -1,66 +0,0 @@ -serializer = new CacheSerializer; - } - - public function test_integer_roundtrip(): void - { - $this->assertSame(42, $this->serializer->unserialize($this->serializer->serialize(42))); - } - - public function test_float_roundtrip(): void - { - $this->assertSame(3.14, $this->serializer->unserialize($this->serializer->serialize(3.14))); - } - - public function test_whole_number_float_roundtrip_preserves_float_type(): void - { - $this->assertSame(5.0, $this->serializer->unserialize($this->serializer->serialize(5.0))); - $this->assertSame(1500.0, $this->serializer->unserialize($this->serializer->serialize(1500.0))); - $this->assertSame(0.0, $this->serializer->unserialize($this->serializer->serialize(0.0))); - $this->assertSame(-2.0, $this->serializer->unserialize($this->serializer->serialize(-2.0))); - } - - public function test_array_roundtrip(): void - { - $data = ['name' => 'Alice', 'age' => 30]; - $this->assertSame($data, $this->serializer->unserialize($this->serializer->serialize($data))); - } - - public function test_zero_roundtrip(): void - { - $this->assertSame(0, $this->serializer->unserialize($this->serializer->serialize(0))); - } - - public function test_unserialize_many_handles_null_entries(): void - { - $serialized = $this->serializer->serialize(['key' => 'val']); - $result = $this->serializer->unserializeMany([$serialized, null, false]); - - $this->assertSame(['key' => 'val'], $result[0]); - $this->assertNull($result[1]); - $this->assertNull($result[2]); - } - - public function test_unserialize_numeric_string_returns_int(): void - { - $this->assertSame(7, $this->serializer->unserialize('7')); - } - - public function test_unserialize_float_string_returns_float(): void - { - $this->assertSame(1.5, $this->serializer->unserialize('1.5')); - } -} diff --git a/tests/Unit/Support/ProjectionClassifierTest.php b/tests/Unit/Support/ProjectionClassifierTest.php deleted file mode 100644 index 423c29e..0000000 --- a/tests/Unit/Support/ProjectionClassifierTest.php +++ /dev/null @@ -1,66 +0,0 @@ -makeBaseQuery(columns: $columns), null); - - $this->assertSame($columns, $resolvedColumns); - $this->assertTrue(ProjectionClassifier::hasCalculatedColumns($resolvedColumns)); - } - - public function test_is_exact_full_model_projection(): void - { - $this->assertTrue(ProjectionClassifier::isExactFullModelProjection(null, 'authors')); - $this->assertTrue(ProjectionClassifier::isExactFullModelProjection(['*'], 'authors')); - $this->assertTrue(ProjectionClassifier::isExactFullModelProjection(['authors.*'], 'authors')); - $this->assertFalse(ProjectionClassifier::isExactFullModelProjection(['id'], 'authors')); - $this->assertFalse(ProjectionClassifier::isExactFullModelProjection(['authors.id'], 'authors')); - } - - public function test_has_required_key(): void - { - $this->assertTrue(ProjectionClassifier::hasRequiredKey(['*'], 'authors', 'id')); - $this->assertTrue(ProjectionClassifier::hasRequiredKey(['authors.*'], 'authors', 'id')); - $this->assertTrue(ProjectionClassifier::hasRequiredKey(['id'], 'authors', 'id')); - $this->assertTrue(ProjectionClassifier::hasRequiredKey(['authors.id'], 'authors', 'id')); - $this->assertFalse(ProjectionClassifier::hasRequiredKey(['name'], 'authors', 'id')); - } - - /** - * @param array|null $columns - */ - private function makeBaseQuery(?array $columns, string $from = 'authors'): Builder - { - $query = new Builder( - connection: $this->createStub(ConnectionInterface::class), - grammar: $this->createStub(QueryGrammar::class), - processor: $this->createStub(Processor::class), - ); - - $query->columns = $columns; - $query->from = $from; - - return $query; - } -} diff --git a/tests/Unit/Support/QueryHasherTest.php b/tests/Unit/Support/QueryHasherTest.php deleted file mode 100644 index ac6eae5..0000000 --- a/tests/Unit/Support/QueryHasherTest.php +++ /dev/null @@ -1,389 +0,0 @@ -app['db']->query(); - } - - private function makeEloquentBuilder(): CacheableBuilder - { - return Author::query(); - } - - public function test_same_query_produces_identical_hash(): void - { - $a = $this->makeBuilder()->from('posts')->where('id', 1); - $b = $this->makeBuilder()->from('posts')->where('id', 1); - - $this->assertSame(QueryHasher::fromQuery($a), QueryHasher::fromQuery($b)); - } - - public function test_different_bindings_produce_different_hash(): void - { - $a = $this->makeBuilder()->from('posts')->where('id', 1); - $b = $this->makeBuilder()->from('posts')->where('id', 2); - - $this->assertNotSame(QueryHasher::fromQuery($a), QueryHasher::fromQuery($b)); - } - - public function test_different_sql_produces_different_hash(): void - { - $a = $this->makeBuilder()->from('posts')->where('id', 1); - $b = $this->makeBuilder()->from('authors')->where('id', 1); - - $this->assertNotSame(QueryHasher::fromQuery($a), QueryHasher::fromQuery($b)); - } - - public function test_use_write_pdo_produces_different_hash(): void - { - $read = $this->makeBuilder()->from('authors')->where('id', 1); - $write = $this->makeBuilder()->from('authors')->where('id', 1)->useWritePdo(); - - $this->assertNotSame(QueryHasher::fromQuery($read), QueryHasher::fromQuery($write)); - } - - public function test_hashes_raw_strings(): void - { - $hash = QueryHasher::hash('some data'); - $this->assertIsString($hash); - $this->assertEquals(32, strlen($hash)); - $this->assertSame(hash('xxh128', 'some data'), $hash); - } - - public function test_pagination_count_hash_differs_from_normalized_query_hash(): void - { - $builder = $this->makeEloquentBuilder()->where('id', 1); - - $this->assertNotSame( - QueryHasher::forModelIndexQuery($builder, $builder->toBase()), - QueryHasher::forPaginationCountQuery($builder, $builder->toBase()) - ); - } - - public function test_pagination_count_hash_is_stable_for_identical_queries(): void - { - $a = $this->makeEloquentBuilder()->where('id', 1); - $b = $this->makeEloquentBuilder()->where('id', 1); - - $this->assertSame( - QueryHasher::forPaginationCountQuery($a, $a->toBase()), - QueryHasher::forPaginationCountQuery($b, $b->toBase()) - ); - } - - public function test_pagination_count_hash_strips_column_selection(): void - { - $a = $this->makeEloquentBuilder()->where('id', 1)->select('id'); - $b = $this->makeEloquentBuilder()->where('id', 1)->select('name'); - - $this->assertSame( - QueryHasher::forPaginationCountQuery($a, $a->toBase()), - QueryHasher::forPaginationCountQuery($b, $b->toBase()) - ); - } - - public function test_pagination_count_hash_differs_when_where_clause_differs(): void - { - $a = $this->makeEloquentBuilder()->where('id', 1); - $b = $this->makeEloquentBuilder()->where('id', 2); - - $this->assertNotSame( - QueryHasher::forPaginationCountQuery($a, $a->toBase()), - QueryHasher::forPaginationCountQuery($b, $b->toBase()) - ); - } - - public function test_order_insensitive_scalar_hash_strips_order_clauses(): void - { - $plain = $this->makeEloquentBuilder()->where('id', '>', 0); - - $this->assertSame( - QueryHasher::forScalarQuery($plain, $plain->toBase(), 'count', ['*']), - QueryHasher::forScalarQuery($plain, $plain->toBase(), 'count', ['*']) - ); - - $a = $this->makeEloquentBuilder()->orderBy('name'); - $b = $this->makeEloquentBuilder()->orderBy('id'); - - $this->assertSame( - QueryHasher::forScalarQuery($a, $a->toBase(), 'count', ['*']), - QueryHasher::forScalarQuery($b, $b->toBase(), 'count', ['*']) - ); - - $this->assertSame( - QueryHasher::forScalarQuery($a, $a->toBase(), 'sum', ['id']), - QueryHasher::forScalarQuery($b, $b->toBase(), 'sum', ['id']) - ); - - $this->assertSame( - QueryHasher::forScalarQuery($a, $a->toBase(), 'exists', []), - QueryHasher::forScalarQuery($b, $b->toBase(), 'exists', []) - ); - } - - public function test_order_sensitive_scalar_hash_keeps_order_clauses(): void - { - $a = $this->makeEloquentBuilder()->orderBy('name'); - $b = $this->makeEloquentBuilder()->orderBy('id'); - - $this->assertNotSame( - QueryHasher::forScalarQuery($a, $a->toBase(), 'value', ['name']), - QueryHasher::forScalarQuery($b, $b->toBase(), 'value', ['name']) - ); - - $this->assertNotSame( - QueryHasher::forScalarQuery($a, $a->toBase(), 'pluck', ['name']), - QueryHasher::forScalarQuery($b, $b->toBase(), 'pluck', ['name']) - ); - } - - public function test_for_relation_query_strips_specific_key(): void - { - $a = $this->makeEloquentBuilder()->where('author_id', 1)->where('active', true); - $b = $this->makeEloquentBuilder()->where('author_id', 2)->where('active', true); - - // Should be same because author_id is stripped - $this->assertSame( - QueryHasher::forRelationQuery('author_id', $a->toBase()), - QueryHasher::forRelationQuery('author_id', $b->toBase()) - ); - - $c = $this->makeEloquentBuilder()->where('author_id', 1)->where('active', false); - - // Should be different because active is NOT stripped - $this->assertNotSame( - QueryHasher::forRelationQuery('author_id', $a->toBase()), - QueryHasher::forRelationQuery('author_id', $c->toBase()) - ); - } - - public function test_relation_hash_same_for_different_fk_batch_sizes(): void - { - $a = $this->makeEloquentBuilder()->whereIn('author_id', [1, 2, 3])->where('active', true); - $b = $this->makeEloquentBuilder()->whereIn('author_id', [4, 5])->where('active', true); - - $this->assertSame( - QueryHasher::forRelationQuery('author_id', $a->toBase()), - QueryHasher::forRelationQuery('author_id', $b->toBase()) - ); - } - - public function test_relation_hash_captures_basic_where_value(): void - { - $a = $this->makeEloquentBuilder()->where('author_id', 1)->where('status', 1); - $b = $this->makeEloquentBuilder()->where('author_id', 1)->where('status', 2); - - $this->assertNotSame( - QueryHasher::forRelationQuery('author_id', $a->toBase()), - QueryHasher::forRelationQuery('author_id', $b->toBase()) - ); - } - - public function test_relation_hash_captures_where_in_values(): void - { - $a = $this->makeEloquentBuilder()->whereIn('type', [1, 2]); - $b = $this->makeEloquentBuilder()->whereIn('type', [3, 4]); - - $this->assertNotSame( - QueryHasher::forRelationQuery('fake_fk', $a->toBase()), - QueryHasher::forRelationQuery('fake_fk', $b->toBase()) - ); - } - - public function test_relation_hash_captures_where_between_values(): void - { - $a = $this->makeEloquentBuilder()->whereBetween('age', [18, 30]); - $b = $this->makeEloquentBuilder()->whereBetween('age', [25, 40]); - - $this->assertNotSame( - QueryHasher::forRelationQuery('fake_fk', $a->toBase()), - QueryHasher::forRelationQuery('fake_fk', $b->toBase()) - ); - } - - public function test_relation_hash_captures_raw_order_bindings(): void - { - $a = $this->makeEloquentBuilder()->orderByRaw('CASE WHEN name = ? THEN 0 ELSE 1 END', ['Alice']); - $b = $this->makeEloquentBuilder()->orderByRaw('CASE WHEN name = ? THEN 0 ELSE 1 END', ['Bob']); - - $this->assertNotSame( - QueryHasher::forRelationQuery('fake_fk', $a->toBase()), - QueryHasher::forRelationQuery('fake_fk', $b->toBase()) - ); - } - - public function test_relation_hash_distinguishes_null_check_types(): void - { - $a = $this->makeEloquentBuilder()->whereNull('deleted_at'); - $b = $this->makeEloquentBuilder()->whereNotNull('deleted_at'); - - $this->assertNotSame( - QueryHasher::forRelationQuery('fake_fk', $a->toBase()), - QueryHasher::forRelationQuery('fake_fk', $b->toBase()) - ); - } - - public function test_relation_hash_distinguishes_null_check_columns(): void - { - $a = $this->makeEloquentBuilder()->whereNull('deleted_at'); - $b = $this->makeEloquentBuilder()->whereNull('published_at'); - - $this->assertNotSame( - QueryHasher::forRelationQuery('fake_fk', $a->toBase()), - QueryHasher::forRelationQuery('fake_fk', $b->toBase()) - ); - } - - public function test_relation_hash_captures_nested_where_difference(): void - { - $a = $this->makeEloquentBuilder()->where(fn($q) => $q->where('active', true)->where('type', 1)); - $b = $this->makeEloquentBuilder()->where(fn($q) => $q->where('active', true)->where('type', 2)); - - $this->assertNotSame( - QueryHasher::forRelationQuery('fake_fk', $a->toBase()), - QueryHasher::forRelationQuery('fake_fk', $b->toBase()) - ); - } - - public function test_relation_hash_captures_exists_subquery_difference(): void - { - $sub1 = $this->makeBuilder()->from('posts')->where('published', true); - $sub2 = $this->makeBuilder()->from('posts')->where('published', false); - - $a = $this->makeEloquentBuilder()->whereExists($sub1); - $b = $this->makeEloquentBuilder()->whereExists($sub2); - - $this->assertNotSame( - QueryHasher::forRelationQuery('fake_fk', $a->toBase()), - QueryHasher::forRelationQuery('fake_fk', $b->toBase()) - ); - } - - public function test_relation_hash_is_stable(): void - { - $builder = $this->makeEloquentBuilder()->where('author_id', 1)->where('active', true); - - $this->assertSame( - QueryHasher::forRelationQuery('author_id', $builder->toBase()), - QueryHasher::forRelationQuery('author_id', $builder->toBase()) - ); - } - - public function test_relation_hash_stable_when_only_fk_where_present(): void - { - $a = $this->makeEloquentBuilder()->where('author_id', 99); - $b = $this->makeEloquentBuilder()->where('author_id', 42); - - $this->assertSame( - QueryHasher::forRelationQuery('author_id', $a->toBase()), - QueryHasher::forRelationQuery('author_id', $b->toBase()) - ); - } - - public function test_normalize_value_for_hash_is_recursive(): void - { - $subquery = $this->makeBuilder()->from('users')->select('id')->where('active', true); - $value = [ - 'nested' => [ - 'query' => $subquery, - 'date' => new \DateTime('2023-01-01 12:00:00'), - ], - ]; - - $normalized = QueryHasher::normalizeValueForHash($value); - - $this->assertIsArray($normalized); - $this->assertArrayHasKey('nested', $normalized); - $this->assertArrayHasKey('query', $normalized['nested']); - $this->assertEquals('select "id" from "users" where "active" = ?', $normalized['nested']['query']['sql']); - $this->assertEquals('2023-01-01 12:00:00', $normalized['nested']['date']); - } - - public function test_relation_hash_differs_for_where_column_constraints(): void - { - $a = $this->makeEloquentBuilder()->whereColumn('a', '=', 'b'); - $b = $this->makeEloquentBuilder()->whereColumn('c', '=', 'd'); - - $this->assertNotSame( - QueryHasher::forRelationQuery('fake_fk', $a->toBase()), - QueryHasher::forRelationQuery('fake_fk', $b->toBase()) - ); - } - - public function test_relation_hash_differs_for_where_integer_in_raw_values(): void - { - $a = $this->makeEloquentBuilder()->whereIntegerInRaw('status', [1, 2, 3]); - $b = $this->makeEloquentBuilder()->whereIntegerInRaw('status', [4, 5, 6]); - - $this->assertNotSame( - QueryHasher::forRelationQuery('fake_fk', $a->toBase()), - QueryHasher::forRelationQuery('fake_fk', $b->toBase()) - ); - } - - public function test_relation_hash_differs_for_where_integer_not_in_raw_values(): void - { - $a = $this->makeEloquentBuilder()->whereIntegerNotInRaw('status', [1, 2]); - $b = $this->makeEloquentBuilder()->whereIntegerNotInRaw('status', [3, 4]); - - $this->assertNotSame( - QueryHasher::forRelationQuery('fake_fk', $a->toBase()), - QueryHasher::forRelationQuery('fake_fk', $b->toBase()) - ); - } - - public function test_relation_hash_differs_for_where_between_columns(): void - { - $a = $this->makeEloquentBuilder()->whereBetweenColumns('price', ['min_price', 'max_price']); - $b = $this->makeEloquentBuilder()->whereBetweenColumns('price', ['low', 'high']); - - $this->assertNotSame( - QueryHasher::forRelationQuery('fake_fk', $a->toBase()), - QueryHasher::forRelationQuery('fake_fk', $b->toBase()) - ); - } - - public function test_relation_hash_differs_for_where_json_contains_value(): void - { - $a = $this->makeEloquentBuilder()->whereJsonContains('settings->theme', 'dark'); - $b = $this->makeEloquentBuilder()->whereJsonContains('settings->theme', 'light'); - - $this->assertNotSame( - QueryHasher::forRelationQuery('fake_fk', $a->toBase()), - QueryHasher::forRelationQuery('fake_fk', $b->toBase()) - ); - } - - public function test_relation_hash_differs_for_where_row_values_constraints(): void - { - $a = $this->makeEloquentBuilder()->whereRowValues(['first_name', 'last_name'], '=', ['John', 'Doe']); - $b = $this->makeEloquentBuilder()->whereRowValues(['first_name', 'last_name'], '=', ['Jane', 'Smith']); - - $this->assertNotSame( - QueryHasher::forRelationQuery('fake_fk', $a->toBase()), - QueryHasher::forRelationQuery('fake_fk', $b->toBase()) - ); - } - - public function test_query_hash_normalizes_binary_string_bindings(): void - { - $a = $this->makeEloquentBuilder()->where('data', "\x80\x81\x82\xFF"); - $b = $this->makeEloquentBuilder()->where('data', "\x83\x84\x85"); - - // Must not throw JsonException from json_encode on invalid UTF-8 - $hashA = QueryHasher::forRelationQuery('fake_fk', $a->toBase()); - $hashB = QueryHasher::forRelationQuery('fake_fk', $b->toBase()); - - $this->assertNotSame($hashA, $hashB); - } -} diff --git a/tests/Unit/Support/RedisScriptsTest.php b/tests/Unit/Support/RedisScriptsTest.php deleted file mode 100644 index c71e9fe..0000000 --- a/tests/Unit/Support/RedisScriptsTest.php +++ /dev/null @@ -1,42 +0,0 @@ -expectException(\RuntimeException::class); - RedisScripts::get('non_existent_script'); - } - - /** Every Lua script on disk must have at least one RedisScripts::get('name') call site in src/. */ - public function test_all_lua_scripts_are_referenced_by_the_source(): void - { - $srcDir = __DIR__ . '/../../../src'; - $luaDir = $srcDir . '/Lua'; - - $scriptNames = array_map( - fn(string $path) => basename($path, '.lua'), - glob($luaDir . '/*.lua') - ); - - $phpSource = ''; - $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($srcDir)); - foreach ($iterator as $file) { - if ($file->getExtension() === 'php') { - $phpSource .= file_get_contents($file->getPathname()); - } - } - - preg_match_all('/RedisScripts::get\([\'"]([^\'"]+)[\'"]\)/', $phpSource, $matches); - $usedNames = array_unique($matches[1]); - - $unused = array_diff($scriptNames, $usedNames); - - $this->assertEmpty($unused, 'Unused Lua scripts found: ' . implode(', ', $unused)); - } -} diff --git a/tests/Unit/Support/RedisStoreTest.php b/tests/Unit/Support/RedisStoreTest.php deleted file mode 100644 index d283e29..0000000 --- a/tests/Unit/Support/RedisStoreTest.php +++ /dev/null @@ -1,699 +0,0 @@ -store = new RedisStore('normcache-test'); - } - - public function test_sets_and_gets_values(): void - { - $this->store->set('foo', 'bar', 60); - $this->assertSame('bar', $this->store->get('foo')); - } - - public function test_sets_nx_ex_values(): void - { - $this->store->delete('foo'); - $this->assertTrue($this->store->setNxEx('foo', 'bar', 60)); - $this->assertSame('bar', $this->store->get('foo')); - - $this->assertFalse($this->store->setNxEx('foo', 'baz', 60)); - $this->assertSame('bar', $this->store->get('foo')); - } - - public function test_deletes_keys(): void - { - $this->store->set('foo', 'bar', 60); - $this->store->delete('foo'); - $this->assertNull($this->store->get('foo')); - } - - public function test_increments_values(): void - { - $this->store->delete('foo'); - $this->assertSame(1, $this->store->increment('foo')); - $this->assertSame(2, $this->store->increment('foo')); - } - - public function test_releases_building_locks(): void - { - $this->store->set('{t}:build:foo', '1', 60); - $this->store->releaseBuilding('{t}:build:foo', '{t}:wake:foo'); - - $this->assertNull($this->store->getRaw('{t}:build:foo')); - $this->assertTrue($this->store->brpop('{t}:wake:foo', 1)); - } - - public function test_release_building_pushes_configured_wake_tokens(): void - { - $store = new RedisStore('normcache-test', wakeTokenCount: 3); - - $store->set('{t}:build:tokens', '1', 60); - $store->releaseBuilding('{t}:build:tokens', '{t}:wake:tokens'); - - $this->assertSame(3, (int) $store->script("return redis.call('LLEN', KEYS[1])", ['{t}:wake:tokens'])); - } - - public function test_gets_many_values(): void - { - $this->store->set('{nc}:foo', 'bar', 60); - $this->store->set('{nc}:baz', 'qux', 60); - - $results = $this->store->getMany(['{nc}:foo', '{nc}:baz', '{nc}:missing']); - - $this->assertSame(['bar', 'qux', null], $results); - } - - public function test_version_fetch_mode_is_not_inferred_from_the_number_of_keys(): void - { - $versionKey = '{nc}:ver:mode-authors:'; - $scheduledKey = '{nc}:scheduled:mode-authors:'; - $auxiliaryKey = '{nc}:aux:mode-authors:'; - $this->store->delete([$versionKey, $scheduledKey, $auxiliaryKey]); - $this->store->setRaw($versionKey, '4', 60); - - $result = $this->store->script( - RedisScripts::get('fetch_version_with_cooldown'), - [$versionKey, $scheduledKey, $auxiliaryKey], - [(string) (int) floor(microtime(true) * 1000), '0'], - ); - - $this->assertSame('4', $result); - } - - public function test_gets_models_for_the_current_version_in_one_script_response(): void - { - $versionKey = '{nc}:ver:authors:'; - $scheduledKey = '{nc}:scheduled:authors:'; - $modelPrefix = '{nc}:model:authors:v'; - $this->store->delete([$versionKey, $scheduledKey, $modelPrefix . '4:1', $modelPrefix . '4:2']); - $this->store->setRaw($versionKey, '4', 60); - $this->store->set($modelPrefix . '4:1', ['id' => 1, 'name' => 'Alice'], 60); - $this->store->set($modelPrefix . '4:2', ['id' => 2, 'name' => 'Bob'], 60); - - [$version, $values] = $this->store->getManyForCurrentVersion( - $versionKey, - $scheduledKey, - $modelPrefix, - [3, 1, 2, 4], - ); - - $this->assertSame(4, $version); - $this->assertSame([ - null, - ['id' => 1, 'name' => 'Alice'], - ['id' => 2, 'name' => 'Bob'], - null, - ], $values); - } - - public function test_applies_a_due_cooldown_before_getting_versioned_models(): void - { - $versionKey = '{nc}:ver:cooldown-authors:'; - $scheduledKey = '{nc}:scheduled:cooldown-authors:'; - $modelPrefix = '{nc}:model:cooldown-authors:v'; - $this->store->delete([$versionKey, $scheduledKey, $modelPrefix . '1:1']); - $this->store->setRaw($versionKey, '0', 60); - $this->store->setRaw($scheduledKey, '0', 60); - $this->store->set($modelPrefix . '1:1', ['id' => 1, 'name' => 'Current'], 60); - - [$version, $values] = $this->store->getManyForCurrentVersion( - $versionKey, - $scheduledKey, - $modelPrefix, - [1], - ); - - $this->assertSame(1, $version); - $this->assertSame([['id' => 1, 'name' => 'Current']], $values); - $this->assertNull($this->store->getRaw($scheduledKey)); - } - - public function test_predis_cluster_get_many_uses_one_same_slot_mget_command(): void - { - $connection = new class extends PredisClusterConnection - { - public array $commands = []; - - public array $values = []; - - public function __construct() {} - - public function command($method, array $parameters = []) - { - $this->commands[] = [$method, $parameters]; - - return array_map(fn($key) => $this->values[$key] ?? null, $parameters); - } - }; - - $store = new RedisStore('normcache-test'); - $connection->values = [ - '{nc}:foo' => $store->serialize('bar'), - '{nc}:baz' => $store->serialize('qux'), - ]; - (new ReflectionProperty(RedisStore::class, 'connection'))->setValue($store, $connection); - - $keys = ['{nc}:foo', '{nc}:baz', '{nc}:missing']; - - $this->assertSame(['bar', 'qux', null], $store->getMany($keys)); - $this->assertSame([['mget', $keys]], $connection->commands); - } - - public function test_runs_lua_scripts(): void - { - $script = "return redis.call('GET', KEYS[1])"; - $this->store->set('foo', 'bar', 60); - - $result = $this->store->script($script, ['foo']); - - $this->assertSame('bar', $this->store->unserialize($result)); - } - - public function test_sets_many_values_if_the_version_matches(): void - { - $this->store->delete(['{t}:ver:1', '{t}:key:1', '{t}:key:2']); - $this->store->setRaw('{t}:ver:1', '1', 60); - - $attrs = [ - '{t}:key:1' => ['id' => 1, 'name' => 'Alice'], - '{t}:key:2' => ['id' => 2, 'name' => 'Bob'], - ]; - - $this->store->setManyIfVersion($attrs, 60, '{t}:ver:1', 1); - - $this->assertSame(['id' => 1, 'name' => 'Alice'], $this->store->get('{t}:key:1')); - $this->assertSame(['id' => 2, 'name' => 'Bob'], $this->store->get('{t}:key:2')); - - // Should NOT update if version mismatch - $attrs2 = ['{t}:key:1' => ['id' => 1, 'name' => 'Charlie']]; - $this->store->setManyIfVersion($attrs2, 60, '{t}:ver:1', 2); - - $this->assertSame(['id' => 1, 'name' => 'Alice'], $this->store->get('{t}:key:1')); - } - - public function test_sets_many_values_if_the_version_matches_and_releases_the_lock(): void - { - $this->store->delete(['{t}:ver:2', '{t}:key:3', '{t}:key:4', '{t}:lock:2', '{t}:wake:2']); - $this->store->setRaw('{t}:ver:2', '1', 60); - $this->store->setNxEx('{t}:lock:2', 'tok', 60); - - $attrs = [ - '{t}:key:3' => ['id' => 3, 'name' => 'Dee'], - '{t}:key:4' => ['id' => 4, 'name' => 'Eve'], - ]; - - $this->store->setManyIfVersion($attrs, 60, '{t}:ver:2', 1, '{t}:lock:2', '{t}:wake:2', 'tok'); - - $this->assertSame(['id' => 3, 'name' => 'Dee'], $this->store->get('{t}:key:3')); - $this->assertSame(['id' => 4, 'name' => 'Eve'], $this->store->get('{t}:key:4')); - $this->assertNull($this->store->getRaw('{t}:lock:2'), 'build lock should be released after the write'); - } - - public function test_set_many_if_version_handles_large_script_batches(): void - { - $this->store->delete(['{t}:ver:large']); - $this->store->setRaw('{t}:ver:large', '1', 60); - - $attrs = []; - for ($i = 0; $i < 10000; $i++) { - $attrs["{t}:key:large:{$i}"] = ['id' => $i, 'name' => "Name {$i}"]; - } - - $this->store->setManyIfVersion($attrs, 60, '{t}:ver:large', 1); - - $this->assertSame(['id' => 9999, 'name' => 'Name 9999'], $this->store->get('{t}:key:large:9999')); - } - - public function test_set_many_if_version_script_chunks_internally(): void - { - $this->store->setRaw('{t}:ver:script-large', '1', 60); - - $count = 8200; - $keys = []; - $values = []; - for ($i = 0; $i < $count; $i++) { - $keys[] = "{t}:key:script-large:{$i}"; - $values[] = $this->store->serialize(['id' => $i]); - } - - $result = $this->store->script( - RedisScripts::get('store_model_attrs'), - array_merge(['{t}:ver:script-large'], $keys), - array_merge(['1', '60', (string) $count, ''], $values) - ); - - $this->assertSame($count, (int) $result); - } - - public function test_skips_writes_but_releases_the_lock_on_version_mismatch(): void - { - $this->store->delete(['{t}:ver:3', '{t}:key:5', '{t}:lock:3', '{t}:wake:3']); - $this->store->setRaw('{t}:ver:3', '2', 60); - $this->store->setNxEx('{t}:lock:3', 'tok', 60); - - $this->store->setManyIfVersion( - ['{t}:key:5' => ['id' => 5]], 60, '{t}:ver:3', 1, '{t}:lock:3', '{t}:wake:3', 'tok' - ); - - $this->assertNull($this->store->get('{t}:key:5')); - $this->assertNull($this->store->getRaw('{t}:lock:3'), 'build lock should still be released even when the write is skipped'); - } - - public function test_releases_the_lock_when_there_is_nothing_to_write(): void - { - $this->store->delete(['{t}:lock:4', '{t}:wake:4']); - $this->store->setNxEx('{t}:lock:4', 'tok', 60); - - $this->store->setManyIfVersion([], 60, '{t}:ver:4', 1, '{t}:lock:4', '{t}:wake:4', 'tok'); - - $this->assertNull($this->store->getRaw('{t}:lock:4')); - } - - public function test_flushes_by_patterns(): void - { - $this->store->set('foo:1', 'a', 60); - $this->store->set('foo:2', 'b', 60); - $this->store->set('bar:1', 'c', 60); - - $count = $this->store->flushByPatterns(['foo:*']); - - $this->assertSame(2, $count); - $this->assertNull($this->store->get('foo:1')); - $this->assertNull($this->store->get('foo:2')); - $this->assertSame('c', $this->store->get('bar:1')); - } - - public function test_predis_cluster_delete_batches_keys_by_hash_tag(): void - { - $connection = new class extends PredisClusterConnection - { - public array $commands = []; - - public function __construct() {} - - public function command($method, array $parameters = []) - { - $this->commands[] = [$method, $parameters]; - - return count($parameters); - } - }; - - $store = new RedisStore('normcache-test'); - (new ReflectionProperty(RedisStore::class, 'connection'))->setValue($store, $connection); - - $store->delete([ - '{nc}:foo:1', - '{nc:content}:foo:1', - '{nc}:foo:2', - 'untagged:1', - 'untagged:2', - ]); - - $this->assertSame([ - ['del', ['{nc}:foo:1', '{nc}:foo:2']], - ['del', ['{nc:content}:foo:1']], - ['del', ['untagged:1']], - ['del', ['untagged:2']], - ], $connection->commands); - } - - public function test_scan_pattern_strips_connection_prefix_from_returned_keys(): void - { - if (env('REDIS_CLUSTER') === 'true' || env('REDIS_CLUSTER') === true) { - $this->markTestSkipped('Connection prefix reconfiguration not supported in cluster mode.'); - } - - config()->set('database.redis.options.prefix', 'laravel:'); - Redis::purge('normcache-test'); - - try { - $store = new RedisStore('normcache-test'); - $store->set('test:query:abc', [1], 60); - $store->set('test:query:def', [2], 60); - $store->set('test:model:1', ['id' => 1], 60); - - $keys = $store->scanPattern('test:query:*'); - - $this->assertNotEmpty($keys); - foreach ($keys as $key) { - $this->assertStringNotContainsString('laravel:', $key, 'scanPattern should strip connection prefix'); - $this->assertStringStartsWith('test:query:', $key); - } - } finally { - Redis::purge('normcache-test'); - config()->set('database.redis.options.prefix', ''); - } - } - - public function test_flush_by_patterns_works_with_connection_prefix(): void - { - if (env('REDIS_CLUSTER') === 'true' || env('REDIS_CLUSTER') === true) { - $this->markTestSkipped('Connection prefix reconfiguration not supported in cluster mode.'); - } - - config()->set('database.redis.options.prefix', 'laravel:'); - Redis::purge('normcache-test'); - - try { - $store = new RedisStore('normcache-test'); - $store->set('test:query:1', 'a', 60); - $store->set('test:query:2', 'b', 60); - $store->set('test:model:1', 'c', 60); - - $count = $store->flushByPatterns(['test:query:*']); - - $this->assertSame(2, $count); - $this->assertNull($store->get('test:query:1')); - $this->assertNull($store->get('test:query:2')); - $this->assertSame('c', $store->get('test:model:1')); - } finally { - Redis::purge('normcache-test'); - config()->set('database.redis.options.prefix', ''); - } - } - - public function test_flush_by_patterns_scans_each_phpredis_cluster_master_once_and_filters_keys(): void - { - $client = new class - { - public array $scans = []; - - public function getOption($option) - { - return 'laravel:'; - } - - public function _masters(): array - { - return ['node-a', 'node-b']; - } - - public function scan(&$cursor, $node, $pattern = null, $count = 0) - { - $this->scans[] = [$node, $pattern]; - $cursor = 0; - - return match ($node) { - 'node-a' => [ - 'laravel:{nc}:test:model:{testing:posts}:1', - 'laravel:{nc}:test:other:keep', - ], - 'node-b' => ['laravel:{nc}:test:query:{testing:posts}:v1:abc'], - default => [], - }; - } - }; - - $connection = new class($client) extends PhpRedisClusterConnection - { - public array $unlinked = []; - - public function __construct(private object $redisClient) {} - - public function _prefix($key) - { - return 'laravel:' . $key; - } - - public function client() - { - return $this->redisClient; - } - - public function unlink($keys) - { - $this->unlinked[] = (array) $keys; - - return count((array) $keys); - } - }; - - $store = new RedisStore('normcache-test'); - (new ReflectionProperty(RedisStore::class, 'connection'))->setValue($store, $connection); - - $deleted = $store->flushByPatterns(['{nc}:test:model:*', '{nc}:test:query:*']); - - $this->assertSame(2, $deleted); - $this->assertSame([ - ['node-a', 'laravel:{nc}:test:*'], - ['node-b', 'laravel:{nc}:test:*'], - ], $client->scans); - $this->assertSame([[ - '{nc}:test:model:{testing:posts}:1', - '{nc}:test:query:{testing:posts}:v1:abc', - ]], $connection->unlinked); - } - - public function test_flush_by_patterns_scans_all_nodes_on_predis_cluster(): void - { - if (env('REDIS_CLUSTER') === 'true' || env('REDIS_CLUSTER') === true) { - $this->markTestSkipped('Client-side Predis sharding test requires a standalone node; cluster SCAN path is covered by ClusterModeTest flush tests.'); - } - - // Target a standalone node for both seeding and the fake cluster. - // Derived from config so local dev environments with non-default ports are respected. - $cfg = $this->app['config']['database.redis.normcache-test'] - ?? ['host' => '127.0.0.1', 'port' => 6379, 'database' => 15]; - $standaloneConn = [ - 'scheme' => 'tcp', - 'host' => $cfg['host'] ?? '127.0.0.1', - 'port' => (int) ($cfg['port'] ?? 6379), - 'database' => (int) ($cfg['database'] ?? 15), - ]; - - $directClient = new PredisClient($standaloneConn); - - for ($i = 0; $i < 2500; $i++) { - $directClient->setex("model:{posts}:{$i}", 60, 'x'); - } - - $this->assertCount(2500, $directClient->keys('model:*')); - - // Use 'predis' cluster type so the client iterates over configured nodes - // without requiring CLUSTER SLOTS (which standalone Redis doesn't support). - $predisClient = new PredisClient([$standaloneConn], ['cluster' => 'predis']); - $clusterConnection = new PredisClusterConnection($predisClient); - - $store = new RedisStore('normcache-test'); - (new ReflectionProperty(RedisStore::class, 'connection'))->setValue($store, $clusterConnection); - - $deleted = $store->flushByPatterns(['model:{posts}:*']); - - $this->assertSame(2500, $deleted); - $this->assertEmpty($directClient->keys('model:*')); - } - - public function test_predis_cluster_scan_checks_all_nodes_for_concrete_hash_tag_pattern(): void - { - $owner = new class - { - public array $patterns = []; - - public function scan($cursor, array $options) - { - $this->patterns[] = $options['match'] ?? null; - - return ['0', ['{nc:content}:test:query:testing:posts:v1:abc']]; - } - }; - - $other = new class - { - public int $calls = 0; - - public function scan($cursor, array $options) - { - $this->calls++; - - return ['0', []]; - } - }; - - $connection = new class($owner, $other) extends PredisClusterConnection - { - public function __construct(private object $owner, private object $other) {} - - public function client() - { - return new class($this->owner, $this->other) implements \IteratorAggregate - { - public function __construct(private object $owner, private object $other) {} - - public function getOptions() - { - return new class - { - public $prefix = null; - }; - } - - public function getConnection() - { - return new class($this->owner) - { - public function __construct(private object $owner) {} - - public function getConnectionBySlot($slot) - { - return $this->owner; - } - }; - } - - public function getIterator(): \Traversable - { - return new \ArrayIterator([$this->owner, $this->other]); - } - }; - } - }; - - $store = new RedisStore('normcache-test'); - (new ReflectionProperty(RedisStore::class, 'connection'))->setValue($store, $connection); - - $this->assertSame( - ['{nc:content}:test:query:testing:posts:v1:abc'], - $store->scanPattern('{nc:content}:test:query:*') - ); - $this->assertSame(['{nc:content}:test:query:*'], $owner->patterns); - $this->assertSame(1, $other->calls); - } - - public function test_predis_cluster_scan_deduplicates_keys_returned_by_multiple_nodes(): void - { - $connection = new class extends PredisClusterConnection - { - public function __construct() {} - - public function client() - { - return new class implements \IteratorAggregate - { - public function getOptions() - { - return new class - { - public $prefix = null; - }; - } - - public function getIterator(): \Traversable - { - return new \ArrayIterator([ - new class - { - public function scan($cursor, array $options) - { - return ['0', ['test:model:{posts}:1', 'test:model:{posts}:2']]; - } - }, - new class - { - public function scan($cursor, array $options) - { - return ['0', ['test:model:{posts}:1']]; - } - }, - ]); - } - }; - } - }; - - $store = new RedisStore('normcache-test'); - (new ReflectionProperty(RedisStore::class, 'connection'))->setValue($store, $connection); - - $this->assertSame( - ['test:model:{posts}:1', 'test:model:{posts}:2'], - $store->scanPattern('test:model:*') - ); - } - - public function test_unserialize_detects_format_by_magic_header(): void - { - if (!extension_loaded('igbinary')) { - $this->markTestSkipped('igbinary extension not available in this environment'); - } - - $store = new RedisStore('normcache-test'); - $data = ['x' => 1]; - - $this->assertSame($data, $store->unserialize(igbinary_serialize($data))); - $this->assertSame($data, $store->unserialize(serialize($data))); - } - - public function test_uses_evalsha_with_fallback(): void - { - $script = 'return ARGV[1]'; - - // Pass a dummy key so Predis cluster can route the command to a slot. - // The script ignores KEYS and only reads ARGV[1]. - $result = $this->store->script($script, ['foo'], ['hello']); - $this->assertSame('hello', $result); - - $result = $this->store->script($script, ['foo'], ['world']); - $this->assertSame('world', $result); - } - - public function test_eval_returns_correct_result_on_first_call(): void - { - (new ReflectionProperty(RedisStore::class, 'shas'))->setValue(null, []); - - $store = new RedisStore('normcache-test'); - $script = RedisScripts::get('fetch_version_with_cooldown'); - - Redis::connection('normcache-test')->setex('ver:{authors}:', 60, '7'); - - $result = $store->script($script, ['ver:{authors}:', 'scheduled:{authors}:'], [(string) (time() * 1000), '0']); - - $this->assertSame('7', $result); - } - - public function test_php_sha_cache_is_populated_after_first_eval(): void - { - (new ReflectionProperty(RedisStore::class, 'shas'))->setValue(null, []); - - $store = new RedisStore('normcache-test'); - $script = RedisScripts::get('fetch_version_with_cooldown'); - - $this->assertArrayNotHasKey($script, (new ReflectionProperty(RedisStore::class, 'shas'))->getValue()); - - Redis::connection('normcache-test')->setex('ver:{authors}:', 60, '2'); - $store->script($script, ['ver:{authors}:', 'scheduled:{authors}:'], [(string) (time() * 1000), '0']); - - $shas = (new ReflectionProperty(RedisStore::class, 'shas'))->getValue(); - $this->assertArrayHasKey($script, $shas); - $this->assertSame(sha1($script), $shas[$script]); - } - - public function test_igbinary_blob_returns_null_when_extension_absent(): void - { - if (!extension_loaded('igbinary')) { - $this->markTestSkipped('igbinary extension not available in this environment'); - } - - $store = new RedisStore('normcache-test'); - $serializer = (new ReflectionProperty($store, 'serializer'))->getValue($store); - (new ReflectionProperty($serializer, 'igbinary'))->setValue($serializer, false); - - $this->assertNull($store->unserialize(igbinary_serialize(['id' => 42]))); - } -} diff --git a/tests/Unit/TableIdentityResolverTest.php b/tests/Unit/TableIdentityResolverTest.php new file mode 100644 index 0000000..83c2075 --- /dev/null +++ b/tests/Unit/TableIdentityResolverTest.php @@ -0,0 +1,184 @@ +connection('pgsql', ['schema' => 'tenant']); + $connection->shouldNotReceive('getSchemaBuilder'); + + $identity = app(TableIdentityResolver::class)->resolve($connection, 'posts'); + + $this->assertSame('tenant', $identity?->schema); + } + + public function test_postgres_takes_the_first_search_path_entry_without_asking_the_database(): void + { + $connection = $this->connection('pgsql', ['search_path' => 'reporting, public']); + $connection->shouldNotReceive('getSchemaBuilder'); + + $identity = app(TableIdentityResolver::class)->resolve($connection, 'posts'); + + $this->assertSame('reporting', $identity?->schema); + } + + public function test_a_user_search_path_resolves_to_the_session_user_per_tenant(): void + { + $resolver = app(TableIdentityResolver::class); + $path = ['username' => null, 'search_path' => '"$user", public']; + + $a = $resolver->resolve($this->connection('pgsql', [...$path, 'username' => 'tenant_a']), 'posts'); + $b = $resolver->resolve($this->connection('pgsql', [...$path, 'username' => 'tenant_b']), 'posts'); + + $this->assertSame('tenant_a', $a?->schema); + $this->assertSame('tenant_b', $b?->schema); + $this->assertNotSame( + $a?->hash, + $b?->hash, + 'two tenants differing only by username must not share a table identity', + ); + } + + public function test_an_empty_user_falls_through_to_the_next_search_path_entry(): void + { + $identity = app(TableIdentityResolver::class)->resolve( + $this->connection('pgsql', ['username' => '', 'search_path' => '"$user", public']), + 'posts', + ); + + $this->assertSame('public', $identity?->schema); + } + + public function test_qualified_postgres_table_supplies_its_schema(): void + { + $identity = app(TableIdentityResolver::class)->resolve( + $this->connection('pgsql', ['schema' => 'public']), + 'tenant.posts as p', + ); + + $this->assertSame('tenant', $identity?->schema); + $this->assertSame('posts', $identity?->table); + } + + public function test_unqualified_sources_are_resolved_without_checking_table_existence(): void + { + $connection = $this->connection('pgsql', ['schema' => 'public']); + $connection->shouldNotReceive('getSchemaBuilder'); + + $identity = app(TableIdentityResolver::class)->resolve($connection, 'not_migrated_yet'); + + $this->assertSame('not_migrated_yet', $identity?->table); + } + + public function test_sqlite_case_and_main_schema_variants_share_one_identity(): void + { + $resolver = app(TableIdentityResolver::class); + $connection = $this->sqliteConnection('case-identity'); + + $lower = $resolver->resolve($connection, 'posts'); + $upper = $resolver->resolve($connection, 'POSTS'); + $qualified = $resolver->resolve($connection, 'MAIN.Posts'); + + $this->assertNotNull($lower); + $this->assertSame('main', $lower->schema); + $this->assertSame('posts', $lower->table); + $this->assertSame($lower->hash, $upper?->hash); + $this->assertSame($lower->hash, $qualified?->hash); + } + + public function test_source_scope_changes_retire_memoized_identities(): void + { + $resolver = app(TableIdentityResolver::class); + $connection = $this->sqliteConnection('source-scope'); + $config = (array) (new \ReflectionProperty(Connection::class, 'config'))->getValue($connection); + $property = new \ReflectionProperty(Connection::class, 'config'); + + $property->setValue($connection, [...$config, 'normcache_scope' => 'tenant-a']); + $first = $resolver->resolve($connection, 'posts'); + + $property->setValue($connection, [...$config, 'normcache_scope' => 'tenant-b']); + $second = $resolver->resolve($connection, 'posts'); + + $this->assertSame('tenant-a', $first?->sourceScope); + $this->assertSame('tenant-b', $second?->sourceScope); + $this->assertNotSame($first?->hash, $second?->hash); + } + + public function test_quoted_identifiers_with_spaces_bypass_table_identity_resolution(): void + { + $identity = app(TableIdentityResolver::class)->resolve( + $this->connection('sqlsrv', ['schema' => 'dbo']), + '[Order Details]', + ); + + $this->assertNull($identity); + } + + public function test_four_part_sql_server_sources_bypass_table_identity_resolution(): void + { + $identity = app(TableIdentityResolver::class)->resolve( + $this->connection('sqlsrv', ['schema' => 'dbo']), + 'server.database.schema.posts', + ); + + $this->assertNull($identity); + } + + public function test_metadata_is_released_when_its_connection_is_discarded(): void + { + $resolver = app(TableIdentityResolver::class); + $connection = $this->sqliteConnection('weak-map'); + + $resolver->resolve($connection, 'posts'); + $this->assertSame(1, $this->cachedConnectionCount($resolver)); + + unset($connection); + gc_collect_cycles(); + + $this->assertSame(0, $this->cachedConnectionCount($resolver)); + } + + private function cachedConnectionCount(TableIdentityResolver $resolver): int + { + $connections = (new \ReflectionProperty($resolver, 'connections'))->getValue($resolver); + + return count($connections); + } + + private function sqliteConnection(string $tenant): Connection + { + $path = sys_get_temp_dir() . '/normcache-' . $tenant . '.sqlite'; + touch($path); + + return new SQLiteConnection( + new \PDO('sqlite:' . $path), + $path, + '', + ['name' => 'tenant', 'driver' => 'sqlite'], + ); + } + + /** @param array $config */ + private function connection(string $driver, array $config): Connection + { + $connection = Mockery::mock(Connection::class); + $connection->shouldReceive('getDriverName')->andReturn($driver); + $connection->shouldReceive('getName')->andReturn('testing'); + $connection->shouldReceive('getDatabaseName')->andReturn('app'); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('getConfig')->andReturn([ + 'name' => 'testing', + ...$config, + ]); + + return $connection; + } +} diff --git a/tests/Unit/TableIdentityTest.php b/tests/Unit/TableIdentityTest.php new file mode 100644 index 0000000..2791d61 --- /dev/null +++ b/tests/Unit/TableIdentityTest.php @@ -0,0 +1,84 @@ + strlen($value) . ':' . $value, + ['nc-table', 'tenant', 'pgsql', 'app', 'public', 'acme_', 'posts'], + )); + + $this->assertSame($encoded, $identity->encoded); + $this->assertSame(hash('xxh128', $encoded), $identity->hash); + $this->assertSame(32, strlen($identity->hash)); + } + + public function test_aliases_are_not_part_of_physical_identity(): void + { + $one = TableIdentity::fromParts('mysql', 'main', 'app', 'app', '', 'posts'); + $two = TableIdentity::fromParts('mysql', 'main', 'app', 'app', '', 'posts'); + + $this->assertSame($one->hash, $two->hash); + } + + public function test_database_sources_are_part_of_physical_identity(): void + { + $one = TableIdentity::fromParts('mysql', 'shard-a', 'app', 'app', '', 'posts'); + $two = TableIdentity::fromParts('mysql', 'shard-b', 'app', 'app', '', 'posts'); + $alias = TableIdentity::fromParts( + 'mysql', + 'shard-b', + 'app', + 'app', + '', + 'posts', + sourceScope: 'shard-a', + ); + + $this->assertNotSame($one->hash, $two->hash); + $this->assertSame($one->hash, $alias->hash); + } + + public function test_sqlite_repair_source_keeps_attached_schema(): void + { + $identity = TableIdentity::fromParts( + 'sqlite', + 'testing', + '/tmp/testing.sqlite', + 'tenant', + '', + 'posts', + ); + + $this->assertSame('tenant.posts', $identity->qualifiedTable()); + } + + public function test_sql_server_repair_source_keeps_database_and_schema(): void + { + $identity = TableIdentity::fromParts( + 'sqlsrv', + 'tenant', + 'catalog', + 'dbo', + '', + 'posts', + ); + + $this->assertSame('catalog.dbo.posts', $identity->qualifiedTable()); + } +} diff --git a/tests/Unit/Values/PivotCacheResultTest.php b/tests/Unit/Values/PivotCacheResultTest.php deleted file mode 100644 index 2379c16..0000000 --- a/tests/Unit/Values/PivotCacheResultTest.php +++ /dev/null @@ -1,29 +0,0 @@ - [['id' => 5]], 2 => null, 3 => false], - ); - - $this->assertSame([2, 3], $result->missedIds()); - } - - public function test_missed_ids_is_empty_when_all_entries_are_present(): void - { - $result = new PivotCacheResult( - seg: 'v1', - data: [1 => [['id' => 5]], 2 => [['id' => 6]]], - ); - - $this->assertSame([], $result->missedIds()); - } -} diff --git a/tests/Unit/WorkflowTestPathsTest.php b/tests/Unit/WorkflowTestPathsTest.php new file mode 100644 index 0000000..7fa250e --- /dev/null +++ b/tests/Unit/WorkflowTestPathsTest.php @@ -0,0 +1,41 @@ +assertFileExists( + self::repositoryRoot() . '/' . $path, + "{$workflow} runs [{$path}], which does not exist.", + ); + } + + /** @return iterable */ + public static function workflowTestPaths(): iterable + { + foreach (glob(self::repositoryRoot() . '/.github/workflows/*.yml') ?: [] as $workflow) { + $name = basename($workflow); + + preg_match_all( + '#(? [$name, $path]; + } + } + } + + private static function repositoryRoot(): string + { + return dirname(__DIR__, 2); + } +} diff --git a/tests/UnitTestCase.php b/tests/UnitTestCase.php index e5e10ff..9f848b1 100644 --- a/tests/UnitTestCase.php +++ b/tests/UnitTestCase.php @@ -3,18 +3,10 @@ namespace NormCache\Tests; use NormCache\CacheServiceProvider; -use NormCache\Support\CacheKeyBuilder; use Orchestra\Testbench\TestCase as OrchestraTestCase; abstract class UnitTestCase extends OrchestraTestCase { - protected function setUp(): void - { - parent::setUp(); - - CacheKeyBuilder::reset(); - } - protected function getPackageProviders($app): array { return [CacheServiceProvider::class]; @@ -42,8 +34,7 @@ protected function defineEnvironment($app): void $app['config']->set('normcache.enabled', true); $app['config']->set('normcache.events', true); $app['config']->set('normcache.key_prefix', 'test:'); - $app['config']->set('normcache.ttl', 3600); + $app['config']->set('normcache.row_ttl', 3600); $app['config']->set('normcache.query_ttl', 60); - $app['config']->set('normcache.cooldown', 0); } }