Class: LowCardTables::LowCardTable::RowCollapser

Inherits:
Object
  • Object
show all
Defined in:
lib/low_card_tables/low_card_table/row_collapser.rb

Overview

The RowCollapser is an object that exists solely to contain the code required to collapse rows when someone removes a column from a low-card table in a migration. It’s not a particularly well-defined object and resulted from an extraction from RowManager; however, it’s still nicer to have this code in a separate object rather than making the RowManager even bigger than it already is.

What are we trying to accomplish here? Well, imagine you have this:

user_statuses
id  deleted  donation_level  gender
 1   false    3              female
 2   false    5              female
 3   false    7              female
 4   false    3              male
 5   false    5              male
 6   false    7              male

…and now imagine we decide to remove the deceased column. If we do nothing, we’ll end up with this:

user_statuses
id  deleted  gender
 1   false   female
 2   false   female
 3   false   female
 4   false   male
 5   false   male
 6   false   male

…but this violates the principle of low-card tables that they have only one row for each unique combination of values. What we need to do is reduce it to this…

user_statuses
id  deleted  gender
 1   false   female
 4   false   male

…and then update all columns in all tables that have a user_status_id like so:

UPDATE users SET user_status_id = 1 WHERE user_status_id IN (2, 3)
UPDATE users SET user_status_id = 4 WHERE user_status_id IN (5, 6)

That’s the job of this class. LowCardTables::HasLowCardTable::LowCardAssociation is responsible for updating the referring tables themselves; however, this class is responsible for the fundamental operation.

In this class, we often refer to the “collapse map”; in the above example, this would be:

#<UserStatus id: 1> => [ #<UserStatus id: 2>, #<UserStatus id: 3> ]
#<UserStatus id: 4> => [ #<UserStatus id: 5>, #<UserStatus id: 6> ]

The keys are the rows of the table that have been collapsed to; the values are arrays of rows that have been collapsed from.

Instance Method Summary collapse

Constructor Details

#initialize(low_card_model, low_card_options) ⇒ RowCollapser

Creates a new instance. low_card_model is the ActiveRecord model class of the low-card table itself; low_card_options is the set of options passed to whatever migration method (e.g., remove_column) was invoked to cause the need for a collapse. Options that we pay attention to are:

:low_card_collapse_rows

If present but false or nil, then no row collapsing will happen due to the migration command; you’ll be left with an invalid low-card table with no unique index, and will need to fix this problem yourself before you can use the table.

:low_card_referrers

Adds one or more models as “referring models” that will have any references to this model updated when the collapsing is done. Generally speaking, it should not be necessary to do this – this code is aggressive about eagerly loading all models, and ensuring that any that refer to this table are used. But this is available in case you need it.

:low_card_update_referring_models

If present but false or nil, then row collapsing will occur as normal, but no referring columns will be updated. You’ll thus have dangling foreign keys in any referring models; you’ll have to update them yourself.



68
69
70
71
72
73
74
75
# File 'lib/low_card_tables/low_card_table/row_collapser.rb', line 68

def initialize(low_card_model, low_card_options)
  unless low_card_model.respond_to?(:is_low_card_table?) && low_card_model.is_low_card_table?
    raise ArgumentError, "You must supply a low-card AR model class, not: #{low_card_model.inspect}"
  end

  @low_card_model = low_card_model
  @low_card_options = low_card_options
end

Instance Method Details

#collapse!Object

This should be called after any migration operation on the table that may have caused it to now have duplicate rows. This method looks at the table, detects duplicate rows, picks out winners (and the corresponding losers), and updates rows and referring rows, contingent upon the low_card_options passed in the constructor.

Notably, you don’t need to tell this method what you did to the table; it simply looks at the current state of the table and deals with duplicate rows. It also means this method is perfectly safe to call on a table that has had no changes, or a table that has had migrations performed on it that don’t result in duplicate rows; it will simply see that there are no duplicate rows in the table, and do nothing.

This method returns the “collapse map”; see the comment on this class overall for more information. This allows you to do anything you want with the calculated collapse. Normally, you don’t have to do anything with it and can ignore it, but it can also be useful if you pass :low_card_update_referring_models => false in the low_card_options.



91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
# File 'lib/low_card_tables/low_card_table/row_collapser.rb', line 91

def collapse!
  # :low_card_collapse_rows tells this method to do nothing at all.
  return if low_card_options.has_key?(:low_card_collapse_rows) && (! low_card_options[:low_card_collapse_rows])

  additional_referring_models = low_card_options[:low_card_referrers]

  # First, we build a map. The keys are Hashes representing each unique combination of attributes found for
  # the table; the value is an Array of all rows (model objects) for that key. (In a normal state, each value
  # would have exactly one element in the array; however, because we may just have migrated the table into a
  # state where we need to collapse the rows, this may not be true at the moment.)
  attributes_to_rows_map = { }
  low_card_model.all.sort_by(&:id).each do |row|
    attributes = value_attributes(row)

    attributes_to_rows_map[attributes] ||= [ ]
    attributes_to_rows_map[attributes] << row
  end

  return { } if (! attributes_to_rows_map.values.detect { |a| a.length > 1 })

  # Now we build the collapse_map, which is very similar to the attributes_to_rows_map, above. We pick the first
  # of the values to be the winner in each case, which, because we've sorted the rows by ID, should be the
  # duplicate row with the lowest ID -- this is as reasonable a way to pick winners as any.
  collapse_map = { }
  attributes_to_rows_map.each do |attributes, rows|
    if rows.length > 1
      winner = rows.shift
      losers = rows

      collapse_map[winner] = losers
    end
  end

  # Figure out which rows we need to delete; this is just all the losers.
  ids_to_delete = collapse_map.values.map { |row_array| row_array.map(&:id) }.flatten.sort
  low_card_model.delete_all([ "id IN (:ids)", { :ids => ids_to_delete } ])

  # Figure out what referring models we need to update.
  all_referring_models = low_card_model.low_card_referring_models | (additional_referring_models || [ ])

  # Run transactions on all of these, plus the low-card model as well.
  #
  # Why do we do this? Isn't just one transaction enough? Well, in default Rails configuration, yes, because all
  # models live on the same database. However, it's so common to use gems (for example, +db_charmer_) that allow
  # different models to live on different databases that we make sure to run transactions on all of them;
  # running nested transactions on the same database is harmless.
  transaction_models = all_referring_models + [ low_card_model ]

  unless low_card_options.has_key?(:low_card_update_referring_models) && (! low_card_options[:low_card_update_referring_models])
    transactions_on(transaction_models) do
      all_referring_models.each do |referring_model|
        referring_model._low_card_update_collapsed_rows(low_card_model, collapse_map)
      end
    end
  end

  # Return the collapse_map.
  collapse_map
end