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,532 @@
1
+ # An identity map that tracks data that's been loaded, and data that we still need to load.
2
+ # As tables are loaded and processed, the identity map will keep a list of child models that have been "seen" and will need to be loaded for the final view.
3
+ # The identity map defines a scope for the associated queries.
4
+ # The scope can be thought of as a set of named filters.
5
+ module Praxis::Mapper
6
+ class IdentityMap
7
+
8
+ class UnloadedRecordException < StandardError; end;
9
+ class UnsupportedModel < StandardError; end;
10
+ class UnknownIdentity < StandardError; end;
11
+
12
+ attr_reader :unloaded, :queries, :blueprint_cache
13
+ attr_accessor :scope
14
+
15
+ class << self
16
+ attr_accessor :config
17
+ end
18
+
19
+
20
+ # Stores given identity map in a thread-local variable
21
+ # @param [IdentityMap] some identity map
22
+ def self.current=(identity_map)
23
+ Thread.current[:_praxis_mapper_identity_map] = identity_map
24
+ end
25
+
26
+
27
+ # @return [IdentityMap] current identity map from thread-local variable
28
+ def self.current
29
+ map = Thread.current[:_praxis_mapper_identity_map]
30
+ raise "current IdentityMap not set" unless map
31
+ map
32
+ end
33
+
34
+
35
+ # @return [Boolean] whether identity map thread-local variable has been set
36
+ def self.current?
37
+ Thread.current.key?(:_praxis_mapper_identity_map) && Thread.current[:_praxis_mapper_identity_map].kind_of?(Praxis::Mapper::IdentityMap)
38
+ end
39
+
40
+
41
+ def clear?
42
+ @rows.empty? &&
43
+ @staged.empty? &&
44
+ @row_keys.empty? &&
45
+ @queries.empty?
46
+ end
47
+
48
+
49
+ # TODO: how come scope can be set from 3 different methods?
50
+ #
51
+ # @param scope [Hash] a set of named filters to apply in query
52
+ # @example {:account => [:account_id, 71], :user => [:user_id, 2]}
53
+ #
54
+ def self.setup!(scope={})
55
+ if self.current?
56
+ if !self.current.clear?
57
+ raise "Denied for a pre-existing condition: Identity map has been used."
58
+ else
59
+ self.current.scope = scope
60
+ return self.current
61
+ end
62
+ else
63
+ self.current = self.new(scope)
64
+ end
65
+ end
66
+
67
+ # TODO: support multiple connections
68
+ def initialize(scope={})
69
+ @connection_manager = ConnectionManager.new
70
+ @scope = scope
71
+ clear!
72
+ end
73
+
74
+ def clear!
75
+ @rows = Hash.new { |h,k| h[k] = Array.new }
76
+
77
+ # for ex:
78
+ # @staged[Instance][:id] = Set.new
79
+ # yields:
80
+ # {Instance => {:id => Set.new(1,2,3), :name => Set.new("George Jr.") } }
81
+ @staged = Hash.new do |hash,model|
82
+ hash[model] = Hash.new do |identity_hash, identity_name|
83
+ identity_hash[identity_name] = Set.new
84
+ end
85
+ end
86
+
87
+ # for ex:
88
+ # @row_keys["instances"][:id][1] = Object.new
89
+ # yields:
90
+ # {"instances"=>{:id=>{1=>Object.new}}
91
+ @row_keys = Hash.new do |row_hash,model|
92
+ row_hash[model] = Hash.new do |primary_keys, key_name|
93
+ primary_keys[key_name] = Hash.new
94
+ end
95
+ end
96
+
97
+ @queries = Hash.new { |h,k| h[k] = Set.new }
98
+
99
+ # see how it feels to store blueprints here
100
+ # for ex:
101
+ # @blueprints[User][some_object] = User.new(some_object)
102
+ @blueprint_cache = Hash.new do |cache,blueprint_class|
103
+ cache[blueprint_class] = Hash.new
104
+ end
105
+
106
+ # TODO: rework this so it's a hash with default values and simplify #index
107
+ @secondary_indexes = Hash.new
108
+ end
109
+
110
+ def load(model, &block)
111
+ raise "Can't load unfinalized model #{model}" unless model.finalized?
112
+
113
+ query_class = @connection_manager.repository(model.repository_name)[:query]
114
+ query = query_class.new(self, model, &block)
115
+
116
+ return finalize_model!(model, query) if query.where == :staged
117
+
118
+ records = query.execute
119
+ add_records(records)
120
+
121
+ # TODO: refactor this to better-hide queries?
122
+ query.freeze
123
+ self.queries[model].add(query)
124
+
125
+ subload(model, query,records)
126
+
127
+ records
128
+ end
129
+
130
+ def stage_for!(spec, records)
131
+ case spec[:type]
132
+ when :many_to_one
133
+ stage_many_to_one(spec, records)
134
+ when :array_to_many
135
+ stage_array_to_many(spec, records)
136
+ when :one_to_many
137
+ stage_one_to_many(spec, records)
138
+ when :many_to_array
139
+ stage_many_to_array(spec, records)
140
+ end
141
+ end
142
+
143
+ def subload(model, query, records)
144
+ query.load.each do |(association_name, block)|
145
+ spec = model.associations.fetch(association_name)
146
+
147
+ associated_model = spec[:model]
148
+
149
+ key, values = stage_for!(spec, records)
150
+
151
+ existing_records = []
152
+ values.reject! do |value|
153
+ if @row_keys[associated_model].has_key?(key) &&
154
+ @row_keys[associated_model][key].has_key?(value)
155
+ existing_records << @row_keys[associated_model][key][value]
156
+ else
157
+ false
158
+ end
159
+ end
160
+
161
+ new_query_class = @connection_manager.repository(associated_model.repository_name)[:query]
162
+ new_query = new_query_class.new(self,associated_model, &block)
163
+
164
+ new_records = new_query.multi_get(key, values).collect do |row|
165
+ m = spec[:model].new(row)
166
+ m._query = new_query
167
+ m
168
+ end
169
+
170
+ self.queries[associated_model].add(new_query)
171
+
172
+ add_records(new_records)
173
+
174
+ subload(associated_model, new_query, new_records + existing_records)
175
+ end
176
+ end
177
+
178
+ def finalize!(*models)
179
+ if models.empty?
180
+ models = @staged.keys
181
+ end
182
+
183
+ did_something = models.any? do |model|
184
+ finalize_model!(model).any?
185
+ end
186
+
187
+ finalize! if did_something
188
+ end
189
+
190
+
191
+ # don't doc. never ever use yourself!
192
+ # FIXME: make private and fix specs that break?
193
+ def finalize_model!(model, query=nil)
194
+ staged_queries = @staged[model].delete(:_queries) || []
195
+ staged_keys = @staged[model].keys
196
+ identities = staged_keys && model.identities
197
+ non_identities = staged_keys - model.identities
198
+
199
+ results = Set.new
200
+
201
+ return results if @staged[model].all? { |(key,values)| values.empty? }
202
+
203
+ if query.nil?
204
+ query_class = @connection_manager.repository(model.repository_name)[:query]
205
+ query = query_class.new(self,model)
206
+ end
207
+
208
+ # Apply any relevant blocks passed to track in the original queries
209
+ staged_queries.each do |staged_query|
210
+ staged_query.track.each do |(association_name, block)|
211
+ next unless block
212
+
213
+ spec = staged_query.model.associations[association_name]
214
+
215
+ if spec[:model] == model
216
+ query.instance_eval(&block)
217
+ if (spec[:type] == :many_to_one || spec[:type] == :array_to_many) && query.where
218
+ file, line = block.source_location
219
+ trace = ["#{file}:#{line}"] | caller
220
+ raise RuntimeError, "Error finalizing model #{model.name} for association #{association_name.inspect} -- using a where clause when tracking associations of type #{spec[:type].inspect} is not supported", trace
221
+ end
222
+ end
223
+ end
224
+ end
225
+
226
+
227
+ # process non-unique staged keys
228
+ # select identity (any one should do) for those keys and stage blindly
229
+ # load and add records.
230
+
231
+ if non_identities.any?
232
+ to_stage = Hash.new do |hash,identity|
233
+ hash[identity] = Set.new
234
+ end
235
+
236
+ non_identities.each do |key|
237
+ values = @staged[model].delete(key)
238
+
239
+ rows = query.multi_get(key, values, select: model.identities)
240
+ rows.each do |row|
241
+ model.identities.each do |identity|
242
+ if identity.kind_of? Array
243
+ to_stage[identity] << row.values_at(*identity)
244
+ else
245
+ to_stage[identity] << row[identity]
246
+ end
247
+ end
248
+ end
249
+ end
250
+
251
+ self.stage(model, to_stage)
252
+ end
253
+
254
+ model.identities.each do |identity_name|
255
+ values = self.get_staged(model,identity_name)
256
+ next if values.empty?
257
+
258
+ query.where = nil # clear out any where clause from non-identity
259
+ records = query.multi_get(identity_name, values).collect do |row|
260
+ m = model.new(row)
261
+ m._query = query
262
+ m
263
+ end
264
+
265
+ add_records(records)
266
+
267
+ # TODO: refactor this to better-hide queries?
268
+ self.queries[model].add(query)
269
+
270
+ results.merge(records)
271
+
272
+ # add nil records for records that were not found by the multi_get
273
+ missing_keys = self.get_staged(model,identity_name)
274
+ missing_keys.each do |missing_key|
275
+ @row_keys[model][identity_name][missing_key] = nil
276
+ get_staged(model, identity_name).delete(missing_key)
277
+ end
278
+
279
+ end
280
+
281
+ query.freeze
282
+
283
+ # TODO: check whether really really did get all the records we should have....
284
+ results.to_a
285
+ end
286
+
287
+
288
+ def row_by_key(model,key, value)
289
+ @row_keys[model][key].fetch(value) do
290
+ raise UnloadedRecordException, "Did not load #{model} with #{key} = #{value.inspect}."
291
+ end
292
+
293
+ end
294
+
295
+
296
+ def rows_for(model)
297
+ @rows[model]
298
+ end
299
+
300
+
301
+ def index(model, key, value)
302
+ @secondary_indexes[model] ||= Hash.new
303
+
304
+ unless @secondary_indexes[model].has_key? key
305
+ @secondary_indexes[model][key] ||= Hash.new
306
+ reindex!(model, key)
307
+ end
308
+
309
+ @secondary_indexes[model][key][value] ||= Array.new
310
+ end
311
+
312
+
313
+ def reindex!(model, key)
314
+ rows_for(model).each do |row|
315
+ val = if key.kind_of? Array
316
+ key.collect { |k| row.send(k) }
317
+ else
318
+ row.send(key)
319
+ end
320
+ index(model, key, val) << row
321
+ end
322
+ end
323
+
324
+
325
+ def all(model,conditions={})
326
+ return rows_for(model) if conditions.empty?
327
+
328
+ key, values = conditions.first
329
+
330
+ # optimize the common case of a single value
331
+ if values.size == 1
332
+ value = values[0]
333
+ if @row_keys[model].has_key?(key)
334
+ res = row_by_key(model, key, value)
335
+ if res
336
+ [row_by_key(model, key, value)]
337
+ else
338
+ []
339
+ end
340
+ else
341
+ index(model, key, value)
342
+ end
343
+ else
344
+ if @row_keys[model].has_key?(key)
345
+ values.collect do |value|
346
+ row_by_key(model, key, value)
347
+ end
348
+ else
349
+ values.each_with_object(Array.new) do |value, results|
350
+ results.push *index(model, key, value)
351
+ end
352
+ end
353
+ end
354
+ end
355
+
356
+
357
+ def get(model,condition)
358
+ key, value = condition.first
359
+
360
+ row_by_key(model, key, value)
361
+ end
362
+
363
+
364
+ def get_staged(model, key)
365
+ @staged[model][key]
366
+ end
367
+
368
+
369
+ def stage(model, data)
370
+ data.each do |key, values|
371
+ unless values.kind_of? Enumerable
372
+ values = [values]
373
+ end
374
+
375
+ # ignore rows we have already loaded... add sanity checking?
376
+ if model.identities.include?(key)
377
+ values.reject! { |k| @row_keys[model][key].has_key? k }
378
+ end
379
+
380
+ get_staged(model,key).merge(values)
381
+ end
382
+ end
383
+
384
+
385
+ def connection(name)
386
+ @connection_manager.checkout(name)
387
+ end
388
+
389
+
390
+ def <<(record)
391
+ model = record.class
392
+
393
+ @rows[model] << record
394
+ record.identity_map = self
395
+
396
+ model.identities.each do |identity|
397
+ key = record.send(identity)
398
+
399
+ get_staged(model, identity).delete(key)
400
+ @row_keys[model][identity][key] = record
401
+ end
402
+ end
403
+
404
+
405
+ def extract_keys(field, records)
406
+ row_keys = []
407
+ if field.kind_of?(Array) # composite identities
408
+ records.each do |record|
409
+ row_key = field.collect { |col| record.send(col) }
410
+ row_keys << row_key unless row_key.include?(nil)
411
+ end
412
+ else
413
+ row_keys.push *records.collect(&field).compact
414
+ end
415
+ row_keys
416
+ end
417
+
418
+
419
+ def stage_many_to_one(tracked_association, records)
420
+ key = tracked_association[:key]
421
+ primary_key = tracked_association[:primary_key] || :id
422
+
423
+ row_keys = extract_keys(key, records)
424
+
425
+ [primary_key, row_keys]
426
+ end
427
+
428
+
429
+ def stage_one_to_many(tracked_association, records)
430
+ key = tracked_association[:key]
431
+ primary_key = tracked_association[:primary_key] || :id
432
+
433
+ row_keys = extract_keys(primary_key, records)
434
+
435
+ [key, row_keys]
436
+ end
437
+
438
+
439
+ def stage_array_to_many(tracked_association, records)
440
+ key = tracked_association[:key]
441
+ primary_key = tracked_association[:primary_key] || :id
442
+
443
+ row_keys = []
444
+ records.collect(&key).each do |keys|
445
+ row_keys.push *keys
446
+ end
447
+
448
+ row_keys.reject! do |row_key|
449
+ row_key.nil? || (row_key.kind_of?(Array) && row_key.include?(nil))
450
+ end
451
+
452
+ [primary_key, row_keys]
453
+ end
454
+
455
+
456
+
457
+ def stage_many_to_array(tracked_association, records)
458
+ raise "not supported yet"
459
+ end
460
+
461
+
462
+ def add_records(records)
463
+ return if records.empty?
464
+
465
+ records_added = Array.new
466
+
467
+ to_stage = Hash.new do |hash,staged_model|
468
+ hash[staged_model] = Hash.new do |identities, identity_name|
469
+ identities[identity_name] = Set.new
470
+ end
471
+ end
472
+
473
+ model = records.first.class
474
+
475
+ tracked_associations = if (query = records.first._query)
476
+ query.tracked_associations.each do |tracked_association|
477
+ associated_model = tracked_association[:model]
478
+ to_stage[associated_model][:_queries] << query
479
+ end
480
+ else
481
+ []
482
+ end
483
+
484
+ tracked_associations.each do |tracked_association|
485
+ associated_model = tracked_association[:model]
486
+ association_type = tracked_association[:type]
487
+
488
+ association_key, row_keys = stage_for!(tracked_association, records)
489
+ row_keys.each do |row_key|
490
+ to_stage[associated_model][association_key].add(row_key)
491
+ end
492
+
493
+ end
494
+
495
+ records.each do |record|
496
+ if add_record(record)
497
+ records_added << record
498
+ end
499
+ end
500
+
501
+ to_stage.each do |model_to_stage, data|
502
+ stage(model_to_stage, data)
503
+ end
504
+
505
+ records_added
506
+ end
507
+
508
+
509
+ def add_record(record)
510
+ model = record.class
511
+ record.identities.each do |identity, key|
512
+ # FIXME: Should we be overwriting (possibly) a "nil" value from before?
513
+ # (due to that row not being found by a previous query)
514
+ # (That'd be odd since that means we tried to load that same identity)
515
+
516
+ return false if @row_keys[model][identity].has_key? key
517
+
518
+ get_staged(model, identity).delete(key)
519
+ @row_keys[model][identity][key] = record
520
+ end
521
+
522
+ record.identity_map = self
523
+ @rows[model] << record
524
+ record
525
+ end
526
+
527
+ def query_statistics
528
+ QueryStatistics.new(queries)
529
+ end
530
+
531
+ end
532
+ end
@@ -0,0 +1,22 @@
1
+ module Praxis::Mapper
2
+
3
+ class NullLogger
4
+
5
+ # do nothing!
6
+ def initialize(*args)
7
+ end
8
+
9
+ def method_missing(*args, &block)
10
+ end
11
+
12
+ end
13
+
14
+ def self.logger
15
+ @@logger ||= NullLogger.new
16
+ end
17
+
18
+ def self.logger=(logger)
19
+ @@logger = logger
20
+ end
21
+
22
+ end