duck_record 0.0.8 → 0.0.9

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 26cdafb4677192d3b19b66b6c691f739e6dc70ae
4
- data.tar.gz: 5c1e47c06bece7781a4812389dbc7218ae240ed1
3
+ metadata.gz: 6ecea50d445f7446966d18efc45e498a6748e965
4
+ data.tar.gz: b218dff359b164ae64f38663b756f9f5de023262
5
5
  SHA512:
6
- metadata.gz: ddf042c24da96f908e7dcb8183566574a7208e02da658e8cf023a8e729e63019a9a659c66a7f782ed8055842f7b45ac5fa74a690bd3d91b2e6901e428a474ee6
7
- data.tar.gz: 6a63e7f449a1815fc742fc93530f90407645bb6d1b51ff5382dc4b6102e4dacd4eeb113b82a49c8125e972289a5ab8c856680dc0ceeabe9db927458c8e47a6ba
6
+ metadata.gz: b296929403c1a0753a179b07db103788861968c0842d02d9bd22f88e4bcb8937755b2975c11d80940e6d02427d6e42c4582166d98c3ee08b5390153b02d1c159
7
+ data.tar.gz: e96ee2e901aa8d146ea730c41d8fa817d0d290d3dcefa6448a21b87e2c68798e8544d075b62f2aa4211bab85b916a0207c32719f7aecdba681d005e61623430f
data/MIT-LICENSE CHANGED
@@ -18,3 +18,24 @@ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
18
  LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
19
  OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
20
  WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21
+
22
+ Copyright (c) 2004-2017 David Heinemeier Hansson
23
+
24
+ Permission is hereby granted, free of charge, to any person obtaining
25
+ a copy of this software and associated documentation files (the
26
+ "Software"), to deal in the Software without restriction, including
27
+ without limitation the rights to use, copy, modify, merge, publish,
28
+ distribute, sublicense, and/or sell copies of the Software, and to
29
+ permit persons to whom the Software is furnished to do so, subject to
30
+ the following conditions:
31
+
32
+ The above copyright notice and this permission notice shall be
33
+ included in all copies or substantial portions of the Software.
34
+
35
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
36
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
37
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
38
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
39
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
40
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
41
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -33,6 +33,11 @@ module DuckRecord
33
33
  @target = nil
34
34
  end
35
35
 
36
+ # Has the \target been already \loaded?
37
+ def loaded?
38
+ !!@target
39
+ end
40
+
36
41
  # Sets the target of this association to <tt>\target</tt>, and the \loaded flag to +true+.
37
42
  def target=(target)
38
43
  @target = target
@@ -35,6 +35,8 @@ module DuckRecord
35
35
  :to_sentence, :to_formatted_s,
36
36
  :shuffle, :split, :index, to: :records
37
37
 
38
+ delegate :target, :loaded?, to: :@association
39
+
38
40
  attr_reader :klass
39
41
  alias :model :klass
40
42
 
@@ -43,10 +45,6 @@ module DuckRecord
43
45
  @association = association
44
46
  end
45
47
 
46
- def target
47
- @association.target
48
- end
49
-
50
48
  ##
51
49
  # :method: first
52
50
  #
@@ -299,6 +299,25 @@ module DuckRecord #:nodoc:
299
299
  def new_record?
300
300
  true
301
301
  end
302
+
303
+ def to_h(include_empty: true)
304
+ hash = serializable_hash
305
+
306
+ self.class.reflections.keys.each do |k|
307
+ records = send(k)
308
+ sub_hash = if records.respond_to?(:to_ary)
309
+ records.to_ary.map { |a| a.to_h }
310
+ else
311
+ records.to_h
312
+ end
313
+
314
+ if include_empty || sub_hash.any?
315
+ hash[k] = sub_hash
316
+ end
317
+ end
318
+
319
+ hash
320
+ end
302
321
  end
303
322
 
304
323
  ActiveSupport.run_load_hooks(:duck_record, Base)
@@ -340,27 +340,27 @@ module DuckRecord
340
340
 
341
341
  private
342
342
 
343
- # Generates a writer method for this association. Serves as a point for
344
- # accessing the objects in the association. For example, this method
345
- # could generate the following:
346
- #
347
- # def pirate_attributes=(attributes)
348
- # assign_nested_attributes_for_one_to_one_association(:pirate, attributes)
349
- # end
350
- #
351
- # This redirects the attempts to write objects in an association through
352
- # the helper methods defined below. Makes it seem like the nested
353
- # associations are just regular associations.
354
- def generate_association_writer(association_name, type)
355
- generated_association_methods.module_eval <<-eoruby, __FILE__, __LINE__ + 1
356
- if method_defined?(:#{association_name}_attributes=)
357
- remove_method(:#{association_name}_attributes=)
358
- end
359
- def #{association_name}_attributes=(attributes)
360
- assign_nested_attributes_for_#{type}_association(:#{association_name}, attributes)
361
- end
362
- eoruby
363
- end
343
+ # Generates a writer method for this association. Serves as a point for
344
+ # accessing the objects in the association. For example, this method
345
+ # could generate the following:
346
+ #
347
+ # def pirate_attributes=(attributes)
348
+ # assign_nested_attributes_for_one_to_one_association(:pirate, attributes)
349
+ # end
350
+ #
351
+ # This redirects the attempts to write objects in an association through
352
+ # the helper methods defined below. Makes it seem like the nested
353
+ # associations are just regular associations.
354
+ def generate_association_writer(association_name, type)
355
+ generated_association_methods.module_eval <<-eoruby, __FILE__, __LINE__ + 1
356
+ if method_defined?(:#{association_name}_attributes=)
357
+ remove_method(:#{association_name}_attributes=)
358
+ end
359
+ def #{association_name}_attributes=(attributes)
360
+ assign_nested_attributes_for_#{type}_association(:#{association_name}, attributes)
361
+ end
362
+ eoruby
363
+ end
364
364
  end
365
365
 
366
366
  # Marks this record to be destroyed as part of the parent's save transaction.
@@ -1,3 +1,5 @@
1
+ require "duck_record/validations/uniqueness_on_real_record"
2
+
1
3
  module DuckRecord
2
4
  # = Active Record \Validations
3
5
  #
@@ -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
+ @finder_class = options[:class_name].safe_constantize
16
+ elsif options[:class].present? && options[:class].is_a?(Class)
17
+ @finder_class = 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
@@ -1,3 +1,3 @@
1
1
  module DuckRecord
2
- VERSION = '0.0.8'
2
+ VERSION = '0.0.9'
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: duck_record
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.8
4
+ version: 0.0.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - jasl
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2017-03-19 00:00:00.000000000 Z
11
+ date: 2017-03-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -52,6 +52,20 @@ dependencies:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
54
  version: '5.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: sqlite3
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
55
69
  description: "It looks like Active Record and quacks like Active Record, but it can't
56
70
  do persistence or querying,\n it's Duck Record! \n Actually it's extract from
57
71
  Active Record.\n Used for creating virtual models like ActiveType or ModelAttribute
@@ -118,6 +132,7 @@ files:
118
132
  - lib/duck_record/type/time.rb
119
133
  - lib/duck_record/type/unsigned_integer.rb
120
134
  - lib/duck_record/validations.rb
135
+ - lib/duck_record/validations/uniqueness_on_real_record.rb
121
136
  - lib/duck_record/version.rb
122
137
  - lib/tasks/acts_as_record_tasks.rake
123
138
  homepage: https://github.com/jasl/duck_record