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 +5 -5
- data/CHANGELOG.md +6 -0
- data/lib/praxis/extensions/field_selection/active_record_query_selector.rb +27 -31
- data/lib/praxis/extensions/field_selection/sequel_query_selector.rb +35 -39
- data/lib/praxis/mapper/active_model_compat.rb +38 -3
- data/lib/praxis/mapper/resource.rb +3 -3
- data/lib/praxis/mapper/selector_generator.rb +98 -75
- data/lib/praxis/mapper/sequel_compat.rb +42 -3
- data/lib/praxis/plugins/mapper_plugin.rb +16 -2
- data/lib/praxis/version.rb +1 -1
- data/praxis.gemspec +3 -0
- data/spec/praxis/extensions/field_selection/active_record_query_selector_spec.rb +106 -0
- data/spec/praxis/extensions/field_selection/sequel_query_selector_spec.rb +147 -0
- data/spec/praxis/extensions/field_selection/support/spec_resources_active_model.rb +130 -0
- data/spec/praxis/extensions/field_selection/support/spec_resources_sequel.rb +106 -0
- data/spec/praxis/mapper/selector_generator_spec.rb +275 -283
- data/spec/spec_helper.rb +11 -0
- data/spec/support/be_deep_equal_matcher.rb +39 -0
- data/spec/support/spec_resources.rb +42 -49
- metadata +37 -4
- data/spec/spec_app/app/models/person.rb +0 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
|
-
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: d869bce23150f16857b3d92fb1e4e6fe98b0b747
|
4
|
+
data.tar.gz: bf2e0bac5b872453a6ae142f518591a2fae67180
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: d2975bad774d35e3c7a98067442258fff5d7670f161ee4b5653dfe5b038f1af341b9fb21fefcff733d9f2a7c96a9ee742014919c29148d87826433b15291dcb0
|
7
|
+
data.tar.gz: cf8665de0a625dee430a30b23a07328bebd8d60cffb2aad9e7dc3aed9503c21afd463aa024ee97fefb7ce639db008716aa4996ee70e3361d91d102b5408c6adc
|
data/CHANGELOG.md
CHANGED
@@ -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
|
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:,
|
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
|
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,
|
16
|
+
@query = add_select(query: query, selector_node: selector)
|
17
|
+
eager_hash = _eager(selector)
|
31
18
|
|
32
|
-
@query.includes(
|
19
|
+
@query = @query.includes(eager_hash)
|
20
|
+
explain_query(query, eager_hash) if debug
|
21
|
+
|
22
|
+
@query
|
33
23
|
end
|
34
24
|
|
35
|
-
def
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
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
|
46
|
-
|
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
|
50
|
-
|
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, :
|
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:,
|
11
|
+
def initialize(query:, selectors:)
|
9
12
|
@selector = selectors
|
10
|
-
@
|
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
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
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
|
-
|
29
|
-
@
|
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(
|
27
|
+
def _eager(selector_node)
|
42
28
|
lambda do |dset|
|
43
|
-
|
29
|
+
dset = add_select(query: dset, selector_node: selector_node)
|
44
30
|
|
45
|
-
|
46
|
-
|
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
|
56
|
-
|
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
|
60
|
-
|
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
|
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
|
-
|
218
|
-
|
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
|
-
|
8
|
-
|
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
|
17
|
-
|
18
|
-
@seen[resource] << fields
|
6
|
+
def initialize(resource)
|
7
|
+
@resource = resource
|
19
8
|
|
20
|
-
|
21
|
-
|
22
|
-
|
9
|
+
@select = Set.new
|
10
|
+
@select_star = false
|
11
|
+
@tracks = Hash.new
|
23
12
|
end
|
24
13
|
|
25
|
-
def
|
26
|
-
|
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(
|
21
|
+
def map_property(name, fields)
|
30
22
|
if resource.properties.key?(name)
|
31
|
-
add_property(
|
23
|
+
add_property(name, fields)
|
32
24
|
elsif resource.model._praxis_associations.key?(name)
|
33
|
-
add_association(
|
25
|
+
add_association(name, fields)
|
34
26
|
else
|
35
|
-
add_select(
|
27
|
+
add_select(name)
|
36
28
|
end
|
37
29
|
end
|
38
30
|
|
39
|
-
def
|
40
|
-
|
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
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
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
|
-
|
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
|
-
|
86
|
-
|
87
|
-
|
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(
|
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
|
-
#
|
95
|
-
if
|
96
|
-
|
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(
|
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(
|
91
|
+
add_association(head, new_fields)
|
111
92
|
end
|
112
93
|
|
113
|
-
def apply_dependency(
|
94
|
+
def apply_dependency(dependency)
|
114
95
|
case dependency
|
115
96
|
when Symbol
|
116
|
-
map_property(
|
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(
|
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
|