low_card_tables 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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