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.
- checksums.yaml +7 -0
- data/.gitignore +18 -0
- data/.travis.yml +59 -0
- data/Gemfile +17 -0
- data/LICENSE +21 -0
- data/README.md +75 -0
- data/Rakefile +6 -0
- data/lib/low_card_tables.rb +72 -0
- data/lib/low_card_tables/active_record/base.rb +55 -0
- data/lib/low_card_tables/active_record/migrations.rb +223 -0
- data/lib/low_card_tables/active_record/relation.rb +35 -0
- data/lib/low_card_tables/active_record/scoping.rb +87 -0
- data/lib/low_card_tables/errors.rb +74 -0
- data/lib/low_card_tables/has_low_card_table/base.rb +114 -0
- data/lib/low_card_tables/has_low_card_table/low_card_association.rb +273 -0
- data/lib/low_card_tables/has_low_card_table/low_card_associations_manager.rb +143 -0
- data/lib/low_card_tables/has_low_card_table/low_card_dynamic_method_manager.rb +224 -0
- data/lib/low_card_tables/has_low_card_table/low_card_objects_manager.rb +80 -0
- data/lib/low_card_tables/low_card_table/base.rb +184 -0
- data/lib/low_card_tables/low_card_table/cache.rb +214 -0
- data/lib/low_card_tables/low_card_table/cache_expiration/exponential_cache_expiration_policy.rb +151 -0
- data/lib/low_card_tables/low_card_table/cache_expiration/fixed_cache_expiration_policy.rb +23 -0
- data/lib/low_card_tables/low_card_table/cache_expiration/has_cache_expiration.rb +100 -0
- data/lib/low_card_tables/low_card_table/cache_expiration/no_caching_expiration_policy.rb +13 -0
- data/lib/low_card_tables/low_card_table/cache_expiration/unlimited_cache_expiration_policy.rb +13 -0
- data/lib/low_card_tables/low_card_table/row_collapser.rb +175 -0
- data/lib/low_card_tables/low_card_table/row_manager.rb +681 -0
- data/lib/low_card_tables/low_card_table/table_unique_index.rb +134 -0
- data/lib/low_card_tables/version.rb +4 -0
- data/lib/low_card_tables/version_support.rb +52 -0
- data/low_card_tables.gemspec +69 -0
- data/spec/low_card_tables/helpers/database_helper.rb +148 -0
- data/spec/low_card_tables/helpers/query_spy_helper.rb +47 -0
- data/spec/low_card_tables/helpers/system_helpers.rb +63 -0
- data/spec/low_card_tables/system/basic_system_spec.rb +254 -0
- data/spec/low_card_tables/system/bulk_system_spec.rb +334 -0
- data/spec/low_card_tables/system/caching_system_spec.rb +531 -0
- data/spec/low_card_tables/system/migrations_system_spec.rb +747 -0
- data/spec/low_card_tables/system/options_system_spec.rb +581 -0
- data/spec/low_card_tables/system/queries_system_spec.rb +142 -0
- data/spec/low_card_tables/system/validations_system_spec.rb +88 -0
- data/spec/low_card_tables/unit/active_record/base_spec.rb +53 -0
- data/spec/low_card_tables/unit/active_record/migrations_spec.rb +207 -0
- data/spec/low_card_tables/unit/active_record/relation_spec.rb +47 -0
- data/spec/low_card_tables/unit/active_record/scoping_spec.rb +101 -0
- data/spec/low_card_tables/unit/has_low_card_table/base_spec.rb +79 -0
- data/spec/low_card_tables/unit/has_low_card_table/low_card_association_spec.rb +287 -0
- data/spec/low_card_tables/unit/has_low_card_table/low_card_associations_manager_spec.rb +190 -0
- data/spec/low_card_tables/unit/has_low_card_table/low_card_dynamic_method_manager_spec.rb +234 -0
- data/spec/low_card_tables/unit/has_low_card_table/low_card_objects_manager_spec.rb +70 -0
- data/spec/low_card_tables/unit/low_card_table/base_spec.rb +207 -0
- data/spec/low_card_tables/unit/low_card_table/cache_expiration/exponential_cache_expiration_policy_spec.rb +128 -0
- data/spec/low_card_tables/unit/low_card_table/cache_expiration/fixed_cache_expiration_policy_spec.rb +25 -0
- data/spec/low_card_tables/unit/low_card_table/cache_expiration/has_cache_expiration_policy_spec.rb +100 -0
- data/spec/low_card_tables/unit/low_card_table/cache_expiration/no_caching_expiration_policy_spec.rb +14 -0
- data/spec/low_card_tables/unit/low_card_table/cache_expiration/unlimited_cache_expiration_policy_spec.rb +14 -0
- data/spec/low_card_tables/unit/low_card_table/cache_spec.rb +282 -0
- data/spec/low_card_tables/unit/low_card_table/row_collapser_spec.rb +109 -0
- data/spec/low_card_tables/unit/low_card_table/row_manager_spec.rb +918 -0
- data/spec/low_card_tables/unit/low_card_table/table_unique_index_spec.rb +117 -0
- metadata +206 -0
@@ -0,0 +1,143 @@
|
|
1
|
+
require 'low_card_tables/has_low_card_table/low_card_association'
|
2
|
+
require 'low_card_tables/errors'
|
3
|
+
|
4
|
+
module LowCardTables
|
5
|
+
module HasLowCardTable
|
6
|
+
# The LowCardAssociationsManager is a relatively simple object; it manages the LowCardAssociation objects for a
|
7
|
+
# given model class that refers to at least one low-card table. Conceptually, it does little more than maintain
|
8
|
+
# an Array of such associations.
|
9
|
+
#
|
10
|
+
# (Note that storing this data as an Array is important: part of the contract of the low-card system is that
|
11
|
+
# later calls to +has_low_card_table+ supersede earlier calls, so order is key. Yes, Ruby hashes are ordered in
|
12
|
+
# recent Ruby versions...but we support 1.8.7, too.)
|
13
|
+
class LowCardAssociationsManager
|
14
|
+
# Returns an Array of all LowCardAssociation objects for the given +model_class+.
|
15
|
+
attr_reader :associations
|
16
|
+
|
17
|
+
# Creates a new instance. You should only ever create one instance per model class.
|
18
|
+
def initialize(model_class)
|
19
|
+
if (! superclasses(model_class).include?(::ActiveRecord::Base))
|
20
|
+
raise ArgumentError, "You must supply an ActiveRecord model, not: #{model_class}"
|
21
|
+
elsif model_class.is_low_card_table?
|
22
|
+
raise ArgumentError, "A low-card table can't itself have low-card associations: #{model_class}"
|
23
|
+
end
|
24
|
+
|
25
|
+
@model_class = model_class
|
26
|
+
@associations = [ ]
|
27
|
+
@collapsing_update_scheme = :default
|
28
|
+
|
29
|
+
install_methods!
|
30
|
+
end
|
31
|
+
|
32
|
+
# Called when +has_low_card_table+ is declared within the model class; this does all the actual work of
|
33
|
+
# setting up the association. This removes any previous associations with the same name; again, we have a
|
34
|
+
# 'last caller wins' policy.
|
35
|
+
def has_low_card_table(association_name, options = { })
|
36
|
+
unless association_name.kind_of?(Symbol) || (association_name.kind_of?(String) && association_name.strip.length > 0)
|
37
|
+
raise ArgumentError, "You must supply an association name, not: #{association_name.inspect}"
|
38
|
+
end
|
39
|
+
|
40
|
+
association_name = association_name.to_s.strip.downcase
|
41
|
+
@associations.delete_if { |a| a.association_name.to_s.strip.downcase == association_name }
|
42
|
+
|
43
|
+
@associations << LowCardTables::HasLowCardTable::LowCardAssociation.new(@model_class, association_name, options)
|
44
|
+
|
45
|
+
@model_class._low_card_dynamic_method_manager.sync_methods!
|
46
|
+
end
|
47
|
+
|
48
|
+
# Called when someone has called ::ActiveRecord::Base#reset_column_information on the low-card model in question.
|
49
|
+
# This simply tells the LowCardDynamicMethodManager to sync the methods on this model class, thus updating the
|
50
|
+
# set of delegated methods to match the new columns.
|
51
|
+
def low_card_column_information_reset!(low_card_model)
|
52
|
+
@model_class._low_card_dynamic_method_manager.sync_methods!
|
53
|
+
end
|
54
|
+
|
55
|
+
# Retrieves the low-card association with the given name. Raises LowCardTables::Errors::LowCardAssociationNotFoundError
|
56
|
+
# if not found.
|
57
|
+
def _low_card_association(name)
|
58
|
+
maybe_low_card_association(name) || (raise LowCardTables::Errors::LowCardAssociationNotFoundError, "There is no low-card association named '#{name}' for #{@model_class.name}; there are associations named: #{@associations.map(&:association_name).sort.join(", ")}.")
|
59
|
+
end
|
60
|
+
|
61
|
+
# Just like _low_card_association, but returns nil when an association is not found, rather than raising an error.
|
62
|
+
def maybe_low_card_association(name)
|
63
|
+
@associations.detect { |a| a.association_name.to_s.strip.downcase == name.to_s.strip.downcase }
|
64
|
+
end
|
65
|
+
|
66
|
+
# Updates all foreign keys that the given model_instance has to their correct values, given the set of attributes
|
67
|
+
# that are associated with that model instance.
|
68
|
+
def low_card_update_foreign_keys!(model_instance)
|
69
|
+
ensure_correct_class!(model_instance)
|
70
|
+
|
71
|
+
@associations.each do |association|
|
72
|
+
association.update_foreign_key!(model_instance)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
DEFAULT_COLLAPSING_UPDATE_VALUE = 10_000
|
77
|
+
|
78
|
+
# Gets, or sets, the current scheme for updating rows in this table when a low-card table has a column removed and
|
79
|
+
# thus needs to have rows collapsed. Passing +nil+ or no arguments retrieves the current scheme; passing anything
|
80
|
+
# else sets it. You can set this to:
|
81
|
+
#
|
82
|
+
# [:default] Rows will be updated in chunks of +DEFAULT_COLLAPSING_UPDATE_VALUE+ rows.
|
83
|
+
# [:none] Nothing will be done; you are entirely on your own, and will have dangling foreign keys.
|
84
|
+
# [a positive integer] Rows will be updated in chunks of this many rows at once.
|
85
|
+
# [something that responds to :call] This object will have #call invoked on it when rows need to be updated; it
|
86
|
+
# will be passed the map of 'winners' to 'losers', and is responsible for
|
87
|
+
# updating rows any way you want.
|
88
|
+
def low_card_value_collapsing_update_scheme(new_scheme = nil)
|
89
|
+
if (! new_scheme)
|
90
|
+
@collapsing_update_scheme
|
91
|
+
elsif new_scheme == :default || new_scheme == :none
|
92
|
+
@collapsing_update_scheme = new_scheme
|
93
|
+
elsif new_scheme.kind_of?(Integer)
|
94
|
+
raise ArgumentError, "You must specify an integer >= 1, not #{new_scheme.inspect}" unless new_scheme >= 1
|
95
|
+
@collapsing_update_scheme = new_scheme
|
96
|
+
elsif new_scheme.respond_to?(:call)
|
97
|
+
@collapsing_update_scheme = new_scheme
|
98
|
+
else
|
99
|
+
raise ArgumentError, "Invalid collapsing update scheme: #{new_scheme.inspect}"
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
# Called when a low-card model has just collapsed rows, presumably because it has had a column removed. This is
|
104
|
+
# responsible for updating each foreign-key column, using the proper low_card_value_collapsing_update_scheme.
|
105
|
+
def _low_card_update_collapsed_rows(low_card_model, collapse_map)
|
106
|
+
update_scheme = @collapsing_update_scheme
|
107
|
+
update_scheme = DEFAULT_COLLAPSING_UPDATE_VALUE if update_scheme == :default
|
108
|
+
|
109
|
+
@associations.each do |association|
|
110
|
+
if association.low_card_class == low_card_model
|
111
|
+
association.update_collapsed_rows(collapse_map, update_scheme)
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
private
|
117
|
+
# Makes sure that +model_instance+ is an instance of the model class this LowCardAssociationsManager is for.
|
118
|
+
def ensure_correct_class!(model_instance)
|
119
|
+
unless model_instance.kind_of?(@model_class)
|
120
|
+
raise ArgumentError, %{Somehow, you passed #{model_instance}, an instance of #{model_instance.class}, to the LowCardAssociationsManager for #{@model_class}.}
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
# Installs any methods we need on the model class -- right now, this is just our +before_save+ hook.
|
125
|
+
def install_methods!
|
126
|
+
@model_class.send(:before_save, :low_card_update_foreign_keys!)
|
127
|
+
end
|
128
|
+
|
129
|
+
# Fetches the entire superclass chain of a Class, up to, but not including, Object.
|
130
|
+
def superclasses(c)
|
131
|
+
out = [ ]
|
132
|
+
|
133
|
+
c = c.superclass
|
134
|
+
while c != Object
|
135
|
+
out << c
|
136
|
+
c = c.superclass
|
137
|
+
end
|
138
|
+
|
139
|
+
out
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
@@ -0,0 +1,224 @@
|
|
1
|
+
module LowCardTables
|
2
|
+
module HasLowCardTable
|
3
|
+
# This object is responsible for maintaining the set of methods that get automatically delegated when you declare
|
4
|
+
# +has_low_card_table+ on an ::ActiveRecord model -- it both maintains the set of methods defined on the
|
5
|
+
# _low_card_dynamic_methods_module for that class, and directs the calls in the right place at runtime.
|
6
|
+
#
|
7
|
+
# Secondarily, it also is responsible for transforming query specifications -- #scope_from_query takes the set of
|
8
|
+
# constraints you passed, as a Hash, to ::ActiveRecord::Relation#where, and transforms them using low-card
|
9
|
+
# information. So:
|
10
|
+
#
|
11
|
+
# { :deleted => true, :deceased => false }
|
12
|
+
#
|
13
|
+
# might become
|
14
|
+
#
|
15
|
+
# { :user_status_id => [ 1, 3, 9, 17 ] }
|
16
|
+
#
|
17
|
+
# While it might seem odd for that functionality to live in this class, it actually makes sense; this is the
|
18
|
+
# class that knows what method names in the low-card class those keys map to, after all.
|
19
|
+
class LowCardDynamicMethodManager
|
20
|
+
def initialize(model_class)
|
21
|
+
@model_class = model_class
|
22
|
+
@method_delegation_map = { }
|
23
|
+
end
|
24
|
+
|
25
|
+
# Given an instance of the model class we're maintaining methods for, the name of a method to invoke, and
|
26
|
+
# arguments passed to that method, runs the correct method. This is therefore a dispatcher -- rather than attempt
|
27
|
+
# to define the methods on the _low_card_dynamic_methods_module at all times to directly call the right low-card
|
28
|
+
# object, we simply have them all call through here, instead, and do the dispatch at runtime. This simplifies
|
29
|
+
# the nature of the dynamic methods considerably.
|
30
|
+
def run_low_card_method(object, method_name, args)
|
31
|
+
ensure_correct_class!(object)
|
32
|
+
|
33
|
+
method_data = @method_delegation_map[method_name.to_s]
|
34
|
+
unless method_data
|
35
|
+
raise NameError, "Whoa -- we're trying to call a delegated low-card method #{method_name.inspect} on #{object}, of class #{object.class}, but somehow the LowCardDynamicMethodManager has no knowledge of that method?!? We know about: #{@method_delegation_map.keys.sort.inspect}"
|
36
|
+
end
|
37
|
+
|
38
|
+
(association, association_method_name) = method_data
|
39
|
+
|
40
|
+
if association_method_name == :_low_card_object
|
41
|
+
# e.g., my_user.status
|
42
|
+
object._low_card_objects_manager.object_for(association)
|
43
|
+
elsif association_method_name == :_low_card_foreign_key
|
44
|
+
# e.g., my_user.user_status_id
|
45
|
+
object._low_card_objects_manager.foreign_key_for(association)
|
46
|
+
elsif association_method_name == :_low_card_foreign_key=
|
47
|
+
# e.g., my_user.user_status_id =
|
48
|
+
object._low_card_objects_manager.set_foreign_key_for(association, *args)
|
49
|
+
else
|
50
|
+
# e.g., my_user.deleted =
|
51
|
+
low_card_object = object.send(association.association_name)
|
52
|
+
low_card_object.send(association_method_name, *args)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
# Given a base ::ActiveRecord::Relation scope (which can of course just be a model class itself), and a set of
|
57
|
+
# query constraints as passed into ::ActiveRecord::Relation#where (which must be a Hash -- for the other forms
|
58
|
+
# of #where, our override of ::ActiveRecord::Relation#where doesn't call this method but just passes through to
|
59
|
+
# the underlying method), returns a new scope that is the result of applying those constraints correctly to
|
60
|
+
# the +base_scope+.
|
61
|
+
#
|
62
|
+
# The constraints in the query_hash need not all be, or even any be, constraints on a low-card table; any non-
|
63
|
+
# low-card constraints are simply passed through verbatim. But constraints on a low-card table -- whether they're
|
64
|
+
# implicit, like
|
65
|
+
#
|
66
|
+
# User.where(:deleted => false)
|
67
|
+
#
|
68
|
+
# or explicit, like
|
69
|
+
#
|
70
|
+
# User.where(:status => { :deleted => false })
|
71
|
+
#
|
72
|
+
# ...are transformed into explicit references to the low-card foreign-key column:
|
73
|
+
#
|
74
|
+
# User.where(:user_status_id => [ 1, 3, 4, 7, 8, 10 ])
|
75
|
+
def scope_from_query(base_scope, query_hash)
|
76
|
+
non_low_card_constraints = { }
|
77
|
+
low_card_association_to_constraint_map = { }
|
78
|
+
|
79
|
+
# We iterate through the query hash, building up two hashes:
|
80
|
+
#
|
81
|
+
# * non_low_card_constraints is the set of all constraints that have nothing to do with a low-card table;
|
82
|
+
# * low_card_association_to_constraint_map maps low-card association names to a Hash of the constraints applied
|
83
|
+
# to that association; the constraints in the Hash use key names that are the actual low-card column names
|
84
|
+
# (i.e., we translate them from whatever delegated method names were present in the referring class)
|
85
|
+
query_hash.each do |query_key, query_value|
|
86
|
+
low_card_delegation = @method_delegation_map[query_key.to_s]
|
87
|
+
|
88
|
+
# Does this constraint even mention a low-card column or association name?
|
89
|
+
if low_card_delegation
|
90
|
+
(association, method) = low_card_delegation
|
91
|
+
|
92
|
+
# e.g., User.where(:status => { ... })
|
93
|
+
if method == :_low_card_object
|
94
|
+
if (! query_value.kind_of?(Hash))
|
95
|
+
raise ArgumentError, %{You are trying to constrain on #{@model_class.name}.#{query_key}, which is a low-card association,
|
96
|
+
but the value you passed, #{query_value.inspect}, is not a Hash. Either pass a Hash,
|
97
|
+
or constrain on #{association.foreign_key_column_name} explicitly, and find IDs
|
98
|
+
yourself, using #{association.low_card_class.name}#ids_matching.}
|
99
|
+
end
|
100
|
+
|
101
|
+
low_card_association_to_constraint_map[association] ||= { }
|
102
|
+
low_card_association_to_constraint_map[association].merge!(query_value)
|
103
|
+
# e.g., User.where(:user_status_id => ...)
|
104
|
+
elsif method == :_low_card_foreign_key
|
105
|
+
non_low_card_constraints[query_key] = query_value
|
106
|
+
# e.g., User.where(:deleted => false)
|
107
|
+
else
|
108
|
+
low_card_association_to_constraint_map[association] ||= { }
|
109
|
+
low_card_association_to_constraint_map[association][method] = query_value
|
110
|
+
end
|
111
|
+
else
|
112
|
+
# e.g., User.where(:name => ...)
|
113
|
+
non_low_card_constraints[query_key] = query_value
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
out = base_scope
|
118
|
+
# See the comment in LowCardTables::ActiveRecord::Relation -- this is so that when we call #where, below,
|
119
|
+
# we don't end up creating infinite mutual recursion. +_low_card_direct+ is our 'escape hatch'.
|
120
|
+
out = base_scope.where(non_low_card_constraints.merge(:_low_card_direct => true)) if non_low_card_constraints.size > 0
|
121
|
+
|
122
|
+
# This is gross. In ActiveRecord v3, doing something like this:
|
123
|
+
#
|
124
|
+
# Model.where(:x => [ 1, 2, 3 ]).where(:x => [ 3, 4, 5 ])
|
125
|
+
#
|
126
|
+
# ...results in "... WHERE x IN (3, 4, 5)" -- i.e., it's last-clause wins, and the first one is totally
|
127
|
+
# ignored. While this sucks in general (in my opinion), it's genuinely a problem for our system; we need to
|
128
|
+
# be able to say Model.where(:deleted => false).where(:deceased => false) and only get back non-deleted, alive
|
129
|
+
# users -- and, underneath, both those queries transform to conditions on :user_status_id.
|
130
|
+
#
|
131
|
+
# Our workaround is to instead use text-based queries for these conditions, because:
|
132
|
+
#
|
133
|
+
# Model.where("x IN :ids", :ids => [ 1, 2, 3 ]).where("x IN :ids", :ids => [ 3, 4, 5 ])
|
134
|
+
#
|
135
|
+
# ...results in "... WHERE x IN (1, 2, 3) AND x IN (3, 4, 5)", which gives us the right value. (ActiveRecord
|
136
|
+
# doesn't ever parse SQL you hand to it, so it has no way of knowing these are conditions on the same column --
|
137
|
+
# so it keeps both clauses.)
|
138
|
+
#
|
139
|
+
# ActiveRecord 4 does the right thing here (IMHO) and behaves identically whether you pass in a Hash or a text
|
140
|
+
# clause. However, our hack works fine with both versions, so we'll keep it for now.
|
141
|
+
low_card_association_to_constraint_map.each do |association, constraints|
|
142
|
+
ids = association.low_card_class.low_card_ids_matching(constraints)
|
143
|
+
out = out.where("#{association.foreign_key_column_name} IN (:ids)", :ids => ids)
|
144
|
+
end
|
145
|
+
|
146
|
+
|
147
|
+
out
|
148
|
+
end
|
149
|
+
|
150
|
+
# This method is responsible for doing two things:
|
151
|
+
#
|
152
|
+
# * Most importantly, it sets up @method_delegation_map. This maps the name of every dynamic method that can be
|
153
|
+
# invoked on an instance of the model class to the low-card method that it should delegate to. (It calls
|
154
|
+
# LowCardTables::HasLowCardTable::LowCardAssociation#class_method_name_to_low_card_method_name_map to figure
|
155
|
+
# out what methods should be delegated for each association.) There are a few 'special' method names:
|
156
|
+
# +:_low_card_object+ means 'return the associated low-card object itself' (e.g., my_user.status);
|
157
|
+
# +:_low_card_foreign_key+ means 'return the associated low-card foreign key' (e.g., my_user.user_status_id);
|
158
|
+
# +:_low_card_foreign_key=+ means 'set the associated low-card foreign key'.
|
159
|
+
# * Secondly, it makes sure that, for each of these methods, the _low_card_dynamic_methods_module has installed
|
160
|
+
# a method that delegates to #run_low_card_method on this object -- and that no other methods are installed
|
161
|
+
# on that module.
|
162
|
+
#
|
163
|
+
# This method implements the 'last association wins' policy, by simply going through the asssociations in
|
164
|
+
# order of definition and letting them overwrite previous associations' method names, if they collide.
|
165
|
+
#
|
166
|
+
# Rather than trying to dynamically add and remove methods as associations are added, columns are removed, etc.,
|
167
|
+
# it is _far_ simpler to do what we do here: simply rebuild the map from scratch on each call -- and then apply
|
168
|
+
# the differences to the _low_card_dynamic_methods_module.
|
169
|
+
def sync_methods!
|
170
|
+
currently_delegated_methods = @method_delegation_map.keys
|
171
|
+
|
172
|
+
@method_delegation_map = { }
|
173
|
+
|
174
|
+
associations.each do |association|
|
175
|
+
@method_delegation_map[association.association_name.to_s] = [ association, :_low_card_object ]
|
176
|
+
@method_delegation_map[association.foreign_key_column_name.to_s] = [ association, :_low_card_foreign_key ]
|
177
|
+
@method_delegation_map[association.foreign_key_column_name.to_s + "="] = [ association, :_low_card_foreign_key= ]
|
178
|
+
|
179
|
+
association.class_method_name_to_low_card_method_name_map.each do |desired_name, association_method_name|
|
180
|
+
desired_name = desired_name.to_s
|
181
|
+
@method_delegation_map[desired_name] = [ association, association_method_name ]
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
remove_delegated_methods!(currently_delegated_methods - @method_delegation_map.keys)
|
186
|
+
add_delegated_methods!(@method_delegation_map.keys - currently_delegated_methods)
|
187
|
+
end
|
188
|
+
|
189
|
+
private
|
190
|
+
# Returns all associations that should be used for this object.
|
191
|
+
def associations
|
192
|
+
@model_class._low_card_associations_manager.associations
|
193
|
+
end
|
194
|
+
|
195
|
+
# Makes sure the given object is an instance of the class we're handling dynamic methods for.
|
196
|
+
def ensure_correct_class!(object)
|
197
|
+
unless object.kind_of?(@model_class)
|
198
|
+
raise ArgumentError, "You passed #{object.inspect}, an instance of #{object.class.name}, to the LowCardDynamicMethodManager for #{@model_class}."
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
# Removes all methods with any of the specified names from the _low_card_dynamic_methods_module.
|
203
|
+
def remove_delegated_methods!(method_names)
|
204
|
+
mod = @model_class._low_card_dynamic_methods_module
|
205
|
+
|
206
|
+
method_names.each do |method_name|
|
207
|
+
mod.module_eval("remove_method :#{method_name}")
|
208
|
+
end
|
209
|
+
end
|
210
|
+
|
211
|
+
# Adds delegated methods for all the given names to the _low_card_dynamic_methods_module.
|
212
|
+
def add_delegated_methods!(method_names)
|
213
|
+
mod = @model_class._low_card_dynamic_methods_module
|
214
|
+
|
215
|
+
method_names.each do |delegated_method|
|
216
|
+
mod.module_eval(%{
|
217
|
+
def #{delegated_method}(*args)
|
218
|
+
self.class._low_card_dynamic_method_manager.run_low_card_method(self, :#{delegated_method}, args)
|
219
|
+
end})
|
220
|
+
end
|
221
|
+
end
|
222
|
+
end
|
223
|
+
end
|
224
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
module LowCardTables
|
2
|
+
module HasLowCardTable
|
3
|
+
# Unlike the LowCardAssociationsManager, the LowCardObjectsManager belongs to a particular _instance_ of a model
|
4
|
+
# class that refers to a low-card table. Its responsibility is straightforward: it holds onto the actual instances
|
5
|
+
# of the low-card class that we return in response to someone accessing a low-card association. (More concretely:
|
6
|
+
# when you say my_user.status, you get back an instance of UserStatus; if you say my_user.status again, you get back
|
7
|
+
# the same instance of UserStatus. This class is responsible for creating that object in the first place, and
|
8
|
+
# holding onto it so that the same one gets returned all the time.)
|
9
|
+
#
|
10
|
+
# In an ordinary Rails association, you'd get back live, normal instances of the associated class -- just like if
|
11
|
+
# you'd said, say, <tt>UserStatus.find(...)</tt> in the first place. However, this is inappropriate for low-card
|
12
|
+
# associations, because the whole 'trick' of the low-card system is that changing the attributes of the
|
13
|
+
# conceptually-associated +UserStatus+ object actually just changes <em>which +UserStatus+ object you're pointing
|
14
|
+
# at</em>, rather than actually changing a +UserStatus+ row at all.
|
15
|
+
#
|
16
|
+
# We perform a simple trick here instead, composed of three parts:
|
17
|
+
#
|
18
|
+
# * Returned objects from low-card associations are actually clones (Object#dup) of the normal low-card objects
|
19
|
+
# you'd ordinarily get; this is necessary so that clients can assign attributes to them in any way they want,
|
20
|
+
# and there's no "crosstalk".
|
21
|
+
# * Returned objects have their ID removed (through a simple <tt>object.id = nil</tt>); this prevents a whole class
|
22
|
+
# of common coding mistakes. Say you retrieve the low-card object associated with a particular referring object,
|
23
|
+
# and then modify some of its attributes. Without this change, you'd now be in a situation where you have an
|
24
|
+
# associated ActiveRecord object (the low-card object) that has a particular set of attributes, and a particular
|
25
|
+
# ID...and yet, when you call #save -- which will succeed! -- that particular ID doesn't have that set of
|
26
|
+
# attributes at all. It would be way too easy to write code that looks completely correct, and yet fails; for
|
27
|
+
# example, you could grab the ID of that associated object and assign it to other referring rows. So we strip the
|
28
|
+
# ID off completely.
|
29
|
+
# * Returned objects cannot be directly saved at all; if you call #save or #save! on them, you'll get an exception,
|
30
|
+
# telling you not to do that. The low-card system itself needs to be in complete control of what rows get created,
|
31
|
+
# and be able to change referring IDs instead.
|
32
|
+
#
|
33
|
+
# These tricks actually happen in LowCardTables::HasLowCardTable::LowCardAssociation#create_low_card_object_for
|
34
|
+
# and LowCardTables::LowCardTable::Base#_low_card_disable_save_when_needed!, but it makes sense to document them
|
35
|
+
# here, since this class is most clearly given this responsibility.
|
36
|
+
class LowCardObjectsManager
|
37
|
+
# Creates a new instance of the LowCardObjectsManager, tied to a particular _instance_ of a low-card model.
|
38
|
+
# That is, +model_instance+ should be an instance of +UserStatus+ or something similar.
|
39
|
+
def initialize(model_instance)
|
40
|
+
@model_instance = model_instance
|
41
|
+
@objects = { }
|
42
|
+
end
|
43
|
+
|
44
|
+
# Returns the low-card object that corresponds to the given LowCardAssociation for this model instance.
|
45
|
+
def object_for(association)
|
46
|
+
association_name = association.association_name.to_s.strip.downcase
|
47
|
+
@objects[association_name] ||= begin
|
48
|
+
association = model_instance.class._low_card_associations_manager._low_card_association(association_name)
|
49
|
+
association.create_low_card_object_for(model_instance)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
# Returns the foreign key for the given LowCardAssociation for this model instance. This wouldn't really have to
|
54
|
+
# go through this class for any great reason right now, but, given that #set_foreign_key_for does, it makes a lot
|
55
|
+
# of sense to keep the logic here.
|
56
|
+
def foreign_key_for(association)
|
57
|
+
model_instance[association.foreign_key_column_name]
|
58
|
+
end
|
59
|
+
|
60
|
+
# Sets the foreign key for the given LowCardAssociation for this model instance. We allow users to do this
|
61
|
+
# directly (e.g., <tt>my_user.user_status_id = 17</tt>) so that they can, for example, store +UserStatus+ IDs
|
62
|
+
# out-of-band (in memcache, Redis, or whatever) for any reasons they want. When they do assign the foreign-key
|
63
|
+
# value, we need to invalidate the associated low-card object, since any previous data there is now no longer
|
64
|
+
# valid.
|
65
|
+
def set_foreign_key_for(association, new_value)
|
66
|
+
model_instance[association.foreign_key_column_name] = new_value
|
67
|
+
invalidate_object_for(association)
|
68
|
+
new_value
|
69
|
+
end
|
70
|
+
|
71
|
+
private
|
72
|
+
# Removes the mapped object for a given association -- this simply deletes the object from our hash.
|
73
|
+
def invalidate_object_for(association)
|
74
|
+
@objects.delete(association.association_name)
|
75
|
+
end
|
76
|
+
|
77
|
+
attr_reader :model_instance
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,184 @@
|
|
1
|
+
require 'active_support/concern'
|
2
|
+
require 'low_card_tables/low_card_table/cache_expiration/has_cache_expiration'
|
3
|
+
require 'low_card_tables/low_card_table/row_manager'
|
4
|
+
|
5
|
+
module LowCardTables
|
6
|
+
module LowCardTable
|
7
|
+
# LowCardTables::LowCardTable::Base is the module that's included into any ActiveRecord model that you declare
|
8
|
+
# +is_low_card_table+ on. As such, it defines the API that's available on low-card tables. (The standard
|
9
|
+
# ActiveRecord API does, of course, still remain available, so you can also use that if you want.)
|
10
|
+
#
|
11
|
+
# Be careful of the distinction between the ClassMethods and instance methods here. ClassMethods are available on
|
12
|
+
# the low-card table as a whole; instance methods apply, of course, to each row individually.
|
13
|
+
module Base
|
14
|
+
extend ActiveSupport::Concern
|
15
|
+
|
16
|
+
# All low-card tables can have their cache-expiration policy set individually.
|
17
|
+
include LowCardTables::LowCardTable::CacheExpiration::HasCacheExpiration
|
18
|
+
|
19
|
+
# Set up cache-policy inheritance -- see HasCacheExpiration for more details.
|
20
|
+
included do
|
21
|
+
low_card_cache_policy_inherits_from ::LowCardTables
|
22
|
+
end
|
23
|
+
|
24
|
+
# This method is a critical entry point from the rest of the low-card system. For example, given our usual
|
25
|
+
# User/UserStatus example -- when you save a User object, the low-card system grabs the associated UserStatus
|
26
|
+
# object and creates a hash, mapping all of the columns in UserStatus to their corresponding values. Next, it
|
27
|
+
# iterates through its in-memory cache, using this method to determine which of the rows in the cache matches
|
28
|
+
# the hash it extracted -- and, when it finds one, that's the row it uses to get the low-card ID to assign
|
29
|
+
# in the associated table.
|
30
|
+
#
|
31
|
+
# *IMPORTANT*: this is not the _only_ context in which this method is used, but merely one example.
|
32
|
+
#
|
33
|
+
# It's possible to override this method to alter behavior; for example, you could use this to translate symbols
|
34
|
+
# to integers, pin values, or otherwise transform data. But be extremely careful when you do this, as you're
|
35
|
+
# playing with a very low-level part of the low-card system.
|
36
|
+
#
|
37
|
+
# Note that the hashes supplied can be partial or complete; that is, they may specify any subset of the values
|
38
|
+
# in this table, or all of them. This method must work accordingly -- if the hashes are partial, then, if this
|
39
|
+
# row's values for the keys that are specified match, then it should return true.
|
40
|
+
#
|
41
|
+
# This is the highest-level, most 'bulk' method -- it asks whether this row matches _any_ of the hashes in the
|
42
|
+
# supplied array.
|
43
|
+
def _low_card_row_matches_any_hash?(hashes)
|
44
|
+
hashes.detect { |hash| _low_card_row_matches_hash?(hash) }
|
45
|
+
end
|
46
|
+
|
47
|
+
# This is called by #_low_card_row_matches_any_hash?, in a loop; it asks whether this row matches the hash
|
48
|
+
# provided. See #_low_card_row_matches_any_hash? for more details. You can override this method instead of that
|
49
|
+
# one, if its semantics work better for your purposes, since its behavior will affect that of
|
50
|
+
# #_low_card_row_matches_any_hash?.
|
51
|
+
def _low_card_row_matches_hash?(hash)
|
52
|
+
hash.keys.all? { |key| _low_card_column_matches?(key, hash[key]) }
|
53
|
+
end
|
54
|
+
|
55
|
+
# This is called by _low_card_row_matches_hash?, in a loop; it asks whether the given column (+key+) matches
|
56
|
+
# the given value (+value+). See #_low_card_row_matches_any_hash? for more details. You can override this method
|
57
|
+
# instead of #_low_card_row_matches_any_hash? or #_low_card_row_matches_hash?, if its semantics work better for
|
58
|
+
# your purposes, since its behavior will affect those methods as well.
|
59
|
+
def _low_card_column_matches?(key, value)
|
60
|
+
self[key.to_s] == value
|
61
|
+
end
|
62
|
+
|
63
|
+
# This method is called from methods like #low_card_rows_matching, when passed a block -- its job is simply to
|
64
|
+
# see if this row is matched by the given block. It's hard to imagine a different implementation than this one,
|
65
|
+
# but it's here in case you want to override it.
|
66
|
+
def _low_card_row_matches_block?(block)
|
67
|
+
block.call(self)
|
68
|
+
end
|
69
|
+
|
70
|
+
module ClassMethods
|
71
|
+
# Declares that this is a low-card table. This should only ever be used on tables that are,
|
72
|
+
# in fact, low-card tables.
|
73
|
+
#
|
74
|
+
# options can contain:
|
75
|
+
#
|
76
|
+
# [:exclude_column_names] Excludes the specified Array of column names from being treated
|
77
|
+
# as low-card columns; this happens by default to created_at and
|
78
|
+
# updated_at. These columns will not be touched by the low-card
|
79
|
+
# code, meaning they have to be nullable or have defaults.
|
80
|
+
# [:max_row_count] The low-card system has a check built in to start raising errors if you
|
81
|
+
# appear to be storing data in a low-card table that is, in fact, not actually
|
82
|
+
# of low cardinality. The effect that doing this has is to explode the number
|
83
|
+
# of rows in the low-card table, so the check simply tests the total number
|
84
|
+
# of rows in the table. This defaults to 5,000
|
85
|
+
# (in LowCardTables::LowCardTable::Cache::DEFAULT_MAX_ROW_COUNT). If you really
|
86
|
+
# do have a valid low-card table with more than this number of rows, you can
|
87
|
+
# override that limit here.
|
88
|
+
def is_low_card_table(options = { })
|
89
|
+
self.low_card_options = options
|
90
|
+
_low_card_disable_save_when_needed!
|
91
|
+
end
|
92
|
+
|
93
|
+
# See LowCardTables::HasLowCardTable::LowCardObjectsManager for more details. In short, you should never be
|
94
|
+
# saving low-card objects directly; you should rather let the low-card Gem create such rows for you
|
95
|
+
# automatically, based on the attributes you assign to the model.
|
96
|
+
#
|
97
|
+
# This method is invoked only once, when #is_low_card_table is called.
|
98
|
+
def _low_card_disable_save_when_needed!
|
99
|
+
send(:define_method, :save_low_card_row!) do |*args|
|
100
|
+
begin
|
101
|
+
@_low_card_saves_allowed = true
|
102
|
+
save!(*args)
|
103
|
+
ensure
|
104
|
+
@_low_card_saves_allowed = false
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
send(:define_method, :save_low_card_row) do |*args|
|
109
|
+
begin
|
110
|
+
@_low_card_saves_allowed = true
|
111
|
+
save(*args)
|
112
|
+
ensure
|
113
|
+
@_low_card_saves_allowed = false
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
%w{save save!}.each do |method_name|
|
118
|
+
send(:define_method, method_name) do |*args|
|
119
|
+
if @_low_card_saves_allowed
|
120
|
+
super(*args)
|
121
|
+
else
|
122
|
+
raise LowCardTables::Errors::LowCardCannotSaveAssociatedLowCardObjectsError, %{You just tried to save a model that represents a row in a low-card table.
|
123
|
+
You can't do this, because the entire low-card system relies on the fact that low-card rows
|
124
|
+
are immutable once created. Changing this row would therefore change the logical state of
|
125
|
+
many, many rows that are associated with this one, and that is almost certainly not what
|
126
|
+
you want.
|
127
|
+
|
128
|
+
Instead, simply modify the low-card attributes directly -- typically on the associated object
|
129
|
+
(e.g., my_user.deleted = true), or on the low-card object (my_user.status.deleted = true),
|
130
|
+
and then save the associated object instead (my_user.save!). This will trigger the low-card
|
131
|
+
system to recompute which low-card row the object should be associated with, and update it
|
132
|
+
as needed, which is almost certainly what you actually want.
|
133
|
+
|
134
|
+
If you are absolutely certain you know what you're doing, you can call #save_low_card_row!
|
135
|
+
on this object, and it will save, but make sure you understand ALL the implications first.}
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
# Is this a low-card table? Since this module has been included into the class in question (which happens via
|
142
|
+
# #is_low_card_table), then the answer is always, 'yes'.
|
143
|
+
def is_low_card_table?
|
144
|
+
true
|
145
|
+
end
|
146
|
+
|
147
|
+
# This is a method provided by ActiveRecord::Base. When the set of columns on a low-card table has changed, we
|
148
|
+
# need to tell the row manager, so that it can flush its caches.
|
149
|
+
def reset_column_information
|
150
|
+
out = super
|
151
|
+
_low_card_row_manager.column_information_reset!
|
152
|
+
out
|
153
|
+
end
|
154
|
+
|
155
|
+
# This returns the set of low-card options specified for this class in #is_low_card_table.
|
156
|
+
def low_card_options
|
157
|
+
@_low_card_options ||= { }
|
158
|
+
end
|
159
|
+
|
160
|
+
# This sets the set of low-card options.
|
161
|
+
def low_card_options=(options)
|
162
|
+
@_low_card_options = options
|
163
|
+
end
|
164
|
+
|
165
|
+
# Returns the associated LowCardTables::LowCardTable::RowManager object for this class, which is where an awful
|
166
|
+
# lot of the real work happens.
|
167
|
+
def _low_card_row_manager
|
168
|
+
@_low_card_row_manager ||= LowCardTables::LowCardTable::RowManager.new(self)
|
169
|
+
end
|
170
|
+
|
171
|
+
# All of these methods get delegated to the LowCardRowManager, which does most of the actual work. We prefix
|
172
|
+
# them all with +low_card_+ in order to ensure that we can't possibly collide with methods provided by other
|
173
|
+
# Gems or by client code, since many of the names are pretty generic (e.g., +all_rows+).
|
174
|
+
[ :all_rows, :row_for_id, :rows_for_ids, :rows_matching, :ids_matching, :find_ids_for, :find_or_create_ids_for,
|
175
|
+
:find_rows_for, :find_or_create_rows_for, :flush_cache!, :referring_models, :value_column_names, :referred_to_by,
|
176
|
+
:collapse_rows_and_update_referrers!, :ensure_has_unique_index!, :remove_unique_index! ].each do |delegated_method_name|
|
177
|
+
define_method("low_card_#{delegated_method_name}") do |*args|
|
178
|
+
_low_card_row_manager.send(delegated_method_name, *args)
|
179
|
+
end
|
180
|
+
end
|
181
|
+
end
|
182
|
+
end
|
183
|
+
end
|
184
|
+
end
|