diff --git a/CHANGELOG.md b/CHANGELOG.md index 9586a08..63230f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### Changed + +- **Faster builds, with less memory.** Statamic builds each entry's URL by running its route through Antlers with a new parser every time. Routes that are only placeholders (`/books/{slug}`, `{parent_uri}/{slug}`) now have their values put in directly; anything else still goes through Antlers. On the demo's 9,000-entry nested page builder site the build went from 21.5 s to 15.5 s and its memory from 356 MB to 285 MB. +- **Entries are cheaper to read.** Statamic snapshots an entry's state every time one is read, so it can later say what changed. Flatpack now works that snapshot out only when something asks for it. Reading 9,000 entries through a query went from 81 to 32 microseconds each. + ## 1.1.1 (2026-11-10) ### Fixed diff --git a/src/FlatpackEntry.php b/src/FlatpackEntry.php index 007e446..1c9fa57 100644 --- a/src/FlatpackEntry.php +++ b/src/FlatpackEntry.php @@ -5,15 +5,20 @@ use NineteenNinetyFour\Flatpack\Lazy\LazyBody; use NineteenNinetyFour\Flatpack\Lazy\LazyData; use Statamic\Entries\Entry; +use Statamic\Facades\Blink; /** * Statamic's Entry with lazy heavy fields. Two core paths read every value and would load all * bodies during indexing; both are routed around the placeholders here: * - routeData(): a route template only sees the lazy fields it names * - dirty-state snapshots keep placeholders instead of loading bodies + * The dirty-state snapshot is also only worked out when something asks for it. */ class FlatpackEntry extends Entry { + /** This entry as it was when last read or saved, until its dirty-state snapshot is needed. */ + private ?self $asRead = null; + public function data($data = null) { if (func_num_args() === 0) { @@ -44,6 +49,86 @@ public function getCurrentDirtyStateAttributes(): array return $this->withSwappedData(fn ($d) => collect($d->rawItems()), fn () => parent::getCurrentDirtyStateAttributes()); } + /** + * Statamic snapshots an entry's state every time one is read from the Stache, so it can + * later say what changed. Most entries are never asked. Keeping a copy of the entry as it + * was, and working the snapshot out from that copy only when something asks, costs a + * shallow clone per read instead. + */ + public function syncOriginal(): static + { + $this->asRead = null; + $asRead = clone $this; + + // Older Statamic versions don't give a cloned entry its own data. + if ($asRead->data === $this->data) { + $asRead->data = clone $this->data; + } + + // Dates can be changed in place, so the copy needs its own. + if ($asRead->date instanceof \DateTimeInterface) { + $asRead->date = clone $asRead->date; + } + + $this->asRead = $asRead; + + return $this; + } + + public function getOriginal($key = null, $fallback = null) + { + $this->resolveOriginal(); + + return parent::getOriginal($key, $fallback); + } + + public function isDirty($attributes = null): bool + { + $this->resolveOriginal(); + + return parent::isDirty($attributes); + } + + private function resolveOriginal(): void + { + if (! $asRead = $this->asRead) { + return; + } + + $this->asRead = null; + + if (! $asRead->origin) { + $this->original = $asRead->getCurrentDirtyStateAttributes(); + + return; + } + + // Statamic remembers an entry's origin by the entry's id, so the copy would be given + // this entry's origin as it is now. The copy has to see the origin it had when it was + // read, and what's remembered has to be put back afterwards. + $key = $asRead->getOriginBlinkKey(); + $remembered = Blink::get($key); + + if ($origin = $asRead->getOriginByString($asRead->origin)) { + Blink::put($key, $origin); + } else { + Blink::forget($key); + } + + try { + $this->original = $asRead->getCurrentDirtyStateAttributes(); + } finally { + $remembered ? Blink::put($key, $remembered) : Blink::forget($key); + } + } + + public function __sleep() + { + $this->resolveOriginal(); + + return array_values(array_diff(parent::__sleep(), ['asRead'])); + } + /** Run $fn with this entry's (and its origins') unloaded LazyData temporarily replaced. */ private function withSwappedData(callable $swap, callable $fn) { diff --git a/src/Routing/FastUrlBuilder.php b/src/Routing/FastUrlBuilder.php new file mode 100644 index 0000000..5ab1055 --- /dev/null +++ b/src/Routing/FastUrlBuilder.php @@ -0,0 +1,116 @@ + 1, 'false' => 1, 'null' => 1, 'void' => 1, 'if' => 1, 'else' => 1, 'elseif' => 1, 'unless' => 1, 'elseunless' => 1, + 'endif' => 1, 'endunless' => 1, 'and' => 1, 'or' => 1, 'xor' => 1, 'not' => 1, 'switch' => 1, 'once' => 1, 'endonce' => 1, + 'noparse' => 1, 'endnoparse' => 1, 'stop' => 1, 'recursive' => 1, 'new' => 1, 'bwa' => 1, 'bwo' => 1, 'bxor' => 1, 'bnot' => 1, 'bsl' => 1, 'bsr' => 1, + 'list' => 1, 'as' => 1, 'arr' => 1, 'merge' => 1, 'pluck' => 1, 'where' => 1, 'take' => 1, 'skip' => 1, 'orderby' => 1, 'groupby' => 1, + ]; + + private static ?PlainTextParser $plain = null; + + private static ?object $noData = null; + + public function build($route) + { + $pattern = is_array($route) ? ($route[$this->content->locale()] ?? null) : $route; + + if (! is_string($pattern) || str_contains($pattern, '{{')) { + return parent::build($route); + } + + $data = null; + $simple = true; + + // The same placeholders Statamic turns into Antlers variables. + $filled = preg_replace_callback('/{\s*([a-zA-Z0-9_\-]+)\s*}/', function ($match) use (&$data, &$simple) { + $name = $match[1]; + + if (! preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $name) || isset(self::RESERVED[strtolower($name)])) { + $simple = false; + + return ''; + } + + $data ??= array_merge($this->content->routeData(), $this->merged ?? []); + $value = $data[$name] ?? null; + + if (is_int($value)) { + return (string) $value; + } + + if (is_string($value) && strpbrk($value, '{}') === false) { + return $value; + } + + $simple = false; + + return ''; + }, $pattern); + + if (! $simple || $filled === null || strpbrk($filled, '{}') !== false) { + return parent::build($route); + } + + // Creating an Antlers parser also records which tags and modifiers exist at that moment, + // and a tag registered after the view engine was created is only seen once that happens + // again. Statamic's builder did it for every URL, so it is still done here. + app(EnvironmentDetails::class); + + // Statamic's builder does the rest. It has no values left to look up, so it is given + // content with no route data rather than working the entry's out a second time. + $content = $this->content; + $merged = $this->merged ?? []; + $thrown = null; + + $this->content = self::$noData ??= new class + { + public function routeData() + { + return []; + } + }; + $this->merged = []; + + try { + // usingParser() doesn't put the usual parser back if the callback throws, so nothing is thrown through it. + $url = Antlers::usingParser(self::$plain ??= new PlainTextParser, function () use ($filled, &$thrown) { + try { + return parent::build($filled); + } catch (\Throwable $e) { + $thrown = $e; + + return null; + } + }); + } finally { + $this->content = $content; + $this->merged = $merged; + } + + if ($thrown) { + throw $thrown; + } + + return $url; + } +} diff --git a/src/Routing/PlainTextParser.php b/src/Routing/PlainTextParser.php new file mode 100644 index 0000000..5e0613d --- /dev/null +++ b/src/Routing/PlainTextParser.php @@ -0,0 +1,57 @@ +app->booting(function ($app) { $app->bind(\Statamic\Contracts\Entries\Entry::class, FlatpackEntry::class); $app->bind(\Statamic\Stache\Traverser::class, Stache\FastTraverser::class); + $app->bind(\Statamic\Contracts\Routing\UrlBuilder::class, Routing\FastUrlBuilder::class); // Statamic parses with its own fast parser where it has one; older versions get Flatpack's. $app->singleton(\Statamic\Yaml\Yaml::class, fn () => new LeanYaml( Flatpack::statamicHasFastParser() ? new \Symfony\Component\Yaml\Yaml : new Yaml\SymfonyFast diff --git a/tests/parity/dirty.php b/tests/parity/dirty.php new file mode 100644 index 0000000..ba850e9 --- /dev/null +++ b/tests/parity/dirty.php @@ -0,0 +1,71 @@ + + +use Statamic\Facades\Entry; + +[$_, $appDir, $out] = $argv; +require "$appDir/vendor/autoload.php"; +$app = require "$appDir/bootstrap/app.php"; +$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); + +$state = fn ($e, array $keys) => [ + 'dirty' => $e->isDirty(), + 'dirty_keys' => array_values(array_filter($keys, fn ($k) => $e->isDirty($k))), + 'original' => array_map(fn ($k) => $e->getOriginal($k), array_combine($keys, $keys)), +]; +$keys = ['origin', 'slug', 'date', 'published', 'title', 'locale', 'collection']; + +// Each case starts from a freshly read entry. Statamic remembers origins per request, and the +// unsaved changes made here would otherwise be remembered into the next case. +$fresh = function ($id) { + Statamic\Facades\Blink::flush(); + + return Entry::find($id); +}; +$result = []; + +$translations = Entry::query()->where('collection', 'blog')->where('site', 'fr')->get()->filter(fn ($e) => $e->hasOrigin())->sortBy->id()->values(); +$posts = Entry::query()->where('collection', 'blog')->where('site', 'en')->get()->sortBy->id()->values(); + +foreach ($translations->take(3) as $i => $translation) { + $id = $translation->id(); + $other = $posts->first(fn ($e) => $e->id() !== $translation->origin()->id()); + + $e = $fresh($id); + $result["$i untouched"] = $state($e, $keys); + + $e = $fresh($id); + $e->origin($other); + $result["$i origin changed"] = $state($e, $keys) + ['origin_now' => $e->origin()->id()]; + + $e = $fresh($id); + $e->origin(null); + $result["$i origin removed"] = $state($e, $keys) + ['origin_now' => $e->origin()?->id()]; + + $e = $fresh($id); + $e->set('title', 'Changed in memory')->slug('changed-in-memory')->published(! $e->published()); + $result["$i data, slug and published changed"] = $state($e, $keys); +} + +foreach ($posts->take(3) as $i => $post) { + $e = $fresh($post->id()); + $e->date()?->addDays(3); + $result["post $i date moved in place"] = $state($e, $keys); + + $e = $fresh($post->id()); + $e->data()->put('title', 'Put straight into the data'); + $result["post $i data changed through the collection"] = $state($e, $keys); + + $e = unserialize(serialize($fresh($post->id()))); + $e->set('title', 'After a round trip'); + $result["post $i serialised and back"] = $state($e, $keys); + + $e = clone $fresh($post->id()); + $e->slug('cloned'); + $result["post $i cloned"] = $state($e, $keys); +} + +file_put_contents($out, json_encode($result, JSON_PRETTY_PRINT)); +echo count($result), " dirty-state answers\n"; diff --git a/tests/parity/generate.php b/tests/parity/generate.php index 820a922..cd95995 100644 --- a/tests/parity/generate.php +++ b/tests/parity/generate.php @@ -123,6 +123,22 @@ } } +// ---- notes and guides: routes that aren't only plain placeholders, so URLs still come from Antlers +$write("$c/collections/notes.yaml", $yaml(['title' => 'Notes', 'sites' => ['en'], 'route' => '/notes/{{ slug }}-{{ title | length }}'])); +$write("$c/collections/guides.yaml", $yaml(['title' => 'Guides', 'sites' => ['en'], 'route' => '/guides/{topics}/{level}/{slug}'])); +for ($i = 0; $i < 6; $i++) { + $slug = strtolower(str_replace(' ', '-', $title(2))).'-'.$i; + $write("$c/collections/notes/en/$slug.md", "---\n".$yaml(['id' => $uuid("note-$i"), 'title' => $title(3)])."---\n"); + // topics is a list and level is sometimes missing or has braces in it: none of those can be put in directly + $guide = ['id' => $uuid("guide-$i"), 'title' => $title(3), 'topics' => ['alpha', 'beta']]; + if ($i % 3 === 1) { + $guide['level'] = 'intro'; + } elseif ($i % 3 === 2) { + $guide['level'] = 'a{b}c'; + } + $write("$c/collections/guides/en/$slug.md", "---\n".$yaml($guide)."---\n"); +} + // ---- team: single site, structured without root, orderable $write("$c/collections/team.yaml", $yaml(['title' => 'Team', 'sites' => ['en'], 'route' => '/team/{slug}', 'structure' => ['max_depth' => 1]])); $team = []; diff --git a/tests/parity/run.sh b/tests/parity/run.sh index f1819e0..d165a47 100755 --- a/tests/parity/run.sh +++ b/tests/parity/run.sh @@ -19,6 +19,13 @@ reset "$O"; reset "$C"; build dump cold; same "cold read" cold dump warm; same "warm read" warm +oracle "$HERE/dirty.php" "$O" "$OUT/o-dirty.json" >/dev/null && flatpack "$HERE/dirty.php" "$C" "$OUT/c-dirty.json" >/dev/null +check "change tracking: same original values and dirty state" python3 -c " +import json,sys +a=json.load(open(sys.argv[1])); b=json.load(open(sys.argv[2])) +[print(' ', k, a[k], '->', b.get(k)) for k in a if a[k]!=b.get(k)] +sys.exit(0 if a==b and len(a)>10 else 1)" "$OUT/o-dirty.json" "$OUT/c-dirty.json" + oracle "$HERE/mutate.php" "$O" "$OUT/o-mutate.json" >/dev/null flatpack "$HERE/mutate.php" "$C" "$OUT/c-mutate.json" >/dev/null check "saves: same steps and outcomes" python3 -c "