fix: N+1 query optimization and lazy loading prevention#132
fix: N+1 query optimization and lazy loading prevention#132ManukMinasyan merged 22 commits intomainfrom
Conversation
…plicate computation
…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.
There was a problem hiding this comment.
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
BulkCustomFieldValueWriterfor 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.
| 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)) / ?)'; |
There was a problem hiding this comment.
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.
Summary
Model::preventLazyLoading()in non-production to catch lazy loading at runtimeteamrelationship in all CRM Filament resourcesorder_columnon seeded tasks/opportunities causing undefined Kanban board orderChanges
Query optimizations:
once()to prevent duplicate computationstageCustomField()withonce()BulkCustomFieldValueWriterfor batch custom field inserts instead of per-entity writesCASE WHENSQLLazy loading prevention:
Model::preventLazyLoading()in non-production environmentsteamin CompanyResource, PeopleResource, OpportunityResource, NoteResource, TaskResourcecurrentTeam+ownedTeamsin SystemAdmin UserResourceOnboard seeding fixes:
order_columnviaDecimalPositionfor tasks/opportunities (SortableTrait events suppressed bywithoutEvents())columnIdentifierprefix (cfv.removed)Model::withoutEvents(), addFixtureRegistry::clear()deleted_at IS NULLmissing in TopTeamsTableWidget last activity expressionTests added:
Test plan