mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 05:09:38 +02:00
Stronger security unique index and data migration
Note to self hosters: If you started self hosting prior to this commit, you may have duplicate securities in your database. This is usually not a problem, but if you'd like to clean things up, you can run the data migration by opening a terminal on the machine you're hosting with and running: ```sh rake data_migration:migrate_duplicate_securities ```
This commit is contained in:
parent
e4ee06c9f6
commit
fe24117c50
3 changed files with 74 additions and 2 deletions
|
@ -0,0 +1,13 @@
|
||||||
|
class StrongerUniquenessConstraintOnSecurities < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
remove_index :securities, [ :ticker, :exchange_operating_mic ], unique: true
|
||||||
|
|
||||||
|
# Matches our ActiveRecord validation:
|
||||||
|
# - uppercase ticker
|
||||||
|
# - either exchange_operating_mic or empty string (unique index doesn't work with NULL values)
|
||||||
|
add_index :securities,
|
||||||
|
"UPPER(ticker), COALESCE(UPPER(exchange_operating_mic), '')",
|
||||||
|
unique: true,
|
||||||
|
name: "index_securities_on_ticker_and_exchange_operating_mic_unique"
|
||||||
|
end
|
||||||
|
end
|
4
db/schema.rb
generated
4
db/schema.rb
generated
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.2].define(version: 2025_05_21_112347) do
|
ActiveRecord::Schema[7.2].define(version: 2025_05_22_174753) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
|
@ -517,9 +517,9 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_21_112347) do
|
||||||
t.datetime "failed_fetch_at"
|
t.datetime "failed_fetch_at"
|
||||||
t.integer "failed_fetch_count", default: 0, null: false
|
t.integer "failed_fetch_count", default: 0, null: false
|
||||||
t.datetime "last_health_check_at"
|
t.datetime "last_health_check_at"
|
||||||
|
t.index "upper((ticker)::text), COALESCE(upper((exchange_operating_mic)::text), ''::text)", name: "index_securities_on_ticker_and_exchange_operating_mic_unique", unique: true
|
||||||
t.index ["country_code"], name: "index_securities_on_country_code"
|
t.index ["country_code"], name: "index_securities_on_country_code"
|
||||||
t.index ["exchange_operating_mic"], name: "index_securities_on_exchange_operating_mic"
|
t.index ["exchange_operating_mic"], name: "index_securities_on_exchange_operating_mic"
|
||||||
t.index ["ticker", "exchange_operating_mic"], name: "index_securities_on_ticker_and_exchange_operating_mic", unique: true
|
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "security_prices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "security_prices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
|
|
@ -20,4 +20,63 @@ namespace :data_migration do
|
||||||
puts "Error updating webhook for Plaid item #{item.plaid_id}: #{error.message}"
|
puts "Error updating webhook for Plaid item #{item.plaid_id}: #{error.message}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
desc "Migrate duplicate securities"
|
||||||
|
# 2025-05-22: older data allowed multiple rows with the same
|
||||||
|
# ticker / exchange_operating_mic (case-insensitive, NULLs collapsed).
|
||||||
|
# This task:
|
||||||
|
# 1. Finds each duplicate group
|
||||||
|
# 2. Chooses the earliest-created row as the keeper
|
||||||
|
# 3. Re-points holdings and trades to the keeper
|
||||||
|
# 4. Destroys the duplicate (which also removes its prices)
|
||||||
|
task migrate_duplicate_securities: :environment do
|
||||||
|
puts "==> Scanning for duplicate securities…"
|
||||||
|
|
||||||
|
duplicate_sets = Security
|
||||||
|
.select("UPPER(ticker) AS up_ticker,
|
||||||
|
COALESCE(UPPER(exchange_operating_mic), '') AS up_mic,
|
||||||
|
COUNT(*) AS dup_count")
|
||||||
|
.group("up_ticker, up_mic")
|
||||||
|
.having("COUNT(*) > 1")
|
||||||
|
.to_a
|
||||||
|
|
||||||
|
puts "Found #{duplicate_sets.size} duplicate groups."
|
||||||
|
|
||||||
|
duplicate_sets.each_with_index do |set, idx|
|
||||||
|
# Fetch duplicates ordered by creation; the first row becomes keeper
|
||||||
|
duplicates_scope = Security
|
||||||
|
.where("UPPER(ticker) = ? AND COALESCE(UPPER(exchange_operating_mic), '') = ?",
|
||||||
|
set.up_ticker, set.up_mic)
|
||||||
|
.order(:created_at)
|
||||||
|
|
||||||
|
keeper = duplicates_scope.first
|
||||||
|
next unless keeper
|
||||||
|
|
||||||
|
duplicates = duplicates_scope.offset(1)
|
||||||
|
|
||||||
|
dup_ids = duplicates.ids
|
||||||
|
|
||||||
|
# Skip if nothing to merge (defensive; shouldn't occur)
|
||||||
|
next if dup_ids.empty?
|
||||||
|
|
||||||
|
begin
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
updated_holdings = Holding.where(security_id: dup_ids).update_all(security_id: keeper.id)
|
||||||
|
updated_trades = Trade.where(security_id: dup_ids).update_all(security_id: keeper.id)
|
||||||
|
|
||||||
|
# Ensure no rows remain pointing at duplicates before deletion
|
||||||
|
raise "Leftover holdings detected" if Holding.where(security_id: dup_ids).exists?
|
||||||
|
raise "Leftover trades detected" if Trade.where(security_id: dup_ids).exists?
|
||||||
|
|
||||||
|
duplicates.each(&:destroy!) # destroys its security_prices via dependent: :destroy
|
||||||
|
end
|
||||||
|
|
||||||
|
puts "[#{idx + 1}/#{duplicate_sets.size}] Merged #{dup_ids.join(', ')} → #{keeper.id} (#{updated_holdings} holdings, #{updated_trades} trades)"
|
||||||
|
rescue => e
|
||||||
|
puts "ERROR migrating #{dup_ids.join(', ')}: #{e.message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
puts "✅ Duplicate security migration complete."
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue