praxis-mapper 3.1.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +26 -0
  3. data/.rspec +3 -0
  4. data/.travis.yml +4 -0
  5. data/CHANGELOG.md +83 -0
  6. data/Gemfile +3 -0
  7. data/Gemfile.lock +102 -0
  8. data/Guardfile +11 -0
  9. data/LICENSE +22 -0
  10. data/README.md +19 -0
  11. data/Rakefile +14 -0
  12. data/lib/praxis-mapper/config_hash.rb +40 -0
  13. data/lib/praxis-mapper/connection_manager.rb +102 -0
  14. data/lib/praxis-mapper/finalizable.rb +38 -0
  15. data/lib/praxis-mapper/identity_map.rb +532 -0
  16. data/lib/praxis-mapper/logging.rb +22 -0
  17. data/lib/praxis-mapper/model.rb +430 -0
  18. data/lib/praxis-mapper/query/base.rb +213 -0
  19. data/lib/praxis-mapper/query/sql.rb +183 -0
  20. data/lib/praxis-mapper/query_statistics.rb +46 -0
  21. data/lib/praxis-mapper/resource.rb +226 -0
  22. data/lib/praxis-mapper/support/factory_girl.rb +104 -0
  23. data/lib/praxis-mapper/support/memory_query.rb +34 -0
  24. data/lib/praxis-mapper/support/memory_repository.rb +44 -0
  25. data/lib/praxis-mapper/support/schema_dumper.rb +66 -0
  26. data/lib/praxis-mapper/support/schema_loader.rb +56 -0
  27. data/lib/praxis-mapper/support.rb +2 -0
  28. data/lib/praxis-mapper/version.rb +5 -0
  29. data/lib/praxis-mapper.rb +60 -0
  30. data/praxis-mapper.gemspec +38 -0
  31. data/spec/praxis-mapper/connection_manager_spec.rb +117 -0
  32. data/spec/praxis-mapper/identity_map_spec.rb +905 -0
  33. data/spec/praxis-mapper/logging_spec.rb +9 -0
  34. data/spec/praxis-mapper/memory_repository_spec.rb +56 -0
  35. data/spec/praxis-mapper/model_spec.rb +389 -0
  36. data/spec/praxis-mapper/query/base_spec.rb +317 -0
  37. data/spec/praxis-mapper/query/sql_spec.rb +184 -0
  38. data/spec/praxis-mapper/resource_spec.rb +154 -0
  39. data/spec/praxis_mapper_spec.rb +21 -0
  40. data/spec/spec_fixtures.rb +12 -0
  41. data/spec/spec_helper.rb +63 -0
  42. data/spec/support/spec_models.rb +215 -0
  43. data/spec/support/spec_resources.rb +39 -0
  44. metadata +298 -0
@@ -0,0 +1,430 @@
1
+ # -*- coding: utf-8 -*-
2
+ # This is an abstract class.
3
+ # Does not have any ORM logic, but instead relies on a data store repository.
4
+
5
+ module Praxis::Mapper
6
+ class Model
7
+ extend Finalizable
8
+
9
+ attr_accessor :_resource, :identity_map, :_query
10
+
11
+ class << self
12
+ attr_accessor :_identities
13
+ attr_reader :associations, :config, :serialized_fields, :contexts
14
+ end
15
+
16
+ def self.inherited(klass)
17
+ super
18
+
19
+ klass.instance_eval do
20
+ @config = {
21
+ :belongs_to => {},
22
+ :excluded_scopes => [],
23
+ :identities => []
24
+ }
25
+ @associations = {}
26
+ @serialized_fields = {}
27
+ @contexts = Hash.new
28
+ end
29
+
30
+
31
+ end
32
+
33
+ # Internal finalize! logic
34
+ def self._finalize!
35
+ self.define_data_accessors *self.identities.flatten
36
+
37
+ self.associations.each do |name,config|
38
+ self.associations[name] = config.to_hash
39
+ end
40
+
41
+ self.define_associations
42
+
43
+ super
44
+ end
45
+
46
+ # Implements Praxis::Mapper DSL directive 'excluded_scopes'.
47
+ # Gets or sets the excluded scopes for this model.
48
+ # Exclusion means that the named condition cannot be applied.
49
+ #
50
+ # @param *scopes [Array] list of scopes to exclude
51
+ # @return [Array] configured list of scopes
52
+ # @example excluded_scopes :account, :deleted_at
53
+ def self.excluded_scopes(*scopes)
54
+ if scopes.any?
55
+ self.config[:excluded_scopes] = scopes
56
+ else
57
+ self.config.fetch(:excluded_scopes)
58
+ end
59
+ end
60
+
61
+ # Gets or sets the repository for this model.
62
+ #
63
+ # @param name [Symbol] repository name
64
+ # @return [Symbol] repository name or :default
65
+ def self.repository_name(name=nil)
66
+ if name
67
+ self.config[:repository_name] = name
68
+ else
69
+ self.config.fetch(:repository_name, :default)
70
+ end
71
+ end
72
+
73
+ # Implements Praxis::Mapper DSL directive 'table_name'.
74
+ # Gets or sets the SQL-like table name.
75
+ # Can also be thought of as a namespace in the repository.
76
+ #
77
+ # @param name [Symbol] table name
78
+ # @return [Symbol] table name or nil
79
+ # @example table_name 'json_array_model'
80
+ def self.table_name(name=nil)
81
+ if name
82
+ self.config[:table_name] = name
83
+ else
84
+ self.config.fetch(:table_name, nil)
85
+ end
86
+ end
87
+
88
+ # Implements Praxis::Mapper DSL directive 'belongs_to'.
89
+ # If name and belongs_to_options are given, upserts the association.
90
+ # If only name is given, gets the named association.
91
+ # Else, returns all configured associations.
92
+ #
93
+ # @param name [String] name of association to set or get
94
+ # @param belongs_to_options [Hash] new association options
95
+ # @option :model [Model] the associated model
96
+ # @option :fk [String] associated field name
97
+ # @option :source_key [String] local field name
98
+ # @option :type [Symbol] type of mapping, :scalar or :array
99
+ # @return [Array] all configured associations
100
+ # @example
101
+ # belongs_to :parents, :model => ParentModel,
102
+ # :source_key => :parent_ids,
103
+ # :fk => :id,
104
+ # :type => :array
105
+ def self.belongs_to(name=nil, belongs_to_options={})
106
+ if !belongs_to_options.empty?
107
+ warn "DEPRECATION: `#{self}.belongs_to` is deprecated. Use `many_to_one` or `array_to_many` instead."
108
+
109
+ opts = {:fk => :id}.merge(belongs_to_options)
110
+
111
+ opts[:key] = opts.delete(:source_key)
112
+ opts[:primary_key] = opts.delete(:fk) if opts.has_key?(:fk)
113
+
114
+ if (opts.delete(:type) == :array)
115
+ opts[:type] = :array_to_many
116
+ else
117
+ opts[:type] = :many_to_one
118
+ end
119
+
120
+ self.associations[name] = opts
121
+
122
+
123
+ define_belongs_to(name, opts)
124
+ else
125
+ raise "Calling Model.belongs to fetch association information is no longer supported. Use Model.associations instead."
126
+ end
127
+ end
128
+
129
+
130
+ # Define one_to_many (aka: has_many)
131
+ def self.one_to_many(name, &block)
132
+ self.associations[name] = ConfigHash.from(type: :one_to_many, &block)
133
+ end
134
+
135
+
136
+ # Define many_to_one (aka: belongs_to)
137
+ def self.many_to_one(name, &block)
138
+ self.associations[name] = ConfigHash.from(type: :many_to_one, &block)
139
+ end
140
+
141
+ # Define array_to_many (aka: belongs_to where the key attribute is an array)
142
+ def self.array_to_many(name, &block)
143
+ self.associations[name] = ConfigHash.from(type: :array_to_many, &block)
144
+ end
145
+
146
+ # Define many_to_array (aka: has_many where the key attribute is an array)
147
+ def self.many_to_array(name, &block)
148
+ self.associations[name] = ConfigHash.from(type: :many_to_array, &block)
149
+ end
150
+
151
+
152
+ # Adds given identity to the list of model identities.
153
+ # May be an array for composite keys.
154
+ def self.identity(name)
155
+ @_identities ||= Array.new
156
+ @_identities << name
157
+ self.config[:identities] << name
158
+ end
159
+
160
+
161
+ # Implements Praxis::Mapper DSL directive 'identities'.
162
+ # Gets or sets list of identity fields.
163
+ #
164
+ # @param *names [Array] list of identity fields to set
165
+ # @return [Array] configured list of identity fields
166
+ # @example identities :id, :type
167
+ def self.identities(*names)
168
+ if names.any?
169
+ self.config[:identities] = names
170
+ @_identities = names
171
+ else
172
+ self.config.fetch(:identities)
173
+ end
174
+ end
175
+
176
+
177
+ # Implements Praxis::Mapper DSL directive 'yaml'.
178
+ # This will perform YAML.load on serialized data.
179
+ #
180
+ # @param name [String] name of field that is serialized as YAML
181
+ # @param opts [Hash]
182
+ # @options :default [String] default value?
183
+ #
184
+ # @example yaml :parent_ids, :default => []
185
+ def self.yaml(name, opts={})
186
+ @serialized_fields[name] = :yaml
187
+ define_serialized_accessor(name, YAML, opts)
188
+ end
189
+
190
+
191
+ # Implements Praxis::Mapper DSL directive 'json'.
192
+ # This will perform JSON.load on serialized data.
193
+ #
194
+ # @param name [String] name of field that is serialized as JSON
195
+ # @param opts [Hash]
196
+ # @options :default [String] default value?
197
+ #
198
+ # @example yaml :parent_ids, :default => []
199
+ def self.json(name, opts={})
200
+ @serialized_fields[name] = :json
201
+ define_serialized_accessor(name, JSON, opts)
202
+ end
203
+
204
+ def self.define_serialized_accessor(name, serializer, **opts)
205
+ define_method(name) do
206
+ @deserialized_data[name] ||= if (value = @data.fetch(name))
207
+ serializer.load(value)
208
+ else
209
+ opts[:default]
210
+ end
211
+ end
212
+
213
+ define_method("_raw_#{name}".to_sym) do
214
+ @data.fetch name
215
+ end
216
+ end
217
+
218
+ def self.context(name, &block)
219
+ default = Hash.new do |hash, key|
220
+ hash[key] = Array.new
221
+ end
222
+ @contexts[name] = ConfigHash.from(default, &block).to_hash
223
+ end
224
+
225
+
226
+ def self.define_data_accessors(*names)
227
+ names.each do |name|
228
+ self.define_data_accessor(name)
229
+ end
230
+ end
231
+
232
+
233
+ def self.define_data_accessor(name)
234
+ module_eval <<-RUBY, __FILE__, __LINE__ + 1
235
+ def #{name}
236
+ @__#{name} ||= @data.fetch(#{name.inspect}) do
237
+ raise "field #{name.inspect} not loaded for #{self.inspect}."
238
+ end.freeze
239
+ end
240
+ RUBY
241
+ end
242
+
243
+ def self.define_associations
244
+ self.associations.each do |name, association|
245
+ self.define_association(name,association)
246
+ end
247
+ end
248
+
249
+ def self.define_association(name, association)
250
+ case association[:type]
251
+ when :many_to_one
252
+ self.define_many_to_one(name, association)
253
+ when :array_to_many
254
+ self.define_array_to_many(name, association)
255
+ when :one_to_many
256
+ self.define_one_to_many(name, association)
257
+ when :many_to_array
258
+ self.define_many_to_array(name, association)
259
+ end
260
+ end
261
+
262
+ # has_many
263
+ def self.define_one_to_many(name, association)
264
+ model = association[:model]
265
+ primary_key = association.fetch(:primary_key, :id)
266
+
267
+ if primary_key.kind_of?(Array)
268
+ define_method(name) do
269
+ pk = primary_key.collect { |k| self.send(k) }
270
+ self.identity_map.all(model,association[:key] => [pk])
271
+ end
272
+ else
273
+ define_method(name) do
274
+ pk = self.send(primary_key)
275
+ self.identity_map.all(model,association[:key] => [pk])
276
+ end
277
+ end
278
+ end
279
+
280
+ def self.define_many_to_one(name, association)
281
+ model = association[:model]
282
+ primary_key = association.fetch(:primary_key, :id)
283
+
284
+ if association[:key].kind_of?(Array)
285
+ key = "["
286
+ key += association[:key].collect { |k| "self.#{k}" }.join(", ")
287
+ key += "]"
288
+ else
289
+ key = "self.#{association[:key]}"
290
+ end
291
+
292
+ module_eval <<-RUBY, __FILE__, __LINE__ + 1
293
+ def #{name}
294
+ return nil if #{key}.nil?
295
+ @__#{name} ||= self.identity_map.get(#{model.name},#{primary_key.inspect} => #{key})
296
+ end
297
+ RUBY
298
+
299
+ end
300
+
301
+
302
+ def self.define_array_to_many(name, association)
303
+ model = association[:model]
304
+ primary_key = association.fetch(:primary_key, :id)
305
+ key = association.fetch(:key)
306
+
307
+ module_eval <<-RUBY, __FILE__, __LINE__ + 1
308
+ def #{name}
309
+ return nil if #{key}.nil?
310
+ @__#{name} ||= self.identity_map.all(#{model.name},#{primary_key.inspect} => #{key})
311
+ end
312
+ RUBY
313
+
314
+ end
315
+
316
+
317
+ def self.define_many_to_array(name, association)
318
+ model = association[:model]
319
+ primary_key = association.fetch(:primary_key, :id)
320
+ key_name = association.fetch(:key)
321
+
322
+ if primary_key.kind_of?(Array)
323
+ key = "["
324
+ key += primary_key.collect { |k| "self.#{k}" }.join(", ")
325
+ key += "]"
326
+ else
327
+ key = "self.#{primary_key}"
328
+ end
329
+
330
+ module_eval <<-RUBY, __FILE__, __LINE__ + 1
331
+ def #{name}
332
+ key = #{key}
333
+ return nil if key.nil?
334
+ @__#{name} ||= self.identity_map.all(#{model.name}).
335
+ select { |record| record.#{key_name}.include? key }
336
+ end
337
+ RUBY
338
+ end
339
+
340
+
341
+
342
+ # The belongs_to association creates a one-to-one match with another model.
343
+ # In database terms, this association says that this class contains the foreign key.
344
+ #
345
+ # @param name [Symbol] name of association; typically the same as associated model name
346
+ # @param opts [Hash] association options
347
+ # @option :model [Model] the associated model
348
+ # @option :fk [String] associated field name
349
+ # @option :source_key [String] local field name
350
+ # @option :type [Symbol] type of mapping, :scalar or :array
351
+ #
352
+ # @example
353
+ # define_belongs_to(:customer, {:model => Customer, :fk => :id, :source_key => :customer_id, :type => scalar})
354
+ #
355
+ # @see http://guides.rubyonrails.org/v2.3.11/association_basics.html#belongs-to-association-reference
356
+ def self.define_belongs_to(name, opts)
357
+ model = opts.fetch(:model)
358
+ type = opts.fetch(:type, :many_to_one) # :scalar has no meaning other than it's not an array
359
+
360
+ case opts.fetch(:type, :many_to_one)
361
+ when :many_to_one
362
+ return self.define_many_to_one(name, opts)
363
+ when :array_to_many
364
+ return self.define_array_to_many(name, opts)
365
+ end
366
+ end
367
+
368
+
369
+ # Looks up in the identity map first.
370
+ #
371
+ # @param condition ?
372
+ # @return [Model] matching record
373
+ def self.get(condition)
374
+ IdentityMap.current.get(self, condition)
375
+ end
376
+
377
+
378
+ # Looks up in the identity map first.
379
+ #
380
+ # @param condition [Hash] ?
381
+ # @return [Array<Model>] all matching records
382
+ def self.all(condition={})
383
+ IdentityMap.current.all(self, condition)
384
+ end
385
+
386
+
387
+ def initialize(data)
388
+ @data = data
389
+ @deserialized_data = {}
390
+ @query = nil
391
+ end
392
+
393
+ InspectedFields = [:@data, :@deserialized_data].freeze
394
+ def inspect
395
+ "#<#{self.class}:0x#{object_id.to_s(16)} #{
396
+ instance_variables.select{|v| InspectedFields.include? v}.map {|var|
397
+ "#{var}: #{instance_variable_get(var).inspect}"
398
+ }.join("#{' '}")
399
+ }#{' '}>"
400
+ end
401
+
402
+ def respond_to_missing?(name, *)
403
+ @data.key?(name) || super
404
+ end
405
+
406
+
407
+ def method_missing(name, *args)
408
+ if @data.has_key? name
409
+ self.class.define_data_accessor(name)
410
+ self.send(name)
411
+ else
412
+ super
413
+ end
414
+ end
415
+
416
+
417
+ def identities
418
+ self.class._identities.each_with_object(Hash.new) do |identity, hash|
419
+ case identity
420
+ when Symbol
421
+ hash[identity] = @data[identity].freeze
422
+ else
423
+ hash[identity] = @data.values_at(*identity).collect(&:freeze)
424
+ end
425
+ end
426
+ end
427
+
428
+ end
429
+
430
+ end
@@ -0,0 +1,213 @@
1
+ module Praxis::Mapper
2
+ module Query
3
+
4
+ # Abstract base class for assembling read queries for a data store.
5
+ # May be implemented for SQL, CQL, etc.
6
+ # Collects query statistics.
7
+ #
8
+ # @see lib/support/memory_query.rb
9
+ class Base
10
+ MULTI_GET_BATCH_SIZE = 4_096
11
+
12
+ attr_reader :identity_map, :model, :statistics, :contexts
13
+ attr_writer :where
14
+
15
+ # Sets up a read query.
16
+ #
17
+ # @param identity_map [Praxis::Mapper::IdentityMap] handle to a Praxis::Mapper identity map
18
+ # @param model [Praxis::Mapper::Model] handle to a Praxis::Mapper model
19
+ # @param &block [Block] will be instance_eval'ed here
20
+ def initialize(identity_map, model, &block)
21
+ @identity_map = identity_map
22
+ @model = model
23
+
24
+ @select = nil
25
+
26
+ @where = nil
27
+
28
+ @limit = nil
29
+ @track = Set.new
30
+ @load = Set.new
31
+ @contexts = Set.new
32
+
33
+ @statistics = Hash.new(0) # general-purpose hash
34
+
35
+ if block_given?
36
+ self.instance_eval(&block)
37
+ end
38
+ end
39
+
40
+ # @return handle to configured data store
41
+ def connection
42
+ identity_map.connection(model.repository_name)
43
+ end
44
+
45
+ # Gets or sets an SQL-like 'SELECT' clause to this query.
46
+ # TODO: fix any specs or code that uses alias
47
+ #
48
+ # @param *fields [Array] list of fields, of type Symbol, String, or Hash
49
+ # @return [Hash] current list of fields
50
+ #
51
+ # @example select(:account_id, "user_id", {"create_time" => :created_at})
52
+ def select(*fields)
53
+ if fields.any?
54
+ @select = {} if @select.nil?
55
+ fields.each do |field|
56
+ case field
57
+ when Symbol, String
58
+ @select[field] = nil
59
+ when Hash
60
+ field.each do |alias_name, column_name|
61
+ @select[alias_name] = column_name
62
+ end
63
+ else
64
+ raise "unknown field type: #{field.class.name}"
65
+ end
66
+ end
67
+ else
68
+ return @select
69
+ end
70
+ end
71
+
72
+
73
+ # Gets or sets an SQL-like 'WHERE' clause to this query.
74
+ #
75
+ # @param value [String] a 'WHERE' clause
76
+ #
77
+ # @example where("deployment_id=2")
78
+ def where(value=nil)
79
+ if value
80
+ @where = value
81
+ else
82
+ return @where
83
+ end
84
+ end
85
+
86
+ # Gets or sets an SQL-like 'LIMIT' clause to this query.
87
+ #
88
+ # @param value [String] a 'LIMIT' clause
89
+ #
90
+ # @example limit("LIMIT 10 OFFSET 20")
91
+ def limit(value=nil)
92
+ if value
93
+ @limit = value
94
+ else
95
+ return @limit
96
+ end
97
+ end
98
+
99
+ # @param *values [Array] a list of associations to track
100
+ def track(*values, &block)
101
+ if values.any?
102
+ if block_given?
103
+ raise "block and multiple values not supported" if values.size > 1
104
+ @track << [values.first, block]
105
+ else
106
+ @track.merge(values)
107
+ end
108
+ else
109
+ return @track
110
+ end
111
+ end
112
+
113
+ # @param *values [Array] a list of associations to load immediately after this
114
+ def load(*values, &block)
115
+ if values.any?
116
+ if block_given?
117
+ raise "block and multiple values not supported" if values.size > 1
118
+ @load << [values.first, block]
119
+ else
120
+ @load.merge(values)
121
+ end
122
+ else
123
+ return @load
124
+ end
125
+ end
126
+
127
+ def context(name=nil)
128
+ @contexts << name
129
+ spec = model.contexts.fetch(name) do
130
+ raise "context #{name.inspect} not found for #{model}"
131
+ end
132
+
133
+ select *spec[:select]
134
+ track *spec[:track]
135
+ end
136
+
137
+
138
+ # @return [Array] a list of associated models
139
+ def tracked_associations
140
+ track.collect do |(name, _)|
141
+ model.associations.fetch(name) do
142
+ raise "association #{name.inspect} not found for #{model}"
143
+ end
144
+ end.uniq
145
+ end
146
+
147
+ # Executes multi-get read query and returns all matching records.
148
+ #
149
+ # @param identity [Symbol|Array] a simple or composite key for this model
150
+ # @param values [Array] list of identifier values (ideally a sorted set)
151
+ # @return [Array] list of matching records, wrapped as models
152
+ def multi_get(identity, values, select: nil)
153
+ if self.frozen?
154
+ raise TypeError.new "can not reuse a frozen query"
155
+ end
156
+
157
+ statistics[:multi_get] += 1
158
+
159
+ records = []
160
+
161
+ original_select = @select
162
+ self.select *select.flatten.uniq if select
163
+
164
+ values.each_slice(MULTI_GET_BATCH_SIZE) do |batch|
165
+ # create model objects for each row
166
+ records += _multi_get(identity, batch)
167
+ end
168
+
169
+ statistics[:records_loaded] += records.size
170
+ records
171
+ ensure
172
+ @select = original_select unless self.frozen?
173
+ end
174
+
175
+ # Executes assembled read query and returns all matching records.
176
+ #
177
+ # @return [Array] list of matching records, wrapped as models
178
+ def execute
179
+ if self.frozen?
180
+ raise TypeError.new "can not reuse a frozen query"
181
+ end
182
+ statistics[:execute] += 1
183
+
184
+ rows = _execute
185
+
186
+ statistics[:records_loaded] += rows.size
187
+ rows.collect do |row|
188
+ m = model.new(row)
189
+ m._query = self
190
+ m
191
+ end
192
+ end
193
+
194
+
195
+ # Subclasses Must Implement
196
+ def _multi_get(identity, values)
197
+ raise "subclass responsibility"
198
+ end
199
+
200
+ # Subclasses Must Implement
201
+ def _execute
202
+ raise "subclass responsibility"
203
+ end
204
+
205
+ # Subclasses Must Implement
206
+ # the sql or "sql-like" representation of the query
207
+ def describe
208
+ raise "subclass responsibility"
209
+ end
210
+
211
+ end
212
+ end
213
+ end