low_card_tables 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +18 -0
  3. data/.travis.yml +59 -0
  4. data/Gemfile +17 -0
  5. data/LICENSE +21 -0
  6. data/README.md +75 -0
  7. data/Rakefile +6 -0
  8. data/lib/low_card_tables.rb +72 -0
  9. data/lib/low_card_tables/active_record/base.rb +55 -0
  10. data/lib/low_card_tables/active_record/migrations.rb +223 -0
  11. data/lib/low_card_tables/active_record/relation.rb +35 -0
  12. data/lib/low_card_tables/active_record/scoping.rb +87 -0
  13. data/lib/low_card_tables/errors.rb +74 -0
  14. data/lib/low_card_tables/has_low_card_table/base.rb +114 -0
  15. data/lib/low_card_tables/has_low_card_table/low_card_association.rb +273 -0
  16. data/lib/low_card_tables/has_low_card_table/low_card_associations_manager.rb +143 -0
  17. data/lib/low_card_tables/has_low_card_table/low_card_dynamic_method_manager.rb +224 -0
  18. data/lib/low_card_tables/has_low_card_table/low_card_objects_manager.rb +80 -0
  19. data/lib/low_card_tables/low_card_table/base.rb +184 -0
  20. data/lib/low_card_tables/low_card_table/cache.rb +214 -0
  21. data/lib/low_card_tables/low_card_table/cache_expiration/exponential_cache_expiration_policy.rb +151 -0
  22. data/lib/low_card_tables/low_card_table/cache_expiration/fixed_cache_expiration_policy.rb +23 -0
  23. data/lib/low_card_tables/low_card_table/cache_expiration/has_cache_expiration.rb +100 -0
  24. data/lib/low_card_tables/low_card_table/cache_expiration/no_caching_expiration_policy.rb +13 -0
  25. data/lib/low_card_tables/low_card_table/cache_expiration/unlimited_cache_expiration_policy.rb +13 -0
  26. data/lib/low_card_tables/low_card_table/row_collapser.rb +175 -0
  27. data/lib/low_card_tables/low_card_table/row_manager.rb +681 -0
  28. data/lib/low_card_tables/low_card_table/table_unique_index.rb +134 -0
  29. data/lib/low_card_tables/version.rb +4 -0
  30. data/lib/low_card_tables/version_support.rb +52 -0
  31. data/low_card_tables.gemspec +69 -0
  32. data/spec/low_card_tables/helpers/database_helper.rb +148 -0
  33. data/spec/low_card_tables/helpers/query_spy_helper.rb +47 -0
  34. data/spec/low_card_tables/helpers/system_helpers.rb +63 -0
  35. data/spec/low_card_tables/system/basic_system_spec.rb +254 -0
  36. data/spec/low_card_tables/system/bulk_system_spec.rb +334 -0
  37. data/spec/low_card_tables/system/caching_system_spec.rb +531 -0
  38. data/spec/low_card_tables/system/migrations_system_spec.rb +747 -0
  39. data/spec/low_card_tables/system/options_system_spec.rb +581 -0
  40. data/spec/low_card_tables/system/queries_system_spec.rb +142 -0
  41. data/spec/low_card_tables/system/validations_system_spec.rb +88 -0
  42. data/spec/low_card_tables/unit/active_record/base_spec.rb +53 -0
  43. data/spec/low_card_tables/unit/active_record/migrations_spec.rb +207 -0
  44. data/spec/low_card_tables/unit/active_record/relation_spec.rb +47 -0
  45. data/spec/low_card_tables/unit/active_record/scoping_spec.rb +101 -0
  46. data/spec/low_card_tables/unit/has_low_card_table/base_spec.rb +79 -0
  47. data/spec/low_card_tables/unit/has_low_card_table/low_card_association_spec.rb +287 -0
  48. data/spec/low_card_tables/unit/has_low_card_table/low_card_associations_manager_spec.rb +190 -0
  49. data/spec/low_card_tables/unit/has_low_card_table/low_card_dynamic_method_manager_spec.rb +234 -0
  50. data/spec/low_card_tables/unit/has_low_card_table/low_card_objects_manager_spec.rb +70 -0
  51. data/spec/low_card_tables/unit/low_card_table/base_spec.rb +207 -0
  52. data/spec/low_card_tables/unit/low_card_table/cache_expiration/exponential_cache_expiration_policy_spec.rb +128 -0
  53. data/spec/low_card_tables/unit/low_card_table/cache_expiration/fixed_cache_expiration_policy_spec.rb +25 -0
  54. data/spec/low_card_tables/unit/low_card_table/cache_expiration/has_cache_expiration_policy_spec.rb +100 -0
  55. data/spec/low_card_tables/unit/low_card_table/cache_expiration/no_caching_expiration_policy_spec.rb +14 -0
  56. data/spec/low_card_tables/unit/low_card_table/cache_expiration/unlimited_cache_expiration_policy_spec.rb +14 -0
  57. data/spec/low_card_tables/unit/low_card_table/cache_spec.rb +282 -0
  58. data/spec/low_card_tables/unit/low_card_table/row_collapser_spec.rb +109 -0
  59. data/spec/low_card_tables/unit/low_card_table/row_manager_spec.rb +918 -0
  60. data/spec/low_card_tables/unit/low_card_table/table_unique_index_spec.rb +117 -0
  61. metadata +206 -0
@@ -0,0 +1,35 @@
1
+ module LowCardTables
2
+ module ActiveRecord
3
+ # This module gets included into ::ActiveRecord::Relation, and is the reason that you can say, for example:
4
+ #
5
+ # User.where(:deleted => false)
6
+ #
7
+ # ...when :deleted is actually an attribute on a referenced low-card table. It overrides #where to call
8
+ # LowCardTables::HasLowCardTable::LowCardDynamicMethodManager#scope_from_query if this is a table that has any
9
+ # associated low-card tables; that method, in turn, knows how to create a proper WHERE clause for low-card
10
+ # attributes.
11
+ module Relation
12
+ # Overrides ::ActiveRecord::Relation#where to add support for low-card tables.
13
+ def where(*args)
14
+ # Escape early if this is a model that has nothing to do with any low-card tables.
15
+ return super(*args) unless has_any_low_card_tables?
16
+
17
+ if args.length == 1 && args[0].kind_of?(Hash)
18
+ # This is a gross hack -- our overridden #where calls LowCardDynamicMethodManager#scope_from_query,
19
+ # but that, in turn, needs to call #where, and we don't want an infinite mutual recursion. So, if we
20
+ # see :_low_card_direct in the Hash passed in, we remove it and then go straight to the superclass --
21
+ # i.e., this is the 'escape hatch' for LowCardDynamicMethodManager#scope_from_query.
22
+ direct = args[0].delete(:_low_card_direct)
23
+
24
+ if direct
25
+ super(*args)
26
+ else
27
+ _low_card_dynamic_method_manager.scope_from_query(self, args[0])
28
+ end
29
+ else
30
+ super(*args)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,87 @@
1
+ require 'active_support/concern'
2
+
3
+ module LowCardTables
4
+ module ActiveRecord
5
+ # This module gets included into ::ActiveRecord::Scoping. It overrides #scope to do one thing, and one thing only:
6
+ # it checks to see if you're defining a scope that constrains on low-card columns, statically. ('Statically' here
7
+ # means the deprecated-in-Rails-4.0+ style of 'scope :foo, where(...)' rather than 'scope :foo { where(...) }'.)
8
+ # If it finds that you're trying to define such a scope, it throws an error.
9
+ #
10
+ # Why does it do this? Statically-defined scopes have their #where calls evaluated only once, at class-load time.
11
+ # But part of the design of the low-card system is that new low-card rows can be added at runtime, and new rows
12
+ # may well fit whatever constraint you're applying in this scope. The low-card system translates calls such as this:
13
+ #
14
+ # where(:deleted => false)
15
+ #
16
+ # into clauses like this:
17
+ #
18
+ # WHERE user_status_id IN (1, 3, 4, 5, 8, 9, 12)
19
+ #
20
+ # Because new rows can be added at any time, the list of status IDs needs to be able to be computed dynamically.
21
+ # Static scopes prevent this, so we detect this condition here and raise an exception when it occurs.
22
+ #
23
+ # This sort of problem, by the way, is one of the reasons why static scope definitions are deprecated in Rails
24
+ # 4.x.
25
+ module Scoping
26
+ extend ActiveSupport::Concern
27
+
28
+ module ClassMethods
29
+ # Overrides #scope to check for statically-defined scopes against low-card attributes, as discussed in the
30
+ # comment for LowCardTables::ActiveRecord::Scoping.
31
+ def scope(name, scope_options = {}, &block)
32
+ # First, go invoke the superclass method.
33
+ out = super(name, scope_options, &block)
34
+
35
+ # We're safe if it's not a statically-defined scope.
36
+ return out if block
37
+ # We're also safe if this class doesn't refer to any low-card tables, because then it could not possibly
38
+ # have been constraining on any low-card columns.
39
+ return out if (! self.has_any_low_card_tables?)
40
+ # If you defined a scope that isn't an actual ::ActiveRecord::Relation, you're fine.
41
+ return out unless scope_options.kind_of?(::ActiveRecord::Relation)
42
+
43
+ # ::ActiveRecord::Relation#where_values gets you a list of the 'where clauses' applied in the relation.
44
+ used_associations = scope_options.where_values.map do |where_value|
45
+ # Let's grab the SQL...
46
+ sql = if where_value.respond_to?(:to_sql)
47
+ where_value.to_sql
48
+ elsif where_value.kind_of?(String)
49
+ where_value
50
+ end
51
+
52
+ # ...and just search it for the foreign-key name. Is this foolproof? No; it's possible that you'll get some
53
+ # false positives. Is this a big deal? No -- because changing a static scope to dynamic really has no
54
+ # drawbacks at all, so there's a trivial fix for any false positives.
55
+ self._low_card_associations_manager.associations.select do |association|
56
+ foreign_key = association.foreign_key_column_name
57
+ sql =~ /#{foreign_key}/i
58
+ end
59
+ end.flatten
60
+
61
+ # Here's where we check for our problem and blow up if it's there.
62
+ if used_associations.length > 0
63
+ raise LowCardTables::Errors::LowCardStaticScopeError, %{You defined a named scope, #{name.inspect}, on model #{self.name}. This scope
64
+ appears to constrain on the following foreign keys, which point to low-card tables.
65
+ Because this scope is defined statically (e.g., 'scope :foo, where(...)' rather than
66
+ 'scope :foo { where(...) }'), these conditions will only be evaluated a single time,
67
+ at startup.
68
+
69
+ This means that if additional low-card rows get created that match the criteria for
70
+ this scope, they will never be picked up no matter what (as the WHERE clause is
71
+ frozen in time forever), and you will miss critical data.
72
+
73
+ The fix for this is simple: define this scope dynamically (i.e., enclose the
74
+ call to #where in a block). This will cause the conditions to be evaluated every
75
+ time you use it, thus updating the set of IDs used on every call, properly.
76
+
77
+ The foreign keys you appear to be constraining on are:
78
+
79
+ #{used_associations.map(&:foreign_key_column_name).sort.join(", ")}}
80
+ end
81
+
82
+ out
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,74 @@
1
+ module LowCardTables
2
+ # This module contains definitions of all exception classes used by +low_card_tables+. Note that +low_card_tables+
3
+ # does not try to wrap any exception bubbled up by methods it calls as a LowCardError; rather, these are just used
4
+ # to raise exceptions specific to +low_card_tables+.
5
+ #
6
+ # Any errors that are raised due to programming errors -- i.e., defensive detection of things that should never,
7
+ # ever occur in the real world -- are left as ordinary StandardError instances, since you almost certainly don't
8
+ # want to ever catch them.
9
+ module Errors
10
+ # All errors raised by LowCardTables inherit from this error. All errors raised are subclasses of this error, and
11
+ # are never direct instances of this class.
12
+ class LowCardError < StandardError; end
13
+
14
+ # The superclass of the two errors below -- errors having to do with the schema of the low-card table.
15
+ class LowCardColumnError < LowCardError; end
16
+
17
+ # Raised when the client specifies a column in a low-card table that doesn't actually exist -- for example, when
18
+ # trying to create new rows, or match against existing rows.
19
+ class LowCardColumnNotPresentError < LowCardColumnError; end
20
+ # Raised when the client does not specify a column in a call that requires all columns to be specified -- for
21
+ # example, when trying to find the ID of a row by its attributes.
22
+ class LowCardColumnNotSpecifiedError < LowCardColumnError; end
23
+
24
+ # Raised when there is no unique index present on the low-card table across all value columns. This index is
25
+ # required for operation of +low_card_tables+; various facilities in its support for migrations (and an explicit
26
+ # API call) will create or remove it for you at your request, or just maintain it automatically.
27
+ class LowCardNoUniqueIndexError < LowCardError; end
28
+
29
+ # Raised when you explicitly ask for a row or rows by ID, and no such ID exists in the database. +ids+ is included
30
+ # as an attribute of the error class, and contains an array of the IDs that were not found.
31
+ class LowCardIdNotFoundError < LowCardError
32
+ def initialize(message, ids)
33
+ super(message)
34
+ @ids = ids
35
+ end
36
+
37
+ attr_reader :ids
38
+ end
39
+
40
+ # The superclass of the error below -- raised when there's a problem with associations from a referring class to
41
+ # one or more low-card tables.
42
+ class LowCardAssociationError < LowCardError; end
43
+ # Raised internally when asked for an association from a referring class by name, and no such association exists.
44
+ class LowCardAssociationNotFoundError < LowCardAssociationError; end
45
+
46
+ # Raised when a client tries to call #save or #save! on an associated low-card row (e.g., my_user.status.save!);
47
+ # this is disallowed because it circumvents the entire purpose/idea of the low-card system.
48
+ class LowCardCannotSaveAssociatedLowCardObjectsError < LowCardError; end
49
+
50
+ # Raised when you try to create a low-card row or rows that the database treats as invalid. Typically, this means
51
+ # you violated a database constraint when trying to create those rows.
52
+ class LowCardInvalidLowCardRowsError < LowCardError; end
53
+
54
+ # Raised when you try to define a scope involving a low-card table in a static way, rather than dynamic. (e.g., you
55
+ # say, in class User, scope :alive, where(:deleted => false)) These scopes have their 'where' definitions evaluated
56
+ # only once, at class definition time, and, as such, cannot ever pick up any new IDs of low-card rows that later
57
+ # get created that match their definition. As such, we completely disallow their creation, and raise this error if
58
+ # you try.
59
+ #
60
+ # The solution is trivial, and is to define them dynamically --
61
+ # <tt>scope :alive, -> { where(:deleted => false) }</tt>. This is what ActiveRecord >= 4.0 requires, anyway -- the
62
+ # static form is deprecated.
63
+ class LowCardStaticScopeError < LowCardError; end
64
+
65
+ # Raised when you try to use +low_card_tables+ with a database that isn't supported. It's very easy to support new
66
+ # databases; you just have to teach the RowManager how to obtain an exclusive table lock on your type of database.
67
+ class LowCardUnsupportedDatabaseError < LowCardError; end
68
+
69
+ # Raised when we detect that there are more than (by default) 5,000 rows in a low-card table; this is taken as a
70
+ # sign that you screwed up and added an attribute to your table that isn't actually of low cardinality. You can
71
+ # adjust this threshold, if necessary, using the option +:max_row_count+ on your +is_low_card_table+ definition.
72
+ class LowCardTooManyRowsError < LowCardError; end
73
+ end
74
+ end
@@ -0,0 +1,114 @@
1
+ require 'active_support/concern'
2
+ require 'low_card_tables/has_low_card_table/low_card_associations_manager'
3
+ require 'low_card_tables/has_low_card_table/low_card_objects_manager'
4
+ require 'low_card_tables/has_low_card_table/low_card_dynamic_method_manager'
5
+
6
+ module LowCardTables
7
+ module HasLowCardTable
8
+ # This module gets included (once) into any class that has declared a reference to at least one low-card table,
9
+ # using #has_low_card_table. It is just a holder for several related objects that do all the actual work of
10
+ # implementing the referring side of the low-card system.
11
+ module Base
12
+ # Documentation for class methods that get included via the ClassMethods module (which ActiveSupport::Concern
13
+ # picks up).
14
+
15
+ ##
16
+ # :singleton-method: has_low_card_table
17
+ # :call-seq:
18
+ # has_low_card_table(association_name, options = nil)
19
+ #
20
+ # Declares that this model class has a reference to a low-card table. +association_name+ is the name of the
21
+ # association to create. +options+ can contain:
22
+ #
23
+ # [:delegate] If nil, no methods will be created in this class that delegate to the low-card table at all.
24
+ # If an Array, only methods matching those strings/symbols will be created.
25
+ # If a Hash, must contain a single key, +:except+, which maps to an Array; all methods except those
26
+ # methods will be created.
27
+ # Not specifying +:delegate+ causes it to delegate all methods in the low-card table.
28
+ # [:prefix] If true, then delegated methods will be named with a prefix of the association name -- for example,
29
+ # +status_deleted+, +status_donation_level+, and so on.
30
+ # If a String or Symbol, then delegated methods will be named with that prefix -- for example,
31
+ # +foo_deleted+, +foo_donation_level+, and so on.
32
+ # Not specifying +:prefix+ is the same as saying +:prefix+ => +nil+, which causes methods not to be
33
+ # prefixed with anything.
34
+ # [:foreign_key] Specifies the column in the referring table that contains the foreign key to the low-card table,
35
+ # just as in ActiveRecord associations. If not specified, it defaults to
36
+ # #{self.name.underscore}_#{association_name}_id -- for example, +user_status_id+.
37
+ # [:class] Specifies the model class of the low-card table, as a String, Symbol, or Class object. If not
38
+ # specified, defaults to ("#{self.name.underscore.singularize}_#{association_name}".camelize.constantize)
39
+ # -- for example, +UserStatus+.
40
+
41
+ ##
42
+ # :singleton-method: low_card_value_collapsing_update_scheme
43
+ # :call-seq:
44
+ # low_card_value_collapsing_update_scheme(scheme)
45
+ #
46
+ # Tells the low-card tables system what to do when a low-card table we refer to removes a column, which causes
47
+ # it to collapse rows and thus necessitiates updating the referring column.
48
+ #
49
+ # * If called with no arguments, returns the current scheme.
50
+ # * If passed an integer >= 1, automatically updates referring columns in this table, in chunks of that many
51
+ # rows. This is the default, with a value of 10,000.
52
+ # * If passed an object that responds to #call, then, when columns need to be updated, the passed object is called
53
+ # with a Hash. This Hash has, as keys, instances of the low-card model class; these are the rows that will be
54
+ # preserved. Each key maps to an Array of one or more instances of the low-card model class; these are the
55
+ # rows that are to be replaced with the key. The passed object is then responsible for updating any values that
56
+ # correspond to rows in a value Array with the corresponding key value.
57
+ # * If passed +:none+, then no updating is performed at all. You're on your own -- and you will have dangling
58
+ # foreign keys if you do nothing.
59
+
60
+ extend ActiveSupport::Concern
61
+
62
+
63
+ module ClassMethods
64
+ # Several methods go straight to the LowCardAssociationsManager.
65
+ delegate :has_low_card_table, :_low_card_association, :_low_card_update_collapsed_rows, :low_card_value_collapsing_update_scheme, :to => :_low_card_associations_manager
66
+
67
+ # This overrides the implementation in LowCardTables::ActiveRecord::Base -- the only way we get included in
68
+ # a class is if that class has declared has_low_card_table to at least one table.
69
+ def has_any_low_card_tables?
70
+ true
71
+ end
72
+
73
+ # The LowCardAssociationsManager keeps track of which low-card tables this table refers to; see its
74
+ # documentation for more information.
75
+ def _low_card_associations_manager
76
+ @_low_card_associations_manager ||= LowCardTables::HasLowCardTable::LowCardAssociationsManager.new(self)
77
+ end
78
+
79
+ # The LowCardDynamicMethodManager is responsible for maintaining the right delegated method names in the
80
+ # _low_card_dynamic_methods_module; see its documentation for more information.
81
+ def _low_card_dynamic_method_manager
82
+ @_low_card_dynamic_method_manager ||= LowCardTables::HasLowCardTable::LowCardDynamicMethodManager.new(self)
83
+ end
84
+
85
+ # This maintains a single module that gets included into this class; it is the place where we add all
86
+ # delegated methods. We use a module rather than defining them directly on this class so that users can still
87
+ # override them and use #super to call our implementation, if desired.
88
+ def _low_card_dynamic_methods_module
89
+ @_low_card_dynamic_methods_module ||= begin
90
+ out = Module.new
91
+ self.const_set(:LowCardDynamicMethods, out)
92
+ include out
93
+ out
94
+ end
95
+ end
96
+ end
97
+
98
+ # Updates the current values of all low-card reference columns according to the current attributes. This is
99
+ # automatically called in a #before_save hook; you can also call it yourself at any time. Note that this can
100
+ # cause new low-card rows to be created, if the current combination of attributes for a given low-card table
101
+ # has not been used before.
102
+ def low_card_update_foreign_keys!
103
+ self.class._low_card_associations_manager.low_card_update_foreign_keys!(self)
104
+ end
105
+
106
+ # Returns the LowCardObjectsManager, which is responsible for maintaining the set of low-card objects accessed
107
+ # by this model object -- the instances of the low-card class that are "owned" by this object. See that class's
108
+ # documentation for more information.
109
+ def _low_card_objects_manager
110
+ @_low_card_objects_manager ||= LowCardTables::HasLowCardTable::LowCardObjectsManager.new(self)
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,273 @@
1
+ module LowCardTables
2
+ module HasLowCardTable
3
+ # A LowCardAssociation represents a single association between a referring model class and a referred-to low-card
4
+ # model class. Note that this represents an association between _classes_, not between _objects_ -- that is, there
5
+ # is one instance of this class for a relationship from one referring class to one referred-to class, no matter
6
+ # how many model objects are instantiated.
7
+ class LowCardAssociation
8
+ # Returns the name of the association -- this will always have been the first arguent to +has_low_card_table+.
9
+ attr_reader :association_name
10
+
11
+ # Creates a new instance. model_class is the Class (which must inherit from ActiveRecord::Base) that is the
12
+ # referring model; association_name is the name of the association. options can contain any of the options
13
+ # accepted by LowCardTables::HasLowCardTables::Base#has_low_card_table.
14
+ def initialize(model_class, association_name, options)
15
+ @model_class = model_class
16
+ @association_name = association_name.to_s
17
+ @options = options.with_indifferent_access
18
+
19
+ # We call this here so that if things are configured incorrectly, you'll get an exception at the moment you
20
+ # try to associate the tables, rather than at runtime when you try to actually use them. Blowing up early is
21
+ # good. :)
22
+ foreign_key_column_name
23
+
24
+ low_card_class.low_card_referred_to_by(model_class)
25
+ end
26
+
27
+ # Returns a Hash that maps the names of methods that should be added to the referring class to the names of
28
+ # methods they should invoke on the low-card class. This takes into account both the +:delegate+ option (via
29
+ # its internal call to #delegated_method_names) and the +:prefix+ option.
30
+ def class_method_name_to_low_card_method_name_map
31
+ return { } if options.has_key?(:delegate) && (! options[:delegate])
32
+
33
+ out = { }
34
+
35
+ delegated_method_names.each do |column_name|
36
+ desired_method_name = case options[:prefix]
37
+ when true then "#{association_name}_#{column_name}"
38
+ when String, Symbol then "#{options[:prefix]}_#{column_name}"
39
+ when nil then column_name
40
+ else raise ArgumentError, "Invalid :prefix option: #{options[:prefix].inspect}"
41
+ end
42
+
43
+ out[desired_method_name] = column_name
44
+ out[desired_method_name + "="] = column_name + "="
45
+ end
46
+
47
+ out
48
+ end
49
+
50
+ # Returns an Array of names of methods on the low-card table that should be delegated to. This may be different
51
+ # than the names of methods on the referring class, because of the :prefix option.
52
+ def delegated_method_names
53
+ value_column_names = low_card_class.low_card_value_column_names.map(&:to_s)
54
+
55
+ if options.has_key?(:delegate) && (! options[:delegate])
56
+ [ ]
57
+ elsif options[:delegate].kind_of?(Array) || options[:delegate].kind_of?(String) || options[:delegate].kind_of?(Symbol)
58
+ out = Array(options[:delegate]).map(&:to_s)
59
+ extra = out - value_column_names
60
+
61
+ if extra.length > 0
62
+ raise ArgumentError, "You told us to delegate the following methods to low-card class #{low_card_class}, but that model doesn't have these columns: #{extra.join(", ")}; it has these columns: #{value_column_names.join(", ")}"
63
+ end
64
+ out
65
+ elsif options[:delegate] && options[:delegate].kind_of?(Hash) && options[:delegate].keys.map(&:to_s) == %w{except}
66
+ excluded = (options[:delegate][:except] || options[:delegate]['except']).map(&:to_s)
67
+ extra = excluded - value_column_names
68
+
69
+ if extra.length > 0
70
+ raise ArgumentError, "You told us to delegate all but the following methods to low-card class #{low_card_class}, but that model doesn't have these columns: #{extra.join(", ")}; it has these columns: #{value_column_names.join(", ")}"
71
+ end
72
+
73
+ value_column_names - excluded
74
+ elsif (! options.has_key?(:delegate)) || options[:delegate] == true
75
+ value_column_names
76
+ else
77
+ raise ArgumentError, "Invalid value for :delegate: #{options[:delegate].inspect}"
78
+ end
79
+ end
80
+
81
+ # Given an instance of the referring class, returns an instance of the low-card class that is configured correctly
82
+ # for the current value of the referring column.
83
+ def create_low_card_object_for(model_instance)
84
+ ensure_correct_class!(model_instance)
85
+
86
+ id = get_id_from_model(model_instance)
87
+
88
+ out = nil
89
+ if id
90
+ template = low_card_class.low_card_row_for_id(id)
91
+ out = template.dup
92
+ out.id = nil
93
+ out
94
+ else
95
+ out = low_card_class.new
96
+ end
97
+
98
+ out
99
+ end
100
+
101
+ # Computes the correct name of the foreign-key column based on the options passed in.
102
+ def foreign_key_column_name
103
+ @foreign_key_column_name ||= begin
104
+ out = options[:foreign_key]
105
+
106
+ unless out
107
+ out = "#{@model_class.name.underscore}_#{association_name}"
108
+ out = $1 if out =~ %r{/[^/]+$}i
109
+ out = out + "_id"
110
+ end
111
+
112
+ out = out.to_s if out.kind_of?(Symbol)
113
+
114
+ column = model_class.columns.detect { |c| c.name.strip.downcase == out.strip.downcase }
115
+ unless column
116
+ raise ArgumentError, %{You said that #{model_class} has_low_card_table :#{association_name}, and we
117
+ have a foreign-key column name of #{out.inspect}, but #{model_class} doesn't seem
118
+ to have a column named that at all. Did you misspell it? Or perhaps something else is wrong?
119
+
120
+ The model class has these columns: #{model_class.columns.map(&:name).sort.join(", ")}}
121
+ end
122
+
123
+ out
124
+ end
125
+ end
126
+
127
+ # When a low-card table has a column removed, it will typically have duplicate rows; these duplicate rows are
128
+ # then deleted. But then referring tables need to be updated. This method gets called at that point, with a map
129
+ # of <winner row> => <array of loser rows>, and the +collapsing_update_scheme+ declared by this referring
130
+ # model class. It is responsible for handling whatever collapsing update scheme has been declared properly.
131
+ def update_collapsed_rows(collapse_map, collapsing_update_scheme)
132
+ if collapsing_update_scheme.respond_to?(:call)
133
+ collapsing_update_scheme.call(collapse_map)
134
+ elsif collapsing_update_scheme == :none
135
+ # nothing to do
136
+ else
137
+ row_chunk_size = collapsing_update_scheme
138
+ current_id = @model_class.order("#{@model_class.primary_key} ASC").first.id
139
+
140
+ while true
141
+ current_id = update_collapsed_rows_batch(current_id, row_chunk_size, collapse_map)
142
+ break if (! current_id)
143
+ end
144
+ end
145
+ end
146
+
147
+ # Updates the foreign key for this association on the given model instance. This is called by
148
+ # LowCardTables::HasLowCardTable::Base#low_card_update_foreign_keys!, which is primarily invoked by a
149
+ # +:before_save+ filter and alternatively can be invoked manually.
150
+ def update_foreign_key!(model_instance)
151
+ hash = { }
152
+
153
+ low_card_object = model_instance._low_card_objects_manager.object_for(self)
154
+
155
+ low_card_class.low_card_value_column_names.each do |value_column_name|
156
+ hash[value_column_name] = low_card_object[value_column_name]
157
+ end
158
+
159
+ new_id = low_card_class.low_card_find_or_create_ids_for(hash)
160
+
161
+ unless get_id_from_model(model_instance) == new_id
162
+ set_id_on_model(model_instance, new_id)
163
+ end
164
+ end
165
+
166
+ # Figures out what the low-card class this association should use is; this uses convention, with some overrides.
167
+ #
168
+ # By default, for a class User that <tt>has_low_card_table :status</tt>, it looks for a class UserStatus. This
169
+ # is intentionally different from Rails' normal conventions, where it would simply look for a class Status. This
170
+ # is because low-card tables are almost always unique to their owning table -- _i.e._, the case where multiple
171
+ # tables say +has_low_card_table+ to the same low-card table is very rare. (This is just because having multiple
172
+ # tables that have -- <em>and always will have</em> -- the same set of low-card attributes is also quite rare.)
173
+ # Hence, we use a little more default specificity in the naming.
174
+ def low_card_class
175
+ @low_card_class ||= begin
176
+ # e.g., class User has_low_card_table :status => UserStatus
177
+ out = options[:class] || "#{model_class.name.underscore.singularize}_#{association_name}"
178
+
179
+ out = out.to_s if out.kind_of?(Symbol)
180
+ out = out.camelize if out.kind_of?(String)
181
+
182
+ if out.kind_of?(String)
183
+ begin
184
+ out = out.constantize
185
+ rescue NameError => ne
186
+ raise ArgumentError, %{You said that #{model_class} has_low_card_table :#{association_name}, and we have a
187
+ :class of #{out.inspect}, but, when we tried to load that class (via #constantize),
188
+ we got a NameError. Perhaps you misspelled it, or something else is wrong?
189
+
190
+ NameError: (#{ne.class.name}): #{ne.message}}
191
+ end
192
+ end
193
+
194
+ unless out.kind_of?(Class)
195
+ raise ArgumentError, %{You said that #{model_class} has_low_card_table :#{association_name} with a
196
+ :class of #{out.inspect}, but that isn't a String or Symbol that represents a class,
197
+ or a valid Class object itself.}
198
+ end
199
+
200
+ unless out.respond_to?(:is_low_card_table?) && out.is_low_card_table?
201
+ raise ArgumentError, %{You said that #{model_class} has_low_card_table :#{association_name},
202
+ and we have class #{out} for that low-card table (which is a Class), but it
203
+ either isn't an ActiveRecord model or, if so, it doesn't think it is a low-card
204
+ table itself (#is_low_card_table? returns false).
205
+
206
+ Perhaps you need to declare 'is_low_card_table' on that class?}
207
+ end
208
+
209
+ out
210
+ end
211
+ end
212
+
213
+ private
214
+ attr_reader :options, :model_class
215
+
216
+ # This is the method that actually updates rows in the referring table when a column is removed from a low-card
217
+ # table (and hence IDs are collapsed). It's called repeatedly, in a loop, from #update_collapsed_rows. One call
218
+ # of this method updates one 'chunk' of rows, where the row-chunk size is whatever was specified by a call to
219
+ # LowCardTables::HasLowCardTable::Base#low_card_value_collapsing_update_scheme. When it's done, it either returns
220
+ # +nil+, if there are no more rows to update, or the ID of the next row that should be updated, if there are.
221
+ #
222
+ # +starting_id+ is the primary-key value that this chunk should start at. (We always update rows in ascending
223
+ # primary-key order, starting with the smallest primary key.) +row_chunk_size+ is the number of rows that should
224
+ # be updated. +collapse_map+ contains only objects of the low-card class, mapping 'winners' to arrays of 'losers'.
225
+ # (That is, we must update all rows containing any ID found in an array of values to the corresponding ID found
226
+ # in the key.)
227
+ #
228
+ # Note that this method goes out of its way to not have a common bug: if you simply update rows from
229
+ # +starting_id+ to <tt>starting_id + row_chunk_size</tt>, then large gaps in the ID space will destroy performance
230
+ # completely. Rather than ever doing math on the primary key, we just tell the database to order rows in primary-
231
+ # key order and do chunks of the appropriate size.
232
+ def update_collapsed_rows_batch(starting_id, row_chunk_size, collapse_map)
233
+ starting_at_starting_id = model_class.where("#{model_class.primary_key} >= :starting_id", :starting_id => starting_id)
234
+
235
+ # Databases will return no rows if asked for an offset past the end of the table.
236
+ one_past_ending_row = starting_at_starting_id.order("#{model_class.primary_key} ASC").offset(row_chunk_size).first
237
+ one_past_ending_id = one_past_ending_row.id if one_past_ending_row
238
+
239
+ base_scope = starting_at_starting_id
240
+ if one_past_ending_id
241
+ base_scope = base_scope.where("#{model_class.primary_key} < :one_past_ending_id", :one_past_ending_id => one_past_ending_id)
242
+ end
243
+
244
+ # Do a series of updates -- one per entry in the +collapse_map+.
245
+ collapse_map.each do |collapse_to, collapse_from_array|
246
+ conditional = base_scope.where([ "#{foreign_key_column_name} IN (:collapse_from)", { :collapse_from => collapse_from_array.map(&:id) } ])
247
+ conditional.update_all([ "#{foreign_key_column_name} = :collapse_to", { :collapse_to => collapse_to.id } ])
248
+ end
249
+
250
+ one_past_ending_id
251
+ end
252
+
253
+ # Fetches the ID from the referring model, by simply grabbing the value of its foreign-key column.
254
+ def get_id_from_model(model_instance)
255
+ model_instance[foreign_key_column_name]
256
+ end
257
+
258
+ # Sets the ID on the referring model, by setting the value of its foreign-key column.
259
+ def set_id_on_model(model_instance, new_id)
260
+ model_instance[foreign_key_column_name] = new_id
261
+ end
262
+
263
+ # Ensures that the given +model_instance+ is an instance of the referring model class.
264
+ def ensure_correct_class!(model_instance)
265
+ unless model_instance.kind_of?(model_class)
266
+ raise %{Whoa! The LowCardAssociation '#{association_name}' for class #{model_class} somehow
267
+ was passed a model of class #{model_instance.class} (model: #{model_instance}),
268
+ which is not of the correct class.}
269
+ end
270
+ end
271
+ end
272
+ end
273
+ end