activeentity 0.0.1.beta1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (74) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +42 -0
  3. data/README.md +145 -0
  4. data/Rakefile +29 -0
  5. data/lib/active_entity.rb +73 -0
  6. data/lib/active_entity/aggregations.rb +276 -0
  7. data/lib/active_entity/associations.rb +146 -0
  8. data/lib/active_entity/associations/embedded/association.rb +134 -0
  9. data/lib/active_entity/associations/embedded/builder/association.rb +100 -0
  10. data/lib/active_entity/associations/embedded/builder/collection_association.rb +69 -0
  11. data/lib/active_entity/associations/embedded/builder/embedded_in.rb +38 -0
  12. data/lib/active_entity/associations/embedded/builder/embeds_many.rb +13 -0
  13. data/lib/active_entity/associations/embedded/builder/embeds_one.rb +16 -0
  14. data/lib/active_entity/associations/embedded/builder/singular_association.rb +28 -0
  15. data/lib/active_entity/associations/embedded/collection_association.rb +188 -0
  16. data/lib/active_entity/associations/embedded/collection_proxy.rb +310 -0
  17. data/lib/active_entity/associations/embedded/embedded_in_association.rb +31 -0
  18. data/lib/active_entity/associations/embedded/embeds_many_association.rb +15 -0
  19. data/lib/active_entity/associations/embedded/embeds_one_association.rb +19 -0
  20. data/lib/active_entity/associations/embedded/singular_association.rb +35 -0
  21. data/lib/active_entity/attribute_assignment.rb +85 -0
  22. data/lib/active_entity/attribute_decorators.rb +90 -0
  23. data/lib/active_entity/attribute_methods.rb +330 -0
  24. data/lib/active_entity/attribute_methods/before_type_cast.rb +78 -0
  25. data/lib/active_entity/attribute_methods/primary_key.rb +98 -0
  26. data/lib/active_entity/attribute_methods/query.rb +35 -0
  27. data/lib/active_entity/attribute_methods/read.rb +47 -0
  28. data/lib/active_entity/attribute_methods/serialization.rb +90 -0
  29. data/lib/active_entity/attribute_methods/time_zone_conversion.rb +91 -0
  30. data/lib/active_entity/attribute_methods/write.rb +63 -0
  31. data/lib/active_entity/attributes.rb +165 -0
  32. data/lib/active_entity/base.rb +303 -0
  33. data/lib/active_entity/coders/json.rb +15 -0
  34. data/lib/active_entity/coders/yaml_column.rb +50 -0
  35. data/lib/active_entity/core.rb +281 -0
  36. data/lib/active_entity/define_callbacks.rb +17 -0
  37. data/lib/active_entity/enum.rb +234 -0
  38. data/lib/active_entity/errors.rb +80 -0
  39. data/lib/active_entity/gem_version.rb +17 -0
  40. data/lib/active_entity/inheritance.rb +278 -0
  41. data/lib/active_entity/integration.rb +78 -0
  42. data/lib/active_entity/locale/en.yml +45 -0
  43. data/lib/active_entity/model_schema.rb +115 -0
  44. data/lib/active_entity/nested_attributes.rb +592 -0
  45. data/lib/active_entity/readonly_attributes.rb +47 -0
  46. data/lib/active_entity/reflection.rb +441 -0
  47. data/lib/active_entity/serialization.rb +25 -0
  48. data/lib/active_entity/store.rb +242 -0
  49. data/lib/active_entity/translation.rb +24 -0
  50. data/lib/active_entity/type.rb +73 -0
  51. data/lib/active_entity/type/date.rb +9 -0
  52. data/lib/active_entity/type/date_time.rb +9 -0
  53. data/lib/active_entity/type/decimal_without_scale.rb +15 -0
  54. data/lib/active_entity/type/hash_lookup_type_map.rb +25 -0
  55. data/lib/active_entity/type/internal/timezone.rb +17 -0
  56. data/lib/active_entity/type/json.rb +30 -0
  57. data/lib/active_entity/type/modifiers/array.rb +72 -0
  58. data/lib/active_entity/type/registry.rb +92 -0
  59. data/lib/active_entity/type/serialized.rb +71 -0
  60. data/lib/active_entity/type/text.rb +11 -0
  61. data/lib/active_entity/type/time.rb +21 -0
  62. data/lib/active_entity/type/type_map.rb +62 -0
  63. data/lib/active_entity/type/unsigned_integer.rb +17 -0
  64. data/lib/active_entity/validate_embedded_association.rb +305 -0
  65. data/lib/active_entity/validations.rb +50 -0
  66. data/lib/active_entity/validations/absence.rb +25 -0
  67. data/lib/active_entity/validations/associated.rb +60 -0
  68. data/lib/active_entity/validations/length.rb +26 -0
  69. data/lib/active_entity/validations/presence.rb +68 -0
  70. data/lib/active_entity/validations/subset.rb +76 -0
  71. data/lib/active_entity/validations/uniqueness_in_embedding.rb +99 -0
  72. data/lib/active_entity/version.rb +10 -0
  73. data/lib/tasks/active_entity_tasks.rake +6 -0
  74. metadata +155 -0
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveEntity
4
+ # = Active Entity Errors
5
+ #
6
+ # Generic Active Entity exception class.
7
+ class ActiveEntityError < StandardError
8
+ end
9
+
10
+ # Raised when the single-table inheritance mechanism fails to locate the subclass
11
+ # (for example due to improper usage of column that
12
+ # {ActiveEntity::Base.inheritance_attribute}[rdoc-ref:ModelSchema::ClassMethods#inheritance_attribute]
13
+ # points to).
14
+ class SubclassNotFound < ActiveEntityError
15
+ end
16
+
17
+ # Raised when an object assigned to an association has an incorrect type.
18
+ #
19
+ # class Ticket < ActiveEntity::Base
20
+ # has_many :patches
21
+ # end
22
+ #
23
+ # class Patch < ActiveEntity::Base
24
+ # belongs_to :ticket
25
+ # end
26
+ #
27
+ # # Comments are not patches, this assignment raises AssociationTypeMismatch.
28
+ # @ticket.patches << Comment.new(content: "Please attach tests to your patch.")
29
+ class AssociationTypeMismatch < ActiveEntityError
30
+ end
31
+
32
+ # Raised when unserialized object's type mismatches one specified for serializable field.
33
+ class SerializationTypeMismatch < ActiveEntityError
34
+ end
35
+
36
+ # Raised when association is being configured improperly or user tries to use
37
+ # offset and limit together with
38
+ # {ActiveEntity::Base.has_many}[rdoc-ref:Associations::ClassMethods#has_many] or
39
+ # {ActiveEntity::Base.has_and_belongs_to_many}[rdoc-ref:Associations::ClassMethods#has_and_belongs_to_many]
40
+ # associations.
41
+ class ConfigurationError < ActiveEntityError
42
+ end
43
+
44
+ # Raised on attempt to update record that is instantiated as read only.
45
+ class ReadOnlyRecord < ActiveEntityError
46
+ end
47
+
48
+ # Raised when attribute has a name reserved by Active Entity (when attribute
49
+ # has name of one of Active Entity instance methods).
50
+ class DangerousAttributeError < ActiveEntityError
51
+ end
52
+
53
+ # Raised when unknown attributes are supplied via mass assignment.
54
+ UnknownAttributeError = ActiveModel::UnknownAttributeError
55
+
56
+ # Raised when an error occurred while doing a mass assignment to an attribute through the
57
+ # {ActiveEntity::Base#attributes=}[rdoc-ref:AttributeAssignment#attributes=] method.
58
+ # The exception has an +attribute+ property that is the name of the offending attribute.
59
+ class AttributeAssignmentError < ActiveEntityError
60
+ attr_reader :exception, :attribute
61
+
62
+ def initialize(message = nil, exception = nil, attribute = nil)
63
+ super(message)
64
+ @exception = exception
65
+ @attribute = attribute
66
+ end
67
+ end
68
+
69
+ # Raised when there are multiple errors while doing a mass assignment through the
70
+ # {ActiveEntity::Base#attributes=}[rdoc-ref:AttributeAssignment#attributes=]
71
+ # method. The exception has an +errors+ property that contains an array of AttributeAssignmentError
72
+ # objects, each corresponding to the error while assigning to an attribute.
73
+ class MultiparameterAssignmentErrors < ActiveEntityError
74
+ attr_reader :errors
75
+
76
+ def initialize(errors = nil)
77
+ @errors = errors
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveEntity
4
+ # Returns the version of the currently loaded Active Entity as a <tt>Gem::Version</tt>
5
+ def self.gem_version
6
+ Gem::Version.new VERSION::STRING
7
+ end
8
+
9
+ module VERSION
10
+ MAJOR = 0
11
+ MINOR = 0
12
+ TINY = 1
13
+ PRE = "beta1"
14
+
15
+ STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".")
16
+ end
17
+ end
@@ -0,0 +1,278 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/hash/indifferent_access"
4
+
5
+ module ActiveEntity
6
+ # == Single table inheritance
7
+ #
8
+ # Active Entity allows inheritance by storing the name of the class in a column that by
9
+ # default is named "type" (can be changed by overwriting <tt>Base.inheritance_attribute</tt>).
10
+ # This means that an inheritance looking like this:
11
+ #
12
+ # class Company < ActiveEntity::Base; end
13
+ # class Firm < Company; end
14
+ # class Client < Company; end
15
+ # class PriorityClient < Client; end
16
+ #
17
+ # When you do <tt>Firm.create(name: "37signals")</tt>, this record will be saved in
18
+ # the companies table with type = "Firm". You can then fetch this row again using
19
+ # <tt>Company.where(name: '37signals').first</tt> and it will return a Firm object.
20
+ #
21
+ # Be aware that because the type column is an attribute on the record every new
22
+ # subclass will instantly be marked as dirty and the type column will be included
23
+ # in the list of changed attributes on the record. This is different from non
24
+ # Single Table Inheritance(STI) classes:
25
+ #
26
+ # Company.new.changed? # => false
27
+ # Firm.new.changed? # => true
28
+ # Firm.new.changes # => {"type"=>["","Firm"]}
29
+ #
30
+ # If you don't have a type column defined in your table, single-table inheritance won't
31
+ # be triggered. In that case, it'll work just like normal subclasses with no special magic
32
+ # for differentiating between them or reloading the right type with find.
33
+ #
34
+ # Note, all the attributes for all the cases are kept in the same table. Read more:
35
+ # https://www.martinfowler.com/eaaCatalog/singleTableInheritance.html
36
+ #
37
+ module Inheritance
38
+ extend ActiveSupport::Concern
39
+
40
+ included do
41
+ # Determines whether to store the full constant name including namespace when using STI.
42
+ # This is true, by default.
43
+ class_attribute :store_full_sti_class, instance_writer: false, default: true
44
+ end
45
+
46
+ module ClassMethods
47
+ # Determines if one of the attributes passed in is the inheritance column,
48
+ # and if the inheritance column is attr accessible, it initializes an
49
+ # instance of the given subclass instead of the base class.
50
+ def new(attributes = nil, &block)
51
+ if abstract_class? || self == Base
52
+ raise NotImplementedError, "#{self} is an abstract class and cannot be instantiated."
53
+ end
54
+
55
+ if has_attribute?(inheritance_attribute)
56
+ subclass = subclass_from_attributes(attributes)
57
+
58
+ if subclass.nil? && base_class?
59
+ subclass = subclass_from_attributes(_default_attributes)
60
+ end
61
+ end
62
+
63
+ if subclass && subclass != self
64
+ subclass.new(attributes, &block)
65
+ else
66
+ super
67
+ end
68
+ end
69
+
70
+ # Returns +true+ if this does not need STI type condition. Returns
71
+ # +false+ if STI type condition needs to be applied.
72
+ def descends_from_active_entity?
73
+ if self == Base
74
+ false
75
+ elsif superclass.abstract_class?
76
+ superclass.descends_from_active_entity?
77
+ else
78
+ superclass == Base || !has_attribute?(inheritance_attribute)
79
+ end
80
+ end
81
+
82
+ def finder_needs_type_condition? #:nodoc:
83
+ # This is like this because benchmarking justifies the strange :false stuff
84
+ :true == (@finder_needs_type_condition ||= descends_from_active_entity? ? :false : :true)
85
+ end
86
+
87
+ # Returns the class descending directly from ActiveEntity::Base, or
88
+ # an abstract class, if any, in the inheritance hierarchy.
89
+ #
90
+ # If A extends ActiveEntity::Base, A.base_class will return A. If B descends from A
91
+ # through some arbitrarily deep hierarchy, B.base_class will return A.
92
+ #
93
+ # If B < A and C < B and if A is an abstract_class then both B.base_class
94
+ # and C.base_class would return B as the answer since A is an abstract_class.
95
+ def base_class
96
+ unless self < Base
97
+ raise ActiveEntityError, "#{name} doesn't belong in a hierarchy descending from Active Entity"
98
+ end
99
+
100
+ if superclass == Base || superclass.abstract_class?
101
+ self
102
+ else
103
+ superclass.base_class
104
+ end
105
+ end
106
+
107
+ # Returns whether the class is a base class.
108
+ # See #base_class for more information.
109
+ def base_class?
110
+ base_class == self
111
+ end
112
+
113
+ # Set this to +true+ if this is an abstract class (see
114
+ # <tt>abstract_class?</tt>).
115
+ # If you are using inheritance with Active Entity and don't want a class
116
+ # to be considered as part of the STI hierarchy, you must set this to
117
+ # true.
118
+ # +ApplicationRecord+, for example, is generated as an abstract class.
119
+ #
120
+ # Consider the following default behaviour:
121
+ #
122
+ # Shape = Class.new(ActiveEntity::Base)
123
+ # Polygon = Class.new(Shape)
124
+ # Square = Class.new(Polygon)
125
+ #
126
+ # Shape.table_name # => "shapes"
127
+ # Polygon.table_name # => "shapes"
128
+ # Square.table_name # => "shapes"
129
+ # Shape.create! # => #<Shape id: 1, type: nil>
130
+ # Polygon.create! # => #<Polygon id: 2, type: "Polygon">
131
+ # Square.create! # => #<Square id: 3, type: "Square">
132
+ #
133
+ # However, when using <tt>abstract_class</tt>, +Shape+ is omitted from
134
+ # the hierarchy:
135
+ #
136
+ # class Shape < ActiveEntity::Base
137
+ # self.abstract_class = true
138
+ # end
139
+ # Polygon = Class.new(Shape)
140
+ # Square = Class.new(Polygon)
141
+ #
142
+ # Shape.table_name # => nil
143
+ # Polygon.table_name # => "polygons"
144
+ # Square.table_name # => "polygons"
145
+ # Shape.create! # => NotImplementedError: Shape is an abstract class and cannot be instantiated.
146
+ # Polygon.create! # => #<Polygon id: 1, type: nil>
147
+ # Square.create! # => #<Square id: 2, type: "Square">
148
+ #
149
+ # Note that in the above example, to disallow the creation of a plain
150
+ # +Polygon+, you should use <tt>validates :type, presence: true</tt>,
151
+ # instead of setting it as an abstract class. This way, +Polygon+ will
152
+ # stay in the hierarchy, and Active Entity will continue to correctly
153
+ # derive the table name.
154
+ attr_accessor :abstract_class
155
+
156
+ # Returns whether this class is an abstract class or not.
157
+ def abstract_class?
158
+ defined?(@abstract_class) && @abstract_class == true
159
+ end
160
+
161
+ def sti_name
162
+ store_full_sti_class ? name : name.demodulize
163
+ end
164
+
165
+ def inherited(subclass)
166
+ subclass.instance_variable_set(:@_type_candidates_cache, Concurrent::Map.new)
167
+ super
168
+ end
169
+
170
+ protected
171
+
172
+ # Returns the class type of the record using the current module as a prefix. So descendants of
173
+ # MyApp::Business::Account would appear as MyApp::Business::AccountSubclass.
174
+ def compute_type(type_name)
175
+ if type_name.start_with?("::")
176
+ # If the type is prefixed with a scope operator then we assume that
177
+ # the type_name is an absolute reference.
178
+ ActiveSupport::Dependencies.constantize(type_name)
179
+ else
180
+ type_candidate = @_type_candidates_cache[type_name]
181
+ if type_candidate && type_constant = ActiveSupport::Dependencies.safe_constantize(type_candidate)
182
+ return type_constant
183
+ end
184
+
185
+ # Build a list of candidates to search for
186
+ candidates = []
187
+ name.scan(/::|$/) { candidates.unshift "#{$`}::#{type_name}" }
188
+ candidates << type_name
189
+
190
+ candidates.each do |candidate|
191
+ constant = ActiveSupport::Dependencies.safe_constantize(candidate)
192
+ if candidate == constant.to_s
193
+ @_type_candidates_cache[type_name] = candidate
194
+ return constant
195
+ end
196
+ end
197
+
198
+ raise NameError.new("uninitialized constant #{candidates.first}", candidates.first)
199
+ end
200
+ end
201
+
202
+ private
203
+
204
+ # Called by +instantiate+ to decide which class to use for a new
205
+ # record instance. For single-table inheritance, we check the record
206
+ # for a +type+ column and return the corresponding class.
207
+ def discriminate_class_for_record(record)
208
+ if using_single_table_inheritance?(record)
209
+ find_sti_class(record[inheritance_attribute])
210
+ else
211
+ super
212
+ end
213
+ end
214
+
215
+ def using_single_table_inheritance?(record)
216
+ record[inheritance_attribute].present? && has_attribute?(inheritance_attribute)
217
+ end
218
+
219
+ def find_sti_class(type_name)
220
+ type_name = base_class.type_for_attribute(inheritance_attribute).cast(type_name)
221
+ subclass = begin
222
+ if store_full_sti_class
223
+ ActiveSupport::Dependencies.constantize(type_name)
224
+ else
225
+ compute_type(type_name)
226
+ end
227
+ rescue NameError
228
+ raise SubclassNotFound,
229
+ "The single-table inheritance mechanism failed to locate the subclass: '#{type_name}'. " \
230
+ "This error is raised because the attribute '#{inheritance_attribute}' is reserved for storing the class in case of inheritance. " \
231
+ "Please rename this attribute if you didn't intend it to be used for storing the inheritance class " \
232
+ "or overwrite #{name}.inheritance_attribute to use another attribute for that information."
233
+ end
234
+ unless subclass == self || descendants.include?(subclass)
235
+ raise SubclassNotFound, "Invalid single-table inheritance type: #{subclass.name} is not a subclass of #{name}"
236
+ end
237
+ subclass
238
+ end
239
+
240
+ # Detect the subclass from the inheritance column of attrs. If the inheritance column value
241
+ # is not self or a valid subclass, raises ActiveEntity::SubclassNotFound
242
+ def subclass_from_attributes(attrs)
243
+ attrs = attrs.to_h if attrs.respond_to?(:permitted?)
244
+ if attrs.is_a?(Hash)
245
+ subclass_name = attrs[inheritance_attribute] || attrs[inheritance_attribute.to_sym]
246
+
247
+ if subclass_name.present?
248
+ find_sti_class(subclass_name)
249
+ end
250
+ end
251
+ end
252
+ end
253
+
254
+ def initialize_dup(other)
255
+ super
256
+ ensure_proper_type
257
+ end
258
+
259
+ private
260
+
261
+ def initialize_internals_callback
262
+ super
263
+ ensure_proper_type
264
+ end
265
+
266
+ # Sets the attribute used for single table inheritance to this class name if this is not the
267
+ # ActiveEntity::Base descendant.
268
+ # Considering the hierarchy Reply < Message < ActiveEntity::Base, this makes it possible to
269
+ # do Reply.new without having to set <tt>Reply[Reply.inheritance_attribute] = "Reply"</tt> yourself.
270
+ # No such attribute would be set for objects of the Message class in that example.
271
+ def ensure_proper_type
272
+ klass = self.class
273
+ if klass.finder_needs_type_condition?
274
+ _write_attribute(klass.inheritance_attribute, klass.sti_name)
275
+ end
276
+ end
277
+ end
278
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/string/filters"
4
+
5
+ module ActiveEntity
6
+ module Integration
7
+ extend ActiveSupport::Concern
8
+
9
+ # Returns a +String+, which Action Pack uses for constructing a URL to this
10
+ # object. The default implementation returns this record's id as a +String+,
11
+ # or +nil+ if this record's unsaved.
12
+ #
13
+ # For example, suppose that you have a User model, and that you have a
14
+ # <tt>resources :users</tt> route. Normally, +user_path+ will
15
+ # construct a path with the user object's 'id' in it:
16
+ #
17
+ # user = User.find_by(name: 'Phusion')
18
+ # user_path(user) # => "/users/1"
19
+ #
20
+ # You can override +to_param+ in your model to make +user_path+ construct
21
+ # a path using the user's name instead of the user's id:
22
+ #
23
+ # class User < ActiveEntity::Base
24
+ # def to_param # overridden
25
+ # name
26
+ # end
27
+ # end
28
+ #
29
+ # user = User.find_by(name: 'Phusion')
30
+ # user_path(user) # => "/users/Phusion"
31
+ def to_param
32
+ # We can't use alias_method here, because method 'id' optimizes itself on the fly.
33
+ id && id.to_s # Be sure to stringify the id for routes
34
+ end
35
+
36
+ module ClassMethods
37
+ # Defines your model's +to_param+ method to generate "pretty" URLs
38
+ # using +method_name+, which can be any attribute or method that
39
+ # responds to +to_s+.
40
+ #
41
+ # class User < ActiveEntity::Base
42
+ # to_param :name
43
+ # end
44
+ #
45
+ # user = User.find_by(name: 'Fancy Pants')
46
+ # user.id # => 123
47
+ # user_path(user) # => "/users/123-fancy-pants"
48
+ #
49
+ # Values longer than 20 characters will be truncated. The value
50
+ # is truncated word by word.
51
+ #
52
+ # user = User.find_by(name: 'David Heinemeier Hansson')
53
+ # user.id # => 125
54
+ # user_path(user) # => "/users/125-david-heinemeier"
55
+ #
56
+ # Because the generated param begins with the record's +id+, it is
57
+ # suitable for passing to +find+. In a controller, for example:
58
+ #
59
+ # params[:id] # => "123-fancy-pants"
60
+ # User.find(params[:id]).id # => 123
61
+ def to_param(method_name = nil)
62
+ if method_name.nil?
63
+ super()
64
+ else
65
+ define_method :to_param do
66
+ if (default = super()) &&
67
+ (result = send(method_name).to_s).present? &&
68
+ (param = result.squish.parameterize.truncate(20, separator: /-/, omission: "")).present?
69
+ "#{default}-#{param}"
70
+ else
71
+ default
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end