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