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