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