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

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