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,10 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "activerecord", "~> 5.1.6"
6
+ gem "sqlite3", "~> 1.3.6"
7
+ gem "pg"
8
+ gem "mysql2"
9
+
10
+ gemspec path: "../"
@@ -0,0 +1,10 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "activerecord", "~> 5.2.0"
6
+ gem "sqlite3"
7
+ gem "pg"
8
+ gem "mysql2"
9
+
10
+ gemspec path: "../"
data/init.rb ADDED
@@ -0,0 +1 @@
1
+ require 'virtual_attributes'
@@ -0,0 +1 @@
1
+ require "active_record/virtual_attributes"
@@ -0,0 +1,143 @@
1
+ require "active_support/concern"
2
+ require "active_record"
3
+
4
+ require "active_record/virtual_attributes/virtual_includes"
5
+ require "active_record/virtual_attributes/virtual_arel"
6
+ require "active_record/virtual_attributes/virtual_delegates"
7
+
8
+ module ActiveRecord
9
+ module VirtualAttributes
10
+ extend ActiveSupport::Concern
11
+ include ActiveRecord::VirtualAttributes::VirtualIncludes
12
+ include ActiveRecord::VirtualAttributes::VirtualArel
13
+ include ActiveRecord::VirtualAttributes::VirtualDelegates
14
+
15
+ module Type
16
+ # TODO: do we actually need symbol types?
17
+ class Symbol < ActiveRecord::Type::String
18
+ def type; :symbol; end
19
+ end
20
+
21
+ class StringSet < ActiveRecord::Type::Value
22
+ def type; :string_set; end
23
+ end
24
+
25
+ class NumericSet < ActiveRecord::Type::Value
26
+ def type; :numeric_set; end
27
+ end
28
+ end
29
+
30
+ ActiveRecord::Type.register(:numeric_set, Type::NumericSet)
31
+ ActiveRecord::Type.register(:string_set, Type::StringSet)
32
+ ActiveRecord::Type.register(:symbol, Type::Symbol)
33
+
34
+ included do
35
+ class_attribute :virtual_attributes_to_define, :instance_accessor => false
36
+ self.virtual_attributes_to_define = {}
37
+ end
38
+
39
+ module ClassMethods
40
+
41
+ #
42
+ # Definition
43
+ #
44
+
45
+ # Compatibility method: `virtual_attribute` is a more accurate name
46
+ def virtual_column(name, type_or_options, **options)
47
+ if type_or_options.kind_of?(Hash)
48
+ options = options.merge(type_or_options)
49
+ type = options.delete(:type)
50
+ else
51
+ type = type_or_options
52
+ end
53
+
54
+ virtual_attribute(name, type, **options)
55
+ end
56
+
57
+ def virtual_attribute(name, type, **options)
58
+ name = name.to_s
59
+ reload_schema_from_cache
60
+
61
+ self.virtual_attributes_to_define =
62
+ virtual_attributes_to_define.merge(name => [type, options])
63
+ end
64
+
65
+ #
66
+ # Introspection
67
+ #
68
+
69
+ def virtual_attribute?(name)
70
+ load_schema
71
+ has_attribute?(name) && (
72
+ !respond_to?(:column_for_attribute) ||
73
+ column_for_attribute(name).kind_of?(ActiveRecord::ConnectionAdapters::NullColumn)
74
+ )
75
+ end
76
+
77
+ def virtual_attribute_names
78
+ if respond_to?(:column_names)
79
+ attribute_names - column_names
80
+ else
81
+ attribute_names
82
+ end
83
+ end
84
+
85
+ def attributes_builder # :nodoc:
86
+ unless defined?(@attributes_builder) && @attributes_builder
87
+ defaults = _default_attributes.except(*(column_names - [primary_key]))
88
+ # change necessary for rails 5.0 and 5.1 - (changed/introduced in https://github.com/rails/rails/pull/31894)
89
+ defaults = defaults.except(*virtual_attribute_names)
90
+ # end change
91
+ @attributes_builder = ActiveRecord::AttributeSet::Builder.new(attribute_types, defaults)
92
+ end
93
+ @attributes_builder
94
+ end
95
+
96
+ private
97
+
98
+ def load_schema!
99
+ super
100
+
101
+ virtual_attributes_to_define.each do |name, (type, options)|
102
+ type = type.call if type.respond_to?(:call)
103
+ type = ActiveRecord::Type.lookup(type, **options.except(:uses, :arel)) if type.kind_of?(Symbol)
104
+
105
+ define_virtual_attribute(name, type, **options.slice(:uses, :arel))
106
+ end
107
+
108
+ virtual_delegates_to_define.each do |method_name, (method, options)|
109
+ define_virtual_delegate(method_name, method, options)
110
+ end
111
+ end
112
+
113
+ def define_virtual_attribute(name, cast_type, uses: nil, arel: nil)
114
+ attribute_types[name] = cast_type
115
+ define_virtual_include(name, uses) if uses
116
+ define_virtual_arel(name, arel) if arel
117
+ end
118
+ end
119
+ end
120
+ end
121
+ require "active_record/virtual_attributes/virtual_reflections"
122
+ require "active_record/virtual_attributes/virtual_fields"
123
+
124
+ #
125
+ # Class extensions
126
+ #
127
+
128
+ # this patch is no longer necessary for 5.2
129
+ if ActiveRecord.version.to_s < "5.2"
130
+ require "active_record/attribute"
131
+ module ActiveRecord
132
+ # This is a bug in rails 5.0 and 5.1, but it is made much worse by virtual attributes
133
+ class Attribute
134
+ def with_value_from_database(value)
135
+ # self.class.from_database(name, value, type)
136
+ initialized? ? self.class.from_database(name, value, type) : self
137
+ end
138
+ end
139
+ end
140
+ end
141
+
142
+ require "active_record/virtual_attributes/virtual_total"
143
+ require "active_record/virtual_attributes/arel_groups"
@@ -0,0 +1,14 @@
1
+ # this is from https://github.com/rails/arel/pull/435
2
+ # this allows sorting and where clauses to work with virtual_attribute columns
3
+ if defined?(Arel::Nodes::Grouping)
4
+ module Arel
5
+ module Nodes
6
+ class Grouping
7
+ include Arel::Expressions
8
+ include Arel::AliasPredication
9
+ include Arel::OrderPredications
10
+ include Arel::Math
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1 @@
1
+ require "active_record/virtual_attributes/rspec/have_virtual_attribute"
@@ -0,0 +1,44 @@
1
+ # TODO: expose this to classes that include this gem
2
+
3
+ # legacy matcher
4
+ RSpec::Matchers.define :have_virtual_column do |name, type|
5
+ match do |klass|
6
+ expect(klass.has_attribute?(name)).to be_truthy
7
+ expect(klass.virtual_attribute?(name)).to be_truthy
8
+ expect(klass.type_for_attribute(name).type).to eq(type)
9
+ klass.instance_methods.include?(name.to_sym)
10
+ end
11
+
12
+ failure_message do |klass|
13
+ "expected #{klass.name} to have virtual column #{name.inspect} with type #{type.inspect}"
14
+ end
15
+
16
+ failure_message_when_negated do |klass|
17
+ "expected #{klass.name} to not have virtual column #{name.inspect} with type #{type.inspect}"
18
+ end
19
+
20
+ description do
21
+ "expect the object to have the virtual column"
22
+ end
23
+ end
24
+
25
+ RSpec::Matchers.define :have_virtual_attribute do |name, type|
26
+ match do |klass|
27
+ expect(klass.has_attribute?(name)).to be_truthy
28
+ expect(klass.virtual_attribute?(name)).to be_truthy
29
+ expect(klass.type_for_attribute(name).type).to eq(type)
30
+ klass.instance_methods.include?(name.to_sym)
31
+ end
32
+
33
+ failure_message do |klass|
34
+ "expected #{klass.name} to have virtual column #{name.inspect} with type #{type.inspect}"
35
+ end
36
+
37
+ failure_message_when_negated do |klass|
38
+ "expected #{klass.name} to not have virtual column #{name.inspect} with type #{type.inspect}"
39
+ end
40
+
41
+ description do
42
+ "expect the object to have the virtual column"
43
+ end
44
+ end
@@ -0,0 +1,5 @@
1
+ module ActiveRecord
2
+ module VirtualAttributes
3
+ VERSION = "1.0.0"
4
+ end
5
+ end
@@ -0,0 +1,48 @@
1
+ module ActiveRecord
2
+ module VirtualAttributes
3
+ # VirtualArel associates arel with an attribute
4
+ #
5
+ # Model.virtual_attribute :field, :string, :arel => -> (t) { t.grouping(t[:field2]) } }
6
+ # Model.select(:field)
7
+ #
8
+ # is equivalent to:
9
+ #
10
+ # Model.select(Model.arel_table.grouping(Model.arel_table[:field2]).as(:field))
11
+ # Model.attribute_supported_by_sql?(:field) # => true
12
+ module VirtualArel
13
+ extend ActiveSupport::Concern
14
+
15
+ included do
16
+ class_attribute :_virtual_arel, :instance_accessor => false
17
+ self._virtual_arel = {}
18
+ end
19
+
20
+ module ClassMethods
21
+ def arel_attribute(column_name, arel_table = self.arel_table)
22
+ load_schema
23
+ if virtual_attribute?(column_name) && !attribute_alias?(column_name)
24
+ col = _virtual_arel[column_name.to_s]
25
+ col.call(arel_table) if col
26
+ else
27
+ super
28
+ end
29
+ end
30
+
31
+ # supported by sql if
32
+ # - it is an attribute alias
33
+ # - it is an attribute that is non virtual
34
+ # - it is an attribute that is virtual and has arel defined
35
+ def attribute_supported_by_sql?(name)
36
+ load_schema
37
+ try(:attribute_alias?, name) ||
38
+ (has_attribute?(name) && (!virtual_attribute?(name) || !!_virtual_arel[name.to_s]))
39
+ end
40
+ private
41
+
42
+ def define_virtual_arel(name, arel)
43
+ self._virtual_arel = _virtual_arel.merge(name => arel)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,276 @@
1
+ module ActiveRecord
2
+ module VirtualAttributes
3
+ # VirtualDelegate is the same as delegate, but adds sql support, and a default when a value is not found
4
+ #
5
+ # Model.belongs_to :association
6
+ # Model.virtual_delegate :field1, :field2, to: :association
7
+ #
8
+ # Model.select(:field1) # now works
9
+ module VirtualDelegates
10
+ extend ActiveSupport::Concern
11
+
12
+ included do
13
+ class_attribute :virtual_delegates_to_define, :instance_accessor => false
14
+ self.virtual_delegates_to_define = {}
15
+ end
16
+
17
+ module ClassMethods
18
+
19
+ #
20
+ # Definition
21
+ #
22
+
23
+ def virtual_delegate(*methods)
24
+ options = methods.extract_options!
25
+ unless (to = options[:to])
26
+ raise ArgumentError, 'Delegation needs an association. Supply an options hash with a :to key as the last argument (e.g. delegate :hello, to: :greeter).'
27
+ end
28
+
29
+ to = to.to_s
30
+ if to.include?(".") && methods.size > 1
31
+ raise ArgumentError, 'Delegation only supports specifying a method name when defining a single virtual method'
32
+ end
33
+
34
+ if to.count(".") > 1
35
+ raise ArgumentError, 'Delegation needs a single association. Supply an option hash with a :to key with only 1 period (e.g. delegate :hello, to: "greeter.greeting")'
36
+ end
37
+
38
+ allow_nil = options[:allow_nil]
39
+ default = options[:default]
40
+
41
+ # put method entry per method name.
42
+ # This better supports reloading of the class and changing the definitions
43
+ methods.each do |method|
44
+ method_prefix = virtual_delegate_name_prefix(options[:prefix], to)
45
+ method_name = "#{method_prefix}#{method}"
46
+ if to.include?(".") # to => "target.method"
47
+ to, method = to.split(".")
48
+ options[:to] = to
49
+ end
50
+
51
+ define_delegate(method_name, method, :to => to, :allow_nil => allow_nil, :default => default)
52
+
53
+ self.virtual_delegates_to_define =
54
+ virtual_delegates_to_define.merge(method_name => [method, options])
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ # define virtual_attribute for delegates
61
+ #
62
+ # this is called at schema load time (and not at class definition time)
63
+ #
64
+ # @param method_name [Symbol] name of the attribute on the source class to be defined
65
+ # @param col [Symbol] name of the attribute on the associated class to be referenced
66
+ # @option options :to [Symbol] name of the association from the source class to be referenced
67
+ # @option options :arel [Proc] (optional and not common)
68
+ # @option options :uses [Array|Symbol|Hash] sql includes hash. (default: to)
69
+ def define_virtual_delegate(method_name, col, options)
70
+ unless (to = options[:to]) && (to_ref = reflection_with_virtual(to.to_s))
71
+ raise ArgumentError, 'Delegation needs an association. Supply an options hash with a :to key as the last argument (e.g. delegate :hello, to: :greeter).'
72
+ end
73
+
74
+ col = col.to_s
75
+ type = to_ref.klass.type_for_attribute(col)
76
+ raise "unknown attribute #{to}##{col} referenced in #{name}" unless type
77
+ arel = virtual_delegate_arel(col, to_ref)
78
+ define_virtual_attribute(method_name, type, :uses => (options[:uses] || to), :arel => arel)
79
+ end
80
+
81
+ # see activesupport module/delegation.rb
82
+ def define_delegate(method_name, method, to: nil, allow_nil: nil, default: nil)
83
+ location = caller_locations(2, 1).first
84
+ file, line = location.path, location.lineno
85
+
86
+ # Attribute writer methods only accept one argument. Makes sure []=
87
+ # methods still accept two arguments.
88
+ definition = (method =~ /[^\]]=$/) ? 'arg' : '*args, &block'
89
+ default = default ? " || #{default.inspect}" : nil
90
+ # The following generated method calls the target exactly once, storing
91
+ # the returned value in a dummy variable.
92
+ #
93
+ # Reason is twofold: On one hand doing less calls is in general better.
94
+ # On the other hand it could be that the target has side-effects,
95
+ # whereas conceptually, from the user point of view, the delegator should
96
+ # be doing one call.
97
+ if allow_nil
98
+ method_def = <<-METHOD
99
+ def #{method_name}(#{definition})
100
+ return self[:#{method_name}]#{default} if has_attribute?(:#{method_name})
101
+ _ = #{to}
102
+ if !_.nil? || nil.respond_to?(:#{method})
103
+ _.#{method}(#{definition})
104
+ end#{default}
105
+ end
106
+ METHOD
107
+ else
108
+ exception = %(raise Module::DelegationError, "#{self}##{method_name} delegated to #{to}.#{method}, but #{to} is nil: \#{self.inspect}")
109
+
110
+ method_def = <<-METHOD
111
+ def #{method_name}(#{definition})
112
+ return self[:#{method_name}]#{default} if has_attribute?(:#{method_name})
113
+ _ = #{to}
114
+ _.#{method}(#{definition})#{default}
115
+ rescue NoMethodError => e
116
+ if _.nil? && e.name == :#{method}
117
+ #{exception}
118
+ else
119
+ raise
120
+ end
121
+ end
122
+ METHOD
123
+ end
124
+ method_def = method_def.split("\n").map(&:strip).join(';')
125
+ module_eval(method_def, file, line)
126
+ end
127
+
128
+ def virtual_delegate_name_prefix(prefix, to)
129
+ "#{prefix == true ? to : prefix}_" if prefix
130
+ end
131
+
132
+ # @param col [String] attribute name
133
+ # @param to_ref [Association] association from source class to target association
134
+ # @return [Proc] lambda to return arel that selects the attribute in a sub-query
135
+ # @return [Nil] if the attribute (col) can not be represented in sql.
136
+ #
137
+ # To generate a proc, the following cases must happen:
138
+ # - the column has sql (virtual_column with arel OR real sql attribute)
139
+ # - the association has sql representation (a real association has sql)
140
+ # - the association is to a single record (has_one or belongs_to)
141
+ #
142
+ # See select_from_alias for examples
143
+
144
+ def virtual_delegate_arel(col, to_ref)
145
+ # ensure the column has sql and the association is reachable via sql
146
+ # There is currently no way to propagate sql over a virtual association
147
+ if to_ref.klass.arel_attribute(col) && reflect_on_association(to_ref.name)
148
+ if to_ref.macro == :has_one || to_ref.macro == :belongs_to
149
+ blk = ->(arel) { arel.limit = 1 } if to_ref.macro == :has_one
150
+ lambda do |t|
151
+ if ActiveRecord.version.to_s >= "5.1"
152
+ join_keys = to_ref.join_keys
153
+ else
154
+ join_keys = to_ref.join_keys(to_ref.klass)
155
+ end
156
+ src_model_id = arel_attribute(join_keys.foreign_key, t)
157
+ VirtualDelegates.select_from_alias(to_ref, col, join_keys.key, src_model_id, &blk)
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
163
+
164
+ # select_from_alias: helper method for virtual_delegate_arel to construct the sql
165
+ # see also virtual_delegate_arel
166
+ #
167
+ # @param to_ref [Association] association from source class to target association
168
+ # @param col [String] attribute name
169
+ # @param to_model_col_name [String]
170
+ # @param src_model_id [Arel::Attribute]
171
+ # @return [Arel::Node] Arel representing the sql for this join
172
+ #
173
+ # example
174
+ #
175
+ # for the given belongs_to class definition:
176
+ #
177
+ # class Vm
178
+ # belongs_to :hosts #, :foreign_key => :host_id, :primary_key => :id
179
+ # virtual_delegate :name, :to => :host, :prefix => true, :allow_nil => true
180
+ # end
181
+ #
182
+ # The virtual_delegate calls:
183
+ #
184
+ # virtual_delegate_arel("name", Vm.reflection_with_virtual(:host))
185
+ #
186
+ # which calls:
187
+ #
188
+ # select_from_alias(Vm.reflection_with_virtual(:host), "name", "id", Vm.arel_table[:host_id])
189
+ #
190
+ # which produces the sql:
191
+ #
192
+ # SELECT to_model[col] from to_model where to_model[to_model_col_name] = src_model_table[:src_model_id]
193
+ # (SELECT "hosts"."name" FROM "hosts" WHERE "hosts"."id" = "vms"."host_id")
194
+ #
195
+ # ----
196
+ #
197
+ # for the given has_one class definition
198
+ #
199
+ # class Host
200
+ # has_one :hardware
201
+ # virtual_delegate :name, :to => :hardware, :prefix => true, :allow_nil => true
202
+ # end
203
+ #
204
+ # The virtual_delegate calls:
205
+ #
206
+ # virtual_delegate_arel("name", Host.reflection_with_virtual(:hardware))
207
+ #
208
+ # which at runtime will call select_from_alias:
209
+ #
210
+ # select_from_alias(Host.reflection_with_virtual(:hardware), "name", "host_id", Host.arel_table[:id])
211
+ #
212
+ # which produces the sql (ala arel):
213
+ #
214
+ # #select to_model[col] from to_model where to_model[to_model_col_name] = src_model_table[:src_model_id]
215
+ # (SELECT "hardwares"."name" FROM "hardwares" WHERE "hardwares"."host_id" = "hosts"."id")
216
+ #
217
+ # ----
218
+ #
219
+ # for the given self join class definition:
220
+ #
221
+ # class Vm
222
+ # belongs_to :src_template, :class => Vm
223
+ # virtual_delegate :name, :to => :src_template, :prefix => true, :allow_nil => true
224
+ # end
225
+ #
226
+ # The virtual_delegate calls:
227
+ #
228
+ # virtual_delegate_arel("name", Vm.reflection_with_virtual(:src_template))
229
+ #
230
+ # which calls:
231
+ #
232
+ # select_from_alias(Vm.reflection_with_virtual(:src_template), "name", "src_template_id", Vm.arel_table[:id])
233
+ #
234
+ # which produces the sql:
235
+ #
236
+ # #select to_model[col] from to_model where to_model[to_model_col_name] = src_model_table[:src_model_id]
237
+ # (SELECT "vms_sub"."name" FROM "vms" AS "vms_ss" WHERE "vms_ss"."id" = "vms"."src_template_id")
238
+ #
239
+
240
+ def self.select_from_alias(to_ref, col, to_model_col_name, src_model_id)
241
+ query = if to_ref.scope
242
+ to_ref.klass.instance_exec(nil, &to_ref.scope)
243
+ else
244
+ to_ref.klass.all
245
+ end
246
+
247
+ to_table = select_from_alias_table(to_ref.klass, src_model_id.relation)
248
+ to_model_id = to_ref.klass.arel_attribute(to_model_col_name, to_table)
249
+ to_column = to_ref.klass.arel_attribute(col, to_table)
250
+ arel = query.except(:select).select(to_column).arel
251
+ .from(to_table)
252
+ .where(to_model_id.eq(src_model_id))
253
+
254
+ yield arel if block_given?
255
+
256
+ Arel.sql("(#{arel.to_sql})")
257
+ end
258
+
259
+ # determine table reference to use for a sub query
260
+ #
261
+ # typically to_table is just the table used for the to_ref
262
+ # but if it is a self join, then it will also have an alias
263
+ def self.select_from_alias_table(to_klass, src_relation)
264
+ to_table = to_klass.arel_table
265
+ # if a self join, alias the second table to a different name
266
+ if to_table.table_name == src_relation.table_name
267
+ # use a dup to not modify the primary table in the model
268
+ to_table = to_table.dup
269
+ # use a table alias to not conflict with table name in the primary query
270
+ to_table.table_alias = "#{to_table.table_name}_sub"
271
+ end
272
+ to_table
273
+ end
274
+ end
275
+ end
276
+ end