tallty_duck_record 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (79) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +41 -0
  3. data/README.md +82 -0
  4. data/Rakefile +28 -0
  5. data/lib/core_ext/array_without_blank.rb +46 -0
  6. data/lib/duck_record.rb +65 -0
  7. data/lib/duck_record/associations.rb +130 -0
  8. data/lib/duck_record/associations/association.rb +271 -0
  9. data/lib/duck_record/associations/belongs_to_association.rb +71 -0
  10. data/lib/duck_record/associations/builder/association.rb +127 -0
  11. data/lib/duck_record/associations/builder/belongs_to.rb +44 -0
  12. data/lib/duck_record/associations/builder/collection_association.rb +45 -0
  13. data/lib/duck_record/associations/builder/embeds_many.rb +9 -0
  14. data/lib/duck_record/associations/builder/embeds_one.rb +9 -0
  15. data/lib/duck_record/associations/builder/has_many.rb +11 -0
  16. data/lib/duck_record/associations/builder/has_one.rb +20 -0
  17. data/lib/duck_record/associations/builder/singular_association.rb +33 -0
  18. data/lib/duck_record/associations/collection_association.rb +476 -0
  19. data/lib/duck_record/associations/collection_proxy.rb +1160 -0
  20. data/lib/duck_record/associations/embeds_association.rb +92 -0
  21. data/lib/duck_record/associations/embeds_many_association.rb +203 -0
  22. data/lib/duck_record/associations/embeds_many_proxy.rb +892 -0
  23. data/lib/duck_record/associations/embeds_one_association.rb +48 -0
  24. data/lib/duck_record/associations/foreign_association.rb +11 -0
  25. data/lib/duck_record/associations/has_many_association.rb +17 -0
  26. data/lib/duck_record/associations/has_one_association.rb +39 -0
  27. data/lib/duck_record/associations/singular_association.rb +73 -0
  28. data/lib/duck_record/attribute.rb +213 -0
  29. data/lib/duck_record/attribute/user_provided_default.rb +30 -0
  30. data/lib/duck_record/attribute_assignment.rb +118 -0
  31. data/lib/duck_record/attribute_decorators.rb +89 -0
  32. data/lib/duck_record/attribute_methods.rb +325 -0
  33. data/lib/duck_record/attribute_methods/before_type_cast.rb +76 -0
  34. data/lib/duck_record/attribute_methods/dirty.rb +107 -0
  35. data/lib/duck_record/attribute_methods/read.rb +78 -0
  36. data/lib/duck_record/attribute_methods/serialization.rb +66 -0
  37. data/lib/duck_record/attribute_methods/write.rb +70 -0
  38. data/lib/duck_record/attribute_mutation_tracker.rb +108 -0
  39. data/lib/duck_record/attribute_set.rb +98 -0
  40. data/lib/duck_record/attribute_set/yaml_encoder.rb +41 -0
  41. data/lib/duck_record/attributes.rb +262 -0
  42. data/lib/duck_record/base.rb +300 -0
  43. data/lib/duck_record/callbacks.rb +324 -0
  44. data/lib/duck_record/coders/json.rb +13 -0
  45. data/lib/duck_record/coders/yaml_column.rb +48 -0
  46. data/lib/duck_record/core.rb +262 -0
  47. data/lib/duck_record/define_callbacks.rb +23 -0
  48. data/lib/duck_record/enum.rb +139 -0
  49. data/lib/duck_record/errors.rb +71 -0
  50. data/lib/duck_record/inheritance.rb +130 -0
  51. data/lib/duck_record/locale/en.yml +46 -0
  52. data/lib/duck_record/model_schema.rb +71 -0
  53. data/lib/duck_record/nested_attributes.rb +555 -0
  54. data/lib/duck_record/nested_validate_association.rb +262 -0
  55. data/lib/duck_record/persistence.rb +39 -0
  56. data/lib/duck_record/readonly_attributes.rb +36 -0
  57. data/lib/duck_record/reflection.rb +650 -0
  58. data/lib/duck_record/serialization.rb +26 -0
  59. data/lib/duck_record/translation.rb +22 -0
  60. data/lib/duck_record/type.rb +77 -0
  61. data/lib/duck_record/type/array.rb +36 -0
  62. data/lib/duck_record/type/array_without_blank.rb +36 -0
  63. data/lib/duck_record/type/date.rb +7 -0
  64. data/lib/duck_record/type/date_time.rb +7 -0
  65. data/lib/duck_record/type/decimal_without_scale.rb +13 -0
  66. data/lib/duck_record/type/internal/abstract_json.rb +33 -0
  67. data/lib/duck_record/type/internal/timezone.rb +15 -0
  68. data/lib/duck_record/type/json.rb +6 -0
  69. data/lib/duck_record/type/registry.rb +97 -0
  70. data/lib/duck_record/type/serialized.rb +63 -0
  71. data/lib/duck_record/type/text.rb +9 -0
  72. data/lib/duck_record/type/time.rb +19 -0
  73. data/lib/duck_record/type/unsigned_integer.rb +15 -0
  74. data/lib/duck_record/validations.rb +67 -0
  75. data/lib/duck_record/validations/subset.rb +74 -0
  76. data/lib/duck_record/validations/uniqueness_on_real_record.rb +248 -0
  77. data/lib/duck_record/version.rb +3 -0
  78. data/lib/tasks/acts_as_record_tasks.rake +4 -0
  79. metadata +181 -0
@@ -0,0 +1,9 @@
1
+ module DuckRecord
2
+ module Type
3
+ class Text < ActiveModel::Type::String # :nodoc:
4
+ def type
5
+ :text
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,19 @@
1
+ module DuckRecord
2
+ module Type
3
+ class Time < ActiveModel::Type::Time
4
+ include Internal::Timezone
5
+
6
+ class Value < DelegateClass(::Time) # :nodoc:
7
+ end
8
+
9
+ def serialize(value)
10
+ case value = super
11
+ when ::Time
12
+ Value.new(value)
13
+ else
14
+ value
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,15 @@
1
+ module DuckRecord
2
+ module Type
3
+ class UnsignedInteger < ActiveModel::Type::Integer # :nodoc:
4
+ private
5
+
6
+ def max_value
7
+ super * 2
8
+ end
9
+
10
+ def min_value
11
+ 0
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,67 @@
1
+ require "duck_record/validations/uniqueness_on_real_record"
2
+ require "duck_record/validations/subset"
3
+
4
+ module DuckRecord
5
+ class RecordInvalid < DuckRecordError
6
+ attr_reader :record
7
+
8
+ def initialize(record = nil)
9
+ if record
10
+ @record = record
11
+ errors = @record.errors.full_messages.join(", ")
12
+ message = I18n.t(:"#{@record.class.i18n_scope}.errors.messages.record_invalid", errors: errors, default: :"errors.messages.record_invalid")
13
+ else
14
+ message = "Record invalid"
15
+ end
16
+
17
+ super(message)
18
+ end
19
+ end
20
+
21
+ # = Active Record \Validations
22
+ #
23
+ # Active Record includes the majority of its validations from ActiveModel::Validations
24
+ # all of which accept the <tt>:on</tt> argument to define the context where the
25
+ # validations are active. Active Record will always supply either the context of
26
+ # <tt>:create</tt> or <tt>:update</tt> dependent on whether the model is a
27
+ # {new_record?}[rdoc-ref:Persistence#new_record?].
28
+ module Validations
29
+ extend ActiveSupport::Concern
30
+ include ActiveModel::Validations
31
+
32
+ # Runs all the validations within the specified context. Returns +true+ if
33
+ # no errors are found, +false+ otherwise.
34
+ #
35
+ # Aliased as #validate.
36
+ #
37
+ # If the argument is +false+ (default is +nil+), the context is set to <tt>:default</tt>.
38
+ #
39
+ # \Validations with no <tt>:on</tt> option will run no matter the context. \Validations with
40
+ # some <tt>:on</tt> option will only run in the specified context.
41
+ def valid?(context = nil)
42
+ context ||= default_validation_context
43
+ output = super(context)
44
+ errors.empty? && output
45
+ end
46
+
47
+ def valid!(context = nil)
48
+ if valid?(context)
49
+ true
50
+ else
51
+ raise RecordInvalid.new(self)
52
+ end
53
+ end
54
+
55
+ alias_method :validate, :valid?
56
+
57
+ private
58
+
59
+ def default_validation_context
60
+ :default
61
+ end
62
+
63
+ def perform_validations(options = {})
64
+ options[:validate] == false || valid?(options[:context])
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DuckRecord
4
+ module Validations
5
+ class SubsetValidator < ActiveModel::EachValidator # :nodoc:
6
+ ERROR_MESSAGE = "An object with the method #include? or a proc, lambda or symbol is required, " \
7
+ "and must be supplied as the :in (or :within) option of the configuration hash"
8
+
9
+ def check_validity!
10
+ unless delimiter.respond_to?(:include?) || delimiter.respond_to?(:call) || delimiter.respond_to?(:to_sym)
11
+ raise ArgumentError, ERROR_MESSAGE
12
+ end
13
+ end
14
+
15
+ def validate_each(record, attribute, value)
16
+ unless subset?(record, value)
17
+ record.errors.add(attribute, :subset, options.except(:in, :within).merge!(value: value))
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def delimiter
24
+ @delimiter ||= options[:in] || options[:within]
25
+ end
26
+
27
+ def subset?(record, value)
28
+ return false unless value.respond_to?(:to_a)
29
+
30
+ enumerable = value.to_a
31
+ members =
32
+ if delimiter.respond_to?(:call)
33
+ delimiter.call(record)
34
+ elsif delimiter.respond_to?(:to_sym)
35
+ record.send(delimiter)
36
+ else
37
+ delimiter
38
+ end
39
+
40
+ (members & enumerable).size == enumerable.size
41
+ end
42
+ end
43
+
44
+ module HelperMethods
45
+ # Validates whether the value of the specified attribute is available in a
46
+ # particular enumerable object.
47
+ #
48
+ # class Person < ActiveRecord::Base
49
+ # validates_inclusion_of :gender, in: %w( m f )
50
+ # validates_inclusion_of :age, in: 0..99
51
+ # validates_inclusion_of :format, in: %w( jpg gif png ), message: "extension %{value} is not included in the list"
52
+ # validates_inclusion_of :states, in: ->(person) { STATES[person.country] }
53
+ # validates_inclusion_of :karma, in: :available_karmas
54
+ # end
55
+ #
56
+ # Configuration options:
57
+ # * <tt>:in</tt> - An enumerable object of available items. This can be
58
+ # supplied as a proc, lambda or symbol which returns an enumerable. If the
59
+ # enumerable is a numerical, time or datetime range the test is performed
60
+ # with <tt>Range#cover?</tt>, otherwise with <tt>include?</tt>. When using
61
+ # a proc or lambda the instance under validation is passed as an argument.
62
+ # * <tt>:within</tt> - A synonym(or alias) for <tt>:in</tt>
63
+ # * <tt>:message</tt> - Specifies a custom error message (default is: "is
64
+ # not included in the list").
65
+ #
66
+ # There is also a list of default options supported by every validator:
67
+ # +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+.
68
+ # See <tt>ActiveModel::Validations#validates</tt> for more information
69
+ def validates_subset_of(*attr_names)
70
+ validates_with SubsetValidator, _merge_attributes(attr_names)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,248 @@
1
+ module DuckRecord
2
+ module Validations
3
+ class UniquenessOnRealRecordValidator < ActiveModel::EachValidator # :nodoc:
4
+ def initialize(options)
5
+ unless defined?(ActiveRecord)
6
+ raise NameError, "Not found Active Record."
7
+ end
8
+
9
+ if options[:conditions] && !options[:conditions].respond_to?(:call)
10
+ raise ArgumentError, "#{options[:conditions]} was passed as :conditions but is not callable. " \
11
+ "Pass a callable instead: `conditions: -> { where(approved: true) }`"
12
+ end
13
+
14
+ @finder_class = if options[:class_name].present?
15
+ options[:class_name].safe_constantize
16
+ elsif options[:class].present? && options[:class].is_a?(Class)
17
+ options[:class]
18
+ else
19
+ nil
20
+ end
21
+
22
+ unless @finder_class
23
+ raise ArgumentError, "Must provide one of option :class_name or :class."
24
+ end
25
+ unless @finder_class < ActiveRecord::Base
26
+ raise ArgumentError, ":class must be an Active Record model, but got #{@finder_class}."
27
+ end
28
+ if @finder_class.abstract_class?
29
+ raise ArgumentError, ":class can't be an abstract class."
30
+ end
31
+
32
+ @primary_key = options[:primary_key]
33
+
34
+ super({ case_sensitive: true }.merge!(options))
35
+ end
36
+
37
+ def validate_each(record, attribute, value)
38
+ table = @finder_class.arel_table
39
+ value = map_enum_attribute(@finder_class, attribute, value)
40
+
41
+ relation = build_relation(@finder_class, table, attribute, value)
42
+ if @primary_key.present? && record.respond_to?(@primary_key)
43
+ if @finder_class.primary_key
44
+ relation = relation.where.not(@finder_class.primary_key => record.send(@primary_key))
45
+ else
46
+ raise ActiveRecord::UnknownPrimaryKey.new(@finder_class, "Can not validate uniqueness for persisted record without primary key.")
47
+ end
48
+ end
49
+ relation = scope_relation(record, table, relation)
50
+ relation = relation.merge(options[:conditions]) if options[:conditions]
51
+
52
+ if relation.exists?
53
+ error_options = options.except(:case_sensitive, :scope, :conditions)
54
+ error_options[:value] = value
55
+
56
+ record.errors.add(attribute, :taken, error_options)
57
+ end
58
+ end
59
+
60
+ protected
61
+
62
+ def build_relation(klass, table, attribute, value) #:nodoc:
63
+ if reflection = klass._reflect_on_association(attribute)
64
+ attribute = reflection.foreign_key
65
+ value = value.attributes[reflection.klass.primary_key] unless value.nil?
66
+ end
67
+
68
+ # the attribute may be an aliased attribute
69
+ if klass.attribute_alias?(attribute)
70
+ attribute = klass.attribute_alias(attribute)
71
+ end
72
+
73
+ attribute_name = attribute.to_s
74
+
75
+ column = klass.columns_hash[attribute_name]
76
+ cast_type = klass.type_for_attribute(attribute_name)
77
+ value = cast_type.serialize(value)
78
+ value = klass.connection.type_cast(value)
79
+
80
+ comparison = if !options[:case_sensitive] && !value.nil?
81
+ # will use SQL LOWER function before comparison, unless it detects a case insensitive collation
82
+ klass.connection.case_insensitive_comparison(table, attribute, column, value)
83
+ else
84
+ klass.connection.case_sensitive_comparison(table, attribute, column, value)
85
+ end
86
+ if value.nil?
87
+ klass.unscoped.where(comparison)
88
+ else
89
+ bind = ActiveRecord::Relation::QueryAttribute.new(attribute_name, value, ActiveRecord::Type::Value.new)
90
+ klass.unscoped.where(comparison, bind)
91
+ end
92
+ rescue RangeError
93
+ klass.none
94
+ end
95
+
96
+ def scope_relation(record, table, relation)
97
+ Array(options[:scope]).each do |scope_item|
98
+ scope_value = if record.class._reflect_on_association(scope_item)
99
+ record.association(scope_item).reader
100
+ else
101
+ record._read_attribute(scope_item)
102
+ end
103
+ relation = relation.where(scope_item => scope_value)
104
+ end
105
+
106
+ relation
107
+ end
108
+
109
+ def map_enum_attribute(klass, attribute, value)
110
+ mapping = klass.defined_enums[attribute.to_s]
111
+ value = mapping[value] if value && mapping
112
+ value
113
+ end
114
+ end
115
+
116
+ module ClassMethods
117
+ # Validates whether the value of the specified attributes are unique
118
+ # across the system. Useful for making sure that only one user
119
+ # can be named "davidhh".
120
+ #
121
+ # class Person < ActiveRecord::Base
122
+ # validates_uniqueness_of :user_name
123
+ # end
124
+ #
125
+ # It can also validate whether the value of the specified attributes are
126
+ # unique based on a <tt>:scope</tt> parameter:
127
+ #
128
+ # class Person < ActiveRecord::Base
129
+ # validates_uniqueness_of :user_name, scope: :account_id
130
+ # end
131
+ #
132
+ # Or even multiple scope parameters. For example, making sure that a
133
+ # teacher can only be on the schedule once per semester for a particular
134
+ # class.
135
+ #
136
+ # class TeacherSchedule < ActiveRecord::Base
137
+ # validates_uniqueness_of :teacher_id, scope: [:semester_id, :class_id]
138
+ # end
139
+ #
140
+ # It is also possible to limit the uniqueness constraint to a set of
141
+ # records matching certain conditions. In this example archived articles
142
+ # are not being taken into consideration when validating uniqueness
143
+ # of the title attribute:
144
+ #
145
+ # class Article < ActiveRecord::Base
146
+ # validates_uniqueness_of :title, conditions: -> { where.not(status: 'archived') }
147
+ # end
148
+ #
149
+ # When the record is created, a check is performed to make sure that no
150
+ # record exists in the database with the given value for the specified
151
+ # attribute (that maps to a column). When the record is updated,
152
+ # the same check is made but disregarding the record itself.
153
+ #
154
+ # Configuration options:
155
+ #
156
+ # * <tt>:message</tt> - Specifies a custom error message (default is:
157
+ # "has already been taken").
158
+ # * <tt>:scope</tt> - One or more columns by which to limit the scope of
159
+ # the uniqueness constraint.
160
+ # * <tt>:conditions</tt> - Specify the conditions to be included as a
161
+ # <tt>WHERE</tt> SQL fragment to limit the uniqueness constraint lookup
162
+ # (e.g. <tt>conditions: -> { where(status: 'active') }</tt>).
163
+ # * <tt>:case_sensitive</tt> - Looks for an exact match. Ignored by
164
+ # non-text columns (+true+ by default).
165
+ # * <tt>:allow_nil</tt> - If set to +true+, skips this validation if the
166
+ # attribute is +nil+ (default is +false+).
167
+ # * <tt>:allow_blank</tt> - If set to +true+, skips this validation if the
168
+ # attribute is blank (default is +false+).
169
+ # * <tt>:if</tt> - Specifies a method, proc or string to call to determine
170
+ # if the validation should occur (e.g. <tt>if: :allow_validation</tt>,
171
+ # or <tt>if: Proc.new { |user| user.signup_step > 2 }</tt>). The method,
172
+ # proc or string should return or evaluate to a +true+ or +false+ value.
173
+ # * <tt>:unless</tt> - Specifies a method, proc or string to call to
174
+ # determine if the validation should not occur (e.g. <tt>unless: :skip_validation</tt>,
175
+ # or <tt>unless: Proc.new { |user| user.signup_step <= 2 }</tt>). The
176
+ # method, proc or string should return or evaluate to a +true+ or +false+
177
+ # value.
178
+ #
179
+ # === Concurrency and integrity
180
+ #
181
+ # Using this validation method in conjunction with
182
+ # {ActiveRecord::Base#save}[rdoc-ref:Persistence#save]
183
+ # does not guarantee the absence of duplicate record insertions, because
184
+ # uniqueness checks on the application level are inherently prone to race
185
+ # conditions. For example, suppose that two users try to post a Comment at
186
+ # the same time, and a Comment's title must be unique. At the database-level,
187
+ # the actions performed by these users could be interleaved in the following manner:
188
+ #
189
+ # User 1 | User 2
190
+ # ------------------------------------+--------------------------------------
191
+ # # User 1 checks whether there's |
192
+ # # already a comment with the title |
193
+ # # 'My Post'. This is not the case. |
194
+ # SELECT * FROM comments |
195
+ # WHERE title = 'My Post' |
196
+ # |
197
+ # | # User 2 does the same thing and also
198
+ # | # infers that their title is unique.
199
+ # | SELECT * FROM comments
200
+ # | WHERE title = 'My Post'
201
+ # |
202
+ # # User 1 inserts their comment. |
203
+ # INSERT INTO comments |
204
+ # (title, content) VALUES |
205
+ # ('My Post', 'hi!') |
206
+ # |
207
+ # | # User 2 does the same thing.
208
+ # | INSERT INTO comments
209
+ # | (title, content) VALUES
210
+ # | ('My Post', 'hello!')
211
+ # |
212
+ # | # ^^^^^^
213
+ # | # Boom! We now have a duplicate
214
+ # | # title!
215
+ #
216
+ # This could even happen if you use transactions with the 'serializable'
217
+ # isolation level. The best way to work around this problem is to add a unique
218
+ # index to the database table using
219
+ # {connection.add_index}[rdoc-ref:ConnectionAdapters::SchemaStatements#add_index].
220
+ # In the rare case that a race condition occurs, the database will guarantee
221
+ # the field's uniqueness.
222
+ #
223
+ # When the database catches such a duplicate insertion,
224
+ # {ActiveRecord::Base#save}[rdoc-ref:Persistence#save] will raise an ActiveRecord::StatementInvalid
225
+ # exception. You can either choose to let this error propagate (which
226
+ # will result in the default Rails exception page being shown), or you
227
+ # can catch it and restart the transaction (e.g. by telling the user
228
+ # that the title already exists, and asking them to re-enter the title).
229
+ # This technique is also known as
230
+ # {optimistic concurrency control}[http://en.wikipedia.org/wiki/Optimistic_concurrency_control].
231
+ #
232
+ # The bundled ActiveRecord::ConnectionAdapters distinguish unique index
233
+ # constraint errors from other types of database errors by throwing an
234
+ # ActiveRecord::RecordNotUnique exception. For other adapters you will
235
+ # have to parse the (database-specific) exception message to detect such
236
+ # a case.
237
+ #
238
+ # The following bundled adapters throw the ActiveRecord::RecordNotUnique exception:
239
+ #
240
+ # * ActiveRecord::ConnectionAdapters::Mysql2Adapter.
241
+ # * ActiveRecord::ConnectionAdapters::SQLite3Adapter.
242
+ # * ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.
243
+ def validates_uniqueness_on_real_record_of(*attr_names)
244
+ validates_with UniquenessOnRealRecordValidator, _merge_attributes(attr_names)
245
+ end
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,3 @@
1
+ module DuckRecord
2
+ VERSION = "1.0.0"
3
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :duck_record do
3
+ # # Task goes here
4
+ # end