praxis-mapper 3.1.1

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