activeentity 0.0.1.beta1

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