diff --git a/app/models/company.py b/app/models/company.py index 2a41eb3..cff770d 100644 --- a/app/models/company.py +++ b/app/models/company.py @@ -41,6 +41,8 @@ class Company(db.Model): # One company per ticker per user. Application-level guards existed on # every creation path but duplicates still got through. db.UniqueConstraint('user_id', 'ticker_symbol', name='uq_company_user_ticker'), + # Dashboard filter_by(user_id=..., is_in_portfolio=True) + db.Index('idx_company_user_portfolio', 'user_id', 'is_in_portfolio'), ) # Relationships diff --git a/app/models/idea_pipeline.py b/app/models/idea_pipeline.py index d782c94..492ab0e 100644 --- a/app/models/idea_pipeline.py +++ b/app/models/idea_pipeline.py @@ -22,6 +22,10 @@ class IdeaPipeline(db.Model): __tablename__ = 'idea_pipeline' + __table_args__ = ( + # Dashboard + too-hard filter_by(user_id=..., status=...) + db.Index('idx_idea_pipeline_user_status', 'user_id', 'status'), + ) id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False, index=True) name = db.Column(db.String(200), nullable=False) @@ -116,6 +120,10 @@ def __repr__(self): class KillSession(db.Model): __tablename__ = 'kill_session' + __table_args__ = ( + # Analytics filter_by(user_id=current_user.id) + db.Index('idx_kill_session_user', 'user_id'), + ) id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False, index=True) idea_id = db.Column(db.Integer, db.ForeignKey('idea_pipeline.id'), nullable=False, index=True) diff --git a/app/models/portfolio.py b/app/models/portfolio.py index af9d5d0..dcb0269 100644 --- a/app/models/portfolio.py +++ b/app/models/portfolio.py @@ -209,6 +209,8 @@ class PortfolioPosition(db.Model): # Ensure one position per user per company __table_args__ = ( db.UniqueConstraint('user_id', 'company_id', name='uq_user_company_position'), + # Portfolio dashboard filter_by(user_id=..., is_active=True) + db.Index('idx_portfolio_position_user_active', 'user_id', 'is_active'), ) # Relationships diff --git a/app/models/research.py b/app/models/research.py index b568375..cf6e2b3 100644 --- a/app/models/research.py +++ b/app/models/research.py @@ -159,6 +159,10 @@ class ResearchProject(db.Model): __tablename__ = 'research_project' __table_args__ = ( db.UniqueConstraint('user_id', 'company_id', name='uq_research_project_user_company'), + # Too-hard service filter_by(user_id=..., decision='pass') + db.Index('idx_research_project_user_decision', 'user_id', 'decision'), + # Priority service filter_by(user_id=..., status='active') + db.Index('idx_research_project_user_status', 'user_id', 'status'), ) id = db.Column(db.Integer, primary_key=True) @@ -354,6 +358,10 @@ class WorkSession(db.Model): and identify which parts of their process are most time-consuming. """ __tablename__ = 'work_session' + __table_args__ = ( + # Time allocation filter(user_id=..., start_time >= ...) + db.Index('idx_work_session_user_start', 'user_id', 'start_time'), + ) id = db.Column(db.Integer, primary_key=True) project_id = db.Column(db.Integer, db.ForeignKey('research_project.id'), nullable=False, index=True) @@ -491,6 +499,10 @@ class ResearchLog(db.Model): This is the raw data that feeds into aggregated metrics. """ __tablename__ = 'research_log' + __table_args__ = ( + # Streak calculation filter(user_id=...) + timestamp + db.Index('idx_research_log_user_timestamp', 'user_id', 'timestamp'), + ) id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) diff --git a/migrations/versions/reconcile_schema_drift.py b/migrations/versions/reconcile_schema_drift.py new file mode 100644 index 0000000..d38947f --- /dev/null +++ b/migrations/versions/reconcile_schema_drift.py @@ -0,0 +1,109 @@ +# StartWithA +# Copyright (C) 2024-2026 Kiran Mathews +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""reconcile schema drift + +The dev/prod databases had drifted from the models with no migration recording +the difference (issue #306). Two schema differences remained un-migrated: + +1. ``uq_company_user_ticker`` on ``company (user_id, ticker_symbol)`` -- declared + in the model but missing from dev/prod, because the ``unique_company_ticker`` + migration self-skips when duplicate companies exist and duplicates existed at + the time it ran. They have since been resolved, so the constraint can finally + be applied where it is still missing. + +2. ``free_research_question.company_id`` -- the model declares it NOT NULL, but + the column was added nullable (``standalone_free_research``, for a backfill) + and never tightened. + +The eight compound performance indexes from ``e059bebc4087`` were the third part +of the drift: they existed in every database but were never declared in the +models, so autogenerate kept wanting to DROP them. Those are fixed purely in the +models (their ``__table_args__``) -- no schema change is needed here, since the +indexes already exist -- and that is why this migration does not touch them. + +This migration is written to be safe on BOTH: +- fresh databases, where ``unique_company_ticker`` already created the constraint + (no duplicates on an empty DB) -- the add is skipped as a no-op; and +- drifted dev/prod, where the constraint is absent -- it is added. + +Revision ID: reconcile_schema_drift +Revises: unique_company_ticker +Create Date: 2026-07-25 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'reconcile_schema_drift' +down_revision = 'unique_company_ticker' +branch_labels = None +depends_on = None + +CONSTRAINT_NAME = 'uq_company_user_ticker' + + +def upgrade(): + conn = op.get_bind() + + # --- uq_company_user_ticker ------------------------------------------ + # Only add it where it is still missing (it is already present on fresh + # databases). If duplicates somehow still exist, skip rather than fail -- + # a blocked deploy helps nobody, and dedupe is a deliberate, non-automatic + # step (see scripts/dedupe_companies.py). + already_present = conn.execute(sa.text( + "SELECT 1 FROM pg_constraint WHERE conname = :name" + ), {"name": CONSTRAINT_NAME}).scalar() + + if not already_present: + duplicates = conn.execute(sa.text(""" + SELECT user_id, ticker_symbol, count(*) AS n + FROM company + GROUP BY user_id, ticker_symbol + HAVING count(*) > 1 + """)).fetchall() + + if duplicates: + detail = ', '.join(f'user {d.user_id}/{d.ticker_symbol} x{d.n}' + for d in duplicates[:10]) + print(f'SKIPPING {CONSTRAINT_NAME}: {len(duplicates)} duplicate ' + f'(user_id, ticker_symbol) group(s) exist -- {detail}. ' + f'Run scripts/dedupe_companies.py to resolve them, then add a ' + f'follow-up migration to apply the constraint.') + else: + op.create_unique_constraint( + CONSTRAINT_NAME, 'company', ['user_id', 'ticker_symbol'] + ) + + # --- free_research_question.company_id NOT NULL ---------------------- + # Every research question references a company; the model has always declared + # this NOT NULL. Fails loudly if any NULL rows exist -- verify (and backfill) + # before deploying to an environment that might have them. + op.alter_column( + 'free_research_question', 'company_id', + existing_type=sa.Integer(), nullable=False, + ) + + +def downgrade(): + op.alter_column( + 'free_research_question', 'company_id', + existing_type=sa.Integer(), nullable=True, + ) + # DROP IF EXISTS: the constraint may have been a no-op add on this database. + op.execute(f'ALTER TABLE company DROP CONSTRAINT IF EXISTS {CONSTRAINT_NAME}')