Class: Spree::Prices::BulkUpsert
- Inherits:
-
Object
- Object
- Spree::Prices::BulkUpsert
- Includes:
- ServiceModule::Base
- Defined in:
- app/services/spree/prices/bulk_upsert.rb
Overview
Bulk-writes Spree::Price rows and sweeps stale placeholder rows in one transaction.
‘spree_prices` is guarded by two partial unique indexes on PG/SQLite (collapsed to one composite index on MySQL):
- base prices (price_list_id IS NULL): unique on (variant_id, currency)
- overrides (price_list_id IS NOT NULL): unique on (variant_id, currency, price_list_id)
A single ‘upsert_all` can only target one index, so rows ship in two batches — base vs override — each routed to the correct ON CONFLICT.
Both indexes are also partial on ‘amount IS NOT NULL`, so `upsert_all` can’t see placeholder rows (amount IS NULL) as conflict targets —filling in a placeholder via upsert inserts a sibling row instead of updating. The post-write sweep removes those.
Constant Summary collapse
- BASE_UNIQUE_BY =
Two partial unique indexes guard ‘spree_prices` on PG/SQLite:
- base prices (price_list_id IS NULL): unique on (variant_id, currency) - overrides (price_list_id IS NOT NULL): unique on (variant_id, currency, price_list_id)A single ‘upsert_all` can only target one index, so base-price rows and override rows ship in separate batches.
%i[variant_id currency].freeze
- OVERRIDE_UNIQUE_BY =
%i[variant_id currency price_list_id].freeze
Instance Method Summary collapse
-
#call(rows:) ⇒ Spree::ServiceModule::Result
Success carries ‘{ price_count: N }` — the number of rows passed to `upsert_all`.
Methods included from ServiceModule::Base
Instance Method Details
#call(rows:) ⇒ Spree::ServiceModule::Result
Returns success carries ‘{ price_count: N }` — the number of rows passed to `upsert_all`.
35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 |
# File 'app/services/spree/prices/bulk_upsert.rb', line 35 def call(rows:) rows = Array(rows).map { |r| r.with_indifferent_access } keyed = rows.select { |r| r[:variant_id].present? && r[:currency].present? } # PG rejects an upsert with two rows hitting the same unique-key # triple in one statement ("ON CONFLICT DO UPDATE command cannot # affect row a second time"). Last-write-wins: keep the last # occurrence of each triple. deduped = keyed.reverse.uniq { |r| [r[:variant_id], r[:currency], r[:price_list_id]] }.reverse upsert_rows, clear_rows = deduped.partition { |r| r[:amount].present? } payload = build_payload(upsert_rows) affected_keys = deduped.map { |r| [r[:variant_id], r[:currency], r[:price_list_id]] } return success(price_count: 0) if affected_keys.empty? base_rows, override_rows = payload.partition { |r| r[:price_list_id].nil? } Spree::Price.transaction do # MySQL treats NULL values as distinct in unique indexes, so # `ON DUPLICATE KEY UPDATE` never fires for base prices — # `upsert_all` would silently insert a sibling row. Route base # rows through a SELECT-then-UPDATE/INSERT path on MySQL only. if base_rows.any? && mysql? upsert_base_rows_for_mysql(base_rows) else upsert_batch(base_rows, BASE_UNIQUE_BY) end upsert_batch(override_rows, OVERRIDE_UNIQUE_BY) sweep(affected_keys, clear_rows) # `upsert_all` and `delete_all` both skip AR callbacks, so the # `Price -> Variant -> Product` `touch:` chain never fires — # downstream caches (`cache_key_with_version`) would stay stale. # Re-trigger the chain with one `.touch` per affected variant. touch_variants(affected_keys.map(&:first).uniq) end success(price_count: payload.length) end |