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,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
|