activerecord-virtual_attributes 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,234 @@
1
+ module ActiveRecord
2
+ module VirtualAttributes
3
+ module VirtualFields
4
+ extend ActiveSupport::Concern
5
+ include ActiveRecord::VirtualAttributes
6
+ include ActiveRecord::VirtualAttributes::VirtualReflections
7
+
8
+ module NonARModels
9
+ def dangerous_attribute_method?(_); false; end
10
+
11
+ def generated_association_methods; self; end
12
+
13
+ def add_autosave_association_callbacks(*_args); self; end
14
+
15
+ def belongs_to_required_by_default; false; end
16
+ end
17
+
18
+ included do
19
+ unless respond_to?(:dangerous_attribute_method?)
20
+ extend NonARModels
21
+ end
22
+ end
23
+
24
+ module ClassMethods
25
+ def virtual_fields_base?
26
+ !(superclass < VirtualFields)
27
+ end
28
+
29
+ def virtual_field?(name)
30
+ virtual_attribute?(name) || virtual_reflection?(name)
31
+ end
32
+
33
+ def remove_virtual_fields(associations)
34
+ case associations
35
+ when String, Symbol
36
+ virtual_field?(associations) ? nil : associations
37
+ when Array
38
+ associations.collect { |association| remove_virtual_fields(association) }.compact
39
+ when Hash
40
+ associations.each_with_object({}) do |(parent, child), h|
41
+ next if virtual_field?(parent)
42
+ reflection = reflect_on_association(parent.to_sym)
43
+ h[parent] = reflection.options[:polymorphic] ? nil : reflection.klass.remove_virtual_fields(child) if reflection
44
+ end
45
+ else
46
+ associations
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+
54
+ module ActiveRecord
55
+ class Base
56
+ include ActiveRecord::VirtualAttributes::VirtualFields
57
+ end
58
+
59
+ module Associations
60
+ class Preloader
61
+ prepend Module.new {
62
+ def preloaders_for_one(association, records, scope)
63
+ klass_map = records.compact.group_by(&:class)
64
+
65
+ loaders = klass_map.keys.group_by { |klass| klass.virtual_includes(association) }.flat_map do |virtuals, klasses|
66
+ subset = klasses.flat_map { |klass| klass_map[klass] }
67
+ preload(subset, virtuals)
68
+ end
69
+
70
+ records_with_association = klass_map.select { |k, rs| k.reflect_on_association(association) }.flat_map { |k, rs| rs }
71
+ if records_with_association.any?
72
+ loaders.concat(super(association, records_with_association, scope))
73
+ end
74
+
75
+ loaders
76
+ end
77
+ }
78
+ end
79
+
80
+ # FIXME: Hopefully we can get this into Rails core so this is no longer
81
+ # required in our codebase, but the rule that are broken here are mostly
82
+ # due to the style of the Rails codebase conflicting with our own.
83
+ # Ignoring them to avoid noise in RuboCop, but allow us to keep the same
84
+ # syntax from the original codebase.
85
+ #
86
+ # rubocop:disable Style/BlockDelimiters, Layout/SpaceAfterComma, Style/HashSyntax
87
+ # rubocop:disable Layout/AlignHash, Metrics/AbcSize, Metrics/MethodLength
88
+ class JoinDependency
89
+ def instantiate(result_set, *_, &block)
90
+ primary_key = aliases.column_alias(join_root, join_root.primary_key)
91
+
92
+ seen = Hash.new { |i, object_id|
93
+ i[object_id] = Hash.new { |j, child_class|
94
+ j[child_class] = {}
95
+ }
96
+ }
97
+
98
+ model_cache = Hash.new { |h,klass| h[klass] = {} }
99
+ parents = model_cache[join_root]
100
+ column_aliases = aliases.column_aliases(join_root)
101
+
102
+ # New Code
103
+ #
104
+ # This monkey patches the ActiveRecord::Associations::JoinDependency to
105
+ # include columns into the main record that might have been added
106
+ # through a `select` clause.
107
+ #
108
+ # This can be seen with the following:
109
+ #
110
+ # Vm.select(Vm.arel_table[Arel.star]).select(:some_vm_virtual_col)
111
+ # .includes(:tags => {}).references(:tags => {})
112
+ #
113
+ # Which will produce a SQL SELECT statement kind of like this:
114
+ #
115
+ # SELECT "vms".*,
116
+ # (<virtual_attribute_arel>) AS some_vm_virtual_col,
117
+ # "vms"."id" AS t0_r0
118
+ # "vms"."vendor" AS t0_r1
119
+ # "vms"."format" AS t0_r1
120
+ # "vms"."version" AS t0_r1
121
+ # ...
122
+ # "tags"."id" AS t1_r0
123
+ # "tags"."name" AS t1_r1
124
+ #
125
+ # This is because rails is trying to reduce the number of queries
126
+ # needed to fetch all of the records in the include, so it grabs the
127
+ # columns for both of the tables together to do it. Unfortuantely (or
128
+ # fortunately... depending on how you look at it), it does not remove
129
+ # any `.select` columns from the query that is run in the process, so
130
+ # that is brought along for the ride, but never used when this method
131
+ # instanciates the objects.
132
+ #
133
+ # The "New Code" here simply also instanciates any extra rows that
134
+ # might have been included in the select (virtual_columns) as well and
135
+ # brought back with the result set.
136
+ unless result_set.empty?
137
+ join_dep_keys = aliases.columns.map(&:right)
138
+ join_root_aliases = column_aliases.map(&:first)
139
+ additional_attributes = result_set.first.keys
140
+ .reject { |k| join_dep_keys.include?(k) }
141
+ .reject { |k| join_root_aliases.include?(k) }
142
+ .map { |k| [k, k] }
143
+ column_aliases += additional_attributes
144
+ end
145
+ # End of New Code
146
+
147
+ message_bus = ActiveSupport::Notifications.instrumenter
148
+
149
+ payload = {
150
+ record_count: result_set.length,
151
+ class_name: join_root.base_klass.name
152
+ }
153
+
154
+ message_bus.instrument('instantiation.active_record', payload) do
155
+ result_set.each { |row_hash|
156
+ parent_key = primary_key ? row_hash[primary_key] : row_hash
157
+ parent = parents[parent_key] ||= join_root.instantiate(row_hash, column_aliases, &block)
158
+ construct(parent, join_root, row_hash, result_set, seen, model_cache, aliases)
159
+ }
160
+ end
161
+
162
+ parents.values
163
+ end
164
+ # rubocop:enable Style/BlockDelimiters, Layout/SpaceAfterComma, Style/HashSyntax
165
+ # rubocop:enable Layout/AlignHash, Metrics/AbcSize, Metrics/MethodLength
166
+ end
167
+ end
168
+
169
+ class Relation
170
+ def without_virtual_includes
171
+ filtered_includes = includes_values && klass.remove_virtual_fields(includes_values)
172
+ if filtered_includes != includes_values
173
+ spawn.tap { |other| other.includes_values = filtered_includes }
174
+ else
175
+ self
176
+ end
177
+ end
178
+
179
+ include(Module.new {
180
+ # From ActiveRecord::FinderMethods
181
+ def find_with_associations
182
+ real = without_virtual_includes
183
+ return super if real.equal?(self)
184
+
185
+ if ActiveRecord.version.to_s >= "5.1"
186
+ recs, join_dep = real.find_with_associations { |relation, join_dependency| [relation, join_dependency] }
187
+ else
188
+ recs = real.find_with_associations
189
+ end
190
+ MiqPreloader.preload(recs, preload_values + includes_values) if includes_values
191
+
192
+ # when 5.0 support is dropped, assume a block given
193
+ if block_given?
194
+ yield recs, join_dep
195
+ end
196
+ recs
197
+ end
198
+
199
+ # From ActiveRecord::QueryMethods
200
+ def select(*fields)
201
+ return super if block_given? || fields.empty?
202
+ # support virtual attributes by adding an alias to the sql phrase for the column
203
+ # it does not add an as() if the column already has an as
204
+ # this code is based upon _select()
205
+ fields.flatten!
206
+ fields.map! do |field|
207
+ if virtual_attribute?(field) && (arel = klass.arel_attribute(field)) && arel.respond_to?(:as)
208
+ arel.as(field.to_s)
209
+ else
210
+ field
211
+ end
212
+ end
213
+ # end support virtual attributes
214
+ super
215
+ end
216
+
217
+ # From ActiveRecord::Calculations
218
+ def calculate(operation, attribute_name)
219
+ # work around 1 until https://github.com/rails/rails/pull/25304 gets merged
220
+ # This allows attribute_name to be a virtual_attribute
221
+ if (arel = klass.arel_attribute(attribute_name)) && virtual_attribute?(attribute_name)
222
+ attribute_name = arel
223
+ end
224
+ # end work around 1
225
+
226
+ # allow calculate to work when including a virtual attribute
227
+ real = without_virtual_includes
228
+ return super if real.equal?(self)
229
+
230
+ real.calculate(operation, attribute_name)
231
+ end
232
+ })
233
+ end
234
+ end
@@ -0,0 +1,33 @@
1
+ module ActiveRecord
2
+ module VirtualAttributes
3
+ # VirtualIncludes associates an includes with an attribute
4
+ #
5
+ # Model.virtual_attribute :field, :string, :includes => :table
6
+ # Model.includes(:field)
7
+ #
8
+ # is equivalent to:
9
+ #
10
+ # Model.includes(:table)
11
+ module VirtualIncludes
12
+ extend ActiveSupport::Concern
13
+
14
+ included do
15
+ class_attribute :_virtual_includes, :instance_accessor => false
16
+ self._virtual_includes = {}
17
+ end
18
+
19
+ module ClassMethods
20
+ def virtual_includes(name)
21
+ load_schema
22
+ _virtual_includes[name.to_s]
23
+ end
24
+
25
+ private
26
+
27
+ def define_virtual_include(name, uses)
28
+ self._virtual_includes = _virtual_includes.merge(name => uses)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,114 @@
1
+ module ActiveRecord
2
+ module VirtualAttributes
3
+ module VirtualReflections
4
+ extend ActiveSupport::Concern
5
+ include ActiveRecord::VirtualAttributes::VirtualIncludes
6
+
7
+ module ClassMethods
8
+
9
+ #
10
+ # Definition
11
+ #
12
+
13
+ def virtual_has_one(name, options = {})
14
+ uses = options.delete(:uses)
15
+ reflection = ActiveRecord::Associations::Builder::HasOne.build(self, name, nil, options)
16
+ add_virtual_reflection(reflection, name, uses, options)
17
+ end
18
+
19
+ def virtual_has_many(name, options = {})
20
+ define_method("#{name.to_s.singularize}_ids") do
21
+ records = send(name)
22
+ records.respond_to?(:ids) ? records.ids : records.collect(&:id)
23
+ end
24
+ uses = options.delete(:uses)
25
+ reflection = ActiveRecord::Associations::Builder::HasMany.build(self, name, nil, options)
26
+ add_virtual_reflection(reflection, name, uses, options)
27
+ end
28
+
29
+ def virtual_belongs_to(name, options = {})
30
+ uses = options.delete(:uses)
31
+ reflection = ActiveRecord::Associations::Builder::BelongsTo.build(self, name, nil, options)
32
+ add_virtual_reflection(reflection, name, uses, options)
33
+ end
34
+
35
+ def virtual_reflection?(name)
36
+ virtual_reflections.key?(name.to_sym)
37
+ end
38
+
39
+ def virtual_reflection(name)
40
+ virtual_reflections[name.to_sym]
41
+ end
42
+
43
+ #
44
+ # Introspection
45
+ #
46
+
47
+ def virtual_reflections
48
+ (virtual_fields_base? ? {} : superclass.virtual_reflections).merge(_virtual_reflections)
49
+ end
50
+
51
+ def reflections_with_virtual
52
+ reflections.symbolize_keys.merge(virtual_reflections)
53
+ end
54
+
55
+ def reflection_with_virtual(association)
56
+ virtual_reflection(association) || reflect_on_association(association)
57
+ end
58
+
59
+ def follow_associations(association_names)
60
+ association_names.inject(self) { |klass, name| klass.try!(:reflect_on_association, name).try!(:klass) }
61
+ end
62
+
63
+ def follow_associations_with_virtual(association_names)
64
+ association_names.inject(self) { |klass, name| klass.try!(:reflection_with_virtual, name).try!(:klass) }
65
+ end
66
+
67
+ # invalid associations return a nil
68
+ # real reflections are followed
69
+ # a virtual association will stop the traversal
70
+ # @returns [nil, Array<Relation>]
71
+ def collect_reflections(association_names)
72
+ klass = self
73
+ association_names.each_with_object([]) do |name, ret|
74
+ reflection = klass.reflect_on_association(name)
75
+ if reflection.nil?
76
+ if klass.reflection_with_virtual(name)
77
+ break(ret)
78
+ else
79
+ break
80
+ end
81
+ end
82
+ klass = reflection.klass
83
+ ret << reflection
84
+ end
85
+ end
86
+
87
+ def collect_reflections_with_virtual(association_names)
88
+ klass = self
89
+ association_names.collect do |name|
90
+ reflection = klass.reflection_with_virtual(name) || break
91
+ klass = reflection.klass
92
+ reflection
93
+ end
94
+ end
95
+
96
+ private
97
+
98
+ def add_virtual_reflection(reflection, name, uses, _options)
99
+ raise ArgumentError, "macro must be specified" unless reflection
100
+ reset_virtual_reflection_information
101
+ _virtual_reflections[name.to_sym] = reflection
102
+ define_virtual_include(name.to_s, uses)
103
+ end
104
+
105
+ def reset_virtual_reflection_information
106
+ end
107
+
108
+ def _virtual_reflections
109
+ @virtual_reflections ||= {}
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,134 @@
1
+ module VirtualAttributes
2
+ module VirtualTotal
3
+ extend ActiveSupport::Concern
4
+
5
+ module ClassMethods
6
+ private
7
+
8
+ # define an attribute to calculating the total of a child
9
+ def virtual_total(name, relation, options = {})
10
+ virtual_aggregate(name, relation, :size, nil, options)
11
+ end
12
+
13
+ # define an attribute to calculating the total of a child
14
+ #
15
+ # example 1:
16
+ #
17
+ # class ExtManagementSystem
18
+ # has_many :vms
19
+ # virtual_total :total_vms, :vms
20
+ # end
21
+ #
22
+ # generates:
23
+ #
24
+ # def total_vms
25
+ # vms.count
26
+ # end
27
+ #
28
+ # virtual_attribute :total_vms, :integer, :uses => :vms, :arel => ...
29
+ #
30
+ # # arel == (SELECT COUNT(*) FROM vms where ems.id = vms.ems_id)
31
+ #
32
+ # example 2:
33
+ #
34
+ # class Hardware
35
+ # has_many :disks
36
+ # virtual_aggregate :allocated_disk_storage, :disks, :sum, :size
37
+ # end
38
+ #
39
+ # generates:
40
+ #
41
+ # def allocated_disk_storage
42
+ # if disks.loaded?
43
+ # disks.blank? ? nil : disks.map { |t| t.size.to_i }.sum
44
+ # else
45
+ # disks.sum(:size) || 0
46
+ # end
47
+ # end
48
+ #
49
+ # virtual_attribute :allocated_disk_storage, :integer, :uses => :disks, :arel => ...
50
+ #
51
+ # # arel => (SELECT sum("disks"."size") where "hardware"."id" = "disks"."hardware_id")
52
+
53
+ def virtual_aggregate(name, relation, method_name = :sum, column = nil, options = {})
54
+ define_virtual_aggregate_method(name, relation, method_name, column)
55
+ reflection = reflect_on_association(relation)
56
+
57
+ if options.key?(:arel)
58
+ arel = options.dup.delete(:arel)
59
+ # if there is no relation to get to the arel, have to throw it away
60
+ arel = nil if !arel || !reflection
61
+ else
62
+ arel = virtual_aggregate_arel(reflection, method_name, column)
63
+ end
64
+
65
+ if arel
66
+ virtual_attribute name, :integer, :uses => options[:uses] || relation, :arel => arel
67
+ else
68
+ virtual_attribute name, :integer, **options
69
+ end
70
+ end
71
+
72
+ def define_virtual_aggregate_method(name, relation, method_name, column)
73
+ if method_name == :size
74
+ define_method(name) do
75
+ (attribute_present?(name) ? self[name] : nil) || send(relation).try(:size) || 0
76
+ end
77
+ else
78
+ define_method(name) do
79
+ (attribute_present?(name) ? self[name] : nil) ||
80
+ begin
81
+ rel = send(relation)
82
+ if rel.loaded?
83
+ rel.blank? ? nil : (rel.map { |t| t.send(column).to_i } || 0).send(method_name)
84
+ else
85
+ # aggregates are not smart enough to handle virtual attributes
86
+ arel_column = rel.klass.arel_attribute(column)
87
+ rel.try(method_name, arel_column) || 0
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+
94
+ def virtual_aggregate_arel(reflection, method_name, column)
95
+ return unless reflection && reflection.macro == :has_many && !reflection.options[:through]
96
+ lambda do |t|
97
+ query = if reflection.scope
98
+ reflection.klass.instance_exec(nil, &reflection.scope)
99
+ else
100
+ reflection.klass.all
101
+ end
102
+
103
+ # ordering will probably screw up aggregations, so clear this out from
104
+ # any calls
105
+ #
106
+ # only clear this out if this isn't a `:size` call as well, since doing
107
+ # a COUNT(*) will allow any ORDER BY to still work properly. This is
108
+ # to avoid any possible edge cases by clearing out the order clause.
109
+ query.order_values = [] if method_name != :size
110
+
111
+ foreign_table = reflection.klass.arel_table
112
+ # need db access for the keys, so delaying all this lookup until call time
113
+ if ActiveRecord.version.to_s >= "5.1"
114
+ join_keys = reflection.join_keys
115
+ else
116
+ join_keys = reflection.join_keys(reflection.klass)
117
+ end
118
+ query = query.where(t[join_keys.foreign_key].eq(foreign_table[join_keys.key]))
119
+
120
+ arel_column = if method_name == :size
121
+ Arel.star.count
122
+ else
123
+ reflection.klass.arel_attribute(column).send(method_name)
124
+ end
125
+ query = query.select(arel_column)
126
+
127
+ t.grouping(Arel::Nodes::SqlLiteral.new(query.to_sql))
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
133
+
134
+ ActiveRecord::Base.send(:include, VirtualAttributes::VirtualTotal)