duck_record 0.0.3 → 0.0.5

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: edf615681e997320dd43475ac1b65efbf4a6355b
4
- data.tar.gz: 2790953d7ec98a37866298eb4c445ed62e71f22c
3
+ metadata.gz: bc1667096dfb88fc6d2b74ea577440eb5bcf8fd0
4
+ data.tar.gz: 86a738d000265404c2309bf4d04ff6a5db11d446
5
5
  SHA512:
6
- metadata.gz: 5bb6d8c090cc556f4edf072de23146850001ebdea769ab6656eb129aec0e07500b2c66aafdca655dc6df3a6efcada0af47f071a48bc3a9d8ee3c1a42545025b5
7
- data.tar.gz: a7ef53be7401e9612c332d2a2f812f92d1a8ed8145d63855ac2da2ce4de415b3e5ff0afbf49219dd9ec1a787dbf81746219932d373edcaede219990f92a0b0ad
6
+ metadata.gz: 2e92f601db7fae6b7a687cb42caa647ba39ca2441f0174a6999feeb5a0d1537eab686c7297734981ca2bb77109b3eaf7f7a5cff7a1da63f2bff1ef6efe15f2a9
7
+ data.tar.gz: e61511a9b89654b61a1350d69a748e44cba5b4ef8fd3ef061667db04f12a1d57e00d1a6ac95dfa63a8c32ddf512d7b146c7432a1f0a7f9ea6ba7937dbd5c9977
data/README.md CHANGED
@@ -7,19 +7,39 @@ Actually it's extract from Active Record.
7
7
  ## Usage
8
8
 
9
9
  ```ruby
10
+ class Person < DuckRecord::Base
11
+ attribute :name, :string
12
+ attribute :age, :integer
13
+
14
+ validates :name, presence: true
15
+ end
16
+
17
+ class Comment < DuckRecord::Base
18
+ attribute :content, :string
19
+
20
+ validates :content, presence: true
21
+ end
22
+
10
23
  class Book < DuckRecord::Base
24
+ has_one :author, class_name: 'Person', validate: true
25
+ accepts_nested_attributes_for :author
26
+
27
+ has_many :comments, validate: true
28
+ accepts_nested_attributes_for :comments
29
+
11
30
  attribute :title, :string
31
+ attribute :tags, :string, array: true
12
32
  attribute :price, :decimal, default: 0
33
+ attribute :meta, :json, default: {}
13
34
  attribute :bought_at, :datetime, default: -> { Time.new }
14
35
 
15
- # some types that cheated from PG
16
- attribute :tags, :string, array: true
17
- attribute :meta, :json, default: {}
18
-
19
36
  validates :title, presence: true
20
37
  end
21
38
  ```
22
39
 
40
+ then use these models like a Active Record model,
41
+ but remember that can't be persisting!
42
+
23
43
  ## Installation
24
44
 
25
45
  Since Duck Record is under early development,
@@ -43,12 +63,11 @@ $ gem install duck_record
43
63
 
44
64
  ## TODO
45
65
 
46
- - `has_one`, `has_many`
47
66
  - refactor that original design for database
48
67
  - update docs
49
68
  - add useful methods
50
69
  - add tests
51
- - let me known..
70
+ - let me know..
52
71
 
53
72
  ## Contributing
54
73
 
data/Rakefile CHANGED
@@ -25,5 +25,4 @@ Rake::TestTask.new(:test) do |t|
25
25
  t.verbose = false
26
26
  end
27
27
 
28
-
29
28
  task default: :test
@@ -0,0 +1,86 @@
1
+ require "active_support/core_ext/array/wrap"
2
+
3
+ module DuckRecord
4
+ module Associations
5
+ # = Active Record Associations
6
+ #
7
+ # This is the root class of all associations ('+ Foo' signifies an included module Foo):
8
+ #
9
+ # Association
10
+ # SingularAssociation
11
+ # HasOneAssociation + ForeignAssociation
12
+ # HasOneThroughAssociation + ThroughAssociation
13
+ # BelongsToAssociation
14
+ # BelongsToPolymorphicAssociation
15
+ # CollectionAssociation
16
+ # HasManyAssociation + ForeignAssociation
17
+ # HasManyThroughAssociation + ThroughAssociation
18
+ class Association #:nodoc:
19
+ attr_reader :owner, :target, :reflection
20
+
21
+ delegate :options, to: :reflection
22
+
23
+ def initialize(owner, reflection)
24
+ reflection.check_validity!
25
+
26
+ @owner, @reflection = owner, reflection
27
+
28
+ reset
29
+ end
30
+
31
+ # Resets the \loaded flag to +false+ and sets the \target to +nil+.
32
+ def reset
33
+ @target = nil
34
+ end
35
+
36
+ # Sets the target of this association to <tt>\target</tt>, and the \loaded flag to +true+.
37
+ def target=(target)
38
+ @target = target
39
+ end
40
+
41
+ # Returns the class of the target. belongs_to polymorphic overrides this to look at the
42
+ # polymorphic_type field on the owner.
43
+ def klass
44
+ reflection.klass
45
+ end
46
+
47
+ # We can't dump @reflection since it contains the scope proc
48
+ def marshal_dump
49
+ ivars = (instance_variables - [:@reflection]).map { |name| [name, instance_variable_get(name)] }
50
+ [@reflection.name, ivars]
51
+ end
52
+
53
+ def marshal_load(data)
54
+ reflection_name, ivars = data
55
+ ivars.each { |name, val| instance_variable_set(name, val) }
56
+ @reflection = @owner.class._reflect_on_association(reflection_name)
57
+ end
58
+
59
+ def initialize_attributes(record, attributes = nil) #:nodoc:
60
+ record.assign_attributes(attributes)
61
+ end
62
+
63
+ private
64
+
65
+ # Raises ActiveRecord::AssociationTypeMismatch unless +record+ is of
66
+ # the kind of the class of the associated objects. Meant to be used as
67
+ # a sanity check when you are about to assign an associated record.
68
+ def raise_on_type_mismatch!(record)
69
+ unless record.is_a?(reflection.klass)
70
+ fresh_class = reflection.class_name.safe_constantize
71
+ unless fresh_class && record.is_a?(fresh_class)
72
+ message = "#{reflection.class_name}(##{reflection.klass.object_id}) expected, "\
73
+ "got #{record.inspect} which is an instance of #{record.class}(##{record.class.object_id})"
74
+ raise ActiveRecord::AssociationTypeMismatch, message
75
+ end
76
+ end
77
+ end
78
+
79
+ def build_record(attributes)
80
+ reflection.build_association(attributes) do |record|
81
+ initialize_attributes(record, attributes)
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,98 @@
1
+ # This is the parent Association class which defines the variables
2
+ # used by all associations.
3
+ #
4
+ # The hierarchy is defined as follows:
5
+ # Association
6
+ # - SingularAssociation
7
+ # - BelongsToAssociation
8
+ # - HasOneAssociation
9
+ # - CollectionAssociation
10
+ # - HasManyAssociation
11
+
12
+ module DuckRecord::Associations::Builder # :nodoc:
13
+ class Association #:nodoc:
14
+ class << self
15
+ attr_accessor :extensions
16
+ end
17
+ self.extensions = []
18
+
19
+ VALID_OPTIONS = [:class_name, :anonymous_class, :validate] # :nodoc:
20
+
21
+ def self.build(model, name, options, &block)
22
+ if model.dangerous_attribute_method?(name)
23
+ raise ArgumentError, "You tried to define an association named #{name} on the model #{model.name}, but " \
24
+ "this will conflict with a method #{name} already defined by Active Record. " \
25
+ "Please choose a different association name."
26
+ end
27
+
28
+ extension = define_extensions model, name, &block
29
+ reflection = create_reflection model, name, options, extension
30
+ define_accessors model, reflection
31
+ define_callbacks model, reflection
32
+ define_validations model, reflection
33
+ reflection
34
+ end
35
+
36
+ def self.create_reflection(model, name, options, extension = nil)
37
+ raise ArgumentError, "association names must be a Symbol" unless name.kind_of?(Symbol)
38
+
39
+ validate_options(options)
40
+
41
+ DuckRecord::Reflection.create(macro, name, options, model)
42
+ end
43
+
44
+ def self.macro
45
+ raise NotImplementedError
46
+ end
47
+
48
+ def self.valid_options(options)
49
+ VALID_OPTIONS + Association.extensions.flat_map(&:valid_options)
50
+ end
51
+
52
+ def self.validate_options(options)
53
+ options.assert_valid_keys(valid_options(options))
54
+ end
55
+
56
+ def self.define_extensions(model, name)
57
+ end
58
+
59
+ def self.define_callbacks(model, reflection)
60
+ Association.extensions.each do |extension|
61
+ extension.build model, reflection
62
+ end
63
+ end
64
+
65
+ # Defines the setter and getter methods for the association
66
+ # class Post < ActiveRecord::Base
67
+ # has_many :comments
68
+ # end
69
+ #
70
+ # Post.first.comments and Post.first.comments= methods are defined by this method...
71
+ def self.define_accessors(model, reflection)
72
+ mixin = model.generated_association_methods
73
+ name = reflection.name
74
+ define_readers(mixin, name)
75
+ define_writers(mixin, name)
76
+ end
77
+
78
+ def self.define_readers(mixin, name)
79
+ mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
80
+ def #{name}(*args)
81
+ association(:#{name}).reader(*args)
82
+ end
83
+ CODE
84
+ end
85
+
86
+ def self.define_writers(mixin, name)
87
+ mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
88
+ def #{name}=(value)
89
+ association(:#{name}).writer(value)
90
+ end
91
+ CODE
92
+ end
93
+
94
+ def self.define_validations(model, reflection)
95
+ # noop
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,48 @@
1
+ # This class is inherited by the has_many and has_many_and_belongs_to_many association classes
2
+
3
+ require "duck_record/associations"
4
+
5
+ module DuckRecord::Associations::Builder # :nodoc:
6
+ class CollectionAssociation < Association #:nodoc:
7
+ CALLBACKS = [:before_add, :after_add]
8
+
9
+ def self.valid_options(options)
10
+ super + [:before_add, :after_add]
11
+ end
12
+
13
+ def self.define_callbacks(model, reflection)
14
+ super
15
+ name = reflection.name
16
+ options = reflection.options
17
+ CALLBACKS.each { |callback_name|
18
+ define_callback(model, callback_name, name, options)
19
+ }
20
+ end
21
+
22
+ def self.define_extensions(model, name)
23
+ if block_given?
24
+ extension_module_name = "#{model.name.demodulize}#{name.to_s.camelize}AssociationExtension"
25
+ extension = Module.new(&Proc.new)
26
+ model.parent.const_set(extension_module_name, extension)
27
+ end
28
+ end
29
+
30
+ def self.define_callback(model, callback_name, name, options)
31
+ full_callback_name = "#{callback_name}_for_#{name}"
32
+
33
+ # TODO : why do i need method_defined? I think its because of the inheritance chain
34
+ model.class_attribute full_callback_name unless model.method_defined?(full_callback_name)
35
+ callbacks = Array(options[callback_name.to_sym]).map do |callback|
36
+ case callback
37
+ when Symbol
38
+ ->(_method, owner, record) { owner.send(callback, record) }
39
+ when Proc
40
+ ->(_method, owner, record) { callback.call(owner, record) }
41
+ else
42
+ ->(method, owner, record) { callback.send(method, owner, record) }
43
+ end
44
+ end
45
+ model.send "#{full_callback_name}=", callbacks
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,7 @@
1
+ module DuckRecord::Associations::Builder # :nodoc:
2
+ class HasMany < CollectionAssociation #:nodoc:
3
+ def self.macro
4
+ :has_many
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,15 @@
1
+ module DuckRecord::Associations::Builder # :nodoc:
2
+ class HasOne < SingularAssociation #:nodoc:
3
+ def self.macro
4
+ :has_one
5
+ end
6
+
7
+ def self.define_validations(model, reflection)
8
+ super
9
+
10
+ if reflection.options[:required]
11
+ model.validates_presence_of reflection.name, message: :required
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,22 @@
1
+ # This class is inherited by the has_one and belongs_to association classes
2
+
3
+ module DuckRecord::Associations::Builder # :nodoc:
4
+ class SingularAssociation < Association #:nodoc:
5
+ def self.define_accessors(model, reflection)
6
+ super
7
+ mixin = model.generated_association_methods
8
+ name = reflection.name
9
+
10
+ define_constructors(mixin, name) if reflection.constructable?
11
+ end
12
+
13
+ # Defines the (build|create)_association methods for belongs_to or has_one association
14
+ def self.define_constructors(mixin, name)
15
+ mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
16
+ def build_#{name}(*args, &block)
17
+ association(:#{name}).build(*args, &block)
18
+ end
19
+ CODE
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,187 @@
1
+ module DuckRecord
2
+ module Associations
3
+ # = Active Record Association Collection
4
+ #
5
+ # CollectionAssociation is an abstract class that provides common stuff to
6
+ # ease the implementation of association proxies that represent
7
+ # collections. See the class hierarchy in Association.
8
+ #
9
+ # CollectionAssociation:
10
+ # HasManyAssociation => has_many
11
+ # HasManyThroughAssociation + ThroughAssociation => has_many :through
12
+ #
13
+ # The CollectionAssociation class provides common methods to the collections
14
+ # defined by +has_and_belongs_to_many+, +has_many+ or +has_many+ with
15
+ # the +:through association+ option.
16
+ #
17
+ # You need to be careful with assumptions regarding the target: The proxy
18
+ # does not fetch records from the database until it needs them, but new
19
+ # ones created with +build+ are added to the target. So, the target may be
20
+ # non-empty and still lack children waiting to be read from the database.
21
+ # If you look directly to the database you cannot assume that's the entire
22
+ # collection because new records may have been added to the target, etc.
23
+ #
24
+ # If you need to work on all current children, new and existing records,
25
+ # +load_target+ and the +loaded+ flag are your friends.
26
+ class CollectionAssociation < Association #:nodoc:
27
+ # Implements the reader method, e.g. foo.items for Foo.has_many :items
28
+ def reader
29
+ @_reader ||= CollectionProxy.new(klass, self)
30
+ end
31
+
32
+ # Implements the writer method, e.g. foo.items= for Foo.has_many :items
33
+ def writer(records)
34
+ replace(records)
35
+ end
36
+
37
+ def reset
38
+ super
39
+ @target = []
40
+ end
41
+
42
+ def build(attributes = {}, &block)
43
+ if attributes.is_a?(Array)
44
+ attributes.collect { |attr| build(attr, &block) }
45
+ else
46
+ add_to_target(build_record(attributes)) do |record|
47
+ yield(record) if block_given?
48
+ end
49
+ end
50
+ end
51
+
52
+ # Add +records+ to this association. Returns +self+ so method calls may
53
+ # be chained. Since << flattens its argument list and inserts each record,
54
+ # +push+ and +concat+ behave identically.
55
+ def concat(*records)
56
+ records = records.flatten
57
+ @target.concat records
58
+ end
59
+
60
+ # Removes all records from the association without calling callbacks
61
+ # on the associated records. It honors the +:dependent+ option. However
62
+ # if the +:dependent+ value is +:destroy+ then in that case the +:delete_all+
63
+ # deletion strategy for the association is applied.
64
+ #
65
+ # You can force a particular deletion strategy by passing a parameter.
66
+ #
67
+ # Example:
68
+ #
69
+ # @author.books.delete_all(:nullify)
70
+ # @author.books.delete_all(:delete_all)
71
+ #
72
+ # See delete for more info.
73
+ def delete_all
74
+ @target.clear
75
+ end
76
+
77
+ # Removes +records+ from this association calling +before_remove+ and
78
+ # +after_remove+ callbacks.
79
+ #
80
+ # This method is abstract in the sense that +delete_records+ has to be
81
+ # provided by descendants. Note this method does not imply the records
82
+ # are actually removed from the database, that depends precisely on
83
+ # +delete_records+. They are in any case removed from the collection.
84
+ def delete(*records)
85
+ return if records.empty?
86
+ @target = @target - records
87
+ end
88
+
89
+ # Deletes the +records+ and removes them from this association calling
90
+ # +before_remove+ , +after_remove+ , +before_destroy+ and +after_destroy+ callbacks.
91
+ #
92
+ # Note that this method removes records from the database ignoring the
93
+ # +:dependent+ option.
94
+ def destroy(*records)
95
+ return if records.empty?
96
+ records = find(records) if records.any? { |record| record.kind_of?(Integer) || record.kind_of?(String) }
97
+ delete_or_destroy(records, :destroy)
98
+ end
99
+
100
+ # Returns the size of the collection by executing a SELECT COUNT(*)
101
+ # query if the collection hasn't been loaded, and calling
102
+ # <tt>collection.size</tt> if it has.
103
+ #
104
+ # If the collection has been already loaded +size+ and +length+ are
105
+ # equivalent. If not and you are going to need the records anyway
106
+ # +length+ will take one less query. Otherwise +size+ is more efficient.
107
+ #
108
+ # This method is abstract in the sense that it relies on
109
+ # +count_records+, which is a method descendants have to provide.
110
+ def size
111
+ @target.size
112
+ end
113
+
114
+ def uniq
115
+ @target.uniq!
116
+ end
117
+
118
+ # Returns true if the collection is empty.
119
+ #
120
+ # If the collection has been loaded
121
+ # it is equivalent to <tt>collection.size.zero?</tt>. If the
122
+ # collection has not been loaded, it is equivalent to
123
+ # <tt>collection.exists?</tt>. If the collection has not already been
124
+ # loaded and you are going to fetch the records anyway it is better to
125
+ # check <tt>collection.length.zero?</tt>.
126
+ def empty?
127
+ @target.blank?
128
+ end
129
+
130
+ # Replace this collection with +other_array+. This will perform a diff
131
+ # and delete/add only records that have changed.
132
+ def replace(other_array)
133
+ @target = other_array
134
+ end
135
+
136
+ def include?(record)
137
+ @target.include?(record)
138
+ end
139
+
140
+ def add_to_target(record, skip_callbacks = false, &block)
141
+ index = @target.index(record)
142
+
143
+ replace_on_target(record, index, skip_callbacks, &block)
144
+ end
145
+
146
+ def replace_on_target(record, index, skip_callbacks)
147
+ callback(:before_add, record) unless skip_callbacks
148
+
149
+ begin
150
+ if index
151
+ record_was = target[index]
152
+ target[index] = record
153
+ else
154
+ target << record
155
+ end
156
+
157
+ yield(record) if block_given?
158
+ rescue
159
+ if index
160
+ target[index] = record_was
161
+ else
162
+ target.delete(record)
163
+ end
164
+
165
+ raise
166
+ end
167
+
168
+ callback(:after_add, record) unless skip_callbacks
169
+
170
+ record
171
+ end
172
+
173
+ private
174
+
175
+ def callback(method, record)
176
+ callbacks_for(method).each do |callback|
177
+ callback.call(method, owner, record)
178
+ end
179
+ end
180
+
181
+ def callbacks_for(callback_name)
182
+ full_callback_name = "#{callback_name}_for_#{reflection.name}"
183
+ owner.class.send(full_callback_name)
184
+ end
185
+ end
186
+ end
187
+ end