1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-19 13:19:39 +02:00

perf(transactions): add kind to Transaction model and remove expensive Transfer joins in aggregations (#2388)

* add kind to transaction model

* Basic transfer creator

* Fix method naming conflict

* Creator form pattern

* Remove stale methods

* Tweak migration

* Remove BaseQuery, write entire query in each class for clarity

* Query optimizations

* Remove unused exchange rate query lines

* Remove temporary cache-warming strategy

* Fix test

* Update transaction search

* Decouple transactions endpoint from IncomeStatement

* Clean up transactions controller

* Update cursor rules

* Cleanup comments, logic in search

* Fix totals logic on transactions view

* Fix pagination

* Optimize search totals query

* Default to last 30 days on transactions page if no filters

* Decouple transactions list from transfer details

* Revert transfer route

* Migration reset

* Bundle update

* Fix matching logic, tests

* Remove unused code
This commit is contained in:
Zach Gollwitzer 2025-06-20 13:31:58 -04:00 committed by GitHub
parent 7aca5a2277
commit 1aae00f586
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 1749 additions and 705 deletions

View file

@ -3,8 +3,6 @@ class TransactionsController < ApplicationController
before_action :store_params!, only: :index
require "digest/md5"
def new
super
@income_categories = Current.family.categories.incomes.alphabetically
@ -13,95 +11,22 @@ class TransactionsController < ApplicationController
def index
@q = search_params
transactions_query = Current.family.transactions.active.search(@q)
@search = Transaction::Search.new(Current.family, filters: @q)
set_focused_record(transactions_query, params[:focused_record_id], default_per_page: 50)
base_scope = @search.transactions_scope
.reverse_chronological
.includes(
{ entry: :account },
:category, :merchant, :tags,
:transfer_as_inflow, :transfer_as_outflow
)
# ------------------------------------------------------------------
# Cache the expensive includes & pagination block so the DB work only
# runs when either the query params change *or* any entry has been
# updated for the current family.
# ------------------------------------------------------------------
@pagy, @transactions = pagy(base_scope, limit: per_page, params: ->(p) { p.except(:focused_record_id) })
latest_update_ts = Current.family.entries.maximum(:updated_at)&.utc&.to_i || 0
items_per_page = (params[:per_page].presence || default_params[:per_page]).to_i
items_per_page = 1 if items_per_page <= 0
current_page = (params[:page].presence || default_params[:page]).to_i
current_page = 1 if current_page <= 0
# Build a compact cache digest: sanitized filters + page info + a
# token that changes on updates *or* deletions.
entries_changed_token = [ latest_update_ts, Current.family.entries.count ].join(":")
digest_source = {
q: @q, # processed & sanitised search params
page: current_page, # requested page number
per: items_per_page, # page size
tok: entries_changed_token
}.to_json
cache_key = Current.family.build_cache_key(
"transactions_idx_#{Digest::MD5.hexdigest(digest_source)}"
)
cache_data = Rails.cache.fetch(cache_key, expires_in: 30.minutes) do
current_page_i = current_page
# Initial query
offset = (current_page_i - 1) * items_per_page
ids = transactions_query
.reverse_chronological
.limit(items_per_page)
.offset(offset)
.pluck(:id)
total_count = transactions_query.count
if ids.empty? && total_count.positive? && current_page_i > 1
current_page_i = (total_count.to_f / items_per_page).ceil
offset = (current_page_i - 1) * items_per_page
ids = transactions_query
.reverse_chronological
.limit(items_per_page)
.offset(offset)
.pluck(:id)
end
{ ids: ids, total_count: total_count, current_page: current_page_i }
# No performance penalty by default. Only runs queries if the record is set.
if params[:focused_record_id].present?
set_focused_record(base_scope, params[:focused_record_id], default_per_page: per_page)
end
ids = cache_data[:ids]
total_count = cache_data[:total_count]
current_page = cache_data[:current_page]
# Build Pagy object (this part is cheap done *after* potential
# page fallback so the pagination UI reflects the adjusted page
# number).
@pagy = Pagy.new(
count: total_count,
page: current_page,
items: items_per_page,
params: ->(p) { p.except(:focused_record_id) }
)
# Fetch the transactions in the cached order
@transactions = Current.family.transactions
.active
.where(id: ids)
.includes(
{ entry: :account },
:category, :merchant, :tags,
transfer_as_outflow: { inflow_transaction: { entry: :account } },
transfer_as_inflow: { outflow_transaction: { entry: :account } }
)
# Preserve the order defined by `ids`
@transactions = ids.map { |id| @transactions.detect { |t| t.id == id } }.compact
@totals = Current.family.income_statement.totals(transactions_scope: transactions_query)
end
def clear_filter
@ -124,6 +49,10 @@ class TransactionsController < ApplicationController
end
updated_params["q"] = q_params.presence
# Add flag to indicate filters were explicitly cleared
updated_params["filter_cleared"] = "1" if updated_params["q"].blank?
Current.session.update!(prev_transaction_page_params: updated_params)
redirect_to transactions_path(updated_params)
@ -185,6 +114,10 @@ class TransactionsController < ApplicationController
end
private
def per_page
params[:per_page].to_i.positive? ? params[:per_page].to_i : 50
end
def needs_rule_notification?(transaction)
return false if Current.user.rule_prompts_disabled
@ -217,7 +150,8 @@ class TransactionsController < ApplicationController
cleaned_params = params.fetch(:q, {})
.permit(
:start_date, :end_date, :search, :amount,
:amount_operator, accounts: [], account_ids: [],
:amount_operator, :active_accounts_only, :excluded_transactions,
accounts: [], account_ids: [],
categories: [], merchants: [], types: [], tags: []
)
.to_h
@ -225,35 +159,9 @@ class TransactionsController < ApplicationController
cleaned_params.delete(:amount_operator) unless cleaned_params[:amount].present?
# -------------------------------------------------------------------
# Performance optimisation
# -------------------------------------------------------------------
# When a user lands on the Transactions page without an explicit date
# filter, the previous behaviour queried *all* historical transactions
# for the family. For large datasets this results in very expensive
# SQL (as shown in Skylight) particularly the aggregation queries
# used for @totals. To keep the UI responsive while still showing a
# sensible period of activity, we fall back to the user's preferred
# default period (stored on User#default_period, defaulting to
# "last_30_days") when **no** date filters have been supplied.
#
# This effectively changes the default view from "all-time" to a
# rolling window, dramatically reducing the rows scanned / grouped in
# Postgres without impacting the UX (the user can always clear the
# filter).
# -------------------------------------------------------------------
if cleaned_params[:start_date].blank? && cleaned_params[:end_date].blank?
period_key = Current.user&.default_period.presence || "last_30_days"
begin
period = Period.from_key(period_key)
cleaned_params[:start_date] = period.start_date
cleaned_params[:end_date] = period.end_date
rescue Period::InvalidKeyError
# Fallback should never happen but keeps things safe.
cleaned_params[:start_date] = 30.days.ago.to_date
cleaned_params[:end_date] = Date.current
end
# Only add default start_date if params are blank AND filters weren't explicitly cleared
if cleaned_params.blank? && params[:filter_cleared].blank?
cleaned_params[:start_date] = 30.days.ago.to_date
end
cleaned_params
@ -263,9 +171,9 @@ class TransactionsController < ApplicationController
if should_restore_params?
params_to_restore = {}
params_to_restore[:q] = stored_params["q"].presence || default_params[:q]
params_to_restore[:page] = stored_params["page"].presence || default_params[:page]
params_to_restore[:per_page] = stored_params["per_page"].presence || default_params[:per_page]
params_to_restore[:q] = stored_params["q"].presence || {}
params_to_restore[:page] = stored_params["page"].presence || 1
params_to_restore[:per_page] = stored_params["per_page"].presence || 50
redirect_to transactions_path(params_to_restore)
else
@ -286,12 +194,4 @@ class TransactionsController < ApplicationController
def stored_params
Current.session.prev_transaction_page_params
end
def default_params
{
q: {},
page: 1,
per_page: 50
}
end
end