Batch net-worth balance lookups to kill the per-account N+1
DashboardAnalyticsController::calculateNetWorthAt ran one balance query per account, and was called once per compared period, so the net-worth endpoint issued ~2*N balance queries. It also re-fetched the account list on every call. Fetch the accounts once and build a single BalanceLookup::forAccounts() over the compared date range (the same batch loader already used by the balance evolution endpoints), then read each balance from memory. Carry-forward semantics match the previous 'latest balance on or before the date' query, so results are unchanged; a query-count regression test locks the balance queries to a fixed number regardless of account count.
This commit is contained in:
parent
0f08c0c662
commit
2208e38071
|
|
@ -6,7 +6,6 @@ use App\Enums\AccountType;
|
|||
use App\Enums\CategoryType;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Account;
|
||||
use App\Models\AccountBalance;
|
||||
use App\Models\Transaction;
|
||||
use App\Services\AccountMetricsService;
|
||||
use App\Services\BalanceLookup;
|
||||
|
|
@ -15,6 +14,7 @@ use App\Services\ExchangeRateService;
|
|||
use App\Services\LoanAmortizationService;
|
||||
use App\Services\PeriodComparator;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DashboardAnalyticsController extends Controller
|
||||
|
|
@ -38,9 +38,21 @@ class DashboardAnalyticsController extends Controller
|
|||
|
||||
$userCurrency = $request->user()->currency_code;
|
||||
|
||||
$accounts = Account::query()
|
||||
->where('user_id', $request->user()->id)
|
||||
->get();
|
||||
|
||||
// Load every account's balance history for the compared range in a
|
||||
// fixed number of queries instead of one balance lookup per account.
|
||||
$lookup = BalanceLookup::forAccounts(
|
||||
$accounts->pluck('id'),
|
||||
$previousPeriod->to,
|
||||
$period->to,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'current' => $this->calculateNetWorthAt($period->to, $userCurrency),
|
||||
'previous' => $this->calculateNetWorthAt($previousPeriod->to, $userCurrency),
|
||||
'current' => $this->calculateNetWorthAt($accounts, $lookup, $period->to, $userCurrency),
|
||||
'previous' => $this->calculateNetWorthAt($accounts, $lookup, $previousPeriod->to, $userCurrency),
|
||||
'currency_code' => $userCurrency,
|
||||
]);
|
||||
}
|
||||
|
|
@ -462,18 +474,15 @@ class DashboardAnalyticsController extends Controller
|
|||
return response()->json($top);
|
||||
}
|
||||
|
||||
private function calculateNetWorthAt(Carbon $date, string $userCurrency): int
|
||||
/**
|
||||
* @param Collection<int, Account> $accounts
|
||||
*/
|
||||
private function calculateNetWorthAt(Collection $accounts, BalanceLookup $lookup, Carbon $date, string $userCurrency): int
|
||||
{
|
||||
$accounts = Account::where('user_id', request()->user()->id)->get();
|
||||
|
||||
$total = 0;
|
||||
|
||||
foreach ($accounts as $account) {
|
||||
$balance = AccountBalance::query()
|
||||
->where('account_id', $account->id)
|
||||
->where('balance_date', '<=', $date->toDateString())
|
||||
->orderBy('balance_date', 'desc')
|
||||
->value('balance') ?? 0;
|
||||
$balance = $lookup->getBalanceAt($account->id, $date);
|
||||
|
||||
$convertedBalance = $this->exchangeRateService->convert(
|
||||
$account->currency_code,
|
||||
|
|
|
|||
|
|
@ -71,6 +71,52 @@ test('net worth calculates assets minus liabilities', function () {
|
|||
]);
|
||||
});
|
||||
|
||||
test('net worth queries account balances a fixed number of times regardless of account count', function () {
|
||||
// Six accounts, each with a current and a prior-period balance. The old
|
||||
// implementation ran one balance query per account per compared period,
|
||||
// so this would scale with the account count; BalanceLookup keeps it flat.
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
$account = Account::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'type' => AccountType::Checking,
|
||||
'currency_code' => 'USD',
|
||||
]);
|
||||
|
||||
AccountBalance::factory()->create([
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => now(),
|
||||
'balance' => 100000,
|
||||
]);
|
||||
AccountBalance::factory()->create([
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => now()->subDays(30),
|
||||
'balance' => 90000,
|
||||
]);
|
||||
}
|
||||
|
||||
$balanceQueries = 0;
|
||||
DB::listen(function ($query) use (&$balanceQueries): void {
|
||||
if (str_starts_with(strtolower($query->sql), 'select') && str_contains($query->sql, 'account_balances')) {
|
||||
$balanceQueries++;
|
||||
}
|
||||
});
|
||||
|
||||
$response = $this->getJson('/api/dashboard/net-worth?'.http_build_query([
|
||||
'from' => now()->subDays(29)->toDateString(),
|
||||
'to' => now()->toDateString(),
|
||||
]));
|
||||
|
||||
$response->assertOk()
|
||||
->assertJson([
|
||||
'current' => 600000, // 6 x 100000
|
||||
'previous' => 540000, // 6 x 90000
|
||||
'currency_code' => 'USD',
|
||||
]);
|
||||
|
||||
// BalanceLookup issues at most three balance queries for the whole request.
|
||||
expect($balanceQueries)->toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
test('net worth response includes currency_code', function () {
|
||||
$response = $this->getJson('/api/dashboard/net-worth?'.http_build_query([
|
||||
'from' => now()->subDays(29)->toDateString(),
|
||||
|
|
|
|||
Loading…
Reference in New Issue