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