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