duck_record 0.0.1

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 (41) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +59 -0
  4. data/Rakefile +29 -0
  5. data/lib/duck_record/attribute/user_provided_default.rb +30 -0
  6. data/lib/duck_record/attribute.rb +221 -0
  7. data/lib/duck_record/attribute_assignment.rb +91 -0
  8. data/lib/duck_record/attribute_methods/before_type_cast.rb +76 -0
  9. data/lib/duck_record/attribute_methods/dirty.rb +124 -0
  10. data/lib/duck_record/attribute_methods/read.rb +78 -0
  11. data/lib/duck_record/attribute_methods/write.rb +65 -0
  12. data/lib/duck_record/attribute_methods.rb +332 -0
  13. data/lib/duck_record/attribute_mutation_tracker.rb +113 -0
  14. data/lib/duck_record/attribute_set/builder.rb +124 -0
  15. data/lib/duck_record/attribute_set/yaml_encoder.rb +41 -0
  16. data/lib/duck_record/attribute_set.rb +99 -0
  17. data/lib/duck_record/attributes.rb +262 -0
  18. data/lib/duck_record/base.rb +296 -0
  19. data/lib/duck_record/callbacks.rb +324 -0
  20. data/lib/duck_record/core.rb +253 -0
  21. data/lib/duck_record/define_callbacks.rb +23 -0
  22. data/lib/duck_record/errors.rb +44 -0
  23. data/lib/duck_record/inheritance.rb +130 -0
  24. data/lib/duck_record/locale/en.yml +48 -0
  25. data/lib/duck_record/model_schema.rb +64 -0
  26. data/lib/duck_record/serialization.rb +19 -0
  27. data/lib/duck_record/translation.rb +22 -0
  28. data/lib/duck_record/type/array.rb +36 -0
  29. data/lib/duck_record/type/decimal_without_scale.rb +13 -0
  30. data/lib/duck_record/type/internal/abstract_json.rb +33 -0
  31. data/lib/duck_record/type/json.rb +6 -0
  32. data/lib/duck_record/type/registry.rb +97 -0
  33. data/lib/duck_record/type/serialized.rb +63 -0
  34. data/lib/duck_record/type/text.rb +9 -0
  35. data/lib/duck_record/type/unsigned_integer.rb +15 -0
  36. data/lib/duck_record/type.rb +66 -0
  37. data/lib/duck_record/validations.rb +40 -0
  38. data/lib/duck_record/version.rb +3 -0
  39. data/lib/duck_record.rb +47 -0
  40. data/lib/tasks/acts_as_record_tasks.rake +4 -0
  41. metadata +126 -0
@@ -0,0 +1,253 @@
1
+ require 'thread'
2
+ require 'active_support/core_ext/hash/indifferent_access'
3
+ require 'active_support/core_ext/object/duplicable'
4
+ require 'active_support/core_ext/string/filters'
5
+
6
+ module DuckRecord
7
+ module Core
8
+ extend ActiveSupport::Concern
9
+
10
+ module ClassMethods
11
+ def allocate
12
+ define_attribute_methods
13
+ super
14
+ end
15
+
16
+ def initialize_find_by_cache # :nodoc:
17
+ @find_by_statement_cache = { true => {}.extend(Mutex_m), false => {}.extend(Mutex_m) }
18
+ end
19
+
20
+ def inherited(child_class) # :nodoc:
21
+ # initialize cache at class definition for thread safety
22
+ child_class.initialize_find_by_cache
23
+ super
24
+ end
25
+
26
+ def initialize_generated_modules # :nodoc:
27
+ generated_association_methods
28
+ end
29
+
30
+ def generated_association_methods
31
+ @generated_association_methods ||= begin
32
+ mod = const_set(:GeneratedAssociationMethods, Module.new)
33
+ private_constant :GeneratedAssociationMethods
34
+ include mod
35
+
36
+ mod
37
+ end
38
+ end
39
+
40
+ # Returns a string like 'Post(id:integer, title:string, body:text)'
41
+ def inspect
42
+ if abstract_class?
43
+ "#{super}(abstract)"
44
+ else
45
+ super
46
+ end
47
+ end
48
+ end
49
+
50
+ # New objects can be instantiated as either empty (pass no construction parameter) or pre-set with
51
+ # attributes but not yet saved (pass a hash with key names matching the associated table column names).
52
+ # In both instances, valid attribute keys are determined by the column names of the associated table --
53
+ # hence you can't have attributes that aren't part of the table columns.
54
+ #
55
+ # ==== Example:
56
+ # # Instantiates a single new object
57
+ # User.new(first_name: 'Jamie')
58
+ def initialize(attributes = nil)
59
+ self.class.define_attribute_methods
60
+ @attributes = self.class._default_attributes.deep_dup
61
+
62
+ init_internals
63
+ initialize_internals_callback
64
+
65
+ assign_attributes(attributes) if attributes
66
+
67
+ yield self if block_given?
68
+ _run_initialize_callbacks
69
+ end
70
+
71
+ # Initialize an empty model object from +coder+. +coder+ should be
72
+ # the result of previously encoding an Active Record model, using
73
+ # #encode_with.
74
+ #
75
+ # class Post < DuckRecord::Base
76
+ # end
77
+ #
78
+ # old_post = Post.new(title: "hello world")
79
+ # coder = {}
80
+ # old_post.encode_with(coder)
81
+ #
82
+ # post = Post.allocate
83
+ # post.init_with(coder)
84
+ # post.title # => 'hello world'
85
+ def init_with(coder)
86
+ coder = LegacyYamlAdapter.convert(self.class, coder)
87
+ @attributes = self.class.yaml_encoder.decode(coder)
88
+
89
+ init_internals
90
+
91
+ self.class.define_attribute_methods
92
+
93
+ yield self if block_given?
94
+
95
+ _run_find_callbacks
96
+ _run_initialize_callbacks
97
+
98
+ self
99
+ end
100
+
101
+ ##
102
+ # :method: clone
103
+ # Identical to Ruby's clone method. This is a "shallow" copy. Be warned that your attributes are not copied.
104
+ # That means that modifying attributes of the clone will modify the original, since they will both point to the
105
+ # same attributes hash. If you need a copy of your attributes hash, please use the #dup method.
106
+ #
107
+ # user = User.first
108
+ # new_user = user.clone
109
+ # user.name # => "Bob"
110
+ # new_user.name = "Joe"
111
+ # user.name # => "Joe"
112
+ #
113
+ # user.object_id == new_user.object_id # => false
114
+ # user.name.object_id == new_user.name.object_id # => true
115
+ #
116
+ # user.name.object_id == user.dup.name.object_id # => false
117
+
118
+ ##
119
+ # :method: dup
120
+ # Duped objects have no id assigned and are treated as new records. Note
121
+ # that this is a "shallow" copy as it copies the object's attributes
122
+ # only, not its associations. The extent of a "deep" copy is application
123
+ # specific and is therefore left to the application to implement according
124
+ # to its need.
125
+ # The dup method does not preserve the timestamps (created|updated)_(at|on).
126
+
127
+ ##
128
+ def initialize_dup(other) # :nodoc:
129
+ @attributes = @attributes.deep_dup
130
+
131
+ _run_initialize_callbacks
132
+
133
+ super
134
+ end
135
+
136
+ # Populate +coder+ with attributes about this record that should be
137
+ # serialized. The structure of +coder+ defined in this method is
138
+ # guaranteed to match the structure of +coder+ passed to the #init_with
139
+ # method.
140
+ #
141
+ # Example:
142
+ #
143
+ # class Post < DuckRecord::Base
144
+ # end
145
+ # coder = {}
146
+ # Post.new.encode_with(coder)
147
+ # coder # => {"attributes" => {"id" => nil, ... }}
148
+ def encode_with(coder)
149
+ self.class.yaml_encoder.encode(@attributes, coder)
150
+ coder['duck_record_yaml_version'] = 2
151
+ end
152
+
153
+ # Clone and freeze the attributes hash such that associations are still
154
+ # accessible, even on destroyed records, but cloned models will not be
155
+ # frozen.
156
+ def freeze
157
+ @attributes = @attributes.clone.freeze
158
+ self
159
+ end
160
+
161
+ # Returns +true+ if the attributes hash has been frozen.
162
+ def frozen?
163
+ @attributes.frozen?
164
+ end
165
+
166
+ # Returns +true+ if the record is read only. Records loaded through joins with piggy-back
167
+ # attributes will be marked as read only since they cannot be saved.
168
+ def readonly?
169
+ @readonly
170
+ end
171
+
172
+ # Marks this record as read only.
173
+ def readonly!
174
+ @readonly = true
175
+ end
176
+
177
+ # Returns the contents of the record as a nicely formatted string.
178
+ def inspect
179
+ # We check defined?(@attributes) not to issue warnings if the object is
180
+ # allocated but not initialized.
181
+ inspection = if defined?(@attributes) && @attributes
182
+ self.class.attribute_names.collect do |name|
183
+ if has_attribute?(name)
184
+ "#{name}: #{attribute_for_inspect(name)}"
185
+ end
186
+ end.compact.join(', ')
187
+ else
188
+ 'not initialized'
189
+ end
190
+
191
+ "#<#{self.class} #{inspection}>"
192
+ end
193
+
194
+ # Takes a PP and prettily prints this record to it, allowing you to get a nice result from <tt>pp record</tt>
195
+ # when pp is required.
196
+ def pretty_print(pp)
197
+ return super if custom_inspect_method_defined?
198
+ pp.object_address_group(self) do
199
+ if defined?(@attributes) && @attributes
200
+ pp.seplist(self.class.attribute_names, proc { pp.text ',' }) do |attribute_name|
201
+ attribute_value = read_attribute(attribute_name)
202
+ pp.breakable " "
203
+ pp.group(1) do
204
+ pp.text attribute_name
205
+ pp.text ":"
206
+ pp.breakable
207
+ pp.pp attribute_value
208
+ end
209
+ end
210
+ else
211
+ pp.breakable " "
212
+ pp.text "not initialized"
213
+ end
214
+ end
215
+ end
216
+
217
+ # Returns a hash of the given methods with their names as keys and returned values as values.
218
+ def slice(*methods)
219
+ Hash[methods.flatten.map! { |method| [method, public_send(method)] }].with_indifferent_access
220
+ end
221
+
222
+ private
223
+
224
+ # +Array#flatten+ will call +#to_ary+ (recursively) on each of the elements of
225
+ # the array, and then rescues from the possible +NoMethodError+. If those elements are
226
+ # +DuckRecord::Base+'s, then this triggers the various +method_missing+'s that we have,
227
+ # which significantly impacts upon performance.
228
+ #
229
+ # So we can avoid the +method_missing+ hit by explicitly defining +#to_ary+ as +nil+ here.
230
+ #
231
+ # See also http://tenderlovemaking.com/2011/06/28/til-its-ok-to-return-nil-from-to_ary.html
232
+ def to_ary
233
+ nil
234
+ end
235
+
236
+ def init_internals
237
+ @readonly = false
238
+ end
239
+
240
+ def initialize_internals_callback
241
+ end
242
+
243
+ def thaw
244
+ if frozen?
245
+ @attributes = @attributes.dup
246
+ end
247
+ end
248
+
249
+ def custom_inspect_method_defined?
250
+ self.class.instance_method(:inspect).owner != DuckRecord::Base.instance_method(:inspect).owner
251
+ end
252
+ end
253
+ end
@@ -0,0 +1,23 @@
1
+ module DuckRecord
2
+ # This module exists because `DuckRecord::AttributeMethods::Dirty` needs to
3
+ # define callbacks, but continue to have its version of `save` be the super
4
+ # method of `DuckRecord::Callbacks`. This will be removed when the removal
5
+ # of deprecated code removes this need.
6
+ module DefineCallbacks
7
+ extend ActiveSupport::Concern
8
+
9
+ CALLBACKS = [
10
+ :after_initialize, :before_validation, :after_validation,
11
+ ]
12
+
13
+ module ClassMethods # :nodoc:
14
+ include ActiveModel::Callbacks
15
+ end
16
+
17
+ included do
18
+ include ActiveModel::Validations::Callbacks
19
+
20
+ define_model_callbacks :initialize, only: :after
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,44 @@
1
+ module DuckRecord
2
+ # = Active Record Errors
3
+ #
4
+ # Generic Active Record exception class.
5
+ class DuckRecordError < StandardError
6
+ end
7
+
8
+ # Raised on attempt to update record that is instantiated as read only.
9
+ class ReadOnlyRecord < DuckRecordError
10
+ end
11
+
12
+ # Raised when attribute has a name reserved by Active Record (when attribute
13
+ # has name of one of Active Record instance methods).
14
+ class DangerousAttributeError < DuckRecordError
15
+ end
16
+
17
+ # Raised when unknown attributes are supplied via mass assignment.
18
+ UnknownAttributeError = ActiveModel::UnknownAttributeError
19
+
20
+ # Raised when an error occurred while doing a mass assignment to an attribute through the
21
+ # {DuckRecord::Base#attributes=}[rdoc-ref:AttributeAssignment#attributes=] method.
22
+ # The exception has an +attribute+ property that is the name of the offending attribute.
23
+ class AttributeAssignmentError < DuckRecordError
24
+ attr_reader :exception, :attribute
25
+
26
+ def initialize(message = nil, exception = nil, attribute = nil)
27
+ super(message)
28
+ @exception = exception
29
+ @attribute = attribute
30
+ end
31
+ end
32
+
33
+ # Raised when there are multiple errors while doing a mass assignment through the
34
+ # {DuckRecord::Base#attributes=}[rdoc-ref:AttributeAssignment#attributes=]
35
+ # method. The exception has an +errors+ property that contains an array of AttributeAssignmentError
36
+ # objects, each corresponding to the error while assigning to an attribute.
37
+ class MultiparameterAssignmentErrors < DuckRecordError
38
+ attr_reader :errors
39
+
40
+ def initialize(errors = nil)
41
+ @errors = errors
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,130 @@
1
+ require "active_support/core_ext/hash/indifferent_access"
2
+
3
+ module DuckRecord
4
+ # == Single table inheritance
5
+ #
6
+ # Active Record allows inheritance by storing the name of the class in a column that by
7
+ # default is named "type" (can be changed by overwriting <tt>Base.inheritance_column</tt>).
8
+ # This means that an inheritance looking like this:
9
+ #
10
+ # class Company < DuckRecord::Base; end
11
+ # class Firm < Company; end
12
+ # class Client < Company; end
13
+ # class PriorityClient < Client; end
14
+ #
15
+ # When you do <tt>Firm.create(name: "37signals")</tt>, this record will be saved in
16
+ # the companies table with type = "Firm". You can then fetch this row again using
17
+ # <tt>Company.where(name: '37signals').first</tt> and it will return a Firm object.
18
+ #
19
+ # Be aware that because the type column is an attribute on the record every new
20
+ # subclass will instantly be marked as dirty and the type column will be included
21
+ # in the list of changed attributes on the record. This is different from non
22
+ # Single Table Inheritance(STI) classes:
23
+ #
24
+ # Company.new.changed? # => false
25
+ # Firm.new.changed? # => true
26
+ # Firm.new.changes # => {"type"=>["","Firm"]}
27
+ #
28
+ # If you don't have a type column defined in your table, single-table inheritance won't
29
+ # be triggered. In that case, it'll work just like normal subclasses with no special magic
30
+ # for differentiating between them or reloading the right type with find.
31
+ #
32
+ # Note, all the attributes for all the cases are kept in the same table. Read more:
33
+ # http://www.martinfowler.com/eaaCatalog/singleTableInheritance.html
34
+ #
35
+ module Inheritance
36
+ extend ActiveSupport::Concern
37
+
38
+ module ClassMethods
39
+ # Determines if one of the attributes passed in is the inheritance column,
40
+ # and if the inheritance column is attr accessible, it initializes an
41
+ # instance of the given subclass instead of the base class.
42
+ def new(*args, &block)
43
+ if abstract_class? || self == Base
44
+ raise NotImplementedError, "#{self} is an abstract class and cannot be instantiated."
45
+ end
46
+
47
+ super
48
+ end
49
+
50
+ # Returns the class descending directly from DuckRecord::Base, or
51
+ # an abstract class, if any, in the inheritance hierarchy.
52
+ #
53
+ # If A extends DuckRecord::Base, A.base_class will return A. If B descends from A
54
+ # through some arbitrarily deep hierarchy, B.base_class will return A.
55
+ #
56
+ # If B < A and C < B and if A is an abstract_class then both B.base_class
57
+ # and C.base_class would return B as the answer since A is an abstract_class.
58
+ def base_class
59
+ unless self < Base
60
+ raise DuckRecordError, "#{name} doesn't belong in a hierarchy descending from DuckRecord"
61
+ end
62
+
63
+ if superclass == Base || superclass.abstract_class?
64
+ self
65
+ else
66
+ superclass.base_class
67
+ end
68
+ end
69
+
70
+ # Set this to true if this is an abstract class (see <tt>abstract_class?</tt>).
71
+ # If you are using inheritance with DuckRecord and don't want child classes
72
+ # to utilize the implied STI table name of the parent class, this will need to be true.
73
+ # For example, given the following:
74
+ #
75
+ # class SuperClass < DuckRecord::Base
76
+ # self.abstract_class = true
77
+ # end
78
+ # class Child < SuperClass
79
+ # self.table_name = 'the_table_i_really_want'
80
+ # end
81
+ #
82
+ #
83
+ # <tt>self.abstract_class = true</tt> is required to make <tt>Child<.find,.create, or any Arel method></tt> use <tt>the_table_i_really_want</tt> instead of a table called <tt>super_classes</tt>
84
+ #
85
+ attr_accessor :abstract_class
86
+
87
+ # Returns whether this class is an abstract class or not.
88
+ def abstract_class?
89
+ defined?(@abstract_class) && @abstract_class == true
90
+ end
91
+
92
+ def inherited(subclass)
93
+ subclass.instance_variable_set(:@_type_candidates_cache, Concurrent::Map.new)
94
+ super
95
+ end
96
+
97
+ protected
98
+
99
+ # Returns the class type of the record using the current module as a prefix. So descendants of
100
+ # MyApp::Business::Account would appear as MyApp::Business::AccountSubclass.
101
+ def compute_type(type_name)
102
+ if type_name.start_with?("::".freeze)
103
+ # If the type is prefixed with a scope operator then we assume that
104
+ # the type_name is an absolute reference.
105
+ ActiveSupport::Dependencies.constantize(type_name)
106
+ else
107
+ type_candidate = @_type_candidates_cache[type_name]
108
+ if type_candidate && type_constant = ActiveSupport::Dependencies.safe_constantize(type_candidate)
109
+ return type_constant
110
+ end
111
+
112
+ # Build a list of candidates to search for
113
+ candidates = []
114
+ name.scan(/::|$/) { candidates.unshift "#{$`}::#{type_name}" }
115
+ candidates << type_name
116
+
117
+ candidates.each do |candidate|
118
+ constant = ActiveSupport::Dependencies.safe_constantize(candidate)
119
+ if candidate == constant.to_s
120
+ @_type_candidates_cache[type_name] = candidate
121
+ return constant
122
+ end
123
+ end
124
+
125
+ raise NameError.new("uninitialized constant #{candidates.first}", candidates.first)
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end