Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/Http/Controllers/PostController.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ public function show($year, $month, $slug)
$relatedPosts = WpPostService::getRelatedPosts($post);

return inertia('PostContent', [
'post' => WpPostResource::make($post),
'post' => WpPostResource::make($post)->detailed(),
'relatedPosts' => WpPostResource::collection($relatedPosts),
]);
}
Expand Down
24 changes: 23 additions & 1 deletion app/Http/Resources/WpPostResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,28 @@
*/
class WpPostResource extends JsonResource
{
/**
* Whether to include the heavy, single-post-only fields.
*/
private bool $detailed = false;

/**
* Opt in to the full payload: processed content, subtitle, tags and author
* biography.
*
* This used to be inferred from $request->routeIs('post'), which meant the
* related posts rendered on that same page were also given the full
* treatment — including running EmbedProcessorService over their content —
* even though the component that renders them only reads the title,
* thumbnail, slug and date.
*/
public function detailed(): static
{
$this->detailed = true;

return $this;
}

/**
* Transform the resource into an array.
*
Expand All @@ -50,7 +72,7 @@ public function toArray(Request $request): array
],
];

if ($request->routeIs('post')) {
if ($this->detailed) {
$postData['content'] = EmbedProcessorService::processContent($this->post_content);
$postData['subtitle'] = $this->metadata->filter(fn ($meta) => $meta->meta_key === 'subtitle')?->first()?->meta_value;
$postData['date']['day'] = $parsedPostDate->format('d');
Expand Down
21 changes: 21 additions & 0 deletions app/Models/WpAttachment.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

/**
* Attachment rows of wp_posts.
*
* Same table as WpPost but deliberately without WpPostScope: that scope filters
* post_type = 'post' and post_status = 'publish', which would exclude every
* attachment (post_type = 'attachment', post_status = 'inherit').
*/
class WpAttachment extends Model
{
protected $table = 'wp_posts';

protected $primaryKey = 'ID';

public $timestamps = false;
}
31 changes: 23 additions & 8 deletions app/Models/WpPost.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOneThrough;
use Illuminate\Support\Facades\DB;

#[ScopedBy(WpPostScope::class)]
Expand All @@ -35,16 +36,30 @@ public function author(): BelongsTo
]);
}

public function getThumbnail()
/**
* Featured image, resolved through the _thumbnail_id meta row.
*
* Modelled as a relation so it can be eager loaded: wp_posts -> wp_postmeta
* (_thumbnail_id) -> wp_posts (the attachment). Reading it through
* $this->metadata() instead would issue two queries for every post.
*/
public function thumbnail(): HasOneThrough
{
if (! $postMeta = $this->metadata()->thumbnailId()->first()) {
return '';
}
return $this->hasOneThrough(
WpAttachment::class,
WpPostMeta::class,
'post_id', // wp_postmeta.post_id -> wp_posts.ID (this model)
'ID', // wp_posts.ID (attachment) -> wp_postmeta.meta_value
'ID',
'meta_value',
)
->where('wp_postmeta.meta_key', '_thumbnail_id')
->where('wp_posts.post_type', 'attachment');
}

return DB::table('wp_posts')
->where('ID', $postMeta->meta_value)
->where('post_type', 'attachment')
->value('guid') ?? '';
public function getThumbnail(): string
{
return $this->thumbnail?->guid ?? '';
}

#[Scope]
Expand Down
5 changes: 4 additions & 1 deletion app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Providers;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
Expand All @@ -19,6 +20,8 @@ public function register(): void
*/
public function boot(): void
{
//
// Turns an accidental lazy load into a loud failure while developing
// and in CI, instead of a silent extra query per record in production.
Model::preventLazyLoading(! $this->app->isProduction());
}
}
17 changes: 10 additions & 7 deletions app/Services/WpPostService.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public static function getHighlightedPosts(): Collection

return cache()->remember($cacheKey, $cacheTTL, function () {
return WpPost::query()
->with(['metadata'])
->with(['terms', 'author', 'thumbnail'])
->whereCategorySlug('destaques')
->limit(self::DEFAULT_LIMIT_HIGHLIGHT_POSTS)
->get();
Expand All @@ -34,7 +34,7 @@ public static function getHomePosts(int $page, ?string $searchTerm = null): Leng

return cache()->remember($cacheKey, $cacheTTL, function () use ($searchTerm) {
return WpPost::query()
->with(['metadata'])
->with(['terms', 'author', 'thumbnail'])
->whereNotCategorySlug('destaques')
->useSearchTerm($searchTerm)
->paginate(self::DEFAULT_PER_PAGE_ON_LISTS);
Expand All @@ -48,7 +48,7 @@ public static function getPostsByCategorySlug(int $page, string $slug): LengthAw

return cache()->remember($cacheKey, $cacheTTL, function () use ($slug) {
return WpPost::query()
->with(['metadata', 'author'])
->with(['terms', 'author', 'thumbnail'])
->whereCategorySlug($slug)
->paginate(self::DEFAULT_PER_PAGE_ON_LISTS);
});
Expand All @@ -61,7 +61,7 @@ public static function getPostsByAuthorNicename(int $page, string $nicename): Le

return cache()->remember($cacheKey, $cacheTTL, function () use ($nicename) {
return WpPost::query()
->with(['metadata', 'author'])
->with(['terms', 'author', 'thumbnail'])
->whereAuthorNicename($nicename)
->paginate(self::DEFAULT_PER_PAGE_ON_LISTS);
});
Expand All @@ -74,7 +74,7 @@ public static function getPostsByYearMonth(int $page, string $year, string $mont

return cache()->remember($cacheKey, $cacheTTL, function () use ($year, $month) {
return WpPost::query()
->with(['metadata', 'author'])
->with(['terms', 'author', 'thumbnail'])
->whereYear('post_date', $year)
->whereMonth('post_date', $month)
->paginate(self::DEFAULT_PER_PAGE_ON_LISTS);
Expand All @@ -88,7 +88,7 @@ public static function getPostsByTagSlug(int $page, string $slug): LengthAwarePa

return cache()->remember($cacheKey, $cacheTTL, function () use ($slug) {
return WpPost::query()
->with(['metadata', 'author'])
->with(['terms', 'author', 'thumbnail'])
->whereTagSlug($slug)
->paginate(self::DEFAULT_PER_PAGE_ON_LISTS);
});
Expand All @@ -101,6 +101,9 @@ public static function getPostByYearMonthSlug($year, $month, $slug): WpPost

return cache()->remember($cacheKey, $cacheTTL, function () use ($year, $month, $slug) {
return WpPost::query()
// The single post view also renders the subtitle meta and the
// author biography, so it needs more than the listings do.
->with(['terms', 'thumbnail', 'metadata', 'author.metadata'])
->whereYear('post_date', $year)
->whereMonth('post_date', $month)
->where('post_name', $slug)
Expand All @@ -124,7 +127,7 @@ public static function getRelatedPosts(WpPost $post, int $limit = 3): Collection
}

return WpPost::query()
->with(['metadata'])
->with(['terms', 'author', 'thumbnail'])
->whereCategorySlug($categorySlug)
->where('ID', '!=', $post->ID)
->limit($limit)
Expand Down
3 changes: 2 additions & 1 deletion phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_DATABASE" value="testing"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="MAIL_MAILER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="QUEUE_CONNECTION" value="sync"/>
Expand Down
7 changes: 0 additions & 7 deletions tests/Feature/ExampleTest.php

This file was deleted.

80 changes: 80 additions & 0 deletions tests/Feature/QueryCountTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

use Illuminate\Support\Facades\DB;
use Tests\Support\Wp;

/**
* Guards against N+1 regressions.
*
* The thresholds are deliberately a little above the current numbers: they are
* meant to catch a per-post query creeping back in, not to break on an extra
* one-off query.
*/
function countQueries(callable $callback): int
{
DB::flushQueryLog();
DB::enableQueryLog();

$callback();

$log = DB::getQueryLog();
DB::disableQueryLog();

return count($log);
}

function seedPosts(int $count): void
{
$author = Wp::user(['user_login' => 'autor', 'user_nicename' => 'autor']);

for ($i = 1; $i <= $count; $i++) {
$post = Wp::post([
'post_author' => $author->ID,
'post_title' => "Post {$i}",
'post_name' => "post-{$i}",
'post_date' => '2026-07-'.str_pad((string) $i, 2, '0', STR_PAD_LEFT).' 12:00:00',
]);

Wp::categorise($post->ID, 'games');
Wp::thumbnail($post->ID);
}
}

it('does not scale queries with the number of posts on the home page', function () {
seedPosts(3);
$few = countQueries(fn () => $this->get('/')->assertOk());

// Fresh state, ten times the posts.
Tests\Support\WordPressSchema::create();
cache()->flush();
seedPosts(30);
$many = countQueries(fn () => $this->get('/')->assertOk());

// The point of the test: the count must not track the post count.
expect($many)->toBe($few);
});

it('keeps the home page within a query budget', function () {
seedPosts(10);

// measured: 7
expect(countQueries(fn () => $this->get('/')->assertOk()))
->toBeLessThanOrEqual(9);
});

it('keeps the single post page within a query budget', function () {
seedPosts(10);

// measured: 11 — higher than the listings because it also loads the
// subtitle meta, the author biography and the related posts.
expect(countQueries(fn () => $this->get('/2026/07/post-1')->assertOk()))
->toBeLessThanOrEqual(13);
});

it('keeps the category listing within a query budget', function () {
seedPosts(10);

// measured: 6
expect(countQueries(fn () => $this->get('/category/games')->assertOk()))
->toBeLessThanOrEqual(8);
});
Loading