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