Skip to content
Merged
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
11 changes: 11 additions & 0 deletions app/services/portfolio_importer.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,17 @@ def _get_or_create_company(self, ticker):
if ticker in self.company_cache:
return self.company_cache[ticker]

# The cache is loaded once when the importer is constructed, so it goes
# stale if anything else creates a company meanwhile. Falling straight
# through to a create in that case is how duplicate companies appeared.
existing = Company.query.filter(
Company.user_id == self.user.id,
db.func.upper(Company.ticker_symbol) == ticker
).first()
if existing:
self.company_cache[ticker] = existing.id
return existing.id

# Try to fetch company info from financial data service
company_name = None
industry = None
Expand Down
28 changes: 19 additions & 9 deletions migrations/versions/unique_company_ticker_per_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,16 @@


def upgrade():
# Duplicates must be resolved before this can apply. Fail with a message
# that says what to do rather than a bare IntegrityError.
# Existing duplicates cannot be merged automatically: deciding which copy
# survives can mean discarding real transaction history, which is not a
# choice a migration should make unattended. Where they exist, skip the
# constraint rather than fail -- a blocked deploy helps nobody, and the
# duplicates are a display annoyance rather than a correctness problem.
#
# Databases without duplicates (fresh installs, clean environments) still
# get full protection. To add it to an environment that has duplicates,
# resolve them with scripts/dedupe_companies.py, then add a follow-up
# migration -- this one will already be marked as applied.
conn = op.get_bind()
duplicates = conn.execute(sa.text("""
SELECT user_id, ticker_symbol, count(*) AS n
Expand All @@ -55,17 +63,19 @@ def upgrade():
""")).fetchall()

if duplicates:
detail = ', '.join(f'user {d.user_id}/{d.ticker_symbol} x{d.n}' for d in duplicates[:10])
raise RuntimeError(
f'Cannot add {CONSTRAINT_NAME}: {len(duplicates)} duplicate '
f'(user_id, ticker_symbol) group(s) still exist -- {detail}. '
'Merge or remove the duplicates before running this migration.'
)
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.')
return

op.create_unique_constraint(
CONSTRAINT_NAME, 'company', ['user_id', 'ticker_symbol']
)


def downgrade():
op.drop_constraint(CONSTRAINT_NAME, 'company', type_='unique')
# The constraint may have been skipped on this database.
op.execute(f'ALTER TABLE company DROP CONSTRAINT IF EXISTS {CONSTRAINT_NAME}')
Loading