dm-core 0.9.2

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 (101) hide show
  1. data/CHANGELOG +144 -0
  2. data/FAQ +74 -0
  3. data/MIT-LICENSE +22 -0
  4. data/QUICKLINKS +12 -0
  5. data/README +143 -0
  6. data/lib/dm-core.rb +213 -0
  7. data/lib/dm-core/adapters.rb +4 -0
  8. data/lib/dm-core/adapters/abstract_adapter.rb +202 -0
  9. data/lib/dm-core/adapters/data_objects_adapter.rb +701 -0
  10. data/lib/dm-core/adapters/mysql_adapter.rb +132 -0
  11. data/lib/dm-core/adapters/postgres_adapter.rb +179 -0
  12. data/lib/dm-core/adapters/sqlite3_adapter.rb +105 -0
  13. data/lib/dm-core/associations.rb +172 -0
  14. data/lib/dm-core/associations/many_to_many.rb +138 -0
  15. data/lib/dm-core/associations/many_to_one.rb +101 -0
  16. data/lib/dm-core/associations/one_to_many.rb +275 -0
  17. data/lib/dm-core/associations/one_to_one.rb +61 -0
  18. data/lib/dm-core/associations/relationship.rb +116 -0
  19. data/lib/dm-core/associations/relationship_chain.rb +74 -0
  20. data/lib/dm-core/auto_migrations.rb +64 -0
  21. data/lib/dm-core/collection.rb +604 -0
  22. data/lib/dm-core/hook.rb +11 -0
  23. data/lib/dm-core/identity_map.rb +45 -0
  24. data/lib/dm-core/is.rb +16 -0
  25. data/lib/dm-core/logger.rb +233 -0
  26. data/lib/dm-core/migrations/destructive_migrations.rb +17 -0
  27. data/lib/dm-core/migrator.rb +29 -0
  28. data/lib/dm-core/model.rb +399 -0
  29. data/lib/dm-core/naming_conventions.rb +52 -0
  30. data/lib/dm-core/property.rb +611 -0
  31. data/lib/dm-core/property_set.rb +158 -0
  32. data/lib/dm-core/query.rb +590 -0
  33. data/lib/dm-core/repository.rb +159 -0
  34. data/lib/dm-core/resource.rb +618 -0
  35. data/lib/dm-core/scope.rb +35 -0
  36. data/lib/dm-core/support.rb +7 -0
  37. data/lib/dm-core/support/array.rb +13 -0
  38. data/lib/dm-core/support/assertions.rb +8 -0
  39. data/lib/dm-core/support/errors.rb +23 -0
  40. data/lib/dm-core/support/kernel.rb +7 -0
  41. data/lib/dm-core/support/symbol.rb +41 -0
  42. data/lib/dm-core/transaction.rb +267 -0
  43. data/lib/dm-core/type.rb +160 -0
  44. data/lib/dm-core/type_map.rb +80 -0
  45. data/lib/dm-core/types.rb +19 -0
  46. data/lib/dm-core/types/boolean.rb +7 -0
  47. data/lib/dm-core/types/discriminator.rb +32 -0
  48. data/lib/dm-core/types/object.rb +20 -0
  49. data/lib/dm-core/types/paranoid_boolean.rb +23 -0
  50. data/lib/dm-core/types/paranoid_datetime.rb +22 -0
  51. data/lib/dm-core/types/serial.rb +9 -0
  52. data/lib/dm-core/types/text.rb +10 -0
  53. data/spec/integration/association_spec.rb +1215 -0
  54. data/spec/integration/association_through_spec.rb +150 -0
  55. data/spec/integration/associations/many_to_many_spec.rb +171 -0
  56. data/spec/integration/associations/many_to_one_spec.rb +123 -0
  57. data/spec/integration/associations/one_to_many_spec.rb +66 -0
  58. data/spec/integration/auto_migrations_spec.rb +398 -0
  59. data/spec/integration/collection_spec.rb +1015 -0
  60. data/spec/integration/data_objects_adapter_spec.rb +32 -0
  61. data/spec/integration/model_spec.rb +68 -0
  62. data/spec/integration/mysql_adapter_spec.rb +85 -0
  63. data/spec/integration/postgres_adapter_spec.rb +732 -0
  64. data/spec/integration/property_spec.rb +224 -0
  65. data/spec/integration/query_spec.rb +376 -0
  66. data/spec/integration/repository_spec.rb +57 -0
  67. data/spec/integration/resource_spec.rb +324 -0
  68. data/spec/integration/sqlite3_adapter_spec.rb +352 -0
  69. data/spec/integration/sti_spec.rb +185 -0
  70. data/spec/integration/transaction_spec.rb +75 -0
  71. data/spec/integration/type_spec.rb +149 -0
  72. data/spec/lib/mock_adapter.rb +27 -0
  73. data/spec/spec_helper.rb +112 -0
  74. data/spec/unit/adapters/abstract_adapter_spec.rb +133 -0
  75. data/spec/unit/adapters/adapter_shared_spec.rb +15 -0
  76. data/spec/unit/adapters/data_objects_adapter_spec.rb +627 -0
  77. data/spec/unit/adapters/postgres_adapter_spec.rb +125 -0
  78. data/spec/unit/associations/many_to_many_spec.rb +14 -0
  79. data/spec/unit/associations/many_to_one_spec.rb +138 -0
  80. data/spec/unit/associations/one_to_many_spec.rb +385 -0
  81. data/spec/unit/associations/one_to_one_spec.rb +7 -0
  82. data/spec/unit/associations/relationship_spec.rb +67 -0
  83. data/spec/unit/associations_spec.rb +205 -0
  84. data/spec/unit/auto_migrations_spec.rb +110 -0
  85. data/spec/unit/collection_spec.rb +174 -0
  86. data/spec/unit/data_mapper_spec.rb +21 -0
  87. data/spec/unit/identity_map_spec.rb +126 -0
  88. data/spec/unit/is_spec.rb +80 -0
  89. data/spec/unit/migrator_spec.rb +33 -0
  90. data/spec/unit/model_spec.rb +339 -0
  91. data/spec/unit/naming_conventions_spec.rb +28 -0
  92. data/spec/unit/property_set_spec.rb +96 -0
  93. data/spec/unit/property_spec.rb +447 -0
  94. data/spec/unit/query_spec.rb +485 -0
  95. data/spec/unit/repository_spec.rb +93 -0
  96. data/spec/unit/resource_spec.rb +557 -0
  97. data/spec/unit/scope_spec.rb +131 -0
  98. data/spec/unit/transaction_spec.rb +493 -0
  99. data/spec/unit/type_map_spec.rb +114 -0
  100. data/spec/unit/type_spec.rb +119 -0
  101. metadata +187 -0
@@ -0,0 +1,61 @@
1
+ module DataMapper
2
+ module Associations
3
+ module OneToOne
4
+ extend Assertions
5
+
6
+ # Setup one to one relationship between two models
7
+ # -
8
+ # @api private
9
+ def self.setup(name, model, options = {})
10
+ assert_kind_of 'name', name, Symbol
11
+ assert_kind_of 'model', model, Model
12
+ assert_kind_of 'options', options, Hash
13
+
14
+ repository_name = model.repository.name
15
+
16
+ model.class_eval <<-EOS, __FILE__, __LINE__
17
+ def #{name}
18
+ #{name}_association.first
19
+ end
20
+
21
+ def #{name}=(child_resource)
22
+ #{name}_association.replace(child_resource.nil? ? [] : [ child_resource ])
23
+ end
24
+
25
+ private
26
+
27
+ def #{name}_association
28
+ @#{name}_association ||= begin
29
+ unless relationship = model.relationships(#{repository_name.inspect})[:#{name}]
30
+ raise ArgumentError, 'Relationship #{name.inspect} does not exist'
31
+ end
32
+ association = Associations::OneToMany::Proxy.new(relationship, self)
33
+ parent_associations << association
34
+ association
35
+ end
36
+ end
37
+ EOS
38
+
39
+ model.relationships(repository_name)[name] = if options.has_key?(:through)
40
+ RelationshipChain.new(
41
+ :child_model => options.fetch(:class_name, Extlib::Inflection.classify(name)),
42
+ :parent_model => model.name,
43
+ :repository_name => repository_name,
44
+ :near_relationship_name => options[:through],
45
+ :remote_relationship_name => options.fetch(:remote_name, name),
46
+ :parent_key => options[:parent_key],
47
+ :child_key => options[:child_key]
48
+ )
49
+ else
50
+ Relationship.new(
51
+ Extlib::Inflection.underscore(Extlib::Inflection.demodulize(model.name)).to_sym,
52
+ repository_name,
53
+ options.fetch(:class_name, Extlib::Inflection.classify(name)),
54
+ model.name,
55
+ options
56
+ )
57
+ end
58
+ end
59
+ end # module HasOne
60
+ end # module Associations
61
+ end # module DataMapper
@@ -0,0 +1,116 @@
1
+ module DataMapper
2
+ module Associations
3
+ class Relationship
4
+ include Assertions
5
+
6
+ OPTIONS = [ :class_name, :child_key, :parent_key, :min, :max, :through ]
7
+
8
+ attr_reader :name, :repository_name, :options, :query
9
+
10
+ def child_key
11
+ @child_key ||= begin
12
+ model_properties = child_model.properties(repository_name)
13
+
14
+ child_key = parent_key.zip(@child_properties || []).map do |parent_property,property_name|
15
+ # TODO: use something similar to DM::NamingConventions to determine the property name
16
+ property_name ||= "#{name}_#{parent_property.name}".to_sym
17
+
18
+ model_properties[property_name] || DataMapper.repository(repository_name) do
19
+ attributes = {}
20
+
21
+ [ :length, :precision, :scale ].each do |attribute|
22
+ attributes[attribute] = parent_property.send(attribute)
23
+ end
24
+
25
+ # NOTE: hack to make each many to many child_key a true key,
26
+ # until I can figure out a better place for this check
27
+ if child_model.respond_to?(:many_to_many)
28
+ attributes[:key] = true
29
+ end
30
+
31
+ child_model.property(property_name, parent_property.primitive, attributes)
32
+ end
33
+ end
34
+ PropertySet.new(child_key)
35
+ end
36
+ end
37
+
38
+ def parent_key
39
+ @parent_key ||= begin
40
+ parent_key = if @parent_properties
41
+ parent_model.properties(repository_name).slice(*@parent_properties)
42
+ else
43
+ parent_model.key(repository_name)
44
+ end
45
+
46
+ PropertySet.new(parent_key)
47
+ end
48
+ end
49
+
50
+ def parent_model
51
+ Class === @parent_model ? @parent_model : self.class.find_const(@parent_model)
52
+ end
53
+
54
+ def child_model
55
+ Class === @child_model ? @child_model : self.class.find_const(@child_model)
56
+ end
57
+
58
+ # @api private
59
+ def get_children(parent, options = {}, finder = :all, *args)
60
+ bind_values = parent_key.get(parent)
61
+ return [] if bind_values.any? { |bind_value| bind_value.nil? }
62
+ query = child_key.to_query(bind_values)
63
+
64
+ DataMapper.repository(repository_name) do
65
+ child_model.send(finder, *(args << @query.merge(options).merge(query)))
66
+ end
67
+ end
68
+
69
+ # @api private
70
+ def get_parent(child)
71
+ bind_values = child_key.get(child)
72
+ return nil if bind_values.any? { |bind_value| bind_value.nil? }
73
+ query = parent_key.to_query(bind_values)
74
+
75
+ DataMapper.repository(repository_name) do
76
+ parent_model.first(@query.merge(query))
77
+ end
78
+ end
79
+
80
+ # @api private
81
+ def attach_parent(child, parent)
82
+ child_key.set(child, parent && parent_key.get(parent))
83
+ end
84
+
85
+ private
86
+
87
+ # +child_model_name and child_properties refers to the FK, parent_model_name
88
+ # and parent_properties refer to the PK. For more information:
89
+ # http://edocs.bea.com/kodo/docs41/full/html/jdo_overview_mapping_join.html
90
+ # I wash my hands of it!
91
+ def initialize(name, repository_name, child_model, parent_model, options = {})
92
+ assert_kind_of 'name', name, Symbol
93
+ assert_kind_of 'repository_name', repository_name, Symbol
94
+ assert_kind_of 'child_model', child_model, String, Class
95
+ assert_kind_of 'parent_model', parent_model, String, Class
96
+
97
+ if child_properties = options[:child_key]
98
+ assert_kind_of 'options[:child_key]', child_properties, Array
99
+ end
100
+
101
+ if parent_properties = options[:parent_key]
102
+ assert_kind_of 'options[:parent_key]', parent_properties, Array
103
+ end
104
+
105
+ @name = name
106
+ @repository_name = repository_name
107
+ @child_model = child_model
108
+ @child_properties = child_properties # may be nil
109
+ @query = options.reject { |k,v| OPTIONS.include?(k) }
110
+ @parent_model = parent_model
111
+ @parent_properties = parent_properties # may be nil
112
+ @options = options
113
+ end
114
+ end # class Relationship
115
+ end # module Associations
116
+ end # module DataMapper
@@ -0,0 +1,74 @@
1
+ module DataMapper
2
+ module Associations
3
+ class RelationshipChain < Relationship
4
+ OPTIONS = [
5
+ :repository_name, :near_relationship_name, :remote_relationship_name,
6
+ :child_model, :parent_model, :parent_key, :child_key,
7
+ :min, :max
8
+ ]
9
+
10
+ undef_method :get_parent
11
+ undef_method :attach_parent
12
+
13
+ def child_model
14
+ near_relationship.child_model
15
+ end
16
+
17
+ # @api private
18
+ def get_children(parent, options = {}, finder = :all, *args)
19
+ query = @query.merge(options).merge(child_key.to_query(parent_key.get(parent)))
20
+
21
+ query[:links] = links
22
+
23
+ DataMapper.repository(parent.repository.name) do
24
+ results = grandchild_model.send(finder, *(args << query))
25
+ # FIXME: remove the need for the uniq.freeze
26
+ finder == :all ? (@mutable ? results.uniq : results.uniq.freeze) : results
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def near_relationship
33
+ parent_model.relationships[@near_relationship_name]
34
+ end
35
+
36
+ def links
37
+ if remote_relationship.kind_of?(RelationshipChain)
38
+ remote_relationship.instance_eval { links } + [remote_relationship.instance_eval { near_relationship } ]
39
+ else
40
+ [ remote_relationship ]
41
+ end
42
+ end
43
+
44
+ def remote_relationship
45
+ near_relationship.child_model.relationships[@remote_relationship_name] ||
46
+ near_relationship.child_model.relationships[@remote_relationship_name.to_s.singularize.to_sym]
47
+ end
48
+
49
+ def grandchild_model
50
+ Class === @child_model ? @child_model : self.class.find_const(@child_model)
51
+ end
52
+
53
+ def initialize(options)
54
+ if (missing_options = OPTIONS - [ :min, :max ] - options.keys ).any?
55
+ raise ArgumentError, "The options #{missing_options * ', '} are required", caller
56
+ end
57
+
58
+ @repository_name = options.fetch(:repository_name)
59
+ @near_relationship_name = options.fetch(:near_relationship_name)
60
+ @remote_relationship_name = options.fetch(:remote_relationship_name)
61
+ @child_model = options.fetch(:child_model)
62
+ @parent_model = options.fetch(:parent_model)
63
+ @parent_properties = options.fetch(:parent_key)
64
+ @child_properties = options.fetch(:child_key)
65
+ @mutable = options.delete(:mutable) || false
66
+
67
+ @name = near_relationship.name
68
+ @query = options.reject{ |key,val| OPTIONS.include?(key) }
69
+ @extra_links = []
70
+ @options = options
71
+ end
72
+ end # class Relationship
73
+ end # module Associations
74
+ end # module DataMapper
@@ -0,0 +1,64 @@
1
+ # TODO: move to dm-more/dm-migrations
2
+
3
+ module DataMapper
4
+ class AutoMigrator
5
+ ##
6
+ # Destructively automigrates the data-store to match the model
7
+ # REPEAT: THIS IS DESTRUCTIVE
8
+ #
9
+ # @param Symbol repository_name the repository to be migrated
10
+ # @calls DataMapper::Resource#auto_migrate!
11
+ def self.auto_migrate(repository_name = nil)
12
+ DataMapper::Resource.descendants.each do |model|
13
+ model.auto_migrate!(repository_name)
14
+ end
15
+ end
16
+
17
+ ##
18
+ # Safely migrates the data-store to match the model
19
+ # preserving data already in the data-store
20
+ #
21
+ # @param Symbol repository_name the repository to be migrated
22
+ # @calls DataMapper::Resource#auto_upgrade!
23
+ def self.auto_upgrade(repository_name = nil)
24
+ DataMapper::Resource.descendants.each do |model|
25
+ model.auto_upgrade!(repository_name)
26
+ end
27
+ end
28
+ end # class AutoMigrator
29
+
30
+ module AutoMigrations
31
+ ##
32
+ # Destructively automigrates the data-store to match the model
33
+ # REPEAT: THIS IS DESTRUCTIVE
34
+ #
35
+ # @param Symbol repository_name the repository to be migrated
36
+ def auto_migrate!(repository_name = nil)
37
+ if self.superclass != Object
38
+ self.superclass.auto_migrate!(repository_name)
39
+ else
40
+ repository_name ||= default_repository_name
41
+ repository(repository_name) do |r|
42
+ (relationships(r.name)||{}).each_value { |relationship| relationship.child_key }
43
+ r.adapter.destroy_model_storage(r, self)
44
+ r.adapter.create_model_storage(r, self)
45
+ end
46
+ end
47
+ end
48
+
49
+ ##
50
+ # Safely migrates the data-store to match the model
51
+ # preserving data already in the data-store
52
+ #
53
+ # @param Symbol repository_name the repository to be migrated
54
+ def auto_upgrade!(repository_name = nil)
55
+ repository_name ||= default_repository_name
56
+ repository(repository_name) do |r|
57
+ (relationships(r.name)||{}).each_value { |relationship| relationship.child_key }
58
+ r.adapter.upgrade_model_storage(r, self)
59
+ end
60
+ end
61
+
62
+ Model.send(:include, self)
63
+ end # module AutoMigrations
64
+ end # module DataMapper
@@ -0,0 +1,604 @@
1
+ module DataMapper
2
+ class Collection < LazyArray
3
+ include Assertions
4
+
5
+ attr_reader :query
6
+
7
+ ##
8
+ # @return [Repository] the repository the collection is
9
+ # associated with
10
+ #
11
+ # @api public
12
+ def repository
13
+ query.repository
14
+ end
15
+
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.
20
+ #
21
+ # @api private
22
+ def load(values)
23
+ add(model.load(values, query))
24
+ end
25
+
26
+ ##
27
+ # reloads the entries associated with this collection
28
+ #
29
+ # @param [DataMapper::Query] query (optional) additional query
30
+ # to scope by. Use this if you want to query a collections result
31
+ # set
32
+ #
33
+ # @see DataMapper::Collection#all
34
+ #
35
+ # @api public
36
+ def reload(query = {})
37
+ @query = scoped_query(query)
38
+ @query.update(:fields => @query.fields | @key_properties)
39
+ replace(all(:reload => true))
40
+ end
41
+
42
+ ##
43
+ # retrieves an entry out of the collection's entry by key
44
+ #
45
+ # @param [DataMapper::Types::*, ...] key keys which uniquely
46
+ # identify a resource in the collection
47
+ #
48
+ # @return [DataMapper::Resource, NilClass] the resource which
49
+ # has the supplied keys
50
+ #
51
+ # @api public
52
+ def get(*key)
53
+ if loaded?
54
+ # loop over the collection to find the matching resource
55
+ detect { |resource| resource.key == key }
56
+ elsif query.limit || query.offset > 0
57
+ # current query is exclusive, find resource within the set
58
+
59
+ # TODO: use a subquery to retrieve the collection and then match
60
+ # it up against the key. This will require some changes to
61
+ # how subqueries are generated, since the key may be a
62
+ # composite key. In the case of DO adapters, it means subselects
63
+ # like the form "(a, b) IN(SELECT a,b FROM ...)", which will
64
+ # require making it so the Query condition key can be a
65
+ # Property or an Array of Property objects
66
+
67
+ # use the brute force approach until subquery lookups work
68
+ lazy_load
69
+ get(*key)
70
+ else
71
+ # current query is all inclusive, lookup using normal approach
72
+ first(model.to_query(repository, key))
73
+ end
74
+ end
75
+
76
+ ##
77
+ # retrieves an entry out of the collection's entry by key,
78
+ # raising an exception if the object cannot be found
79
+ #
80
+ # @param [DataMapper::Types::*, ...] key keys which uniquely
81
+ # identify a resource in the collection
82
+ #
83
+ # @calls DataMapper::Collection#get
84
+ #
85
+ # @raise [ObjectNotFoundError] "Could not find #{model.name} with key #{key.inspect} in collection"
86
+ #
87
+ # @api public
88
+ def get!(*key)
89
+ get(*key) || raise(ObjectNotFoundError, "Could not find #{model.name} with key #{key.inspect} in collection")
90
+ end
91
+
92
+ ##
93
+ # Further refines a collection's conditions. #all provides an
94
+ # interface which simulates a database view.
95
+ #
96
+ # @param [Hash[Symbol, Object], DataMapper::Query] query parameters for
97
+ # an query within the results of the original query.
98
+ #
99
+ # @return [DataMapper::Collection] a collection whose query is the result
100
+ # of a merge
101
+ #
102
+ # @api public
103
+ def all(query = {})
104
+ # TODO: this shouldn't be a kicker if scoped_query() is called
105
+ return self if query.kind_of?(Hash) ? query.empty? : query == self.query
106
+ query = scoped_query(query)
107
+ query.repository.read_many(query)
108
+ end
109
+
110
+ ##
111
+ # Simulates Array#first by returning the first entry (when
112
+ # there are no arguments), or transforms the collection's query
113
+ # by applying :limit => n when you supply an Integer. If you
114
+ # provide a conditions hash, or a Query object, the internal
115
+ # query is scoped and a new collection is returned
116
+ #
117
+ # @param [Integer, Hash[Symbol, Object], Query] args
118
+ #
119
+ # @return [DataMapper::Resource, DataMapper::Collection] The
120
+ # first resource in the entries of this collection, or
121
+ # a new collection whose query has been merged
122
+ #
123
+ # @api public
124
+ def first(*args)
125
+ # TODO: this shouldn't be a kicker if scoped_query() is called
126
+ if loaded?
127
+ if args.empty?
128
+ return super
129
+ elsif args.size == 1 && args.first.kind_of?(Integer)
130
+ limit = args.shift
131
+ return self.class.new(scoped_query(:limit => limit)) { |c| c.replace(super(limit)) }
132
+ end
133
+ end
134
+
135
+ query = args.last.respond_to?(:merge) ? args.pop : {}
136
+ query = scoped_query(query.merge(:limit => args.first || 1))
137
+
138
+ if args.any?
139
+ query.repository.read_many(query)
140
+ else
141
+ query.repository.read_one(query)
142
+ end
143
+ end
144
+
145
+ ##
146
+ # Simulates Array#last by returning the last entry (when
147
+ # there are no arguments), or transforming the collection's
148
+ # query by reversing the declared order, and applying
149
+ # :limit => n when you supply an Integer. If you
150
+ # supply a conditions hash, or a Query object, the
151
+ # internal query is scoped and a new collection is returned
152
+ #
153
+ # @calls Collection#first
154
+ #
155
+ # @api public
156
+ def last(*args)
157
+ return super if loaded? && args.empty?
158
+
159
+ reversed = reverse
160
+
161
+ # tell the collection to reverse the order of the
162
+ # results coming out of the adapter
163
+ reversed.query.add_reversed = !query.add_reversed?
164
+
165
+ reversed.first(*args)
166
+ end
167
+
168
+ ##
169
+ # Simulates Array#at and returns the entrie at that index.
170
+ # Also accepts negative indexes and appropriate reverses
171
+ # the order of the query
172
+ #
173
+ # @calls Collection#first
174
+ # @calls Collection#last
175
+ #
176
+ # @api public
177
+ def at(offset)
178
+ return super if loaded?
179
+ offset >= 0 ? first(:offset => offset) : last(:offset => offset.abs - 1)
180
+ end
181
+
182
+ ##
183
+ # Simulates Array#slice and returns a new Collection
184
+ # whose query has a new offset or limit according to the
185
+ # arguments provided.
186
+ #
187
+ # If you provide a range, the min is used as the offset
188
+ # and the max minues the offset is used as the limit.
189
+ #
190
+ # @param [Integer, Array(Integer), Range] args the offset,
191
+ # offset and limit, or range indicating offsets and limits
192
+ #
193
+ # @return [DataMapper::Resource, DataMapper::Collection]
194
+ # The entry which resides at that offset and limit,
195
+ # or a new Collection object with the set limits and offset
196
+ #
197
+ # @raise [ArgumentError] "arguments may be 1 or 2 Integers,
198
+ # or 1 Range object, was: #{args.inspect}"
199
+ #
200
+ # @alias []
201
+ #
202
+ # @api public
203
+ def slice(*args)
204
+ return at(args.first) if args.size == 1 && args.first.kind_of?(Integer)
205
+
206
+ if args.size == 2 && args.first.kind_of?(Integer) && args.last.kind_of?(Integer)
207
+ offset, limit = args
208
+ elsif args.size == 1 && args.first.kind_of?(Range)
209
+ range = args.first
210
+ offset = range.first
211
+ limit = range.last - offset
212
+ limit += 1 unless range.exclude_end?
213
+ else
214
+ raise ArgumentError, "arguments may be 1 or 2 Integers, or 1 Range object, was: #{args.inspect}", caller
215
+ end
216
+
217
+ all(:offset => offset, :limit => limit)
218
+ end
219
+
220
+ alias [] slice
221
+
222
+ ##
223
+ #
224
+ # @return [DataMapper::Collection] a new collection whose
225
+ # query is sorted in the reverse
226
+ #
227
+ # @see Array#reverse, DataMapper#all, DataMapper::Query#reverse
228
+ #
229
+ # @api public
230
+ def reverse
231
+ all(self.query.reverse)
232
+ end
233
+
234
+ ##
235
+ # @see Array#<<
236
+ #
237
+ # @api public
238
+ def <<(resource)
239
+ super
240
+ relate_resource(resource)
241
+ self
242
+ end
243
+
244
+ ##
245
+ # @see Array#push
246
+ #
247
+ # @api public
248
+ def push(*resources)
249
+ super
250
+ resources.each { |resource| relate_resource(resource) }
251
+ self
252
+ end
253
+
254
+ ##
255
+ # @see Array#unshift
256
+ #
257
+ # @api public
258
+ def unshift(*resources)
259
+ super
260
+ resources.each { |resource| relate_resource(resource) }
261
+ self
262
+ end
263
+
264
+ ##
265
+ # @see Array#replace
266
+ #
267
+ # @api public
268
+ def replace(other)
269
+ if loaded?
270
+ each { |resource| orphan_resource(resource) }
271
+ end
272
+ super
273
+ other.each { |resource| relate_resource(resource) }
274
+ self
275
+ end
276
+
277
+ ##
278
+ # @see Array#pop
279
+ #
280
+ # @api public
281
+ def pop
282
+ orphan_resource(super)
283
+ end
284
+
285
+ ##
286
+ # @see Array#shift
287
+ #
288
+ # @api public
289
+ def shift
290
+ orphan_resource(super)
291
+ end
292
+
293
+ ##
294
+ # @see Array#delete
295
+ #
296
+ # @api public
297
+ def delete(resource, &block)
298
+ orphan_resource(super)
299
+ end
300
+
301
+ ##
302
+ # @see Array#delete_at
303
+ #
304
+ # @api public
305
+ def delete_at(index)
306
+ orphan_resource(super)
307
+ end
308
+
309
+ ##
310
+ # @see Array#clear
311
+ #
312
+ # @api public
313
+ def clear
314
+ if loaded?
315
+ each { |resource| orphan_resource(resource) }
316
+ end
317
+ super
318
+ self
319
+ end
320
+
321
+ ##
322
+ # creates a new array, saves it, and << it onto the collection
323
+ #
324
+ # @param Hash[Symbol => Object] attributes attributes which
325
+ # the new resource should have.
326
+ #
327
+ # @api public
328
+ def create(attributes = {})
329
+ repository.scope do
330
+ resource = model.create(default_attributes.merge(attributes))
331
+ self << resource unless resource.new_record?
332
+ resource
333
+ end
334
+ end
335
+
336
+ def update(attributes = {}, preload = false)
337
+ raise NotImplementedError, 'update *with* validations has not be written yet, try update!'
338
+ end
339
+
340
+ ##
341
+ # batch updates the entries belongs to this collection, and skip
342
+ # validations for all resources.
343
+ #
344
+ # @example Reached the Age of Alchohol Consumption
345
+ # Person.all(:age.gte => 21).update!(:allow_beer => true)
346
+ #
347
+ # @param attributes Hash[Symbol => Object] attributes to update
348
+ # @param reload [FalseClass, TrueClass] if set to true, collection
349
+ # will have loaded resources reflect updates.
350
+ #
351
+ # @return [TrueClass, FalseClass]
352
+ # TrueClass indicates that all entries were affected
353
+ # FalseClass indicates that some entries were affected
354
+ #
355
+ # @api public
356
+ def update!(attributes = {}, reload = false)
357
+ # TODO: delegate to Model.update
358
+ return true if attributes.empty?
359
+
360
+ dirty_attributes = {}
361
+
362
+ model.properties(repository.name).slice(*attributes.keys).each do |property|
363
+ dirty_attributes[property] = attributes[property.name] if property
364
+ end
365
+
366
+ # this should never be done on update! even if collection is loaded. or?
367
+ # each { |resource| resource.attributes = attributes } if loaded?
368
+
369
+ changes = repository.update(dirty_attributes, scoped_query)
370
+
371
+ # need to decide if this should be done in update!
372
+ query.update(attributes)
373
+
374
+ if identity_map.any? && reload
375
+ reload_query = @key_properties.zip(identity_map.keys.transpose).to_hash
376
+ model.all(reload_query.merge(attributes)).reload(:fields => attributes.keys)
377
+ end
378
+
379
+ # this should return true if there are any changes at all. as it skips validations
380
+ # the only way it could be fewer changes is if some resources already was updated.
381
+ # that should not return false? true = 'now all objects have these new values'
382
+ return loaded? ? changes == size : changes > 0
383
+ end
384
+
385
+ def destroy
386
+ raise NotImplementedError, 'destroy *with* validations has not be written yet, try destroy!'
387
+ end
388
+
389
+ ##
390
+ # batch destroy the entries belongs to this collection, and skip
391
+ # validations for all resources.
392
+ #
393
+ # @example The War On Terror (if only it were this easy)
394
+ # Person.all(:terrorist => true).destroy() #
395
+ #
396
+ # @return [TrueClass, FalseClass]
397
+ # TrueClass indicates that all entries were affected
398
+ # FalseClass indicates that some entries were affected
399
+ #
400
+ # @api public
401
+ def destroy!
402
+ # TODO: delegate to Model.destroy
403
+ if loaded?
404
+ return false unless repository.delete(scoped_query) == size
405
+
406
+ each do |resource|
407
+ resource.instance_variable_set(:@new_record, true)
408
+ identity_map.delete(resource.key)
409
+ resource.dirty_attributes.clear
410
+
411
+ model.properties(repository.name).each do |property|
412
+ next unless resource.attribute_loaded?(property.name)
413
+ resource.dirty_attributes[property] = property.get(resource)
414
+ end
415
+ end
416
+ else
417
+ return false unless repository.delete(scoped_query) > 0
418
+ end
419
+
420
+ clear
421
+
422
+ true
423
+ end
424
+
425
+ ##
426
+ # @return [DataMapper::PropertySet] The set of properties this
427
+ # query will be retrieving
428
+ #
429
+ # @api public
430
+ def properties
431
+ PropertySet.new(query.fields)
432
+ end
433
+
434
+ ##
435
+ # @return [DataMapper::Relationship] The model's relationships
436
+ #
437
+ # @api public
438
+ def relationships
439
+ model.relationships(repository.name)
440
+ end
441
+
442
+ ##
443
+ # default values to use when creating a Resource within the Collection
444
+ #
445
+ # @return [Hash] The default attributes for DataMapper::Collection#create
446
+ #
447
+ # @see DataMapper::Collection#create
448
+ #
449
+ # @api public
450
+ def default_attributes
451
+ default_attributes = {}
452
+ query.conditions.each do |tuple|
453
+ operator, property, bind_value = *tuple
454
+
455
+ next unless operator == :eql &&
456
+ property.kind_of?(DataMapper::Property) &&
457
+ ![ Array, Range ].any? { |k| bind_value.kind_of?(k) }
458
+ !@key_properties.include?(property)
459
+
460
+ default_attributes[property.name] = bind_value
461
+ end
462
+ default_attributes
463
+ end
464
+
465
+ protected
466
+
467
+ ##
468
+ # @api private
469
+ #
470
+ # @api public
471
+ def model
472
+ query.model
473
+ end
474
+
475
+ private
476
+
477
+ ##
478
+ # @api public
479
+ def initialize(query, &block)
480
+ assert_kind_of 'query', query, Query
481
+
482
+ unless block_given?
483
+ raise ArgumentError, 'a block must be supplied for lazy loading results', caller
484
+ end
485
+
486
+ @query = query
487
+ @key_properties = model.key(repository.name)
488
+
489
+ super()
490
+
491
+ load_with(&block)
492
+ end
493
+
494
+ ##
495
+ # @api private
496
+ def add(resource)
497
+ query.add_reversed? ? unshift(resource) : push(resource)
498
+ resource
499
+ end
500
+
501
+ ##
502
+ # @api private
503
+ def relate_resource(resource)
504
+ return unless resource
505
+ resource.collection = self
506
+ resource
507
+ end
508
+
509
+ ##
510
+ # @api private
511
+ def orphan_resource(resource)
512
+ return unless resource
513
+ resource.collection = nil if resource.collection == self
514
+ resource
515
+ end
516
+
517
+ ##
518
+ # @api private
519
+ def scoped_query(query = self.query)
520
+ assert_kind_of 'query', query, Query, Hash
521
+
522
+ query.update(keys) if loaded?
523
+
524
+ return self.query if query == self.query
525
+
526
+ query = if query.kind_of?(Hash)
527
+ Query.new(query.has_key?(:repository) ? query.delete(:repository) : self.repository, model, query)
528
+ else
529
+ query
530
+ end
531
+
532
+ if query.limit || query.offset > 0
533
+ set_relative_position(query)
534
+ end
535
+
536
+ self.query.merge(query)
537
+ end
538
+
539
+ ##
540
+ # @api private
541
+ def keys
542
+ keys = map {|r| r.key }
543
+ keys.any? ? @key_properties.zip(keys.transpose).to_hash : {}
544
+ end
545
+
546
+ ##
547
+ # @api private
548
+ def identity_map
549
+ repository.identity_map(model)
550
+ end
551
+
552
+ ##
553
+ # @api private
554
+ def set_relative_position(query)
555
+ return if query == self.query
556
+
557
+ if query.offset == 0
558
+ return if !query.limit.nil? && !self.query.limit.nil? && query.limit <= self.query.limit
559
+ return if query.limit.nil? && self.query.limit.nil?
560
+ end
561
+
562
+ first_pos = self.query.offset + query.offset
563
+ last_pos = self.query.offset + self.query.limit if self.query.limit
564
+
565
+ if limit = query.limit
566
+ if last_pos.nil? || first_pos + limit < last_pos
567
+ last_pos = first_pos + limit
568
+ end
569
+ end
570
+
571
+ if last_pos && first_pos >= last_pos
572
+ raise 'outside range' # TODO: raise a proper exception object
573
+ end
574
+
575
+ query.update(:offset => first_pos)
576
+ query.update(:limit => last_pos - first_pos) if last_pos
577
+ end
578
+
579
+ ##
580
+ # @api private
581
+ def method_missing(method, *args, &block)
582
+ if relationship = relationships[method]
583
+ klass = model == relationship.child_model ? relationship.parent_model : relationship.child_model
584
+
585
+ # TODO: when self.query includes an offset/limit use it as a
586
+ # subquery to scope the results rather than a join
587
+
588
+ query = Query.new(repository, klass)
589
+ query.conditions.push(*self.query.conditions)
590
+ query.update(relationship.query)
591
+ query.update(args.pop) if args.last.kind_of?(Hash)
592
+
593
+ query.update(
594
+ :fields => klass.properties(repository.name).defaults,
595
+ :links => self.query.links + [ relationship ]
596
+ )
597
+
598
+ return klass.all(query, &block)
599
+ end
600
+
601
+ super
602
+ end
603
+ end # class Collection
604
+ end # module DataMapper