Class: Spree::Prices::BulkUpsert

Inherits:
Object
  • Object
show all
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

Methods included from ServiceModule::Base

prepended

Instance Method Details

#call(rows:) ⇒ Spree::ServiceModule::Result

Returns success carries ‘{ price_count: N }` — the number of rows passed to `upsert_all`.

Parameters:

  • rows (Array<Hash>)

    each row must carry ‘variant_id`, `currency`, and `amount`; `price_list_id` and `compare_at_amount` are optional. A blank `amount` is treated as “clear this price.”

Returns:



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