Skip to content

fix: N+1 query optimization and lazy loading prevention#132

Merged
ManukMinasyan merged 22 commits intomainfrom
fix/n-plus-one-query-optimization
Feb 17, 2026
Merged

fix: N+1 query optimization and lazy loading prevention#132
ManukMinasyan merged 22 commits intomainfrom
fix/n-plus-one-query-optimization

Conversation

@ManukMinasyan
Copy link
Copy Markdown
Contributor

@ManukMinasyan ManukMinasyan commented Feb 16, 2026

Summary

  • Fix query hotspots in SystemAdmin widgets and registration seeding
  • Enable Model::preventLazyLoading() in non-production to catch lazy loading at runtime
  • Eager load team relationship in all CRM Filament resources
  • Fix NULL order_column on seeded tasks/opportunities causing undefined Kanban board order
  • Simplify onboard seeding architecture

Changes

Query optimizations:

  • PlatformGrowthStatsWidget: replace per-table COUNT loops with UNION ALL, replace per-point sparkline queries with bucketed GROUP BY
  • TopTeamsTableWidget: memoize expression building with once() to prevent duplicate computation
  • OpportunitiesBoard: memoize stageCustomField() with once()
  • Registration seeding: create BulkCustomFieldValueWriter for batch custom field inserts instead of per-entity writes
  • CreateTeamCustomFields: batch option color updates with CASE WHEN SQL

Lazy loading prevention:

  • Enable Model::preventLazyLoading() in non-production environments
  • Eager load team in CompanyResource, PeopleResource, OpportunityResource, NoteResource, TaskResource
  • Eager load currentTeam + ownedTeams in SystemAdmin UserResource

Onboard seeding fixes:

  • Assign sequential order_column via DecimalPosition for tasks/opportunities (SortableTrait events suppressed by withoutEvents())
  • Fix board columnIdentifier prefix (cfv. removed)
  • Simplify seeder architecture: remove unused context-passing, switch to Model::withoutEvents(), add FixtureRegistry::clear()
  • Pass team directly to seeder to avoid redundant queries
  • Fix deleted_at IS NULL missing in TopTeamsTableWidget last activity expression

Tests added:

  • Registration tests: entity counts, relationships, custom fields, board positions
  • Board tests: column record placement, team isolation, card movement
  • Team tests: non-personal teams don't get demo data

Test plan

  • All tests pass across all CI shards
  • PHPStan clean on all changed files
  • Pint formatting and Rector clean

…tests

Add missing deleted_at IS NULL to TopTeamsTableWidget's
buildLastActivityExpression to exclude soft-deleted records from
last activity calculation. Add upper-bound assertion to registration
seeding test. Remove dump() calls from query count tests.
Replace manual property caching with PHP 8.4 once() in
TopTeamsTableWidget and OpportunitiesBoard. Remove redundant
docblocks and inline comments across CreateTeamCustomFields,
BaseModelSeeder, and NoteSeeder per coding guidelines.
Enable Model::preventLazyLoading() in non-production environments
to catch N+1 queries at runtime. Add eager loading for team
relationship in all 5 CRM Filament resources and currentTeam +
ownedTeams in SystemAdmin UserResource. Remove QueryCounter helper
and query count tests in favor of runtime lazy loading prevention.
Copilot AI review requested due to automatic review settings February 16, 2026 22:31
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements comprehensive N+1 query optimizations and enables lazy loading prevention to improve application performance. The changes address production performance issues detected by Sentry, achieving significant query reductions across multiple areas of the application.

Changes:

  • Enabled Model::preventLazyLoading() in non-production environments to catch N+1 issues at runtime
  • Replaced manual property caching with PHP 8.4's once() helper for cleaner memoization
  • Optimized widget queries using UNION ALL and bucketed GROUP BY patterns, reducing query counts by 51-81%
  • Introduced BulkCustomFieldValueWriter for batch inserting custom field values during seeding
  • Added eager loading of team relationships in all CRM Filament resources to prevent lazy loading violations in policies

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated no comments.

Show a summary per file
File Description
app/Providers/AppServiceProvider.php Enable lazy loading prevention in non-production environments
app/Filament/Resources/*.php Add eager loading of 'team' relationship to prevent policy violations
app-modules/SystemAdmin/src/Filament/Resources/UserResource.php Add eager loading of currentTeam and ownedTeams
app/Filament/Pages/OpportunitiesBoard.php Replace manual memoization with once(), cleanup imports and comments
app-modules/SystemAdmin/src/Filament/Widgets/TopTeamsTableWidget.php Use once() for memoization, fix missing deleted_at check in last activity
app-modules/SystemAdmin/src/Filament/Widgets/PlatformGrowthStatsWidget.php Replace per-table COUNT loops with UNION ALL, use bucketed GROUP BY for sparklines
app/Listeners/CreateTeamCustomFields.php Batch update option colors using CASE WHEN SQL instead of loop
app-modules/OnboardSeed/src/Support/BulkCustomFieldValueWriter.php New utility for bulk inserting custom field values
app-modules/OnboardSeed/src/Support/BaseModelSeeder.php Integrate BulkCustomFieldValueWriter, remove redundant docblocks
app-modules/OnboardSeed/src/ModelSeeders/NoteSeeder.php Cache personalTeamId to reduce redundant queries

Fix domain_name -> domains field code mismatch in company fixtures
that silently skipped domain seeding. Fix getOptionId() using wrong
property name (label -> name) causing all select fields to fall back
to first option. Fix invalid stage names in opportunity fixtures.
Remove lazy load of company->team in PeopleSeeder. Wrap seeding in
withoutModelEvents() to skip observer queries on freshly created
system entities (~52 queries eliminated).
…onboard seeding

SortableTrait's creating event is suppressed by withoutEvents(), leaving
order_column NULL on seeded records. Manually assign sequential positions
using DecimalPosition so Kanban boards render in defined order.

Also simplifies seeder architecture: remove unused context-passing pattern,
eliminate redundant docblocks, fix board columnIdentifier prefix, and add
FixtureRegistry::clear() for safe reuse. Adds registration and team tests.
The batched method was never released in flowforge and loaded all
records without a LIMIT. Use the existing per-column approach which
correctly limits results at the database level.
- Chunk bulk inserts in BulkCustomFieldValueWriter (500 rows) to stay
  within PostgreSQL's parameter limit
- Add SQLite fallback for TIMESTAMP literal and GREATEST in
  TopTeamsTableWidget for test portability
- Restore {{ }} guard in processCustomFieldValues for clarity
… guidelines

Replace 644-line Boost auto-generated copilot-instructions.md with a
concise 130-line review-focused version. Adds tenant isolation, authorization,
and data safety review rules based on actual codebase gap analysis. Removes
MCP tool references and development workflow instructions irrelevant to
code reviewers.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 34 out of 34 changed files in this pull request and generated 4 comments.

Comment on lines +252 to +258
private function bucketExpression(): string
{
if (DB::getDriverName() === 'sqlite') {
return 'CAST((julianday("created_at") - julianday(?)) * 86400 / ? AS INTEGER)';
}

return 'FLOOR(EXTRACT(EPOCH FROM ("created_at" - ?::timestamp)) / ?)';
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bucket index computed by bucketExpression() can equal $points for records exactly at $end because both callers use whereBetween('created_at', [$start, $end]) (inclusive end) and the expression uses FLOOR((created_at - start) / segmentSeconds). Those rows will be dropped by the if ($idx >= 0 && $idx < $points) guard, undercounting the sparkline. Consider making the end boundary exclusive (where('created_at', '<', $end)) or clamping the computed bucket to $points - 1 in SQL (e.g. LEAST(bucket, ?) / CASE) so boundary records are counted.

Copilot uses AI. Check for mistakes.
@ManukMinasyan ManukMinasyan merged commit 71c8b90 into main Feb 17, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants