praxis 2.0.pre.2 → 2.0.pre.3

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA256:
3
- metadata.gz: f23deb4aca50709972c23925ed41fde1fe98baaf7e865513b7f047611bd3af3d
4
- data.tar.gz: 512839b90d37a8ac2cb67a7a14f32a8353160d15ef43d0066c860ec95da3002e
2
+ SHA1:
3
+ metadata.gz: d869bce23150f16857b3d92fb1e4e6fe98b0b747
4
+ data.tar.gz: bf2e0bac5b872453a6ae142f518591a2fae67180
5
5
  SHA512:
6
- metadata.gz: 19ff50cbc1d7c9b003725f96122cfab8c8c221f54d8df5a142299ad260dcb2f9597d504e6761c18badee02a768d18dab81dc76425abda01704a9e61f0aae3769
7
- data.tar.gz: 3b2885d5e46d68848541dfed03dabe8cc1df71a430d1fdd42fc330035f544fee5b05291cf8087c00a51c987c2087e4f6e2a502522566e61a88f50e0adee4a71f
6
+ metadata.gz: d2975bad774d35e3c7a98067442258fff5d7670f161ee4b5653dfe5b038f1af341b9fb21fefcff733d9f2a7c96a9ee742014919c29148d87826433b15291dcb0
7
+ data.tar.gz: cf8665de0a625dee430a30b23a07328bebd8d60cffb2aad9e7dc3aed9503c21afd463aa024ee97fefb7ce639db008716aa4996ee70e3361d91d102b5408c6adc
@@ -1,5 +1,11 @@
1
1
  # Praxis Changelog
2
2
 
3
+ ## next
4
+ - Reworked the field selection DB query generation to support full tree of eager loaded dependencies
5
+ - Built support for both ActiveRecord and Sequel gems
6
+ - Selected DB fields will include/map the defined resource properties and will always include any necessary fields on both sides of the joins for the given associations.
7
+ - Added a configurable option to enable debugging of those generated queries (through `Praxis::Application.instance.config.mapper.debug_queries=true`)
8
+
3
9
  ## 2.0.pre.1
4
10
 
5
11
  - Bring over partial functionality from praxis-mapper and remove dependency on same
@@ -3,51 +3,47 @@ module Praxis
3
3
  module Extensions
4
4
  module FieldSelection
5
5
  class ActiveRecordQuerySelector
6
- attr_reader :selector, :query, :top_model, :resolved, :root
6
+ attr_reader :selector, :query
7
7
  # Gets a dataset, a selector...and should return a dataset with the selector definition applied.
8
- def initialize(query:, model:, selectors:, resolved:)
8
+ def initialize(query:, selectors:)
9
9
  @selector = selectors
10
10
  @query = query
11
- @top_model = model
12
- @resolved = resolved
13
- @seen = Set.new
14
- @root = model.table_name
15
11
  end
16
12
 
17
- def add_select(query:, model:, table_name:)
18
- if (fields = fields_for(model))
19
- # Note, let's always add the pk fields so that associations can load properly
20
- fields = fields | [model.primary_key.to_sym]
21
- query.select(*fields)
22
- else
23
- query
24
- end
25
- end
26
-
27
- def generate
13
+ def generate(debug: false)
28
14
  # TODO: unfortunately, I think we can only control the select clauses for the top model
29
15
  # (as I'm not sure ActiveRecord supports expressing it in the join...)
30
- @query = add_select(query: query, model: top_model, table_name: root)
16
+ @query = add_select(query: query, selector_node: selector)
17
+ eager_hash = _eager(selector)
31
18
 
32
- @query.includes(_eager(top_model, resolved) )
19
+ @query = @query.includes(eager_hash)
20
+ explain_query(query, eager_hash) if debug
21
+
22
+ @query
33
23
  end
34
24
 
35
- def _eager(model, resolved)
36
- tracks = only_assoc_for(model, resolved)
37
- tracks.inject([]) do |dataset, track|
38
- next dataset if @seen.include?([model, track])
39
- @seen << [model, track]
40
- assoc_model = model._praxis_associations[track][:model]
41
- dataset << { track => _eager(assoc_model, resolved[track]) }
42
- end
25
+ def add_select(query:, selector_node:)
26
+ # We're gonna always require the PK of the model, as it is a special case for AR, and the app itself
27
+ # might assume it is always there and not be surprised by the fact that if it isn't, it won't blow up
28
+ # in the same way as any other attribute not being loaded...i.e., ActiveModel::MissingAttributeError: missing attribute: xyz
29
+ select_fields = selector_node.select + [selector_node.resource.model.primary_key.to_sym]
30
+ select_fields.empty? ? query : query.select(*select_fields)
43
31
  end
44
32
 
45
- def only_assoc_for(model, hash)
46
- hash.keys.reject { |assoc| model._praxis_associations[assoc].nil? }
33
+ def _eager(selector_node)
34
+ selector_node.tracks.each_with_object({}) do |(track_name, track_node), h|
35
+ h[track_name] = _eager(track_node)
36
+ end
47
37
  end
48
38
 
49
- def fields_for(model)
50
- selector[model][:select].to_a
39
+ def explain_query(query, eager_hash)
40
+ prev = ActiveRecord::Base.logger
41
+ ActiveRecord::Base.logger = Logger.new(STDOUT)
42
+ ActiveRecord::Base.logger.debug("Query plan for ...#{selector.resource.model} with selectors: #{JSON.generate(selector.dump)}")
43
+ ActiveRecord::Base.logger.debug(" ActiveRecord query: #{selector.resource.model}.includes(#{eager_hash})")
44
+ query.explain
45
+ ActiveRecord::Base.logger.debug("Query plan end")
46
+ ActiveRecord::Base.logger = prev
51
47
  end
52
48
  end
53
49
  end
@@ -1,63 +1,59 @@
1
1
  # frozen_string_literal: true
2
+
3
+ require 'sequel'
4
+
2
5
  module Praxis
3
6
  module Extensions
4
7
  module FieldSelection
5
8
  class SequelQuerySelector
6
- attr_reader :selector, :ds, :top_model, :resolved, :root
9
+ attr_reader :selector, :query
7
10
  # Gets a dataset, a selector...and should return a dataset with the selector definition applied.
8
- def initialize(query:, model:, selectors:, resolved:)
11
+ def initialize(query:, selectors:)
9
12
  @selector = selectors
10
- @ds = query
11
- @top_model = model
12
- @resolved = resolved
13
- @seen = Set.new
14
- @root = model.table_name
13
+ @query = query
15
14
  end
16
15
 
17
- def add_select(ds:, model:, table_name:)
18
- if (fields = fields_for(model))
19
- # Note, let's always add the pk fields so that associations can load properly
20
- fields = fields | model.primary_key | [:id]
21
- qualified = fields.map { |f| Sequel.qualify(table_name, f) }
22
- ds.select(*qualified)
23
- else
24
- ds
16
+ def generate(debug: false)
17
+ @query = add_select(query: query, selector_node: @selector)
18
+
19
+ @query = @selector.tracks.inject(@query) do |ds, (track_name, track_node)|
20
+ ds.eager(track_name => _eager(track_node) )
25
21
  end
26
- end
27
22
 
28
- def generate
29
- @ds = add_select(ds: ds, model: top_model, table_name: root)
30
-
31
- tracks = only_assoc_for(top_model, resolved)
32
- @ds = tracks.inject(@ds) do |dataset, track|
33
- next dataset if @seen.include?([top_model, track])
34
- @seen << [top_model, track]
35
- assoc_model = top_model._praxis_associations[track][:model]
36
- # hash[track] = _eager(assoc_model, resolved[track])
37
- dataset.eager(track => _eager(assoc_model, resolved[track]))
38
- end
23
+ explain_query(query) if debug
24
+ @query
39
25
  end
40
26
 
41
- def _eager(model, resolved)
27
+ def _eager(selector_node)
42
28
  lambda do |dset|
43
- d = add_select(ds: dset, model: model, table_name: model.table_name)
29
+ dset = add_select(query: dset, selector_node: selector_node)
44
30
 
45
- tracks = only_assoc_for(model, resolved)
46
- tracks.inject(d) do |dataset, track|
47
- next dataset if @seen.include?([model, track])
48
- @seen << [model, track]
49
- assoc_model = model._praxis_associations[track][:model]
50
- dataset.eager(track => _eager(assoc_model, resolved[track]))
31
+ dset = selector_node.tracks.inject(dset) do |ds, (track_name, track_node)|
32
+ ds.eager(track_name => _eager(track_node) )
51
33
  end
34
+
52
35
  end
53
36
  end
54
37
 
55
- def only_assoc_for(model, hash)
56
- hash.keys.reject { |assoc| model._praxis_associations[assoc].nil? }
38
+ def add_select(query:, selector_node:)
39
+ # We're gonna always require the PK of the model, as it is a special case for Sequel, and the app itself
40
+ # might assume it is always there and not be surprised by the fact that if it isn't, it won't blow up
41
+ # in the same way as any other attribute not being loaded...i.e., NoMethodError: undefined method `foobar' for #<...>
42
+ select_fields = selector_node.select + [selector_node.resource.model.primary_key.to_sym]
43
+
44
+ table_name = selector_node.resource.model.table_name
45
+ qualified = select_fields.map { |f| Sequel.qualify(table_name, f) }
46
+ query.select(*qualified)
57
47
  end
58
48
 
59
- def fields_for(model)
60
- selector[model][:select].to_a
49
+ def explain_query(ds)
50
+ prev_loggers = Sequel::Model.db.loggers
51
+ stdout_logger = Logger.new($stdout)
52
+ Sequel::Model.db.loggers = [stdout_logger]
53
+ stdout_logger.debug("Query plan for ...#{selector.resource.model} with selectors: #{JSON.generate(selector.dump)}")
54
+ ds.all
55
+ stdout_logger.debug("Query plan end")
56
+ Sequel::Model.db.loggers = prev_loggers
61
57
  end
62
58
  end
63
59
  end
@@ -46,18 +46,53 @@ module Praxis
46
46
  else
47
47
  raise "Unknown association type: #{v.class.name} on #{v.klass.name} for #{v.name}"
48
48
  end
49
+ # Call out any local (i.e., of this model) columns that participate in the association
50
+ info[:local_key_columns] = local_columns_used_for_the_association(info[:type], v)
51
+ info[:remote_key_columns] = remote_columns_used_for_the_association(info[:type], v)
49
52
 
50
53
  if v.is_a?(ActiveRecord::Reflection::ThroughReflection)
51
54
  info[:through] = v.through_reflection.name # TODO: is this correct?
52
55
  end
53
-
54
- # TODO: add more keys for the association to make true praxis mapper functions happy
55
56
  hash[k.to_sym] = info
56
57
  end
57
58
  end
58
59
 
60
+ private
61
+ def local_columns_used_for_the_association(type, assoc_reflection)
62
+ case type
63
+ when :one_to_many
64
+ # The associated table will point to us by key (usually the PK, but not always)
65
+ [assoc_reflection.join_keys.foreign_key.to_sym]
66
+ when :many_to_one
67
+ # We have the FKs to the associated model
68
+ [assoc_reflection.join_keys.foreign_key.to_sym]
69
+ when :many_to_many
70
+ ref = resolve_closest_through_reflection(assoc_reflection)
71
+ # The associated middle table will point to us by key (usually the PK, but not always)
72
+ [ref.join_keys.foreign_key.to_sym] # The foreign key that the last through table points to
73
+ else
74
+ raise "association type #{type} not supported"
75
+ end
76
+ end
77
+
78
+ def remote_columns_used_for_the_association(type, assoc_reflection)
79
+ # It seems that since the reflection is the target of the association, using the join_keys.key
80
+ # will always get us the right column
81
+ case type
82
+ when :one_to_many, :many_to_one, :many_to_many
83
+ [assoc_reflection.join_keys.key.to_sym]
84
+ else
85
+ raise "association type #{type} not supported"
86
+ end
87
+ end
88
+
89
+ # Keep following the association reflections as long as there are middle ones (i.e., through)
90
+ # until we come to the one next to the source
91
+ def resolve_closest_through_reflection(ref)
92
+ return ref unless ref.through_reflection?
93
+ resolve_closest_through_reflection( ref.through_reflection )
94
+ end
59
95
  end
60
-
61
96
  end
62
97
  end
63
98
  end
@@ -212,10 +212,10 @@ module Praxis::Mapper
212
212
  base_query
213
213
  end
214
214
 
215
- def self.craft_field_selection_query(base_query, selectors:, resolved:) # rubocop:disable Metrics/AbcSize
215
+ def self.craft_field_selection_query(base_query, selectors:) # rubocop:disable Metrics/AbcSize
216
216
  if selectors && model._field_selector_query_builder_class
217
- base_query = model._field_selector_query_builder_class.new(query: base_query, model: self.model,
218
- selectors: selectors, resolved: resolved).generate
217
+ debug = Praxis::Application.instance.config.mapper.debug_queries
218
+ base_query = model._field_selector_query_builder_class.new(query: base_query, selectors: selectors).generate(debug: debug)
219
219
  end
220
220
 
221
221
  base_query
@@ -1,101 +1,82 @@
1
1
  module Praxis::Mapper
2
- # Generates a set of selectors given a resource and
3
- # list of resource attributes.
4
- class SelectorGenerator
5
- attr_reader :selectors
6
2
 
7
- def initialize
8
- @selectors = Hash.new do |hash, key|
9
- hash[key] = {select: Set.new, track: Set.new}
10
- end
11
- @seen = Hash.new do |hash, resource|
12
- hash[resource] = Set.new
13
- end
14
- end
3
+ class SelectorGeneratorNode
4
+ attr_reader :select, :model, :resource, :tracks
15
5
 
16
- def add(resource, fields)
17
- return if @seen[resource].include? fields
18
- @seen[resource] << fields
6
+ def initialize(resource)
7
+ @resource = resource
19
8
 
20
- fields.each do |name, field|
21
- map_property(resource, name, field)
22
- end
9
+ @select = Set.new
10
+ @select_star = false
11
+ @tracks = Hash.new
23
12
  end
24
13
 
25
- def select_all(resource)
26
- selectors[resource.model][:select] = true
14
+ def add(fields)
15
+ fields.each do |name, field|
16
+ map_property(name, field)
17
+ end
18
+ self
27
19
  end
28
20
 
29
- def map_property(resource, name, fields)
21
+ def map_property(name, fields)
30
22
  if resource.properties.key?(name)
31
- add_property(resource, name, fields)
23
+ add_property(name, fields)
32
24
  elsif resource.model._praxis_associations.key?(name)
33
- add_association(resource, name, fields)
25
+ add_association(name, fields)
34
26
  else
35
- add_select(resource, name)
27
+ add_select(name)
36
28
  end
37
29
  end
38
30
 
39
- def add_select(resource, name)
40
- return select_all(resource) if name == :*
41
- return if selectors[resource.model][:select] == true
42
-
43
- selectors[resource.model][:select] << name
44
- end
45
-
46
- def add_track(resource, name)
47
- selectors[resource.model][:track] << name
48
- end
49
-
50
- def add_association(resource, name, fields)
31
+ def add_association(name, fields)
32
+
51
33
  association = resource.model._praxis_associations.fetch(name) do
52
34
  raise "missing association for #{resource} with name #{name}"
53
35
  end
54
36
  associated_resource = resource.model_map[association[:model]]
55
-
56
- case association[:type]
57
- when :many_to_one
58
- add_track(resource, name)
59
- Array(association[:key]).each do |akey|
60
- add_select(resource, akey)
61
- end
62
- when :one_to_many
63
- add_track(resource, name)
64
- Array(association[:key]).each do |akey|
65
- add_select(associated_resource, akey)
66
- end
67
- when :many_to_many
68
- # If we haven't explicitly added the "through" option in the association
69
- # then we'll assume the underlying ORM is able to fill in the gap. We will
70
- # simply add the fields for the associated resource below
71
- if association.key? :through
72
- head, *tail = association[:through]
73
- new_fields = tail.reverse.inject(fields) do |thing, step|
74
- {step => thing}
75
- end
76
- return add_association(resource, head, new_fields)
77
- else
78
- add_track(resource, name)
37
+ unless associated_resource
38
+ raise "Whoops! could not find a resource associated with model #{association[:model]} (root resource #{resource})"
39
+ end
40
+ # Add the required columns in this model to make sure the association can be loaded
41
+ association[:local_key_columns].each {|col| add_select(col) }
42
+
43
+ node = SelectorGeneratorNode.new(associated_resource)
44
+ if association[:remote_key_columns].nil?
45
+ binding.pry
46
+ puts association
47
+ end
48
+ unless association[:remote_key_columns].empty?
49
+ # Make sure we add the required columns for this association to the remote model query
50
+ fields = {} if fields == true
51
+ new_fields_as_hash = association[:remote_key_columns].each_with_object({}) do|name, hash|
52
+ hash[name] = true
79
53
  end
80
- else
81
- raise "no select applicable for #{association[:type].inspect}"
54
+ fields.merge!(new_fields_as_hash)
82
55
  end
83
56
 
84
- unless fields == true
85
- # recurse into the field
86
- add(associated_resource,fields)
87
- end
57
+ node.add(fields) unless fields == true
58
+
59
+ self.merge_track(name, node)
60
+ end
61
+
62
+ def add_select(name)
63
+ return @select_star = true if name == :*
64
+ return if @select_star
65
+ @select.add name
88
66
  end
89
67
 
90
- def add_property(resource, name, fields)
68
+ def add_property(name, fields)
91
69
  dependencies = resource.properties[name][:dependencies]
70
+ # Always add the underlying association if we're overriding the name...
71
+ add_association(name, fields) if resource.model._praxis_associations.key?(name)
92
72
  if dependencies
93
73
  dependencies.each do |dependency|
94
- # if dependency includes the name, then map it directly as the field
95
- if dependency == name
96
- add_select(resource, name)
74
+ # To detect recursion, let's allow mapping depending fields to the same name of the property
75
+ # but properly detecting if it's a real association...in which case we've already added it above
76
+ if dependency == name
77
+ add_select(name) unless resource.model._praxis_associations.key?(name)
97
78
  else
98
- apply_dependency(resource, dependency)
79
+ apply_dependency(dependency)
99
80
  end
100
81
  end
101
82
  end
@@ -107,20 +88,62 @@ module Praxis::Mapper
107
88
  {step => thing}
108
89
  end
109
90
 
110
- add_association(resource, head, new_fields)
91
+ add_association(head, new_fields)
111
92
  end
112
93
 
113
- def apply_dependency(resource, dependency)
94
+ def apply_dependency(dependency)
114
95
  case dependency
115
96
  when Symbol
116
- map_property(resource, dependency, {})
97
+ map_property(dependency, true)
117
98
  when String
118
99
  head, tail = dependency.split('.').collect(&:to_sym)
119
100
  raise "String dependencies can not be singular" if tail.nil?
120
101
 
121
- add_association(resource, head, {tail => true})
102
+ add_association(head, {tail => true})
103
+ end
104
+ end
105
+
106
+ def merge_track( track_name, node )
107
+ raise "Cannot merge another node for association #{track_name}: incompatible model" unless node.model == self.model
108
+
109
+ existing = self.tracks[track_name]
110
+ if existing
111
+ node.select.each do|col_name|
112
+ existing.add_select(col_name)
113
+ end
114
+ node.tracks.each do |name, n|
115
+ existing.merge(name, n)
116
+ end
117
+ else
118
+ self.tracks[track_name] = node
122
119
  end
120
+
123
121
  end
124
122
 
123
+ def dump
124
+ hash = {}
125
+ hash[:model] = resource.model
126
+ if !@select.empty? || @select_star
127
+ hash[:columns] = @select_star ? [ :* ] : @select.to_a
128
+ end
129
+ unless @tracks.empty?
130
+ hash[:tracks] = @tracks.each_with_object({}) {|(name, node), hash| hash[name] = node.dump }
131
+ end
132
+ hash
133
+ end
134
+ end
135
+
136
+ # Generates a set of selectors given a resource and
137
+ # list of resource attributes.
138
+ class SelectorGenerator
139
+ # Entry point
140
+ def add(resource, fields)
141
+ @root = SelectorGeneratorNode.new(resource)
142
+ @root.add(fields)
143
+ end
144
+
145
+ def selectors
146
+ @root
147
+ end
125
148
  end
126
149
  end