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
87 changes: 87 additions & 0 deletions backend/app/alembic/versions/20250905_add_performance_indexes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""add performance indexes

Revision ID: perf_idx_001
Revises: fa7a11b93453
Create Date: 2025-09-05 20:53:00.000000

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = 'perf_idx_001'
down_revision = 'fa7a11b93453'
branch_labels = None
depends_on = None


def upgrade():
# Add indexes for frequently queried fields

# Expense table indexes
op.create_index('idx_expense_owner_id', 'expense', ['owner_id'])
op.create_index('idx_expense_date', 'expense', ['date'])
op.create_index('idx_expense_category_id', 'expense', ['category_id'])
op.create_index('idx_expense_subcategory_id', 'expense', ['subcategory_id'])
op.create_index('idx_expense_account_id', 'expense', ['account_id'])
op.create_index('idx_expense_place_id', 'expense', ['place_id'])
op.create_index('idx_expense_owner_date', 'expense', ['owner_id', 'date'])

# Income table indexes
op.create_index('idx_income_owner_id', 'income', ['owner_id'])
op.create_index('idx_income_date', 'income', ['date'])
op.create_index('idx_income_subcategory_id', 'income', ['subcategory_id'])
op.create_index('idx_income_account_id', 'income', ['account_id'])
op.create_index('idx_income_place_id', 'income', ['place_id'])
op.create_index('idx_income_owner_date', 'income', ['owner_id', 'date'])

# Transfer table indexes
op.create_index('idx_transfer_owner_id', 'transfer', ['owner_id'])
op.create_index('idx_transfer_date', 'transfer', ['date'])
op.create_index('idx_transfer_from_acc', 'transfer', ['from_acc'])
op.create_index('idx_transfer_to_acc', 'transfer', ['to_acc'])
op.create_index('idx_transfer_owner_date', 'transfer', ['owner_id', 'date'])

# Account table indexes
op.create_index('idx_account_owner_id', 'account', ['owner_id'])

# Category table indexes
op.create_index('idx_category_owner_id', 'category', ['owner_id'])

# Subcategory table indexes
op.create_index('idx_subcategory_owner_id', 'subcategory', ['owner_id'])
op.create_index('idx_subcategory_category_id', 'subcategory', ['category_id'])

# Place table indexes
op.create_index('idx_place_name', 'place', ['name'])


def downgrade():
# Remove indexes
op.drop_index('idx_expense_owner_id')
op.drop_index('idx_expense_date')
op.drop_index('idx_expense_category_id')
op.drop_index('idx_expense_subcategory_id')
op.drop_index('idx_expense_account_id')
op.drop_index('idx_expense_place_id')
op.drop_index('idx_expense_owner_date')

op.drop_index('idx_income_owner_id')
op.drop_index('idx_income_date')
op.drop_index('idx_income_subcategory_id')
op.drop_index('idx_income_account_id')
op.drop_index('idx_income_place_id')
op.drop_index('idx_income_owner_date')

op.drop_index('idx_transfer_owner_id')
op.drop_index('idx_transfer_date')
op.drop_index('idx_transfer_from_acc')
op.drop_index('idx_transfer_to_acc')
op.drop_index('idx_transfer_owner_date')

op.drop_index('idx_account_owner_id')
op.drop_index('idx_category_owner_id')
op.drop_index('idx_subcategory_owner_id')
op.drop_index('idx_subcategory_category_id')
op.drop_index('idx_place_name')
35 changes: 33 additions & 2 deletions backend/app/app/api/api_v1/endpoints/categories.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from app import crud, models, schemas
from app.api import deps
from app.utilities import redis

router = APIRouter()

Expand All @@ -20,15 +21,39 @@ async def read_categories(
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Retrieve categories.
Retrieve categories with caching.
"""
# Check cache first for non-superuser requests with default pagination
if not crud.user.is_superuser(current_user) and skip == 0 and limit == 100:
cached_categories = await redis.get_cached_user_categories(current_user.id)
if cached_categories:
return cached_categories

if crud.user.is_superuser(current_user):
categories = await crud.category.get_multi(db, skip=skip, limit=limit)
else:
categories = await crud.category.get_multi_by_owner(
db=db, owner_id=current_user.id, skip=skip, limit=limit
)

# Cache the result for regular users with default pagination
if not crud.user.is_superuser(current_user) and skip == 0 and limit == 100:
# Convert SQLAlchemy objects to dict for JSON serialization
categories_data = [
{
"id": cat.id,
"name": cat.name,
"color": cat.color,
"is_income": cat.is_income,
"is_default": cat.is_default,
"total": cat.total,
"owner_id": cat.owner_id,
"created_at": cat.created_at,
"updated_at": cat.updated_at
} for cat in categories
]
await redis.cache_user_categories(current_user.id, categories_data)

return categories


Expand All @@ -45,6 +70,8 @@ async def create_category(
category = await crud.category.create_with_owner(
db=db, obj_in=category_in, owner_id=current_user.id
)
# Invalidate cache after creating new category
await redis.invalidate_user_categories_cache(current_user.id)
return category


Expand Down Expand Up @@ -95,6 +122,8 @@ async def update_category(
category_in.updated_at = datetime.now(timezone.utc)
category = await crud.category.update(db=db, db_obj=category, obj_in=category_in)

# Invalidate cache after updating category
await redis.invalidate_user_categories_cache(current_user.id)
return category


Expand All @@ -114,5 +143,7 @@ async def delete_category(
raise HTTPException(status_code=400, detail="This item cannot be deleted")

await crud.category.remove(db=db, id=id)


# Invalidate cache after deleting category
await redis.invalidate_user_categories_cache(current_user.id)
return schemas.DeletionResponse(message=f"Item {id} deleted")
78 changes: 51 additions & 27 deletions backend/app/app/api/api_v1/endpoints/expenses.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from typing import Any

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import update as updateDb
from sqlalchemy import update as updateDb, select
from sqlalchemy.ext.asyncio import AsyncSession

from app import crud, models, schemas
Expand Down Expand Up @@ -431,50 +431,74 @@ async def delete_expenses_bulk(
if not valid_ids:
raise HTTPException(status_code=404, detail="No valid expenses found")

# Update category and subcategory totals before deleting
# Batch update category and subcategory totals before deleting
category_updates = {}
subcategory_updates = {}

for expense in expenses_to_delete:
# Update category total if it exists
if expense.category_id:
category = await crud.category.get(db=db, id=expense.category_id)

if category:
category_updates[expense.category_id] = category_updates.get(expense.category_id, 0) + expense.amount
if expense.subcategory_id:
subcategory_updates[expense.subcategory_id] = subcategory_updates.get(expense.subcategory_id, 0) + expense.amount

# Batch fetch categories and subcategories
if category_updates:
category_results = await db.execute(
select(models.Category).filter(models.Category.id.in_(category_updates.keys()))
)
categories_dict = {cat.id: cat for cat in category_results.scalars().all()}

for category_id, amount_to_subtract in category_updates.items():
if category_id in categories_dict:
category = categories_dict[category_id]
await db.execute(
updateDb(category.__class__)
.where(category.__class__.id == category.id)
.values(total=category.total - expense.amount)
.values(total=category.total - amount_to_subtract)
.execution_options(synchronize_session="fetch")
)
await db.commit()

# Update subcategory total if it exists
if expense.subcategory_id:
subcategory = await crud.subcategory.get(db=db, id=expense.subcategory_id)

if subcategory:

if subcategory_updates:
subcategory_results = await db.execute(
select(models.Subcategory).filter(models.Subcategory.id.in_(subcategory_updates.keys()))
)
subcategories_dict = {sub.id: sub for sub in subcategory_results.scalars().all()}

for subcategory_id, amount_to_subtract in subcategory_updates.items():
if subcategory_id in subcategories_dict:
subcategory = subcategories_dict[subcategory_id]
await db.execute(
updateDb(subcategory.__class__)
.where(subcategory.__class__.id == subcategory.id)
.values(total=subcategory.total - expense.amount)
.values(total=subcategory.total - amount_to_subtract)
.execution_options(synchronize_session="fetch")
)
await db.commit()

# Now delete the expenses
removed_expenses = await crud.expense.remove_multi(db=db, ids=valid_ids)

# Update balances
# Batch update balances
total_user_expense = sum(expense.amount for expense in removed_expenses)
account_updates = {}

for expense in removed_expenses:
await crud.user.update_balance(
db=db, user_id=current_user.id, is_Expense=True, amount=-expense.amount
)
if expense.account_id:
await crud.account.update_by_id_and_field(
db=db,
owner_id=current_user.id,
id=expense.account_id,
column="total_expenses",
amount=-expense.amount,
)
account_updates[expense.account_id] = account_updates.get(expense.account_id, 0) + expense.amount

# Update user balance once
await crud.user.update_balance(
db=db, user_id=current_user.id, is_Expense=True, amount=-total_user_expense
)

# Batch update account balances
for account_id, amount in account_updates.items():
await crud.account.update_by_id_and_field(
db=db,
owner_id=current_user.id,
id=account_id,
column="total_expenses",
amount=-amount,
)

return schemas.BulkDeletionResponse(
message=f"Deleted {len(removed_expenses)} expenses",
Expand Down
Loading