duck_record 0.0.3 → 0.0.5

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: 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