activerecord-virtual_attributes 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.
@@ -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)