datamapper-dm-core 0.9.11 → 0.10.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (192) hide show
  1. data/.autotest +17 -14
  2. data/.gitignore +3 -1
  3. data/FAQ +6 -5
  4. data/History.txt +5 -39
  5. data/Manifest.txt +67 -76
  6. data/QUICKLINKS +1 -1
  7. data/README.txt +21 -15
  8. data/Rakefile +16 -15
  9. data/SPECS +2 -29
  10. data/TODO +1 -1
  11. data/dm-core.gemspec +11 -15
  12. data/lib/dm-core/adapters/abstract_adapter.rb +182 -185
  13. data/lib/dm-core/adapters/data_objects_adapter.rb +482 -534
  14. data/lib/dm-core/adapters/in_memory_adapter.rb +90 -69
  15. data/lib/dm-core/adapters/mysql_adapter.rb +22 -115
  16. data/lib/dm-core/adapters/oracle_adapter.rb +249 -0
  17. data/lib/dm-core/adapters/postgres_adapter.rb +7 -173
  18. data/lib/dm-core/adapters/sqlite3_adapter.rb +4 -97
  19. data/lib/dm-core/adapters/yaml_adapter.rb +116 -0
  20. data/lib/dm-core/adapters.rb +135 -16
  21. data/lib/dm-core/associations/many_to_many.rb +372 -90
  22. data/lib/dm-core/associations/many_to_one.rb +220 -73
  23. data/lib/dm-core/associations/one_to_many.rb +319 -255
  24. data/lib/dm-core/associations/one_to_one.rb +66 -53
  25. data/lib/dm-core/associations/relationship.rb +560 -158
  26. data/lib/dm-core/collection.rb +1104 -381
  27. data/lib/dm-core/core_ext/kernel.rb +12 -0
  28. data/lib/dm-core/core_ext/symbol.rb +10 -0
  29. data/lib/dm-core/identity_map.rb +4 -34
  30. data/lib/dm-core/migrations.rb +1283 -0
  31. data/lib/dm-core/model/descendant_set.rb +81 -0
  32. data/lib/dm-core/model/hook.rb +45 -0
  33. data/lib/dm-core/model/is.rb +32 -0
  34. data/lib/dm-core/model/property.rb +248 -0
  35. data/lib/dm-core/model/relationship.rb +335 -0
  36. data/lib/dm-core/model/scope.rb +90 -0
  37. data/lib/dm-core/model.rb +570 -369
  38. data/lib/dm-core/property.rb +753 -280
  39. data/lib/dm-core/property_set.rb +141 -98
  40. data/lib/dm-core/query/conditions/comparison.rb +814 -0
  41. data/lib/dm-core/query/conditions/operation.rb +247 -0
  42. data/lib/dm-core/query/direction.rb +43 -0
  43. data/lib/dm-core/query/operator.rb +42 -0
  44. data/lib/dm-core/query/path.rb +102 -0
  45. data/lib/dm-core/query/sort.rb +45 -0
  46. data/lib/dm-core/query.rb +974 -492
  47. data/lib/dm-core/repository.rb +147 -107
  48. data/lib/dm-core/resource.rb +644 -429
  49. data/lib/dm-core/spec/adapter_shared_spec.rb +294 -0
  50. data/lib/dm-core/spec/data_objects_adapter_shared_spec.rb +106 -0
  51. data/lib/dm-core/support/chainable.rb +20 -0
  52. data/lib/dm-core/support/deprecate.rb +12 -0
  53. data/lib/dm-core/support/equalizer.rb +23 -0
  54. data/lib/dm-core/support/logger.rb +13 -0
  55. data/lib/dm-core/{naming_conventions.rb → support/naming_conventions.rb} +6 -6
  56. data/lib/dm-core/transaction.rb +333 -92
  57. data/lib/dm-core/type.rb +98 -60
  58. data/lib/dm-core/types/boolean.rb +1 -1
  59. data/lib/dm-core/types/discriminator.rb +34 -20
  60. data/lib/dm-core/types/object.rb +7 -4
  61. data/lib/dm-core/types/paranoid_boolean.rb +11 -9
  62. data/lib/dm-core/types/paranoid_datetime.rb +11 -9
  63. data/lib/dm-core/types/serial.rb +3 -3
  64. data/lib/dm-core/types/text.rb +3 -4
  65. data/lib/dm-core/version.rb +1 -1
  66. data/lib/dm-core.rb +106 -110
  67. data/script/performance.rb +102 -109
  68. data/script/profile.rb +169 -38
  69. data/spec/lib/adapter_helpers.rb +105 -0
  70. data/spec/lib/collection_helpers.rb +18 -0
  71. data/spec/lib/counter_adapter.rb +34 -0
  72. data/spec/lib/pending_helpers.rb +27 -0
  73. data/spec/lib/rspec_immediate_feedback_formatter.rb +53 -0
  74. data/spec/public/associations/many_to_many_spec.rb +193 -0
  75. data/spec/public/associations/many_to_one_spec.rb +73 -0
  76. data/spec/public/associations/one_to_many_spec.rb +77 -0
  77. data/spec/public/associations/one_to_one_spec.rb +156 -0
  78. data/spec/public/collection_spec.rb +65 -0
  79. data/spec/public/model/relationship_spec.rb +924 -0
  80. data/spec/public/model_spec.rb +159 -0
  81. data/spec/public/property_spec.rb +829 -0
  82. data/spec/public/resource_spec.rb +71 -0
  83. data/spec/public/sel_spec.rb +44 -0
  84. data/spec/public/setup_spec.rb +145 -0
  85. data/spec/public/shared/association_collection_shared_spec.rb +317 -0
  86. data/spec/public/shared/collection_shared_spec.rb +1723 -0
  87. data/spec/public/shared/finder_shared_spec.rb +1619 -0
  88. data/spec/public/shared/resource_shared_spec.rb +924 -0
  89. data/spec/public/shared/sel_shared_spec.rb +112 -0
  90. data/spec/public/transaction_spec.rb +129 -0
  91. data/spec/public/types/discriminator_spec.rb +130 -0
  92. data/spec/semipublic/adapters/abstract_adapter_spec.rb +30 -0
  93. data/spec/semipublic/adapters/in_memory_adapter_spec.rb +12 -0
  94. data/spec/semipublic/adapters/mysql_adapter_spec.rb +17 -0
  95. data/spec/semipublic/adapters/oracle_adapter_spec.rb +194 -0
  96. data/spec/semipublic/adapters/postgres_adapter_spec.rb +17 -0
  97. data/spec/semipublic/adapters/sqlite3_adapter_spec.rb +17 -0
  98. data/spec/semipublic/adapters/yaml_adapter_spec.rb +12 -0
  99. data/spec/semipublic/associations/many_to_one_spec.rb +53 -0
  100. data/spec/semipublic/associations/relationship_spec.rb +194 -0
  101. data/spec/semipublic/associations_spec.rb +177 -0
  102. data/spec/semipublic/collection_spec.rb +142 -0
  103. data/spec/semipublic/property_spec.rb +61 -0
  104. data/spec/semipublic/query/conditions_spec.rb +528 -0
  105. data/spec/semipublic/query/path_spec.rb +443 -0
  106. data/spec/semipublic/query_spec.rb +2626 -0
  107. data/spec/semipublic/resource_spec.rb +47 -0
  108. data/spec/semipublic/shared/resource_shared_spec.rb +126 -0
  109. data/spec/spec.opts +3 -1
  110. data/spec/spec_helper.rb +80 -57
  111. data/tasks/ci.rb +19 -31
  112. data/tasks/dm.rb +43 -48
  113. data/tasks/doc.rb +8 -11
  114. data/tasks/gemspec.rb +5 -5
  115. data/tasks/hoe.rb +15 -16
  116. data/tasks/install.rb +8 -10
  117. metadata +72 -93
  118. data/lib/dm-core/associations/relationship_chain.rb +0 -81
  119. data/lib/dm-core/associations.rb +0 -207
  120. data/lib/dm-core/auto_migrations.rb +0 -105
  121. data/lib/dm-core/dependency_queue.rb +0 -32
  122. data/lib/dm-core/hook.rb +0 -11
  123. data/lib/dm-core/is.rb +0 -16
  124. data/lib/dm-core/logger.rb +0 -232
  125. data/lib/dm-core/migrations/destructive_migrations.rb +0 -17
  126. data/lib/dm-core/migrator.rb +0 -29
  127. data/lib/dm-core/scope.rb +0 -58
  128. data/lib/dm-core/support/array.rb +0 -13
  129. data/lib/dm-core/support/assertions.rb +0 -8
  130. data/lib/dm-core/support/errors.rb +0 -23
  131. data/lib/dm-core/support/kernel.rb +0 -11
  132. data/lib/dm-core/support/symbol.rb +0 -41
  133. data/lib/dm-core/support.rb +0 -7
  134. data/lib/dm-core/type_map.rb +0 -80
  135. data/lib/dm-core/types.rb +0 -19
  136. data/script/all +0 -4
  137. data/spec/integration/association_spec.rb +0 -1382
  138. data/spec/integration/association_through_spec.rb +0 -203
  139. data/spec/integration/associations/many_to_many_spec.rb +0 -449
  140. data/spec/integration/associations/many_to_one_spec.rb +0 -163
  141. data/spec/integration/associations/one_to_many_spec.rb +0 -188
  142. data/spec/integration/auto_migrations_spec.rb +0 -413
  143. data/spec/integration/collection_spec.rb +0 -1073
  144. data/spec/integration/data_objects_adapter_spec.rb +0 -32
  145. data/spec/integration/dependency_queue_spec.rb +0 -46
  146. data/spec/integration/model_spec.rb +0 -197
  147. data/spec/integration/mysql_adapter_spec.rb +0 -85
  148. data/spec/integration/postgres_adapter_spec.rb +0 -731
  149. data/spec/integration/property_spec.rb +0 -253
  150. data/spec/integration/query_spec.rb +0 -514
  151. data/spec/integration/repository_spec.rb +0 -61
  152. data/spec/integration/resource_spec.rb +0 -513
  153. data/spec/integration/sqlite3_adapter_spec.rb +0 -352
  154. data/spec/integration/sti_spec.rb +0 -273
  155. data/spec/integration/strategic_eager_loading_spec.rb +0 -156
  156. data/spec/integration/transaction_spec.rb +0 -75
  157. data/spec/integration/type_spec.rb +0 -275
  158. data/spec/lib/logging_helper.rb +0 -18
  159. data/spec/lib/mock_adapter.rb +0 -27
  160. data/spec/lib/model_loader.rb +0 -100
  161. data/spec/lib/publicize_methods.rb +0 -28
  162. data/spec/models/content.rb +0 -16
  163. data/spec/models/vehicles.rb +0 -34
  164. data/spec/models/zoo.rb +0 -48
  165. data/spec/unit/adapters/abstract_adapter_spec.rb +0 -133
  166. data/spec/unit/adapters/adapter_shared_spec.rb +0 -15
  167. data/spec/unit/adapters/data_objects_adapter_spec.rb +0 -632
  168. data/spec/unit/adapters/in_memory_adapter_spec.rb +0 -98
  169. data/spec/unit/adapters/postgres_adapter_spec.rb +0 -133
  170. data/spec/unit/associations/many_to_many_spec.rb +0 -32
  171. data/spec/unit/associations/many_to_one_spec.rb +0 -159
  172. data/spec/unit/associations/one_to_many_spec.rb +0 -393
  173. data/spec/unit/associations/one_to_one_spec.rb +0 -7
  174. data/spec/unit/associations/relationship_spec.rb +0 -71
  175. data/spec/unit/associations_spec.rb +0 -242
  176. data/spec/unit/auto_migrations_spec.rb +0 -111
  177. data/spec/unit/collection_spec.rb +0 -182
  178. data/spec/unit/data_mapper_spec.rb +0 -35
  179. data/spec/unit/identity_map_spec.rb +0 -126
  180. data/spec/unit/is_spec.rb +0 -80
  181. data/spec/unit/migrator_spec.rb +0 -33
  182. data/spec/unit/model_spec.rb +0 -321
  183. data/spec/unit/naming_conventions_spec.rb +0 -36
  184. data/spec/unit/property_set_spec.rb +0 -90
  185. data/spec/unit/property_spec.rb +0 -753
  186. data/spec/unit/query_spec.rb +0 -571
  187. data/spec/unit/repository_spec.rb +0 -93
  188. data/spec/unit/resource_spec.rb +0 -649
  189. data/spec/unit/scope_spec.rb +0 -142
  190. data/spec/unit/transaction_spec.rb +0 -493
  191. data/spec/unit/type_map_spec.rb +0 -114
  192. data/spec/unit/type_spec.rb +0 -119
@@ -1,189 +1,313 @@
1
+ # TODO: if Collection is scoped by a unique property, should adding
2
+ # new Resources be denied?
3
+
4
+ # TODO: add #copy method
5
+
6
+ # TODO: move Collection#loaded_entries to LazyArray
7
+ # TODO: move Collection#partially_loaded to LazyArray
8
+
1
9
  module DataMapper
10
+ # The Collection class represents a list of resources persisted in
11
+ # a repository and identified by a query.
12
+ #
13
+ # A Collection should act like an Array in every way, except that
14
+ # it will attempt to defer loading until the results from the
15
+ # repository are needed.
16
+ #
17
+ # A Collection is typically returned by the Model#all
18
+ # method.
2
19
  class Collection < LazyArray
3
- include Assertions
20
+ extend Deprecate
21
+
22
+ deprecate :add, :<<
23
+ deprecate :build, :new
4
24
 
25
+ # Returns the Query the Collection is scoped with
26
+ #
27
+ # @return [Query]
28
+ # the Query the Collection is scoped with
29
+ #
30
+ # @api semipublic
5
31
  attr_reader :query
6
32
 
7
- ##
8
- # @return [Repository] the repository the collection is
9
- # associated with
33
+ # Returns the Repository
10
34
  #
11
- # @api public
35
+ # @return [Repository]
36
+ # the Repository this Collection is associated with
37
+ #
38
+ # @api semipublic
12
39
  def repository
13
40
  query.repository
14
41
  end
15
42
 
16
- ##
17
- # loads the entries for the collection. Used by the
18
- # adapters to load the instances of the declared
19
- # model for this collection's query.
43
+ # Returns the Model
20
44
  #
21
- # @api private
22
- def load(values)
23
- add(model.load(values, query))
45
+ # @return [Model]
46
+ # the Model the Collection is associated with
47
+ #
48
+ # @api semipublic
49
+ def model
50
+ query.model
24
51
  end
25
52
 
26
- ##
27
- # reloads the entries associated with this collection
53
+ # Reloads the Collection from the repository
54
+ #
55
+ # If +query+ is provided, updates this Collection's query with its conditions
28
56
  #
29
- # @param [DataMapper::Query] query (optional) additional query
30
- # to scope by. Use this if you want to query a collections result
31
- # set
57
+ # cars_from_91 = Cars.all(:year_manufactured => 1991)
58
+ # cars_from_91.first.year_manufactured = 2001 # note: not saved
59
+ # cars_from_91.reload
60
+ # cars_from_91.first.year #=> 1991
32
61
  #
33
- # @see DataMapper::Collection#all
62
+ # @param [Query, Hash] query (optional)
63
+ # further restrict results with query
64
+ #
65
+ # @return [self]
34
66
  #
35
67
  # @api public
36
- def reload(query = {})
37
- @query = scoped_query(query)
38
- inheritance_property = model.base_model.inheritance_property
39
- fields = (@key_properties | [inheritance_property]).compact
40
- @query.update(:fields => @query.fields | fields)
41
- replace(all(:reload => true))
68
+ def reload(query = nil)
69
+ query = query.nil? ? self.query.dup : self.query.merge(query)
70
+
71
+ # make sure the Identity Map contains all the existing resources
72
+ identity_map = repository.identity_map(model)
73
+
74
+ loaded_entries.each do |resource|
75
+ identity_map[resource.key] = resource
76
+ end
77
+
78
+ properties = model.properties(repository.name)
79
+ fields = properties.key | query.fields
80
+
81
+ if discriminator = properties.discriminator
82
+ fields |= [ discriminator ]
83
+ end
84
+
85
+ # sort fields based on declared order, for more consistent reload queries
86
+ fields = properties & fields
87
+
88
+ # replace the list of resources
89
+ replace(all(query.update(:fields => fields, :reload => true)))
42
90
  end
43
91
 
44
- ##
45
- # retrieves an entry out of the collection's entry by key
92
+ # Lookup a Resource in the Collection by key
93
+ #
94
+ # This looksup a Resource by key, typecasting the key to the
95
+ # proper object if necessary.
46
96
  #
47
- # @param [DataMapper::Types::*, ...] key keys which uniquely
48
- # identify a resource in the collection
97
+ # toyotas = Cars.all(:manufacturer => 'Toyota')
98
+ # toyo = Cars.first(:manufacturer => 'Toyota')
99
+ # toyotas.get(toyo.id) == toyo #=> true
49
100
  #
50
- # @return [DataMapper::Resource, NilClass] the resource which
51
- # has the supplied keys
101
+ # @param [Enumerable] *key
102
+ # keys which uniquely identify a resource in the Collection
103
+ #
104
+ # @return [Resource]
105
+ # Resource which matches the supplied key
106
+ # @return [nil]
107
+ # No Resource matches the supplied key
52
108
  #
53
109
  # @api public
54
110
  def get(*key)
55
- key = model.typecast_key(key)
56
- if loaded?
57
- # find indexed resource (create index first if it does not exist)
58
- each {|r| @cache[r.key] = r } if @cache.empty?
59
- @cache[key]
60
- elsif query.limit || query.offset > 0
111
+ key = model.key(repository.name).typecast(key)
112
+
113
+ resource = @identity_map[key] || if !loaded? && (query.limit || query.offset > 0)
61
114
  # current query is exclusive, find resource within the set
62
115
 
63
- # TODO: use a subquery to retrieve the collection and then match
116
+ # TODO: use a subquery to retrieve the Collection and then match
64
117
  # it up against the key. This will require some changes to
65
118
  # how subqueries are generated, since the key may be a
66
119
  # composite key. In the case of DO adapters, it means subselects
67
- # like the form "(a, b) IN(SELECT a,b FROM ...)", which will
120
+ # like the form "(a, b) IN(SELECT a, b FROM ...)", which will
68
121
  # require making it so the Query condition key can be a
69
122
  # Property or an Array of Property objects
70
123
 
71
124
  # use the brute force approach until subquery lookups work
72
125
  lazy_load
73
- get(*key)
126
+ @identity_map[key]
74
127
  else
75
128
  # current query is all inclusive, lookup using normal approach
76
- first(model.to_query(repository, key))
129
+ first(model.key_conditions(repository, key))
77
130
  end
131
+
132
+ return if resource.nil?
133
+
134
+ orphan_resource(resource)
78
135
  end
79
136
 
80
- ##
81
- # retrieves an entry out of the collection's entry by key,
82
- # raising an exception if the object cannot be found
137
+ # Lookup a Resource in the Collection by key, raising an exception if not found
83
138
  #
84
- # @param [DataMapper::Types::*, ...] key keys which uniquely
85
- # identify a resource in the collection
139
+ # This looksup a Resource by key, typecasting the key to the
140
+ # proper object if necessary.
86
141
  #
87
- # @calls DataMapper::Collection#get
142
+ # @param [Enumerable] *key
143
+ # keys which uniquely identify a resource in the Collection
88
144
  #
89
- # @raise [ObjectNotFoundError] "Could not find #{model.name} with key #{key.inspect} in collection"
145
+ # @return [Resource]
146
+ # Resource which matches the supplied key
147
+ # @return [nil]
148
+ # No Resource matches the supplied key
149
+ #
150
+ # @raise [ObjectNotFoundError] Resource could not be found by key
90
151
  #
91
152
  # @api public
92
153
  def get!(*key)
93
- get(*key) || raise(ObjectNotFoundError, "Could not find #{model.name} with key #{key.inspect} in collection")
154
+ get(*key) || raise(ObjectNotFoundError, "Could not find #{model.name} with key #{key.inspect}")
94
155
  end
95
156
 
96
- ##
97
- # Further refines a collection's conditions. #all provides an
98
- # interface which simulates a database view.
157
+ # Returns a new Collection optionally scoped by +query+
158
+ #
159
+ # This returns a new Collection scoped relative to the current
160
+ # Collection.
99
161
  #
100
- # @param [Hash[Symbol, Object], DataMapper::Query] query parameters for
101
- # an query within the results of the original query.
162
+ # cars_from_91 = Cars.all(:year_manufactured => 1991)
163
+ # toyotas_91 = cars_from_91.all(:manufacturer => 'Toyota')
164
+ # toyotas_91.all? { |car| car.year_manufactured == 1991 } #=> true
165
+ # toyotas_91.all? { |car| car.manufacturer == 'Toyota' } #=> true
102
166
  #
103
- # @return [DataMapper::Collection] a collection whose query is the result
104
- # of a merge
167
+ # If +query+ is a Hash, results will be found by merging +query+ with this Collection's query.
168
+ # If +query+ is a Query, results will be found using +query+ as an absolute query.
169
+ #
170
+ # @param [Hash, Query] query
171
+ # optional parameters to scope results with
172
+ #
173
+ # @return [Collection]
174
+ # Collection scoped by +query+
105
175
  #
106
176
  # @api public
107
- def all(query = {})
108
- # TODO: this shouldn't be a kicker if scoped_query() is called
109
- return self if query.kind_of?(Hash) ? query.empty? : query == self.query
110
- query = scoped_query(query)
111
- query.repository.read_many(query)
177
+ def all(query = nil)
178
+ if query.nil? || (query.kind_of?(Hash) && query.empty?)
179
+ dup
180
+ else
181
+ # TODO: if there is no order parameter, and the Collection is not loaded
182
+ # check to see if the query can be satisfied by the head/tail
183
+
184
+ new_collection(scoped_query(query))
185
+ end
112
186
  end
113
187
 
114
- ##
115
- # Simulates Array#first by returning the first entry (when
116
- # there are no arguments), or transforms the collection's query
117
- # by applying :limit => n when you supply an Integer. If you
118
- # provide a conditions hash, or a Query object, the internal
119
- # query is scoped and a new collection is returned
188
+ # Return the first Resource or the first N Resources in the Collection with an optional query
189
+ #
190
+ # When there are no arguments, return the first Resource in the
191
+ # Collection. When the first argument is an Integer, return a
192
+ # Collection containing the first N Resources. When the last
193
+ # (optional) argument is a Hash scope the results to the query.
120
194
  #
121
- # @param [Integer, Hash[Symbol, Object], Query] args
195
+ # @param [Integer] limit (optional)
196
+ # limit the returned Collection to a specific number of entries
197
+ # @param [Hash] query (optional)
198
+ # scope the returned Resource or Collection to the supplied query
122
199
  #
123
- # @return [DataMapper::Resource, DataMapper::Collection] The
124
- # first resource in the entries of this collection, or
125
- # a new collection whose query has been merged
200
+ # @return [Resource, Collection]
201
+ # The first resource in the entries of this collection,
202
+ # or a new collection whose query has been merged
126
203
  #
127
204
  # @api public
128
205
  def first(*args)
129
- # TODO: this shouldn't be a kicker if scoped_query() is called
130
- if loaded?
131
- if args.empty?
132
- return super
133
- elsif args.size == 1 && args.first.kind_of?(Integer)
134
- limit = args.shift
135
- return self.class.new(scoped_query(:limit => limit)) { |c| c.replace(super(limit)) }
136
- end
137
- end
206
+ last_arg = args.last
138
207
 
139
- query = args.last.respond_to?(:merge) ? args.pop : {}
140
- query = scoped_query(query.merge(:limit => args.first || 1))
208
+ limit = args.first if args.first.kind_of?(Integer)
209
+ with_query = last_arg.respond_to?(:merge) && !last_arg.blank?
141
210
 
142
- if args.any?
143
- query.repository.read_many(query)
211
+ query = with_query ? last_arg : {}
212
+ query = self.query.slice(0, limit || 1).update(query)
213
+
214
+ # TODO: when a query provided, and there are enough elements in head to
215
+ # satisfy the query.limit, filter the head with the query, and make
216
+ # sure it matches the limit exactly. if so, use that result instead
217
+ # of calling all()
218
+ # - this can probably only be done if there is no :order parameter
219
+
220
+ collection = if !with_query && (loaded? || lazy_possible?(head, query.limit))
221
+ new_collection(query, super(query.limit))
144
222
  else
145
- query.repository.read_one(query)
223
+ all(query)
224
+ end
225
+
226
+ if limit
227
+ collection
228
+ else
229
+ collection.to_a.first
146
230
  end
147
231
  end
148
232
 
149
- ##
150
- # Simulates Array#last by returning the last entry (when
151
- # there are no arguments), or transforming the collection's
152
- # query by reversing the declared order, and applying
153
- # :limit => n when you supply an Integer. If you
154
- # supply a conditions hash, or a Query object, the
155
- # internal query is scoped and a new collection is returned
233
+ # Return the last Resource or the last N Resources in the Collection with an optional query
234
+ #
235
+ # When there are no arguments, return the last Resource in the
236
+ # Collection. When the first argument is an Integer, return a
237
+ # Collection containing the last N Resources. When the last
238
+ # (optional) argument is a Hash scope the results to the query.
239
+ #
240
+ # @param [Integer] limit (optional)
241
+ # limit the returned Collection to a specific number of entries
242
+ # @param [Hash] query (optional)
243
+ # scope the returned Resource or Collection to the supplied query
156
244
  #
157
- # @calls Collection#first
245
+ # @return [Resource, Collection]
246
+ # The last resource in the entries of this collection,
247
+ # or a new collection whose query has been merged
158
248
  #
159
249
  # @api public
160
250
  def last(*args)
161
- return super if loaded? && args.empty?
251
+ last_arg = args.last
162
252
 
163
- reversed = reverse
253
+ limit = args.first if args.first.kind_of?(Integer)
254
+ with_query = last_arg.respond_to?(:merge) && !last_arg.blank?
164
255
 
165
- # tell the collection to reverse the order of the
166
- # results coming out of the adapter
167
- reversed.query.add_reversed = !query.add_reversed?
256
+ query = with_query ? last_arg : {}
257
+ query = self.query.slice(0, limit || 1).update(query).reverse!
168
258
 
169
- reversed.first(*args)
259
+ # tell the Query to prepend each result from the adapter
260
+ query.update(:add_reversed => !query.add_reversed?)
261
+
262
+ # TODO: when a query provided, and there are enough elements in tail to
263
+ # satisfy the query.limit, filter the tail with the query, and make
264
+ # sure it matches the limit exactly. if so, use that result instead
265
+ # of calling all()
266
+
267
+ collection = if !with_query && (loaded? || lazy_possible?(tail, query.limit))
268
+ new_collection(query, super(query.limit))
269
+ else
270
+ all(query)
271
+ end
272
+
273
+ if limit
274
+ collection
275
+ else
276
+ collection.to_a.last
277
+ end
170
278
  end
171
279
 
172
- ##
173
- # Simulates Array#at and returns the entry at that index.
174
- # Also accepts negative indexes and appropriate reverses
175
- # the order of the query
280
+ # Lookup a Resource from the Collection by offset
281
+ #
282
+ # @param [Integer] offset
283
+ # offset of the Resource in the Collection
176
284
  #
177
- # @calls Collection#first
178
- # @calls Collection#last
285
+ # @return [Resource]
286
+ # Resource which matches the supplied offset
287
+ # @return [nil]
288
+ # No Resource matches the supplied offset
179
289
  #
180
290
  # @api public
181
291
  def at(offset)
182
- return super if loaded?
183
- offset >= 0 ? first(:offset => offset) : last(:offset => offset.abs - 1)
292
+ if loaded? || partially_loaded?(offset)
293
+ return unless resource = super
294
+ orphan_resource(resource)
295
+ elsif offset >= 0
296
+ first(:offset => offset)
297
+ else
298
+ last(:offset => offset.abs - 1)
299
+ end
184
300
  end
185
301
 
186
- ##
302
+ # Access LazyArray#slice directly
303
+ #
304
+ # Collection#[]= uses this to bypass Collection#slice and access
305
+ # the resources directly so that it can orphan them properly.
306
+ #
307
+ # @api private
308
+ alias superclass_slice slice
309
+ private :superclass_slice
310
+
187
311
  # Simulates Array#slice and returns a new Collection
188
312
  # whose query has a new offset or limit according to the
189
313
  # arguments provided.
@@ -191,479 +315,1078 @@ module DataMapper
191
315
  # If you provide a range, the min is used as the offset
192
316
  # and the max minues the offset is used as the limit.
193
317
  #
194
- # @param [Integer, Array(Integer), Range] args the offset,
195
- # offset and limit, or range indicating offsets and limits
318
+ # @param [Integer, Array(Integer), Range] *args
319
+ # the offset, offset and limit, or range indicating first and last position
196
320
  #
197
- # @return [DataMapper::Resource, DataMapper::Collection]
321
+ # @return [Resource, Collection, nil]
198
322
  # The entry which resides at that offset and limit,
199
323
  # or a new Collection object with the set limits and offset
324
+ # @return [nil]
325
+ # The offset (or starting offset) is out of range
200
326
  #
201
327
  # @raise [ArgumentError] "arguments may be 1 or 2 Integers,
202
328
  # or 1 Range object, was: #{args.inspect}"
203
329
  #
204
- # @alias []
205
- #
206
330
  # @api public
207
- def slice(*args)
208
- return at(args.first) if args.size == 1 && args.first.kind_of?(Integer)
331
+ def [](*args)
332
+ offset, limit = extract_slice_arguments(*args)
209
333
 
210
- if args.size == 2 && args.first.kind_of?(Integer) && args.last.kind_of?(Integer)
211
- offset, limit = args
212
- elsif args.size == 1 && args.first.kind_of?(Range)
213
- range = args.first
214
- offset = range.first
215
- limit = range.last - offset
216
- limit += 1 unless range.exclude_end?
217
- else
218
- raise ArgumentError, "arguments may be 1 or 2 Integers, or 1 Range object, was: #{args.inspect}", caller
334
+ if args.size == 1 && args.first.kind_of?(Integer)
335
+ return at(offset)
219
336
  end
220
337
 
221
- all(:offset => offset, :limit => limit)
338
+ query = sliced_query(offset, limit)
339
+
340
+ if loaded? || partially_loaded?(offset, limit)
341
+ new_collection(query, super)
342
+ else
343
+ new_collection(query)
344
+ end
222
345
  end
223
346
 
224
- alias [] slice
347
+ alias slice []
348
+
349
+ # Deletes and Returns the Resources given by an offset or a Range
350
+ #
351
+ # @param [Integer, Array(Integer), Range] *args
352
+ # the offset, offset and limit, or range indicating first and last position
353
+ #
354
+ # @return [Resource, Collection]
355
+ # The entry which resides at that offset and limit, or
356
+ # a new Collection object with the set limits and offset
357
+ # @return [Resource, Collection, nil]
358
+ # The offset is out of range
359
+ #
360
+ # @api public
361
+ def slice!(*args)
362
+ removed = super
363
+
364
+ resources_removed(removed) unless removed.nil?
365
+
366
+ # Workaround for Ruby <= 1.8.6
367
+ compact! if RUBY_VERSION <= '1.8.6'
368
+
369
+ unless removed.kind_of?(Enumerable)
370
+ return removed
371
+ end
372
+
373
+ offset, limit = extract_slice_arguments(*args)
374
+
375
+ query = sliced_query(offset, limit)
376
+
377
+ new_collection(query, removed)
378
+ end
225
379
 
226
- ##
380
+ # Splice a list of Resources at a given offset or range
227
381
  #
228
- # @return [DataMapper::Collection] a new collection whose
229
- # query is sorted in the reverse
382
+ # When nil is provided instead of a Resource or a list of Resources
383
+ # this will remove all of the Resources at the specified position.
230
384
  #
231
- # @see Array#reverse, DataMapper#all, DataMapper::Query#reverse
385
+ # @param [Integer, Array(Integer), Range] *args
386
+ # The offset, offset and limit, or range indicating first and last position.
387
+ # The last argument may be a Resource, a list of Resources or nil.
388
+ #
389
+ # @return [Resource, Enumerable]
390
+ # the Resource or list of Resources that was spliced into the Collection
391
+ # @return [nil]
392
+ # If nil was used to delete the entries
393
+ #
394
+ # @api public
395
+ def []=(*args)
396
+ orphans = Array(superclass_slice(*args[0..-2]))
397
+
398
+ # relate new resources
399
+ resources = resources_added(super)
400
+
401
+ # mark resources as removed
402
+ resources_removed(orphans - loaded_entries)
403
+
404
+ # ensure remaining orphans are still related
405
+ (orphans & loaded_entries).each { |resource| relate_resource(resource) }
406
+
407
+ resources
408
+ end
409
+
410
+ alias splice []=
411
+
412
+ # Return a copy of the Collection sorted in reverse
413
+ #
414
+ # @return [Collection]
415
+ # Collection equal to +self+ but ordered in reverse
232
416
  #
233
417
  # @api public
234
418
  def reverse
235
- all(self.query.reverse)
419
+ dup.reverse!
420
+ end
421
+
422
+ # Return the Collection sorted in reverse
423
+ #
424
+ # @return [self]
425
+ #
426
+ # @api public
427
+ def reverse!
428
+ query.reverse!
429
+
430
+ # reverse without kicking if possible
431
+ if loaded?
432
+ @array.reverse!
433
+ else
434
+ # reverse and swap the head and tail
435
+ @head, @tail = tail.reverse!, head.reverse!
436
+ end
437
+
438
+ self
236
439
  end
237
440
 
238
- ##
239
- # @see Array#<<
441
+ # Invoke the block for each resource and replace it the return value
442
+ #
443
+ # @yield [Resource] Each resource in the collection
444
+ #
445
+ # @return [self]
446
+ #
447
+ # @api public
448
+ def collect!
449
+ super { |resource| resource_added(yield(resource_removed(resource))) }
450
+ end
451
+
452
+ alias map! collect!
453
+
454
+ # Append one Resource to the Collection and relate it
455
+ #
456
+ # @param [Resource] resource
457
+ # the resource to add to this collection
458
+ #
459
+ # @return [self]
240
460
  #
241
461
  # @api public
242
462
  def <<(resource)
463
+ if resource.kind_of?(Hash)
464
+ resource = new(resource)
465
+ end
466
+
467
+ resource_added(resource)
468
+ super
469
+ end
470
+
471
+ # Appends the resources to self
472
+ #
473
+ # @param [Enumerable] resources
474
+ # List of Resources to append to the collection
475
+ #
476
+ # @return [self]
477
+ #
478
+ # @api public
479
+ def concat(resources)
480
+ resources_added(resources)
243
481
  super
244
- relate_resource(resource)
245
- self
246
482
  end
247
483
 
248
- ##
249
- # @see Array#push
484
+ # Append one or more Resources to the Collection
485
+ #
486
+ # This should append one or more Resources to the Collection and
487
+ # relate each to the Collection.
488
+ #
489
+ # @param [Enumerable] *resources
490
+ # List of Resources to append
491
+ #
492
+ # @return [self]
250
493
  #
251
494
  # @api public
252
495
  def push(*resources)
496
+ resources_added(resources)
253
497
  super
254
- resources.each { |resource| relate_resource(resource) }
255
- self
256
498
  end
257
499
 
258
- ##
259
- # @see Array#unshift
500
+ # Prepend one or more Resources to the Collection
501
+ #
502
+ # This should prepend one or more Resources to the Collection and
503
+ # relate each to the Collection.
504
+ #
505
+ # @param [Enumerable] *resources
506
+ # The Resources to prepend
507
+ #
508
+ # @return [self]
260
509
  #
261
510
  # @api public
262
511
  def unshift(*resources)
512
+ resources_added(resources)
263
513
  super
264
- resources.each { |resource| relate_resource(resource) }
265
- self
266
514
  end
267
515
 
268
- ##
269
- # @see Array#replace
516
+ # Inserts the Resources before the Resource at the offset (which may be negative).
517
+ #
518
+ # @param [Integer] offset
519
+ # The offset to insert the Resources before
520
+ # @param [Enumerable] *resources
521
+ # List of Resources to insert
522
+ #
523
+ # @return [self]
270
524
  #
271
525
  # @api public
272
- def replace(other)
273
- if loaded?
274
- each { |resource| orphan_resource(resource) }
275
- end
526
+ def insert(offset, *resources)
527
+ resources_added(resources)
276
528
  super
277
- other.each { |resource| relate_resource(resource) }
278
- self
279
529
  end
280
530
 
281
- ##
282
- # @see Array#pop
531
+ # Removes and returns the last Resource in the Collection
532
+ #
533
+ # @return [Resource]
534
+ # the last Resource in the Collection
283
535
  #
284
536
  # @api public
285
- def pop
286
- orphan_resource(super)
537
+ def pop(*)
538
+ if removed = super
539
+ resources_removed(removed)
540
+ end
287
541
  end
288
542
 
289
- ##
290
- # @see Array#shift
543
+ # Removes and returns the first Resource in the Collection
544
+ #
545
+ # @return [Resource]
546
+ # the first Resource in the Collection
291
547
  #
292
548
  # @api public
293
- def shift
294
- orphan_resource(super)
549
+ def shift(*)
550
+ if removed = super
551
+ resources_removed(removed)
552
+ end
295
553
  end
296
554
 
297
- ##
298
- # @see Array#delete
555
+ # Remove Resource from the Collection
556
+ #
557
+ # This should remove an included Resource from the Collection and
558
+ # orphan it from the Collection. If the Resource is not within the
559
+ # Collection, it should return nil.
560
+ #
561
+ # @param [Resource] resource the Resource to remove from
562
+ # the Collection
563
+ #
564
+ # @return [Resource]
565
+ # If +resource+ is within the Collection
566
+ # @return [nil]
567
+ # If +resource+ is not within the Collection
299
568
  #
300
569
  # @api public
301
570
  def delete(resource)
302
- orphan_resource(super)
571
+ if resource = super
572
+ resource_removed(resource)
573
+ end
574
+ end
575
+
576
+ # Remove Resource from the Collection by offset
577
+ #
578
+ # This should remove the Resource from the Collection at a given
579
+ # offset and orphan it from the Collection. If the offset is out of
580
+ # range return nil.
581
+ #
582
+ # @param [Integer] offset
583
+ # the offset of the Resource to remove from the Collection
584
+ #
585
+ # @return [Resource]
586
+ # If +offset+ is within the Collection
587
+ # @return [nil]
588
+ # If +offset+ is not within the Collection
589
+ #
590
+ # @api public
591
+ def delete_at(offset)
592
+ if resource = super
593
+ resource_removed(resource)
594
+ end
303
595
  end
304
596
 
305
- ##
306
- # @see Array#delete_at
597
+ # Deletes every Resource for which block evaluates to true.
598
+ #
599
+ # @yield [Resource] Each resource in the Collection
600
+ #
601
+ # @return [self]
307
602
  #
308
603
  # @api public
309
- def delete_at(index)
310
- orphan_resource(super)
604
+ def delete_if
605
+ super { |resource| yield(resource) && resource_removed(resource) }
311
606
  end
312
607
 
313
- ##
314
- # @see Array#clear
608
+ # Deletes every Resource for which block evaluates to true
609
+ #
610
+ # @yield [Resource] Each resource in the Collection
611
+ #
612
+ # @return [Collection]
613
+ # If resources were removed
614
+ # @return [nil]
615
+ # If no resources were removed
616
+ #
617
+ # @api public
618
+ def reject!
619
+ super { |resource| yield(resource) && resource_removed(resource) }
620
+ end
621
+
622
+ # Replace the Resources within the Collection
623
+ #
624
+ # @param [Enumerable] other
625
+ # List of other Resources to replace with
626
+ #
627
+ # @return [self]
628
+ #
629
+ # @api public
630
+ def replace(other)
631
+ other = other.map do |resource|
632
+ if resource.kind_of?(Hash)
633
+ new(resource)
634
+ else
635
+ resource
636
+ end
637
+ end
638
+
639
+ if loaded?
640
+ resources_removed(self - other)
641
+ end
642
+
643
+ super(resources_added(other))
644
+ end
645
+
646
+ # Access Collection#replace directly
647
+ #
648
+ # @api private
649
+ alias collection_replace replace
650
+ private :collection_replace
651
+
652
+ # Removes all Resources from the Collection
653
+ #
654
+ # This should remove and orphan each Resource from the Collection
655
+ #
656
+ # @return [self]
315
657
  #
316
658
  # @api public
317
659
  def clear
318
660
  if loaded?
319
- each { |resource| orphan_resource(resource) }
661
+ resources_removed(self)
320
662
  end
321
663
  super
322
- self
323
664
  end
324
665
 
325
- # builds a new resource and appends it to the collection
666
+ # Finds the first Resource by conditions, or initializes a new
667
+ # Resource with the attributes if none found
326
668
  #
327
- # @param Hash[Symbol => Object] attributes attributes which
328
- # the new resource should have.
669
+ # @param [Hash] conditions
670
+ # The conditions to be used to search
671
+ # @param [Hash] attributes
672
+ # The attributes to be used to initialize the resource with if none found
673
+ # @return [Resource]
674
+ # The instance found by +query+, or created with +attributes+ if none found
329
675
  #
330
676
  # @api public
331
- def build(attributes = {})
332
- repository.scope do
333
- resource = model.new(default_attributes.merge(attributes))
334
- self << resource
335
- resource
336
- end
677
+ def first_or_new(conditions = {}, attributes = {})
678
+ first(conditions) || new(conditions.merge(attributes))
337
679
  end
338
680
 
339
- ##
340
- # creates a new resource, saves it, and appends it to the collection
681
+ # Finds the first Resource by conditions, or creates a new
682
+ # Resource with the attributes if none found
341
683
  #
342
- # @param Hash[Symbol => Object] attributes attributes which
343
- # the new resource should have.
684
+ # @param [Hash] conditions
685
+ # The conditions to be used to search
686
+ # @param [Hash] attributes
687
+ # The attributes to be used to create the resource with if none found
688
+ # @return [Resource]
689
+ # The instance found by +query+, or created with +attributes+ if none found
690
+ #
691
+ # @api public
692
+ def first_or_create(conditions = {}, attributes = {})
693
+ first(conditions) || create(conditions.merge(attributes))
694
+ end
695
+
696
+ # Initializes a Resource and appends it to the Collection
697
+ #
698
+ # @param [Hash] attributes
699
+ # Attributes with which to initialize the new resource
700
+ #
701
+ # @return [Resource]
702
+ # a new Resource initialized with +attributes+
703
+ #
704
+ # @api public
705
+ def new(attributes = {})
706
+ resource = repository.scope { model.new(attributes) }
707
+ self << resource
708
+ resource
709
+ end
710
+
711
+ # Create a Resource in the Collection
712
+ #
713
+ # @param [Hash(Symbol => Object)] attributes
714
+ # attributes to set
715
+ #
716
+ # @return [Resource]
717
+ # the newly created Resource instance
344
718
  #
345
719
  # @api public
346
720
  def create(attributes = {})
347
- repository.scope do
348
- resource = model.create(default_attributes.merge(attributes))
349
- self << resource unless resource.new_record?
350
- resource
351
- end
721
+ _create(true, attributes)
352
722
  end
353
723
 
354
- def update(attributes = {}, preload = false)
355
- raise NotImplementedError, 'update *with* validations has not be written yet, try update!'
724
+ # Create a Resource in the Collection, bypassing hooks
725
+ #
726
+ # @param [Hash(Symbol => Object)] attributes
727
+ # attributes to set
728
+ #
729
+ # @return [Resource]
730
+ # the newly created Resource instance
731
+ #
732
+ # @api public
733
+ def create!(attributes = {})
734
+ _create(false, attributes)
356
735
  end
357
736
 
358
- ##
359
- # batch updates the entries belongs to this collection, and skip
360
- # validations for all resources.
737
+ # Update every Resource in the Collection
361
738
  #
362
- # @example Reached the Age of Alchohol Consumption
363
- # Person.all(:age.gte => 21).update!(:allow_beer => true)
739
+ # Person.all(:age.gte => 21).update(:allow_beer => true)
364
740
  #
365
- # @param attributes Hash[Symbol => Object] attributes to update
366
- # @param reload [FalseClass, TrueClass] if set to true, collection
367
- # will have loaded resources reflect updates.
741
+ # @param [Hash] attributes
742
+ # attributes to update with
368
743
  #
369
- # @return [TrueClass, FalseClass]
370
- # TrueClass indicates that all entries were affected
371
- # FalseClass indicates that some entries were affected
744
+ # @return [Boolean]
745
+ # true if the resources were successfully updated
372
746
  #
373
747
  # @api public
374
- def update!(attributes = {}, reload = false)
375
- # TODO: delegate to Model.update
376
- return true if attributes.empty?
748
+ def update(attributes = {})
749
+ assert_update_clean_only(:update)
377
750
 
378
- dirty_attributes = {}
751
+ dirty_attributes = model.new(attributes).dirty_attributes
752
+ dirty_attributes.empty? || all? { |resource| resource.update(attributes) }
753
+ end
379
754
 
380
- model.properties(repository.name).slice(*attributes.keys).each do |property|
381
- dirty_attributes[property] = attributes[property.name] if property
382
- end
755
+ # Update every Resource in the Collection bypassing validation
756
+ #
757
+ # Person.all(:age.gte => 21).update!(:allow_beer => true)
758
+ #
759
+ # @param [Hash] attributes
760
+ # attributes to update
761
+ #
762
+ # @return [Boolean]
763
+ # true if the resources were successfully updated
764
+ #
765
+ # @api public
766
+ def update!(attributes = {})
767
+ assert_update_clean_only(:update!)
383
768
 
384
- # this should never be done on update! even if collection is loaded. or?
385
- # each { |resource| resource.attributes = attributes } if loaded?
769
+ dirty_attributes = model.new(attributes).dirty_attributes
386
770
 
387
- changes = repository.update(dirty_attributes, scoped_query)
771
+ if dirty_attributes.empty?
772
+ true
773
+ elsif dirty_attributes.any? { |property, value| !property.nullable? && value.nil? }
774
+ false
775
+ else
776
+ unless _update(dirty_attributes)
777
+ return false
778
+ end
388
779
 
389
- # need to decide if this should be done in update!
390
- query.update(attributes)
780
+ if loaded?
781
+ each do |resource|
782
+ dirty_attributes.each { |property, value| property.set!(resource, value) }
783
+ repository.identity_map(model)[resource.key] = resource
784
+ end
785
+ end
391
786
 
392
- if identity_map.any? && reload
393
- reload_query = @key_properties.zip(identity_map.keys.transpose).to_hash
394
- model.all(reload_query.merge(attributes)).reload(:fields => attributes.keys)
787
+ true
395
788
  end
789
+ end
396
790
 
397
- # this should return true if there are any changes at all. as it skips validations
398
- # the only way it could be fewer changes is if some resources already was updated.
399
- # that should not return false? true = 'now all objects have these new values'
400
- return loaded? ? changes == size : changes > 0
791
+ # Save every Resource in the Collection
792
+ #
793
+ # @return [Boolean]
794
+ # true if the resources were successfully saved
795
+ #
796
+ # @api public
797
+ def save
798
+ _save(true)
401
799
  end
402
800
 
801
+ # Save every Resource in the Collection bypassing validation
802
+ #
803
+ # @return [Boolean]
804
+ # true if the resources were successfully saved
805
+ #
806
+ # @api public
807
+ def save!
808
+ _save(false)
809
+ end
810
+
811
+ # Remove every Resource in the Collection from the repository
812
+ #
813
+ # This performs a deletion of each Resource in the Collection from
814
+ # the repository and clears the Collection.
815
+ #
816
+ # @return [Boolean]
817
+ # true if the resources were successfully destroyed
818
+ #
819
+ # @api public
403
820
  def destroy
404
- raise NotImplementedError, 'destroy *with* validations has not be written yet, try destroy!'
821
+ if destroyed = all? { |resource| resource.destroy }
822
+ clear
823
+ end
824
+
825
+ destroyed
405
826
  end
406
827
 
407
- ##
408
- # batch destroy the entries belongs to this collection, and skip
409
- # validations for all resources.
828
+ # Remove all Resources from the repository, bypassing validation
410
829
  #
411
- # @example The War On Terror (if only it were this easy)
412
- # Person.all(:terrorist => true).destroy() #
830
+ # This performs a deletion of each Resource in the Collection from
831
+ # the repository and clears the Collection while skipping
832
+ # validation.
413
833
  #
414
- # @return [TrueClass, FalseClass]
415
- # TrueClass indicates that all entries were affected
416
- # FalseClass indicates that some entries were affected
834
+ # @return [Boolean]
835
+ # true if the resources were successfully destroyed
417
836
  #
418
837
  # @api public
419
838
  def destroy!
420
- # TODO: delegate to Model.destroy
421
- if loaded?
422
- return false unless repository.delete(scoped_query) == size
423
-
424
- each do |resource|
425
- resource.instance_variable_set(:@new_record, true)
426
- identity_map.delete(resource.key)
427
- resource.dirty_attributes.clear
839
+ if query.limit || query.offset > 0 || query.links.any?
840
+ key = model.key(repository.name)
841
+ conditions = Query.target_conditions(self, key, key)
428
842
 
429
- model.properties(repository.name).each do |property|
430
- next unless resource.attribute_loaded?(property.name)
431
- resource.dirty_attributes[property] = property.get(resource)
432
- end
843
+ unless model.all(:repository => repository, :conditions => conditions).destroy!
844
+ return false
433
845
  end
434
846
  else
435
- return false unless repository.delete(scoped_query) > 0
847
+ repository.delete(self)
848
+ mark_loaded
436
849
  end
437
850
 
438
- clear
851
+ if loaded?
852
+ each { |resource| resource.reset }
853
+ clear
854
+ end
439
855
 
440
856
  true
441
857
  end
442
858
 
443
- ##
444
- # @return [DataMapper::PropertySet] The set of properties this
445
- # query will be retrieving
859
+ # Check to see if collection can respond to the method
860
+ #
861
+ # @param [Symbol] method
862
+ # method to check in the object
863
+ # @param [Boolean] include_private
864
+ # if set to true, collection will check private methods
865
+ #
866
+ # @return [Boolean]
867
+ # true if method can be responded to
868
+ #
869
+ # @api public
870
+ def respond_to?(method, include_private = false)
871
+ super || model.respond_to?(method) || relationships.key?(method)
872
+ end
873
+
874
+ # Checks if all the resources have no changes to save
875
+ #
876
+ # @return [Boolean]
877
+ # true if the resource may not be persisted
878
+ #
879
+ # @api public
880
+ def clean?
881
+ !dirty?
882
+ end
883
+
884
+ # Checks if any resources have unsaved changes
885
+ #
886
+ # @return [Boolean]
887
+ # true if a resource may be persisted
888
+ #
889
+ # @api public
890
+ def dirty?
891
+ loaded_entries.any? { |resource| resource.dirty? }
892
+ end
893
+
894
+ # Gets a Human-readable representation of this collection,
895
+ # showing all elements contained in it
896
+ #
897
+ # @return [String]
898
+ # Human-readable representation of this collection, showing all elements
446
899
  #
447
900
  # @api public
901
+ def inspect
902
+ "[#{map { |resource| resource.inspect }.join(', ')}]"
903
+ end
904
+
905
+ # Returns the PropertySet representing the fields in the Collection scope
906
+ #
907
+ # @return [PropertySet]
908
+ # The set of properties this Collection's query will retrieve
909
+ #
910
+ # @api semipublic
448
911
  def properties
449
912
  PropertySet.new(query.fields)
450
913
  end
451
914
 
452
- ##
453
- # @return [DataMapper::Relationship] The model's relationships
915
+ # Returns the Relationships for the Collection's Model
454
916
  #
455
- # @api public
917
+ # @return [Hash]
918
+ # The model's relationships, mapping the name to the
919
+ # Associations::Relationship object
920
+ #
921
+ # @api semipublic
456
922
  def relationships
457
923
  model.relationships(repository.name)
458
924
  end
459
925
 
460
- ##
461
- # default values to use when creating a Resource within the Collection
926
+ private
927
+
928
+ # Initializes a new Collection identified by the query
462
929
  #
463
- # @return [Hash] The default attributes for DataMapper::Collection#create
930
+ # @param [Query] query
931
+ # Scope the results of the Collection
932
+ # @param [Enumerable] resources (optional)
933
+ # List of resources to initialize the Collection with
464
934
  #
465
- # @see DataMapper::Collection#create
935
+ # @return [self]
466
936
  #
467
- # @api public
468
- def default_attributes
469
- default_attributes = {}
470
- query.conditions.each do |tuple|
471
- operator, property, bind_value = *tuple
937
+ # @api private
938
+ def initialize(query, resources = nil)
939
+ raise "#{self.class}#new with a block is deprecated" if block_given?
472
940
 
473
- next unless operator == :eql &&
474
- property.kind_of?(DataMapper::Property) &&
475
- ![ Array, Range ].any? { |k| bind_value.kind_of?(k) }
476
- !@key_properties.include?(property)
941
+ @query = query
942
+ @identity_map = IdentityMap.new
943
+ @removed = Set.new
477
944
 
478
- default_attributes[property.name] = bind_value
945
+ super()
946
+
947
+ # TODO: change LazyArray to not use a load proc at all
948
+ remove_instance_variable(:@load_with_proc)
949
+
950
+ if resources
951
+ replace(resources)
479
952
  end
480
- default_attributes
481
953
  end
482
954
 
483
- ##
484
- # check to see if collection can respond to the method
955
+ # Copies the original Collection state
485
956
  #
486
- # @param method [Symbol] method to check in the object
487
- # @param include_private [FalseClass, TrueClass] if set to true,
488
- # collection will check private methods
957
+ # @param [Collection] original
958
+ # the original collection to copy from
489
959
  #
490
- # @return [TrueClass, FalseClass]
491
- # TrueClass indicates the method can be responded to by the collection
492
- # FalseClass indicates the method can not be responded to by the collection
960
+ # @return [undefined]
493
961
  #
494
- # @api public
495
- def respond_to?(method, include_private = false)
496
- super || model.public_methods(false).map { |m| m.to_s }.include?(method.to_s) || relationships.has_key?(method)
962
+ # @api private
963
+ def initialize_copy(original)
964
+ super
965
+ @query = @query.dup
966
+ @identity_map = @identity_map.dup
967
+ @removed = @removed.dup
497
968
  end
498
969
 
499
- # TODO: add docs
970
+ # Test if the collection is loaded between the offset and limit
971
+ #
972
+ # @param [Integer] offset
973
+ # the offset of the collection to test
974
+ # @param [Integer] limit
975
+ # optional limit for how many entries to be loaded
976
+ #
977
+ # @return [Boolean]
978
+ # true if the collection is loaded from the offset to the limit
979
+ #
500
980
  # @api private
501
- def _dump(*)
502
- Marshal.dump([ query, to_a ])
981
+ def partially_loaded?(offset, limit = 1)
982
+ if offset >= 0
983
+ lazy_possible?(head, offset + limit)
984
+ else
985
+ lazy_possible?(tail, offset.abs)
986
+ end
503
987
  end
504
988
 
505
- # TODO: add docs
989
+ # Lazy loads a Collection
990
+ #
991
+ # @return [self]
992
+ #
506
993
  # @api private
507
- def self._load(marshalled)
508
- query, array = Marshal.load(marshalled)
994
+ def lazy_load
995
+ if loaded?
996
+ return self
997
+ end
998
+
999
+ mark_loaded
1000
+
1001
+ resources = repository.read(query)
1002
+
1003
+ # remove already known results
1004
+ resources -= head if head.any?
1005
+ resources -= tail if tail.any?
1006
+ resources -= @removed.to_a if @removed.any?
1007
+
1008
+ query.add_reversed? ? unshift(*resources.reverse) : concat(resources)
509
1009
 
510
- # XXX: IMHO it is a code smell to be forced to use allocate
511
- # and instance_variable_set to load an object. You should
512
- # be able to use a constructor to provide all the info needed
513
- # to initialize an object. This should be fixed in the edge
514
- # branch dkubb/dm-core
1010
+ # TODO: DRY this up with LazyArray
1011
+ @array.unshift(*head)
1012
+ @array.concat(tail)
515
1013
 
516
- collection = allocate
517
- collection.instance_variable_set(:@query, query)
518
- collection.instance_variable_set(:@array, array)
519
- collection.instance_variable_set(:@loaded, true)
520
- collection.instance_variable_set(:@key_properties, collection.send(:model).key(collection.repository.name))
521
- collection.instance_variable_set(:@cache, {})
522
- collection
1014
+ @head = @tail = nil
1015
+ @reapers.each { |resource| @array.delete_if(&resource) } if @reapers
1016
+ @array.freeze if frozen?
1017
+
1018
+ self
523
1019
  end
524
1020
 
525
- protected
1021
+ # Loaded Resources in the collection
1022
+ #
1023
+ # @return [Array<Resource>]
1024
+ # Resources in the collection
1025
+ #
1026
+ # @api private
1027
+ def loaded_entries
1028
+ loaded? ? self : head + tail
1029
+ end
526
1030
 
527
- ##
1031
+ # Initializes a new Collection
1032
+ #
1033
+ # @return [Collection]
1034
+ # A new Collection object
1035
+ #
528
1036
  # @api private
529
- def model
530
- query.model
1037
+ def new_collection(query, resources = nil, &block)
1038
+ if loaded?
1039
+ resources ||= filter(query)
1040
+ end
1041
+
1042
+ # TOOD: figure out a way to pass not-yet-saved Resources to this newly
1043
+ # created Collection. If the new resource matches the conditions, then
1044
+ # it should be added to the collection (keep in mind limit/offset too)
1045
+
1046
+ self.class.new(query, resources, &block)
531
1047
  end
532
1048
 
533
- private
1049
+ # Creates a resource in the collection
1050
+ #
1051
+ # @param [Boolean] safe
1052
+ # Whether to use the safe or unsafe create
1053
+ # @param [Hash] attributes
1054
+ # Attributes with which to create the new resource
1055
+ #
1056
+ # @return [Resource]
1057
+ # a saved Resource
1058
+ #
1059
+ # @api private
1060
+ def _create(safe, attributes)
1061
+ resource = repository.scope { model.send(safe ? :create : :create!, default_attributes.merge(attributes)) }
1062
+ self << resource if resource.saved?
1063
+ resource
1064
+ end
534
1065
 
535
- ##
536
- # @api public
537
- def initialize(query, &block)
538
- assert_kind_of 'query', query, Query
1066
+ # Updates a collection
1067
+ #
1068
+ # @return [Boolean]
1069
+ # Returns true if collection was updated
1070
+ #
1071
+ # @api private
1072
+ def _update(dirty_attributes)
1073
+ if query.limit || query.offset > 0 || query.links.any?
1074
+ attributes = dirty_attributes.map { |property, value| [ property.name, value ] }.to_hash
539
1075
 
540
- unless block_given?
541
- # It can be helpful (relationship.rb: 112-13, used for SEL) to have a non-lazy Collection.
542
- block = lambda { |c| }
1076
+ key = model.key(repository.name)
1077
+ conditions = Query.target_conditions(self, key, key)
1078
+
1079
+ unless model.all(:repository => repository, :conditions => conditions).update!(attributes)
1080
+ return false
1081
+ end
1082
+ else
1083
+ repository.update(dirty_attributes, self)
543
1084
  end
544
1085
 
545
- @query = query
546
- @key_properties = model.key(repository.name)
547
- @cache = {}
1086
+ true
1087
+ end
548
1088
 
549
- super()
1089
+ # Saves a collection
1090
+ #
1091
+ # @param [Symbol] method
1092
+ # The name of the Resource method to save the collection with
1093
+ #
1094
+ # @return [Boolean]
1095
+ # Returns true if collection was updated
1096
+ #
1097
+ # @api private
1098
+ def _save(safe)
1099
+ loaded_entries.each { |resource| set_default_attributes(resource) }
1100
+ @removed.clear
1101
+ loaded_entries.all? { |resource| resource.send(safe ? :save : :save!) }
1102
+ end
1103
+
1104
+ # Returns default values to initialize new Resources in the Collection
1105
+ #
1106
+ # @return [Hash] The default attributes for new instances in this Collection
1107
+ #
1108
+ # @api private
1109
+ def default_attributes
1110
+ return @default_attributes if @default_attributes
1111
+
1112
+ default_attributes = {}
1113
+
1114
+ conditions = query.conditions
550
1115
 
551
- load_with(&block)
1116
+ if conditions.kind_of?(Query::Conditions::AndOperation)
1117
+ repository_name = repository.name
1118
+ relationships = self.relationships.values
1119
+ properties = model.properties(repository_name)
1120
+ key = model.key(repository_name)
1121
+
1122
+ # if all the key properties are included in the conditions,
1123
+ # then do not allow them to be default attributes
1124
+ if query.condition_properties.to_set.superset?(key.to_set)
1125
+ properties -= key
1126
+ end
1127
+
1128
+ conditions.each do |condition|
1129
+ if condition.kind_of?(Query::Conditions::EqualToComparison) &&
1130
+ (properties.include?(condition.subject) || (condition.relationship? && condition.subject.source_model == model))
1131
+ default_attributes[condition.subject] = condition.value
1132
+ end
1133
+ end
1134
+ end
1135
+
1136
+ @default_attributes = default_attributes.freeze
552
1137
  end
553
1138
 
554
- ##
1139
+ # Set the default attributes for a non-frozen resource
1140
+ #
1141
+ # @param [Resource] resource
1142
+ # the resource to set the default attributes for
1143
+ #
1144
+ # @return [undefined]
1145
+ #
555
1146
  # @api private
556
- def add(resource)
557
- query.add_reversed? ? unshift(resource) : push(resource)
558
- resource
1147
+ def set_default_attributes(resource)
1148
+ unless resource.frozen?
1149
+ resource.attributes = default_attributes
1150
+ end
559
1151
  end
560
1152
 
561
- ##
1153
+ # Relates a Resource to the Collection
1154
+ #
1155
+ # This is used by SEL related code to reload a Resource and the
1156
+ # Collection it belongs to.
1157
+ #
1158
+ # @param [Resource] resource
1159
+ # The Resource to relate
1160
+ #
1161
+ # @return [Resource]
1162
+ # If Resource was successfully related
1163
+ # @return [nil]
1164
+ # If a nil resource was provided
1165
+ #
562
1166
  # @api private
563
1167
  def relate_resource(resource)
564
- return unless resource
565
- resource.collection = self
566
- @cache[resource.key] = resource
1168
+ unless resource.frozen?
1169
+ resource.collection = self
1170
+ end
1171
+
567
1172
  resource
568
1173
  end
569
1174
 
570
- ##
1175
+ # Orphans a Resource from the Collection
1176
+ #
1177
+ # Removes the association between the Resource and Collection so that
1178
+ # SEL related code will not load the Collection.
1179
+ #
1180
+ # @param [Resource] resource
1181
+ # The Resource to orphan
1182
+ #
1183
+ # @return [Resource]
1184
+ # The Resource that was orphaned
1185
+ # @return [nil]
1186
+ # If a nil resource was provided
1187
+ #
571
1188
  # @api private
572
1189
  def orphan_resource(resource)
573
- return unless resource
574
- resource.collection = nil if resource.collection.object_id == self.object_id
575
- @cache.delete(resource.key)
1190
+ if resource.collection.equal?(self) && !resource.frozen?
1191
+ resource.collection = nil
1192
+ end
1193
+
576
1194
  resource
577
1195
  end
578
1196
 
579
- ##
1197
+ # Track the added resource
1198
+ #
1199
+ # @param [Resource] resource
1200
+ # the resource that was added
1201
+ #
1202
+ # @return [Resource]
1203
+ # the resource that was added
1204
+ #
580
1205
  # @api private
581
- def scoped_query(query = self.query)
582
- assert_kind_of 'query', query, Query, Hash
583
-
584
- query.update(keys) if loaded?
1206
+ def resource_added(resource)
1207
+ if resource.saved?
1208
+ @identity_map[resource.key] = resource
1209
+ @removed.delete(resource)
1210
+ else
1211
+ set_default_attributes(resource)
1212
+ end
585
1213
 
586
- return self.query if query == self.query
1214
+ relate_resource(resource)
1215
+ end
587
1216
 
588
- query = if query.kind_of?(Hash)
589
- Query.new(query.has_key?(:repository) ? query.delete(:repository) : self.repository, model, query)
1217
+ # Track the added resources
1218
+ #
1219
+ # @param [Array<Resource>] resources
1220
+ # the resources that were added
1221
+ #
1222
+ # @return [Array<Resource>]
1223
+ # the resources that were added
1224
+ #
1225
+ # @api private
1226
+ def resources_added(resources)
1227
+ if resources.kind_of?(Enumerable)
1228
+ resources.each { |resource| resource_added(resource) }
590
1229
  else
591
- query
1230
+ resource_added(resources)
592
1231
  end
1232
+ end
593
1233
 
594
- if query.limit || query.offset > 0
595
- set_relative_position(query)
1234
+ # Track the removed resource
1235
+ #
1236
+ # @param [Resource] resource
1237
+ # the resource that was removed
1238
+ #
1239
+ # @return [Resource]
1240
+ # the resource that was removed
1241
+ #
1242
+ # @api private
1243
+ def resource_removed(resource)
1244
+ if resource.saved?
1245
+ @identity_map.delete(resource.key)
1246
+ @removed << resource
596
1247
  end
597
1248
 
598
- self.query.merge(query)
1249
+ orphan_resource(resource)
599
1250
  end
600
1251
 
601
- ##
1252
+ # Track the removed resources
1253
+ #
1254
+ # @param [Array<Resource>] resources
1255
+ # the resources that were removed
1256
+ #
1257
+ # @return [Array<Resource>]
1258
+ # the resources that were removed
1259
+ #
602
1260
  # @api private
603
- def keys
604
- keys = map {|r| r.key }
605
- keys.any? ? @key_properties.zip(keys.transpose).to_hash : {}
1261
+ def resources_removed(resources)
1262
+ if resources.kind_of?(Enumerable)
1263
+ resources.each { |resource| resource_removed(resource) }
1264
+ else
1265
+ resource_removed(resources)
1266
+ end
606
1267
  end
607
1268
 
608
- ##
1269
+ # Filter resources in the collection based on a Query
1270
+ #
1271
+ # @param [Query] query
1272
+ # the query to match each resource in the collection
1273
+ #
1274
+ # @return [Array]
1275
+ # the resources that match the Query
1276
+ # @return [nil]
1277
+ # nil if no resources match the Query
1278
+ #
609
1279
  # @api private
610
- def identity_map
611
- repository.identity_map(model)
1280
+ def filter(query)
1281
+ fields = self.query.fields.to_set
1282
+
1283
+ if query.links.empty? &&
1284
+ (query.unique? || (!query.unique? && !self.query.unique?)) &&
1285
+ !query.reload? &&
1286
+ !query.raw? &&
1287
+ query.fields.to_set.subset?(fields) &&
1288
+ query.condition_properties.subset?(fields)
1289
+ then
1290
+ query.filter_records(to_a.dup)
1291
+ end
612
1292
  end
613
1293
 
614
- ##
1294
+ # Return the absolute or relative scoped query
1295
+ #
1296
+ # @param [Query, Hash] query
1297
+ # the query to scope the collection with
1298
+ #
1299
+ # @return [Query]
1300
+ # the absolute or relative scoped query
1301
+ #
615
1302
  # @api private
616
- def set_relative_position(query)
617
- return if query == self.query
618
-
619
- if query.offset == 0
620
- return if !query.limit.nil? && !self.query.limit.nil? && query.limit <= self.query.limit
621
- return if query.limit.nil? && self.query.limit.nil?
1303
+ def scoped_query(query)
1304
+ if query.kind_of?(Query)
1305
+ query.dup
1306
+ else
1307
+ self.query.relative(query)
622
1308
  end
1309
+ end
623
1310
 
624
- first_pos = self.query.offset + query.offset
625
- last_pos = self.query.offset + self.query.limit if self.query.limit
1311
+ # @api private
1312
+ def sliced_query(offset, limit)
1313
+ query = self.query
626
1314
 
627
- if limit = query.limit
628
- if last_pos.nil? || first_pos + limit < last_pos
629
- last_pos = first_pos + limit
630
- end
631
- end
1315
+ if offset >= 0
1316
+ query.slice(offset, limit)
1317
+ else
1318
+ query = query.slice((limit + offset).abs, limit).reverse!
632
1319
 
633
- if last_pos && first_pos >= last_pos
634
- raise 'outside range' # TODO: raise a proper exception object
1320
+ # tell the Query to prepend each result from the adapter
1321
+ query.update(:add_reversed => !query.add_reversed?)
635
1322
  end
636
-
637
- query.update(:offset => first_pos)
638
- query.update(:limit => last_pos - first_pos) if last_pos
639
1323
  end
640
1324
 
641
- ##
642
- # @api private
1325
+ # Delegates to Model, Relationships or the superclass (LazyArray)
1326
+ #
1327
+ # When this receives a method that belongs to the Model the
1328
+ # Collection is scoped to, it will execute the method within the
1329
+ # same scope as the Collection and return the results.
1330
+ #
1331
+ # When this receives a method that is a relationship the Model has
1332
+ # defined, it will execute the association method within the same
1333
+ # scope as the Collection and return the results.
1334
+ #
1335
+ # Otherwise this method will delegate to a method in the superclass
1336
+ # (LazyArray) and return the results.
1337
+ #
1338
+ # @return [Object]
1339
+ # the return values of the delegated methods
1340
+ #
1341
+ # @api public
643
1342
  def method_missing(method, *args, &block)
644
- if model.public_methods(false).map { |m| m.to_s }.include?(method.to_s)
645
- model.send(:with_scope, query) do
646
- model.send(method, *args, &block)
647
- end
648
- elsif relationship = relationships[method]
649
- klass = model == relationship.child_model ? relationship.parent_model : relationship.child_model
650
-
651
- # TODO: when self.query includes an offset/limit use it as a
652
- # subquery to scope the results rather than a join
1343
+ if model.model_method_defined?(method)
1344
+ delegate_to_model(method, *args, &block)
1345
+ elsif relationship = relationships[method] || relationships[method.to_s.singular.to_sym]
1346
+ delegate_to_relationship(relationship, *args)
1347
+ else
1348
+ super
1349
+ end
1350
+ end
653
1351
 
654
- query = Query.new(repository, klass)
655
- query.conditions.push(*self.query.conditions)
656
- query.update(relationship.query)
657
- query.update(args.pop) if args.last.kind_of?(Hash)
1352
+ # Delegate the method to the Model
1353
+ #
1354
+ # @param [Symbol] method
1355
+ # the name of the method in the model to execute
1356
+ # @param [Array] *args
1357
+ # the arguments for the method
1358
+ #
1359
+ # @return [Object]
1360
+ # the return value of the model method
1361
+ #
1362
+ # @api private
1363
+ def delegate_to_model(method, *args, &block)
1364
+ model.__send__(:with_scope, query) do
1365
+ model.send(method, *args, &block)
1366
+ end
1367
+ end
658
1368
 
659
- query.update(
660
- :fields => klass.properties(repository.name).defaults,
661
- :links => [ relationship ] + self.query.links
662
- )
1369
+ # Delegate the method to the Relationship
1370
+ #
1371
+ # @return [Collection]
1372
+ # the associated Resources
1373
+ #
1374
+ # @api private
1375
+ def delegate_to_relationship(relationship, query = nil)
1376
+ relationship.eager_load(self, query)
1377
+ end
663
1378
 
664
- klass.all(query, &block)
665
- else
666
- super
1379
+ # Raises an exception if #update is performed on a dirty resource
1380
+ #
1381
+ # @raise [UpdateConflictError]
1382
+ # raise if the resource is dirty
1383
+ #
1384
+ # @return [undefined]
1385
+ #
1386
+ # @api private
1387
+ def assert_update_clean_only(method)
1388
+ if dirty?
1389
+ raise UpdateConflictError, "##{method} cannot be called on a dirty collection"
667
1390
  end
668
1391
  end
669
1392
  end # class Collection