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