diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0beee22..edaa232 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,6 +4,12 @@ on: push: branches: [main] pull_request: + # Grease overrides framework internals, so a quiet repo can still break: a Laravel patch + # release that changes an overridden method is only caught if CI runs against it. Weekly + # against the latest releases turns upstream drift into a red build within days. + schedule: + - cron: '0 6 * * 1' + workflow_dispatch: jobs: test: @@ -53,13 +59,24 @@ jobs: - name: Parity smoke against real queries (the package's core promise) run: php benchmarks/realworld.php 1 - # Resolve the *lowest* versions our constraints allow, to prove the declared - # floor (illuminate ^12.0) is honest — Grease overrides framework internals, so a + # Resolve the *lowest* versions our constraints allow, to prove each declared + # floor (illuminate ^12.69.2 / ^13.33) is honest — Grease overrides framework internals, so a # method that only landed in a later 12.x patch would slip through "highest" CI. lowest: - name: Lowest deps · PHP 8.2 · Laravel 12 + name: Lowest deps · PHP ${{ matrix.php }} · Laravel ${{ matrix.laravel }} runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - php: '8.2' + laravel: '12.*' + testbench: '10.*' + - php: '8.3' + laravel: '13.*' + testbench: '11.*' + steps: - name: Checkout uses: actions/checkout@v7 @@ -67,15 +84,15 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.2' + php-version: ${{ matrix.php }} extensions: dom, curl, libxml, mbstring, pdo, sqlite, pdo_sqlite coverage: none - - name: Pin Laravel & Testbench to the 12 floor + - name: Pin Laravel & Testbench to this major's floor run: > composer require - "laravel/framework:12.*" - "orchestra/testbench:10.*" + "laravel/framework:${{ matrix.laravel }}" + "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update - name: Install the lowest allowed dependencies diff --git a/CHANGELOG.md b/CHANGELOG.md index 1222a7f..d7c1e99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,44 @@ All notable changes to `grease` are documented here. The format is based on ## [Unreleased] +### Fixed + +- **Hydrated models now carry the query model's runtime table and casts**, exactly as vanilla's + `newInstance()` does. The slim `newFromBuilder` built rows from class defaults, so + query-time casting (`Model::query()->withCasts([...])`) was silently ignored, and a runtime + `setTable()` on the model a query is built from (partitioned / archive tables) produced rows + whose `save()`/`delete()` targeted the *class* table. Both now match vanilla + (`SqlRoundtripTest`); the old "runtime `setTable()`" caveat is gone. A model with no `$table` + also gets its derived table name written onto hydrated rows, as vanilla does — memoized per + class during hydration (only when the model inherits vanilla `getTable()`; any override, e.g. + sharded or `AsPivot`, is called per row exactly as vanilla), so correct hydration costs ~5–10% + per row over the old (incorrect) path, not the ~80% a per-row `Str::pluralStudly()` would. +- **Upstream drift since the last CI run** — Laravel patch releases changed four overridden + methods, and Grease now mirrors each: + - `#[Refreshes]` (13.33) — the hydration snapshot dropped `$refreshes`, so every model after + the first per class silently lost refresh-after-write. + - `resolveClassAttribute()` (13.21 / 13.22) — the cache key now includes the property, and + attributes declared on traits are resolved. + - `Request::all()` (13.26) — input keys keep their position and win a collision with an + uploaded file. Probed once per process, since 12.x keeps the old order. + - `route()` parameters (12.69.2 / 13.31) — `%`, `?` and `#` inside a parameter are escaped + (`%25`/`%3F`/`%23`); the URL fast path emitted them raw, truncating the path. + - `ComponentAttributeBag::merge()` (12.63 / 13.19) — a `style` default is now terminated with + `;` before the component's own style is appended. +- **`HasGreaseWholeStateParityTest`** — the drift net that would have caught the above: it + compares a greased model's *entire* instance state against vanilla (cold, warm, hydrated) for + a model carrying every Eloquent class attribute, plus a tripwire that fails when Laravel adds + an attribute class the fixtures don't cover. + +### Changed + +- **Requires Laravel `^12.69.2 || ^13.33`** (was `^12.0 || ^13.0`). Grease overrides framework + internals; pinning to each major's current patch line lets it mirror the latest vanilla + behaviour instead of branching per patch release. `composer update` within your major. +- CI runs weekly on a schedule (so upstream drift fails the build within days, not at the next + push) and adds a lowest-deps leg for the Laravel 13 floor. +- The Livewire compatibility proof now also runs against Livewire 4. + ## [0.9.0] - 2026-06-27 ### Added diff --git a/README.md b/README.md index 983b69b..b13ec56 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,8 @@ composer bench # phpbench per-op A/B + the SQL suite ## Requirements -PHP 8.2+, Laravel 12/13. +PHP 8.2+, Laravel 12.69.2+ or 13.33+ (the current patch line of each supported major — Grease +overrides framework internals and mirrors the latest vanilla behaviour of each). ## License diff --git a/benchmarks/LEADS.md b/benchmarks/LEADS.md index d5cb696..224529b 100644 --- a/benchmarks/LEADS.md +++ b/benchmarks/LEADS.md @@ -343,3 +343,57 @@ the FPM bar. a pass. 5. **Skip entirely:** the Pipeline/Kernel axis (FPM), the Support-primitives axis, and every item in the negatives ledger. + +--- + +## Follow-on leads (post-ship — added after the original research snapshot) + +### ⏸ All-shallow except fast path for `CleanRequestInput` — SPIKED & PASSED (2026-06-29) +*Axis: HTTP (rider on the shipped `Grease\Http\Middleware\CleanRequestInput` tier) · Confidence: high (mechanism proven) · Status: parked, revisit on Linux + deep-content target* + +**Premise.** `CleanRequestInput` fuses `TrimStrings`+`ConvertEmptyStringsToNull` into one +pass, but it still *inherits* `TransformsRequest::cleanArray`, which rebuilds the dotted +`$keyPrefix.$key` string at **every node** of the input tree (for the per-leaf except +match). On DEEP payloads (a ProseMirror/TipTap content post — `clean_input_ab.php`'s deep +arm is 1,125 leaves / depth 17) that per-node concat becomes the dominant *shared* cost — +the one thing the greased class doesn't fold, so its win-vs-vanilla ratio sags at depth +(−42% small → −35% deep). Lever: when every trim-except pattern is a **dot-free, +wildcard-free literal** (the default — `password`/`current_password`/`password_confirmation`), +`Str::is(pattern, K)` can only ever be true for a *top-level* key (a nested key contains +`.`, which a dot-free literal can never equal). So below depth 1 you can skip BOTH the +prefix build AND the except check entirely. Implementation: an all-shallow flag computed +once per request (`no '*' && no '.'` across the merged pattern list), then a `cleanTop` +(except-check vs bare key) / `cleanDeep` (trim+null only, no key, no prefix) walk split; +non-shallow except sets fall back to the inherited full-prefix `cleanArray`. + +**Measured (macOS, JIT on — `scratchpad/shallow_skip_spike.php`, throwaway):** + +| payload | leaves / depth | vanilla | shipped greased | spike (shallow-skip) | +|---|---|---|---|---| +| small | 10 / 2 | 8.16µs | −41.6% | −42.6% (**−1.7%** vs shipped) | +| wide | 100 / 3 | 71.73µs | −43.6% | −45.0% (**−2.4%** vs shipped) | +| deep document | 1125 / 17 | 1214.72µs | −31.0% | −37.3% (**−9.1%** vs shipped, ~76µs/req) | + +**Parity: byte-identical, including the case with teeth.** Adversarial payload — a key +literally named `password` at depth 2 (vanilla builds `user.profile.password`, +`Str::is('password', …)` is false → it gets trimmed; the shallow path skips the except +check at depth → also trims it) plus a top-level `password` (preserved) and a nested +`current_password` (trimmed → empty → null). All three arms produce identical bytes: +`{"password":" top-secret ","user":{"profile":{"password":"not-excepted-here","current_password":null}},"rows":[{"password":"x"}]}`. +The flag is recomputed per request from live `$except`+`$neverTrim`, so a runtime +`except()` adding a wildcard/dotted pattern correctly drops to the fallback walk. + +**Why PASSED for now (not a dead end — a deferred ship candidate):** +- It's a **−9% on a slice-of-a-slice**: only fires on deep payloads, buys back ~76µs of a + 0.76ms operation. Worth it *if* deep-content endpoints (CMS bodies, rich-text comments) + are a real target; marginal on API-shaped benches. +- **macOS numbers** — per NOTES the −9.1% *ratio* travels better than the absolute µs, but + the 76µs could shrink on Linux to where it doesn't justify the cost. +- **Cost to ship:** a third overridden `TransformsRequest` method (`cleanParameterBag` + + `cleanTop`/`cleanDeep` reimplements the recursive walk) = new parity surface to carry, + on top of the already-shipped `transform` override. + +**Revisit trigger:** confirm the ratio on Linux/`nas`, AND a deep-content endpoint becoming +a stated target. The spike is throwaway (scratchpad, not committed); the deep-payload bench +arm that surfaced it lives in `benchmarks/clean_input_ab.php`. Mechanism is fully captured +above — rebuildable from this entry. diff --git a/benchmarks/class_attribute_ab.php b/benchmarks/class_attribute_ab.php new file mode 100644 index 0000000..59838ff --- /dev/null +++ b/benchmarks/class_attribute_ab.php @@ -0,0 +1,57 @@ + static::resolveClassAttribute($a, $p), null, $class); + foreach ($calls as [$a, $p]) { + $fn($a, $p); // warm + } + $t = hrtime(true); + for ($i = 0; $i < $n; $i++) { + foreach ($calls as [$a, $p]) { + $fn($a, $p); + } + } + + return (hrtime(true) - $t) / ($n * count($calls)); +}; + +for ($r = 0; $r < 5; $r++) { + $v = $run(AbVanillaCA::class); + $g = $run(AbGreasedCA::class); + printf("vanilla %.1fns greased %.1fns %+.1f%%\n", $v, $g, ($g / $v - 1) * 100); +} diff --git a/benchmarks/clean_input_ab.php b/benchmarks/clean_input_ab.php index 0fcb0dc..780b5d3 100644 --- a/benchmarks/clean_input_ab.php +++ b/benchmarks/clean_input_ab.php @@ -15,8 +15,10 @@ * fused one pass, except-merge hoisted, but still Str::is per leaf * fused+hybrid one pass + the compiled hybrid except matcher (Str::is gone) * - * Output is identical (" " --trim--> "" --null), parity-gated before any timing. Two payloads: - * a realistic ~10-leaf request and an input-heavy ~100-leaf one. + * Output is identical (" " --trim--> "" --null), parity-gated before any timing. Three payloads: + * a realistic ~10-leaf request, a wide ~100-leaf one, and a DEEP ProseMirror-style content post + * (the case that actually motivates this — vanilla's per-node dotted-prefix concat grows with + * nesting depth, which the wide payload never exercises). * * php -d xdebug.mode=off -d opcache.jit=tracing -d opcache.enable_cli=1 -d memory_limit=1G \ * benchmarks/clean_input_ab.php [iters] @@ -98,6 +100,83 @@ function largePayload(): array return $p; } +/** + * A realistic *deep* content post: a ProseMirror/TipTap/Lexical-style rich-text document, the + * shape a blog post / CMS body / comment editor actually submits. Unlike largePayload (wide and + * shallow, depth ~2), this is genuinely NESTED — doc → block → (nested list →)* → text node → + * mark → attrs — so it exercises the part of vanilla's cost that grows with DEPTH: every node + * rebuilds the dotted `$keyPrefix.$key` from an ever-longer accumulated string. Deterministic. + */ +function deepDocumentPayload(): array +{ + // A text node carrying marks (links/styles) with their own attr bags — the deep leaves. + $text = function (string $t, bool $linked = false) { + $node = ['type' => 'text', 'text' => $t]; + if ($linked) { + $node['marks'] = [[ + 'type' => 'link', + 'attrs' => ['href' => ' https://example.test/x ', 'title' => '', 'target' => ' _blank '], + ]]; + } + + return $node; + }; + + // A paragraph block: a few text runs, some whitespace-padded, some empty (→ null), some linked. + $paragraph = function (int $i) use ($text) { + return [ + 'type' => 'paragraph', + 'attrs' => ['textAlign' => $i % 3 === 0 ? ' left ' : '', 'indent' => $i % 4], + 'content' => [ + $text(" Sentence $i with a fair bit of text to walk. "), + $text(' ', $i % 2 === 0), + $text('', true), + $text("clean-run-$i"), + ], + ]; + }; + + // A bullet list whose items each hold a paragraph AND a nested sub-list — the depth driver. + $nestedList = function (int $section) use ($paragraph) { + $items = []; + for ($li = 0; $li < 3; $li++) { + $items[] = [ + 'type' => 'listItem', + 'attrs' => ['checked' => $li % 2 === 0], + 'content' => [ + $paragraph($section * 10 + $li), + [ + 'type' => 'bulletList', + 'content' => [[ + 'type' => 'listItem', + 'content' => [$paragraph($section * 100 + $li)], + ]], + ], + ], + ]; + } + + return ['type' => 'bulletList', 'content' => $items]; + }; + + $content = []; + for ($s = 0; $s < 8; $s++) { + $content[] = ['type' => 'heading', 'attrs' => ['level' => ($s % 3) + 1], 'content' => [ + // headings carry a single text run; some padded, some empty + ['type' => 'text', 'text' => $s % 2 === 0 ? " Heading $s " : ''], + ]]; + $content[] = $paragraph($s); + $content[] = $nestedList($s); + } + + return [ + 'title' => ' My Post Title ', + 'slug' => '', + 'status' => 'draft', + 'body' => ['type' => 'doc', 'attrs' => ['version' => 1], 'content' => $content], + ]; +} + $trim = new TrimStrings; $convert = new ConvertEmptyStringsToNull; $fused = new FusedCleanInput; @@ -126,13 +205,30 @@ function largePayload(): array echo 'jit: '.((opcache_get_status(false)['jit']['on'] ?? false) ? 'YES' : 'no')." iters=$iters\n"; -$payloads = ['small' => smallPayload(), 'large' => largePayload()]; +$payloads = [ + 'small' => smallPayload(), + 'large (wide)' => largePayload(), + 'deep document' => deepDocumentPayload(), +]; + +// Max nesting depth of an array tree — the dimension largePayload doesn't exercise. +$maxDepth = function (array $a) use (&$maxDepth): int { + $d = 1; + foreach ($a as $v) { + if (is_array($v)) { + $d = max($d, 1 + $maxDepth($v)); + } + } + + return $d; +}; foreach ($payloads as $label => $payload) { $leaves = 0; array_walk_recursive($payload, function () use (&$leaves) { $leaves++; }); + $depth = $maxDepth($payload); $mk = function () use ($payload): Request { $r = Request::create('/x', 'POST', [], [], [], ['CONTENT_TYPE' => 'application/json'], json_encode($payload)); @@ -169,10 +265,10 @@ function largePayload(): array $fClean = max(0, $fT - $baseline); $hClean = max(0, $hT - $baseline); - echo "\n== $label payload: $leaves leaves — clean cost (request-build baseline subtracted) ==\n"; + echo "\n== $label payload: $leaves leaves, depth $depth — clean cost (request-build baseline subtracted) ==\n"; printf(" %-22s %s\n", 'vanilla (trim+convert)', $us($vClean)); printf(" %-22s %s (%s vs vanilla)\n", 'fused (Str::is)', $us($fClean), $pct($vClean, $fClean)); printf(" %-22s %s (%s vs vanilla, %s vs fused)\n", 'fused + hybrid', $us($hClean), $pct($vClean, $hClean), $pct($fClean, $hClean)); } -echo "\nParity: OK — fused & fused+hybrid byte-identical to vanilla two-pass on both payloads.\n"; +echo "\nParity: OK — fused & fused+hybrid byte-identical to vanilla two-pass on all payloads.\n"; diff --git a/benchmarks/derived_table_hydration_ab.php b/benchmarks/derived_table_hydration_ab.php new file mode 100644 index 0000000..df4101b --- /dev/null +++ b/benchmarks/derived_table_hydration_ab.php @@ -0,0 +1,76 @@ +getTable())` re-derives + * `Str::snake(Str::pluralStudly(class_basename($this)))` for every row; the greased slim + * path memoizes that class-pure name (only when getTable() is vanilla's). Also counts how + * often an ordinary query path calls getTable(). + * + * php benchmarks/derived_table_hydration_ab.php [rows] + */ + +require __DIR__.'/../vendor/autoload.php'; + +use Grease\Concerns\HasGrease; +use Illuminate\Database\Capsule\Manager as DB; +use Illuminate\Database\Eloquent\Model; + +class BlogPostVanilla extends Model {} +class BlogPostGreased extends Model +{ + use HasGrease; +} + +$n = (int) ($argv[1] ?? 200_000); +$row = ['id' => 1, 'title' => 'hello', 'created_at' => '2026-01-01 00:00:00', 'updated_at' => '2026-01-01 00:00:00']; + +$time = function (Model $prototype) use ($n, $row): float { + $prototype->newFromBuilder($row); + $t = hrtime(true); + for ($i = 0; $i < $n; $i++) { + $prototype->newFromBuilder($row); + } + + return (hrtime(true) - $t) / $n; +}; + +for ($r = 0; $r < 3; $r++) { + $v = $time(new BlogPostVanilla); + $g = $time(new BlogPostGreased); + printf("hydrate/row: vanilla %.0fns greased %.0fns %+.1f%%\n", $v, $g, ($g / $v - 1) * 100); +} + +// How many getTable() calls does an ordinary query + hydrate + save make? +$db = new DB; +$db->addConnection(['driver' => 'sqlite', 'database' => ':memory:']); +$db->setAsGlobal(); +$db->bootEloquent(); + +class CountingPost extends Model +{ + public static int $calls = 0; + + protected $guarded = []; + + public function getTable() + { + static::$calls++; + + return parent::getTable(); + } +} + +DB::schema()->create('counting_posts', function ($t) { + $t->id(); + $t->string('title'); + $t->timestamps(); +}); +for ($i = 0; $i < 20; $i++) { + CountingPost::create(['title' => "p$i"]); +} +CountingPost::$calls = 0; +$rows = CountingPost::query()->where('id', '>', 0)->orderBy('id')->get(); +$afterGet = CountingPost::$calls; +$rows->first()->update(['title' => 'x']); +printf("getTable() calls: get() of %d rows = %d, one update() = %d\n", $rows->count(), $afterGet, CountingPost::$calls - $afterGet); diff --git a/composer.json b/composer.json index d9d454a..2aab177 100644 --- a/composer.json +++ b/composer.json @@ -17,14 +17,14 @@ ], "require": { "php": "^8.2", - "illuminate/database": "^12.0 || ^13.0", - "illuminate/events": "^12.0 || ^13.0", - "illuminate/support": "^12.0 || ^13.0", - "illuminate/view": "^12.0 || ^13.0" + "illuminate/database": "^12.69.2 || ^13.33", + "illuminate/events": "^12.69.2 || ^13.33", + "illuminate/support": "^12.69.2 || ^13.33", + "illuminate/view": "^12.69.2 || ^13.33" }, "require-dev": { "laravel/pint": "^1.29", - "livewire/livewire": "^3.0", + "livewire/livewire": "^3.0 || ^4.0", "orchestra/testbench": "^10.0 || ^11.0", "phpbench/phpbench": "^1.2", "phpunit/phpunit": "^11.0 || ^12.0" diff --git a/docs/guide/caveats.md b/docs/guide/caveats.md index 68455a2..4f874ac 100644 --- a/docs/guide/caveats.md +++ b/docs/guide/caveats.md @@ -7,7 +7,7 @@ trivial idiomatic workaround. The full cast contract is asserted **byte-identical to vanilla** in the test suite — every cast type, every edge value, every null, every dirty comparison — across PHP -8.2–8.5 and Laravel 12/13. +8.2–8.5 and Laravel 12.69.2+ / 13.33+. ## The guiding principle @@ -68,24 +68,22 @@ protected $casts = ['ssn' => 'encrypted:string']; This is an undocumented internal — there's no idiomatic reason to override it. -### 3. Reassigning a model's table or date format at runtime +### 3. Reassigning a model's date format at runtime -The hydration and date tiers treat a model's **table** and **date format** as -class-level — which they are in essentially every app (`protected $table`, -`protected $dateFormat`). Two narrow edges fall out of that: +The date tier treats a model's **date format** as class-level — which it is in +essentially every app (`protected $dateFormat`). One narrow edge falls out of that: -- **`setTable()` on the model a query is built from** isn't carried onto the rows it - hydrates — they get the class-default table. (Setting `$table` as a class property, - or on a freshly-constructed model you then query, both work.) - **`setDateFormat()` called per instance** after the date fast-path has been certified for the class can leave that instance using the previously-certified plan. -Both are vanishingly rare — the standard `protected $dateFormat` / `protected $table` -declarations are read correctly and cached once. Toggling **timestamps** -(`$model->timestamps = false` / `withoutTimestamps()`), changing the **primary key** -(`setKeyName()` / `setKeyType()` / `setIncrementing()`), and runtime -`mergeCasts()` / `withCasts()` are all *normal* and fully handled — only the two -runtime reassignments above step outside the cache. +That's vanishingly rare — the standard `protected $dateFormat` declaration is read +correctly and cached once. Toggling **timestamps** (`$model->timestamps = false` / +`withoutTimestamps()`), changing the **primary key** (`setKeyName()` / `setKeyType()` / +`setIncrementing()`), runtime `mergeCasts()` / `withCasts()` — on a model *or* as +query-time casting (`Order::query()->withCasts([...])`) — and a runtime `setTable()` on +the model a query is built from (partitioned / archive tables) are all *normal* and fully +handled: the hydrated rows carry the query model's table and casts exactly as vanilla's +`newInstance()` would. ## What defers to vanilla (correct, just unaccelerated) diff --git a/src/Concerns/HasGreasedClassAttributes.php b/src/Concerns/HasGreasedClassAttributes.php index 5833257..73daefe 100644 --- a/src/Concerns/HasGreasedClassAttributes.php +++ b/src/Concerns/HasGreasedClassAttributes.php @@ -19,20 +19,21 @@ * other tiers on. (A newer L11/L12 feature core hasn't tuned the per-instance cost of.) * * This tier keeps the resolution byte-for-byte identical and only swaps the cache *shape*: - * the concatenated string key becomes a two-level `[$class][$attributeClass]` lookup, with - * the per-class sub-array fetched **once** into a local so the warm path is a single class - * lookup + one `array_key_exists` — no string built, nothing allocated. (The first cut keyed - * into the three-level blueprint and *regressed*: the extra level, traversed three times a - * call, out-cost vanilla's concat — so this uses a flat two-level carve-out instead, which - * the micro-A/B and the eager profile both confirm is the faster shape.) + * the concatenated `"$class@$attributeClass@$property"` key becomes a nested + * `[$class][$attributeClass][$property]` lookup, fetched to the per-attribute leaf in **one** + * `?? null` expression so the warm path is a single hash walk + one `array_key_exists` — no + * string built, nothing allocated. (The first cut keyed into the blueprint and re-traversed + * the levels three times a call, which *regressed* against vanilla's concat; a single fetch is + * what wins — `benchmarks/class_attribute_ab.php`, ~−11% warm.) * - * **Byte-identical, bug-for-bug:** the cold path is verbatim vanilla — the reflection walk up - * the parent chain, `getAttributes($attributeClass)[0]->newInstance()`, the `$property` - * extraction, the `catch (\Exception)` swallow, and the `null` memo for an absent attribute. - * Crucially the key carries the class and attribute but **not** `$property` — exactly like - * vanilla — so when the same attribute is resolved once with a property and once without - * (e.g. `#[Table]` via `getTable()` and via the `timestamps` lookup), both calls return - * whatever the *first* one cached. That quirk is Eloquent's; we reproduce it precisely. + * **Byte-identical:** the cold path is verbatim vanilla — the reflection walk up the parent + * chain (checking each class's own attributes, then its traits'), `getAttributes( + * $attributeClass)[0]->newInstance()`, the `$property` extraction, the `catch (\Exception)` + * swallow, and the `null` memo for an absent attribute. Like vanilla (since + * laravel/framework#60815) the key carries the property too — `[$class][$attributeClass] + * [$property ?? '']` — so `#[Table]` resolved via `getTable()` and via the `timestamps` lookup + * are cached independently. (`null` and `''` share a slot, exactly as vanilla's `'@'.$property` + * concat makes them; `?? ''` also keeps a `null` array offset out of PHP 8.5's deprecation.) * * A carve-out static rather than a blueprint key (like the `getDateFormat` connection cache): * class-level PHP attributes are immutable for a process's lifetime, so this cache never needs @@ -43,9 +44,9 @@ trait HasGreasedClassAttributes { /** - * Resolved class attributes, keyed `[class][attributeClass]`. + * Resolved class attributes, keyed `[class][attributeClass][property]`. * - * @var array> + * @var array>> */ protected static array $greaseClassAttributes = []; @@ -55,11 +56,12 @@ trait HasGreasedClassAttributes protected static function resolveClassAttribute(string $attributeClass, ?string $property = null, ?string $class = null) { $class ??= static::class; + $key = $property ?? ''; - $cache = static::$greaseClassAttributes[$class] ?? null; + $cache = static::$greaseClassAttributes[$class][$attributeClass] ?? null; - if ($cache !== null && array_key_exists($attributeClass, $cache)) { - return $cache[$attributeClass]; + if ($cache !== null && array_key_exists($key, $cache)) { + return $cache[$key]; } try { @@ -71,14 +73,25 @@ protected static function resolveClassAttribute(string $attributeClass, ?string if (count($attributes) > 0) { $instance = $attributes[0]->newInstance(); - return static::$greaseClassAttributes[$class][$attributeClass] + return static::$greaseClassAttributes[$class][$attributeClass][$key] = $property ? $instance->{$property} : $instance; } + + foreach ($reflection->getTraits() as $trait) { + $attributes = $trait->getAttributes($attributeClass); + + if (count($attributes) > 0) { + $instance = $attributes[0]->newInstance(); + + return static::$greaseClassAttributes[$class][$attributeClass][$key] + = $property ? $instance->{$property} : $instance; + } + } } while ($reflection = $reflection->getParentClass()); } catch (\Exception) { // } - return static::$greaseClassAttributes[$class][$attributeClass] = null; + return static::$greaseClassAttributes[$class][$attributeClass][$key] = null; } } diff --git a/src/Concerns/HasGreasedHydration.php b/src/Concerns/HasGreasedHydration.php index ee85b25..c02452b 100644 --- a/src/Concerns/HasGreasedHydration.php +++ b/src/Concerns/HasGreasedHydration.php @@ -2,13 +2,17 @@ namespace Grease\Concerns; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Str; +use ReflectionMethod; + /** * Tier 1 — construction & hydration. * * Eloquent rebuilds class-pure state on every `new Model` (and therefore every * hydrated row): `initializeModelAttributes()` allocates a fresh ReflectionClass, * `initializeHasAttributes()` rebuilds the whole casts array, and - * `newFromBuilder()` re-runs `newInstance()`'s setTable / mergeCasts / fill([]) / + * `newFromBuilder()` re-runs `newInstance()`'s cast self-merge / fill([]) / * double setConnection. None of it depends on instance state. This tier captures * it once per class and applies it by direct copy — the biggest win on any * workload that hydrates many rows. @@ -24,15 +28,27 @@ public function initializeModelAttributes() if (! isset(static::$greaseBlueprint[$class]['model'])) { parent::initializeModelAttributes(); - static::$greaseBlueprint[$class]['model'] = [ - $this->table, $this->connection, $this->primaryKey, $this->keyType, $this->incrementing, - ]; + $snapshot = [$this->table, $this->connection, $this->primaryKey, $this->keyType, $this->incrementing]; + + // 13.33+ also resolves `#[Refreshes]` here. Snapshot it only when set, so the warm + // path is a single isset() on Laravel 12 (no such property — writing it would fall + // through Model::__set into the attributes) and on the common no-attribute model. + if (property_exists($this, 'refreshes') && $this->refreshes !== []) { + $snapshot[5] = $this->refreshes; + } + + static::$greaseBlueprint[$class]['model'] = $snapshot; return; } - [$this->table, $this->connection, $this->primaryKey, $this->keyType, $this->incrementing] - = static::$greaseBlueprint[$class]['model']; + $snapshot = static::$greaseBlueprint[$class]['model']; + + [$this->table, $this->connection, $this->primaryKey, $this->keyType, $this->incrementing] = $snapshot; + + if (isset($snapshot[5])) { + $this->refreshes = $snapshot[5]; + } } protected function initializeHasAttributes() @@ -73,24 +89,56 @@ public function fill(array $attributes) } /** - * Slim hydration: __construct already applied the blueprint (table, casts, - * connection defaults), so skip newInstance()'s redundant setTable / - * mergeCasts(self-merge) / fill([]) / second setConnection. + * Slim hydration: __construct already applied the blueprint (casts, connection + * defaults), so skip newInstance()'s redundant self-merge of casts, fill([]) and + * first setConnection. * - * Caveat: a model that overrides newInstance() to inject construction-time - * state during hydration should not use this tier (or should re-apply that - * state here). Likewise a runtime setTable() on the prototype isn't carried onto - * hydrated rows (they take the class-default table) — class-level $table, the - * normal case, is applied by __construct. See the Caveats page. + * Two things newInstance() carries from the *prototype* (the query's model) are kept, + * because they can differ from the class defaults at runtime: + * - its table (`setTable($this->getTable())`) — a runtime `setTable('orders_2023')` + * on the prototype must reach the hydrated rows, or their save()/delete() would hit + * the class table; a derived table name is written onto the row just as vanilla does + * (see greaseHydrationTable() for why that stays cheap). + * - its casts — `Builder::withCasts()` merges query-time casts into the prototype and + * relies on newInstance() to hand them on. Instances share the blueprint's casts + * array, so the `!==` is a pointer compare until the prototype actually diverged. + * + * Caveat: a model that overrides newInstance() to inject construction-time state + * during hydration should not use this tier (or should re-apply that state here). */ public function newFromBuilder($attributes = [], $connection = null) { $model = new static; $model->exists = true; + $model->setTable($this->greaseHydrationTable()); + + if ($this->casts !== $model->casts) { + $model->mergeCasts($this->casts); + } + $model->setRawAttributes((array) $attributes, true); $model->setConnection($connection ?? $this->getConnectionName()); $model->fireModelEvent('retrieved', false); return $model; } + + /** + * `$this->getTable()`, minus vanilla's per-call re-derivation. For a model with no `$table`, + * vanilla's `getTable()` runs `Str::snake(Str::pluralStudly(class_basename($this)))` — + * ~2µs of inflector work per hydrated row for a class-pure string. When the model inherits + * vanilla `Model::getTable()` unchanged, the derived name is memoized per class (an explicit + * or runtime-set `$table` still wins first, exactly as vanilla). Any override — a sharded + * `getTable()`, AsPivot's, a user's — is called every time, byte-for-byte vanilla. Kept here + * rather than as a trait `getTable()` so it can never collide with another trait's. + */ + protected function greaseHydrationTable(): string + { + $derived = static::$greaseBlueprint[static::class]['derivedTable'] + ??= (new ReflectionMethod($this, 'getTable'))->class === Model::class + ? Str::snake(Str::pluralStudly(class_basename($this))) + : false; + + return $derived === false ? $this->getTable() : ($this->table ?? $derived); + } } diff --git a/src/Http/MemoizesRequestInput.php b/src/Http/MemoizesRequestInput.php index cbb5f44..d14acfb 100644 --- a/src/Http/MemoizesRequestInput.php +++ b/src/Http/MemoizesRequestInput.php @@ -4,14 +4,16 @@ use Grease\Support\CompiledPatternSet; use Illuminate\Http\Concerns\InteractsWithInput; +use Illuminate\Http\Request as BaseRequest; use Illuminate\Support\Arr; +use Symfony\Component\HttpFoundation\File\UploadedFile as SymfonyUploadedFile; /** * Grease HTTP tier — per-instance input memoization. * * Vanilla {@see InteractsWithInput::input()} rebuilds the * merged input map — `$this->getInputSource()->all() + $this->query->all()` — on *every* - * call, and `all()` wraps that in a fresh `array_replace_recursive(..., allFiles())` every + * call, and `all()` wraps that in a fresh `array_replace_recursive(..., allFiles(), ...)` every * call. Nearly every accessor funnels through them: `__get`, `offsetGet`, `offsetExists`, * `toArray`, and most of `InteractsWithData` (`has`/`only`/`except`/`filled`/`whenHas`…), * so a single request rebuilds the same array 10–30×. `isJson()` (called by @@ -75,9 +77,15 @@ trait MemoizesRequestInput */ protected ?array $greaseInputBase = null; - /** Memoized `array_replace_recursive(input(), allFiles())` — the base for `all()`. */ + /** Memoized `input()` + `allFiles()` merge, in vanilla's order — the base for `all()`. */ protected ?array $greaseAllBase = null; + /** + * Whether the installed vanilla `all()` re-applies input over files (laravel/framework#61221, + * 13.26+; never backported to 12.x). Probed once per process. + */ + protected static ?bool $greaseInputOverFiles = null; + /** Memoized `rawurldecode($this->path())` — stable per request; flushed on bag re-seed. */ protected ?string $greaseDecodedPath = null; @@ -106,7 +114,15 @@ public function input($key = null, $default = null) */ public function all($keys = null) { - $input = $this->greaseAllBase ??= array_replace_recursive($this->input(), $this->allFiles()); + if ($this->greaseAllBase === null) { + $input = $this->input(); + + $this->greaseAllBase = (static::$greaseInputOverFiles ??= static::greaseProbeInputOverFiles()) + ? array_replace_recursive($input, $this->allFiles(), $input) + : array_replace_recursive($input, $this->allFiles()); + } + + $input = $this->greaseAllBase; if (! $keys) { return $input; @@ -270,6 +286,18 @@ public function __clone() /** * Drop the memoized input maps. Used by the value mutators and lifecycle paths. */ + /** + * Ask vanilla which merge its `all()` uses: 13.26+ keeps input keys in place and lets input + * win a collision with a file (`replace(input, files, input)`); 12.x lets the file win + * (`replace(input, files)`). An upload-error file needs nothing on disk. + */ + protected static function greaseProbeInputOverFiles(): bool + { + $file = new SymfonyUploadedFile('/grease-probe', 'probe', null, UPLOAD_ERR_NO_FILE); + + return (new BaseRequest(['k' => 'input'], [], [], [], ['k' => $file]))->all()['k'] === 'input'; + } + protected function flushGreaseInput(): void { $this->greaseInputBase = null; diff --git a/src/Routing/UrlGenerator.php b/src/Routing/UrlGenerator.php index dd071aa..988af78 100644 --- a/src/Routing/UrlGenerator.php +++ b/src/Routing/UrlGenerator.php @@ -187,6 +187,12 @@ protected function greaseFastToRoute($route, array $entry, $parameters, bool $ab return null; } + // Vanilla's RouteUrlGenerator::encodeParameter() (laravel/framework#61475): escape + // `%`/`?`/`#` so a value can't truncate the path or smuggle an escape sequence. + if (is_string($value) && strpbrk($value, '%?#') !== false) { + $value = strtr($value, ['%' => '%25', '?' => '%3F', '#' => '%23']); + } + $path .= $value.$segments[$i + 1]; } diff --git a/src/View/ComponentAttributeBag.php b/src/View/ComponentAttributeBag.php index 0d53f9c..ed705b2 100644 --- a/src/View/ComponentAttributeBag.php +++ b/src/View/ComponentAttributeBag.php @@ -61,6 +61,10 @@ public function merge(array $attributeDefaults = [], $escape = true) if ($key === 'style') { $value = Str::finish($value, ';'); + + if (is_string($defaultsValue) && $defaultsValue !== '') { + $defaultsValue = Str::finish($defaultsValue, ';'); + } } $appendable[$key] = implode(' ', array_unique(array_filter([$defaultsValue, $value]))); diff --git a/tests/HasGreaseWholeStateParityTest.php b/tests/HasGreaseWholeStateParityTest.php new file mode 100644 index 0000000..4e5c84e --- /dev/null +++ b/tests/HasGreaseWholeStateParityTest.php @@ -0,0 +1,233 @@ + 'fixture', 'Connection' => 'fixture', 'DateFormat' => 'fixture', + 'Fillable' => 'fixture', 'Guarded' => 'fixture', 'Hidden' => 'fixture', + 'Refreshes' => 'fixture', 'RouteKey' => 'fixture', 'Table' => 'fixture', + 'Touches' => 'fixture', 'Unguarded' => 'fixture', 'Visible' => 'fixture', + 'WithoutIncrementing' => 'fixture', 'WithoutTimestamps' => 'fixture', + // Method-targeted, or class-level but resolved outside any greased booter (static + // registration / builder / collection / factory / policy / resource resolution). + 'Boot' => 'method', 'Initialize' => 'method', 'Scope' => 'method', + 'CollectedBy' => 'not-instance-state', 'ObservedBy' => 'not-instance-state', + 'ScopedBy' => 'not-instance-state', 'UseEloquentBuilder' => 'not-instance-state', + 'UseFactory' => 'not-instance-state', 'UsePolicy' => 'not-instance-state', + 'UseResource' => 'not-instance-state', 'UseResourceCollection' => 'not-instance-state', + ]; + + public function test_every_eloquent_attribute_class_is_accounted_for(): void + { + $dir = dirname((new \ReflectionClass(Model::class))->getFileName()).'/Attributes'; + + $upstream = array_map(fn ($f) => basename($f, '.php'), glob($dir.'/*.php')); + sort($upstream); + + $unaccounted = array_values(array_diff($upstream, array_keys(self::ACCOUNTED_FOR))); + + $this->assertSame([], $unaccounted, 'Laravel added Eloquent attribute(s) '.implode(', ', $unaccounted) + .': put each on a whole-state fixture (and check the greased booters snapshot what it writes), then list it in ACCOUNTED_FOR.'); + } + + public static function pairs(): array + { + return [ + 'every attribute' => [VanillaWholeStateFull::class, GreasedWholeStateFull::class], + 'unguarded / no-increment / no-timestamps' => [VanillaWholeStateFlags::class, GreasedWholeStateFlags::class], + 'plain' => [VanillaWholeStatePlain::class, GreasedWholeStatePlain::class], + ]; + } + + #[DataProvider('pairs')] + public function test_cold_and_warm_instances_match_vanilla(string $vanilla, string $greased): void + { + $greased::flushGreaseBlueprint(); + + $this->assertSame($this->state(new $vanilla), $this->state(new $greased), 'cold'); + $this->assertSame($this->state(new $vanilla), $this->state(new $greased), 'warm'); + } + + #[DataProvider('pairs')] + public function test_hydrated_instances_match_vanilla(string $vanilla, string $greased): void + { + $row = ['id' => 7, 'name' => 'widget']; + + for ($i = 0; $i < 2; $i++) { + $this->assertSame( + $this->state((new $vanilla)->newFromBuilder($row, 'alt')), + $this->state((new $greased)->newFromBuilder($row, 'alt')), + $i === 0 ? 'first hydrate' : 'warm hydrate', + ); + } + } + + /** + * The derived-table memo must only apply to vanilla `Model::getTable()`. An instance-dependent + * override (sharding) is called on every hydration — each prototype hands on its own table. + */ + public function test_instance_dependent_get_table_override_is_honored(): void + { + foreach ([2023, 2024, 2023] as $year) { + $hydrate = function (string $class) use ($year) { + $prototype = new $class; + $prototype->year = $year; + $row = $prototype->newFromBuilder(['id' => 1]); + + return [$this->state($row), $row->getTable()]; + }; + + [$vanillaState, $vanillaTable] = $hydrate(VanillaShardedOrder::class); + [$greasedState, $greasedTable] = $hydrate(GreasedShardedOrder::class); + + $this->assertSame("orders_$year", $vanillaState['table'], 'vanilla hands on the prototype table'); + $this->assertSame($vanillaState, $greasedState, "year $year"); + $this->assertSame($vanillaTable, $greasedTable); + } + } + + /** + * HasGrease must compose with any other trait that defines getTable() (AsPivot, …) — this file + * loading at all proves there's no trait-method collision — and hydrate the same state. + */ + public function test_composes_with_a_trait_that_defines_get_table(): void + { + for ($i = 0; $i < 2; $i++) { + $this->assertSame( + $this->state((new VanillaAsPivotModel)->newFromBuilder(['id' => 1])), + $this->state((new GreasedAsPivotModel)->newFromBuilder(['id' => 1])), + ); + } + } + + /** + * Every instance property, read from Model scope, minus Grease's own bookkeeping. A derived + * table name embeds the class name, so the Vanilla/Greased prefix is normalized away. + */ + private function state(Model $model): array + { + $vars = (fn () => get_object_vars($this))->call($model); + + if (is_string($vars['table'])) { + $vars['table'] = preg_replace('/^(vanilla|greased)_?/', '', $vars['table']); + } + + return array_filter($vars, fn ($key) => ! str_starts_with($key, 'grease'), ARRAY_FILTER_USE_KEY); + } +} + +#[Table(name: 'whole_widgets', key: 'uuid', keyType: 'string', dateFormat: 'Y-m-d H:i')] +#[Connection('alt')] +#[DateFormat('U')] +#[Fillable(['name', 'qty'])] +#[Guarded(['secret'])] +#[Hidden(['secret'])] +#[Visible(['name', 'qty'])] +#[Appends(['label'])] +#[Touches(['owner'])] +#[Refreshes(['version', 'updated_by'])] +#[RouteKey('slug')] +class VanillaWholeStateFull extends Model {} + +#[Table(name: 'whole_widgets', key: 'uuid', keyType: 'string', dateFormat: 'Y-m-d H:i')] +#[Connection('alt')] +#[DateFormat('U')] +#[Fillable(['name', 'qty'])] +#[Guarded(['secret'])] +#[Hidden(['secret'])] +#[Visible(['name', 'qty'])] +#[Appends(['label'])] +#[Touches(['owner'])] +#[Refreshes(['version', 'updated_by'])] +#[RouteKey('slug')] +class GreasedWholeStateFull extends Model +{ + use HasGrease; +} + +#[Unguarded] +#[WithoutIncrementing] +#[WithoutTimestamps] +class VanillaWholeStateFlags extends Model {} + +#[Unguarded] +#[WithoutIncrementing] +#[WithoutTimestamps] +class GreasedWholeStateFlags extends Model +{ + use HasGrease; +} + +class VanillaWholeStatePlain extends Model {} + +class GreasedWholeStatePlain extends Model +{ + use HasGrease; +} + +class VanillaShardedOrder extends Model +{ + public int $year = 2020; + + public function getTable() + { + return 'orders_'.$this->year; + } +} + +class GreasedShardedOrder extends VanillaShardedOrder +{ + use HasGrease; +} + +class VanillaAsPivotModel extends Model +{ + use AsPivot; +} + +class GreasedAsPivotModel extends Model +{ + use AsPivot, HasGrease; +} diff --git a/tests/HasGreasedClassAttributesParityTest.php b/tests/HasGreasedClassAttributesParityTest.php index 547e290..a3c2350 100644 --- a/tests/HasGreasedClassAttributesParityTest.php +++ b/tests/HasGreasedClassAttributesParityTest.php @@ -86,24 +86,51 @@ public function test_inherited_attribute_is_walked_up_the_parent_chain(): void } /** - * Vanilla keys the cache by class+attribute but NOT property, so when the same attribute - * is resolved once with a property and once without, the second call returns whatever the - * first cached. The greased cache must reproduce that order-dependent quirk precisely. + * Vanilla keys the cache by class+attribute+property (laravel/framework#60815), so the same + * attribute resolved once with a property and once without yields two independent answers, + * in either order. (Before 13.21 the key omitted the property and the second call returned + * whatever the first cached — a quirk Grease once reproduced; the floor now excludes it.) */ - public function test_property_less_cache_key_quirk_is_reproduced(): void + public function test_property_is_part_of_the_cache_key_in_either_order(): void { - // Order A: no-property first → both cache the Table *instance*, so the property call - // returns the instance (not the bool). + // Order A: no-property first, then the property → the instance, then the bool. $vA = $this->norm($this->secondOf(VanillaColAFixture::class, [Table::class, null], [Table::class, 'timestamps'])); $gA = $this->norm($this->secondOf(GreasedColAFixture::class, [Table::class, null], [Table::class, 'timestamps'], fresh: true)); $this->assertSame($vA, $gA); - $this->assertIsArray($gA, 'order A second call should return the cached Table instance'); + $this->assertFalse($gA, 'order A second call should return the timestamps bool'); - // Order B: property first → both cache the bool, so the no-property call returns it. + // Order B: property first, then no property → the bool, then the instance. $vB = $this->norm($this->secondOf(VanillaColBFixture::class, [Table::class, 'timestamps'], [Table::class, null])); $gB = $this->norm($this->secondOf(GreasedColBFixture::class, [Table::class, 'timestamps'], [Table::class, null], fresh: true)); $this->assertSame($vB, $gB); - $this->assertFalse($gB, 'order B second call should return the cached timestamps bool'); + $this->assertIsArray($gB, 'order B second call should return the Table instance'); + } + + /** + * Vanilla also reads attributes declared on a *trait* the class (or a parent) uses + * (laravel/framework#60566) — the class's own attribute wins, then its traits, then the + * parent's. Every shape resolved on both sides and compared. + */ + public function test_attributes_declared_on_traits_are_resolved(): void + { + $cases = [ + [VanillaTraitFixture::class, GreasedTraitFixture::class], // trait only + [VanillaTraitChildFixture::class, GreasedTraitChildFixture::class], // trait on parent + [VanillaTraitOwnFixture::class, GreasedTraitOwnFixture::class], // own beats trait + ]; + + foreach ($cases as [$vanilla, $greased]) { + foreach ([[Table::class, 'name'], [Fillable::class, 'columns'], [Table::class, null]] as [$attr, $property]) { + $this->assertSame( + $this->norm($this->resolve($vanilla, $attr, $property)), + $this->norm($this->resolve($greased, $attr, $property)), + "$greased resolve($attr, ".var_export($property, true).')', + ); + } + } + + $this->assertSame('trait_widgets', (new GreasedTraitFixture)->getTable()); + $this->assertSame(['from_trait'], (new GreasedTraitFixture)->getFillable()); } public function test_integration_getters_route_through_the_override(): void @@ -125,14 +152,15 @@ public function test_integration_getters_route_through_the_override(): void public function test_absent_attribute_is_memoized_as_null_in_the_carveout(): void { // The point of the tier: the common no-attribute case caches a null (not a re-resolve), - // in the dedicated carve-out static keyed [class][attributeClass]. + // in the dedicated carve-out static keyed [class][attributeClass][property]. $this->resolve(GreasedPlainFixture::class, Fillable::class, 'columns'); $cache = (new \ReflectionProperty(GreasedPlainFixture::class, 'greaseClassAttributes'))->getValue(); $this->assertArrayHasKey(GreasedPlainFixture::class, $cache); $this->assertArrayHasKey(Fillable::class, $cache[GreasedPlainFixture::class]); - $this->assertNull($cache[GreasedPlainFixture::class][Fillable::class]); + $this->assertArrayHasKey('columns', $cache[GreasedPlainFixture::class][Fillable::class]); + $this->assertNull($cache[GreasedPlainFixture::class][Fillable::class]['columns']); } /** Invoke the protected static resolveClassAttribute via reflection. */ @@ -219,3 +247,29 @@ class GreasedColBFixture extends Model { use HasGreasedClassAttributes; } + +#[Table(name: 'trait_widgets')] +#[Fillable(['from_trait'])] +trait DeclaresTraitAttributes {} + +class VanillaTraitFixture extends Model +{ + use DeclaresTraitAttributes; +} +class VanillaTraitChildFixture extends VanillaTraitFixture {} +#[Table(name: 'own_widgets')] +class VanillaTraitOwnFixture extends Model +{ + use DeclaresTraitAttributes; +} + +class GreasedTraitFixture extends Model +{ + use DeclaresTraitAttributes, HasGreasedClassAttributes; +} +class GreasedTraitChildFixture extends GreasedTraitFixture {} +#[Table(name: 'own_widgets')] +class GreasedTraitOwnFixture extends Model +{ + use DeclaresTraitAttributes, HasGreasedClassAttributes; +} diff --git a/tests/Http/RequestInputParityTest.php b/tests/Http/RequestInputParityTest.php index b3c4bc0..351f14f 100644 --- a/tests/Http/RequestInputParityTest.php +++ b/tests/Http/RequestInputParityTest.php @@ -5,6 +5,7 @@ use Grease\Http\Request as GreasedRequest; use Illuminate\Http\Request; use Illuminate\Http\Request as VanillaRequest; +use Illuminate\Http\UploadedFile; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -37,6 +38,22 @@ private static function json(string $class): VanillaRequest ])); } + /** + * A multipart POST whose uploaded files interleave with — and one collides with — the body + * keys: `all()` must merge them in vanilla's order and precedence (laravel/framework#61221: + * input keys keep their position and win a collision; file-only keys follow). + */ + private static function upload(string $class): VanillaRequest + { + $file = fn () => new UploadedFile(__FILE__, 'probe.php', 'text/plain', null, true); + + return $class::create('/x?q=1', 'POST', ['a' => 'body', 'doc' => 'as-input', 'z' => 1], [], [ + 'avatar' => $file(), + 'doc' => $file(), + 'nested' => ['x' => $file()], + ]); + } + /** * Read-only accessor probes. Each returns a canonical, comparable result. * @@ -68,7 +85,7 @@ private static function probes(): array public static function matrix(): array { $cases = []; - foreach (['get', 'post', 'json'] as $shape) { + foreach (['get', 'post', 'json', 'upload'] as $shape) { foreach (self::probes() as $name => $probe) { $cases["$shape / $name"] = [$shape, $probe]; } diff --git a/tests/Livewire/LivewireParityTest.php b/tests/Livewire/LivewireParityTest.php index 9d5c482..f0e3bb1 100644 --- a/tests/Livewire/LivewireParityTest.php +++ b/tests/Livewire/LivewireParityTest.php @@ -175,9 +175,13 @@ private function normalizedChecksum(array $snapshot): string return Checksum::generate($snapshot); } - /** Strip the per-request-random wire:* attributes, leaving the rendered body to compare. */ + /** + * Strip the per-request-random wire:* attributes — and Livewire 4's `wire:name`, which is + * the component's own name and so differs between the vanilla and greased twins by + * construction — leaving the rendered body to compare. + */ private function body(string $html): string { - return preg_replace('/\s*wire:(id|snapshot|effects)="[^"]*"/', '', $html); + return preg_replace('/\s*wire:(id|snapshot|effects|name)="[^"]*"/', '', $html); } } diff --git a/tests/Pipeline/StackPipelineParityTest.php b/tests/Pipeline/StackPipelineParityTest.php index 3b942d4..0be0eda 100644 --- a/tests/Pipeline/StackPipelineParityTest.php +++ b/tests/Pipeline/StackPipelineParityTest.php @@ -53,7 +53,9 @@ private function probe(int $level): array .' $h = \Grease\Tests\Fixtures\Pipeline\PipelineHarness::class;' .' echo json_encode($h::parityProbe($h::bootLevel((int) $argv[2]), (int) $argv[2]));'; - $cmd = escapeshellarg(PHP_BINARY).' -r '.escapeshellarg($code) + // Deprecations from older dependencies (the lowest-deps leg on a newer PHP) would land + // in stdout ahead of the JSON; silence only those — real errors still surface below. + $cmd = escapeshellarg(PHP_BINARY).' -d '.escapeshellarg('error_reporting=E_ALL & ~E_DEPRECATED').' -r '.escapeshellarg($code) .' '.escapeshellarg($autoload).' '.$level.' 2>&1'; $out = (string) shell_exec($cmd); diff --git a/tests/Routing/UrlGeneratorParityTest.php b/tests/Routing/UrlGeneratorParityTest.php index fa819d9..296d020 100644 --- a/tests/Routing/UrlGeneratorParityTest.php +++ b/tests/Routing/UrlGeneratorParityTest.php @@ -9,6 +9,7 @@ use Illuminate\Routing\Route; use Illuminate\Routing\RouteCollection; use Illuminate\Routing\UrlGenerator as VanillaUrlGenerator; +use Illuminate\Support\Stringable; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -106,6 +107,14 @@ public function resolveChildRouteBinding($childType, $value, $field = null) {} 'value with unicode' => ['users.show', ['user' => 'café'], true], 'value with reserved' => ['users.show', ['user' => 'a;b,c=d'], true], 'integer value' => ['users.show', ['user' => 99], true], + // laravel/framework#61475: `%`/`?`/`#` inside a parameter are escaped so they can't + // truncate the path into a query string / fragment or smuggle an escape sequence. + 'value with question mark' => ['users.show', ['user' => 'what?'], true], + 'value with hash' => ['users.show', ['user' => 'c#-notes'], true], + 'value with percent' => ['users.show', ['user' => '100%'], true], + 'value with pre-encoded escape' => ['users.show', ['user' => 'a%2Fb'], true], + 'value with all three, relative' => ['posts.comments', ['post' => 'x?y', 'comment' => '#1 %'], false], + 'Stringable value' => ['users.show', ['user' => new Stringable('why?')], true], ]; } diff --git a/tests/SqlRoundtripTest.php b/tests/SqlRoundtripTest.php index 80acf41..c664ed1 100644 --- a/tests/SqlRoundtripTest.php +++ b/tests/SqlRoundtripTest.php @@ -167,6 +167,60 @@ public function test_eager_loaded_relations_are_identical(): void $this->assertSame(json_encode($v->toArray()), json_encode($g->toArray())); $this->assertCount(2, $g->first()->posts); } + + /** + * Query-time casting: `Builder::withCasts()` merges into the query's *prototype* model and + * relies on newInstance() to hand those casts to every hydrated row — the greased slim + * newFromBuilder must carry them too. + */ + public function test_query_time_with_casts_reaches_hydrated_rows(): void + { + $query = fn ($class) => $class::query() + ->select('*') + ->selectRaw("'7' as post_count") + ->withCasts(['post_count' => 'integer', 'name' => 'array']) + ->orderBy('id'); + + DB::table('rt_authors')->insert(['name' => '["Ada"]', 'age' => 36]); + + $v = $query(VanillaAuthor::class)->get(); + $g = $query(GreasedAuthor::class)->get(); + + $this->assertSame(7, $g->first()->post_count); + $this->assertSame(['Ada'], $g->first()->name); + $this->assertSame(json_encode($v->toArray()), json_encode($g->toArray())); + + // …and the query-time casts don't leak into the class: a plain query is uncast again. + $this->assertSame('["Ada"]', GreasedAuthor::query()->first()->name); + } + + /** + * A runtime `setTable()` on the prototype (partitioned / archive tables) must reach the + * hydrated rows, so their writes go back to the table they were read from. + */ + public function test_runtime_set_table_reaches_hydrated_rows_and_their_writes(): void + { + Schema::dropIfExists('rt_authors_archive'); + Schema::create('rt_authors_archive', function (Blueprint $t) { + $t->increments('id'); + $t->string('name'); + $t->integer('age')->nullable(); + $t->timestamps(); + }); + DB::table('rt_authors_archive')->insert(['name' => 'Grace', 'age' => 85]); + + foreach ([VanillaAuthor::class, GreasedAuthor::class] as $class) { + $row = (new $class)->setTable('rt_authors_archive')->newQuery()->first(); + + $this->assertSame('rt_authors_archive', $row->getTable(), $class); + + $row->age++; + $row->save(); + } + + $this->assertSame(87, DB::table('rt_authors_archive')->value('age')); + $this->assertSame(0, DB::table('rt_authors')->count(), 'a write leaked into the class table'); + } } class VanillaAuthor extends Model