datamapper-dm-core 0.9.11 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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