praxis 2.0.pre.2 → 2.0.pre.7
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.rspec +0 -1
- data/.ruby-version +1 -0
- data/CHANGELOG.md +32 -0
- data/Gemfile +1 -1
- data/Guardfile +2 -1
- data/Rakefile +1 -7
- data/TODO.md +28 -0
- data/lib/api_browser/package-lock.json +7110 -0
- data/lib/praxis.rb +7 -4
- data/lib/praxis/action_definition.rb +9 -16
- data/lib/praxis/api_general_info.rb +21 -0
- data/lib/praxis/application.rb +1 -2
- data/lib/praxis/bootloader_stages/routing.rb +2 -4
- data/lib/praxis/docs/generator.rb +11 -6
- data/lib/praxis/docs/open_api_generator.rb +255 -0
- data/lib/praxis/docs/openapi/info_object.rb +31 -0
- data/lib/praxis/docs/openapi/media_type_object.rb +59 -0
- data/lib/praxis/docs/openapi/operation_object.rb +40 -0
- data/lib/praxis/docs/openapi/parameter_object.rb +69 -0
- data/lib/praxis/docs/openapi/paths_object.rb +58 -0
- data/lib/praxis/docs/openapi/request_body_object.rb +51 -0
- data/lib/praxis/docs/openapi/response_object.rb +63 -0
- data/lib/praxis/docs/openapi/responses_object.rb +44 -0
- data/lib/praxis/docs/openapi/schema_object.rb +87 -0
- data/lib/praxis/docs/openapi/server_object.rb +24 -0
- data/lib/praxis/docs/openapi/tag_object.rb +21 -0
- data/lib/praxis/extensions/attribute_filtering.rb +2 -0
- data/lib/praxis/extensions/attribute_filtering/active_record_filter_query_builder.rb +148 -157
- data/lib/praxis/extensions/attribute_filtering/active_record_patches.rb +15 -0
- data/lib/praxis/extensions/attribute_filtering/active_record_patches/5x.rb +90 -0
- data/lib/praxis/extensions/attribute_filtering/active_record_patches/6_0.rb +68 -0
- data/lib/praxis/extensions/attribute_filtering/active_record_patches/6_1_plus.rb +58 -0
- data/lib/praxis/extensions/attribute_filtering/filter_tree_node.rb +35 -0
- data/lib/praxis/extensions/attribute_filtering/filtering_params.rb +13 -12
- data/lib/praxis/extensions/attribute_filtering/sequel_filter_query_builder.rb +3 -2
- data/lib/praxis/extensions/field_selection/active_record_query_selector.rb +24 -30
- data/lib/praxis/extensions/field_selection/field_selector.rb +4 -0
- data/lib/praxis/extensions/field_selection/sequel_query_selector.rb +32 -39
- data/lib/praxis/extensions/pagination.rb +130 -0
- data/lib/praxis/extensions/pagination/active_record_pagination_handler.rb +42 -0
- data/lib/praxis/extensions/pagination/header_generator.rb +70 -0
- data/lib/praxis/extensions/pagination/ordering_params.rb +234 -0
- data/lib/praxis/extensions/pagination/pagination_handler.rb +68 -0
- data/lib/praxis/extensions/pagination/pagination_params.rb +374 -0
- data/lib/praxis/extensions/pagination/sequel_pagination_handler.rb +45 -0
- data/lib/praxis/handlers/json.rb +2 -0
- data/lib/praxis/handlers/www_form.rb +5 -0
- data/lib/praxis/handlers/{xml.rb → xml-sample.rb} +6 -0
- data/lib/praxis/links.rb +4 -0
- data/lib/praxis/mapper/active_model_compat.rb +57 -4
- data/lib/praxis/mapper/resource.rb +18 -11
- data/lib/praxis/mapper/selector_generator.rb +99 -75
- data/lib/praxis/mapper/sequel_compat.rb +43 -3
- data/lib/praxis/media_type.rb +1 -56
- data/lib/praxis/multipart/part.rb +5 -2
- data/lib/praxis/plugins/mapper_plugin.rb +17 -3
- data/lib/praxis/plugins/pagination_plugin.rb +71 -0
- data/lib/praxis/resource_definition.rb +4 -12
- data/lib/praxis/response_definition.rb +1 -1
- data/lib/praxis/route.rb +2 -4
- data/lib/praxis/routing_config.rb +4 -8
- data/lib/praxis/tasks/api_docs.rb +23 -0
- data/lib/praxis/tasks/routes.rb +10 -15
- data/lib/praxis/types/media_type_common.rb +10 -0
- data/lib/praxis/types/multipart_array.rb +62 -0
- data/lib/praxis/validation_handler.rb +1 -2
- data/lib/praxis/version.rb +1 -1
- data/praxis.gemspec +7 -5
- data/spec/functional_spec.rb +9 -6
- data/spec/praxis/action_definition_spec.rb +4 -16
- data/spec/praxis/api_general_info_spec.rb +6 -6
- data/spec/praxis/extensions/attribute_filtering/active_record_filter_query_builder_spec.rb +304 -0
- data/spec/praxis/extensions/attribute_filtering/filter_tree_node_spec.rb +39 -0
- data/spec/praxis/extensions/attribute_filtering/filtering_params_spec.rb +34 -0
- data/spec/praxis/extensions/field_expansion_spec.rb +6 -24
- data/spec/praxis/extensions/field_selection/active_record_query_selector_spec.rb +110 -0
- data/spec/praxis/extensions/field_selection/sequel_query_selector_spec.rb +148 -0
- data/spec/praxis/extensions/pagination/active_record_pagination_handler_spec.rb +130 -0
- data/spec/praxis/extensions/support/spec_resources_active_model.rb +173 -0
- data/spec/praxis/extensions/support/spec_resources_sequel.rb +106 -0
- data/spec/praxis/mapper/selector_generator_spec.rb +306 -282
- data/spec/praxis/media_type_spec.rb +5 -129
- data/spec/praxis/request_spec.rb +3 -22
- data/spec/praxis/resource_definition_spec.rb +1 -1
- data/spec/praxis/response_definition_spec.rb +8 -9
- data/spec/praxis/route_spec.rb +2 -9
- data/spec/praxis/routing_config_spec.rb +4 -13
- data/spec/praxis/types/multipart_array_spec.rb +4 -21
- data/spec/spec_app/config/environment.rb +0 -2
- data/spec/spec_app/design/api.rb +7 -1
- data/spec/spec_app/design/media_types/instance.rb +0 -8
- data/spec/spec_app/design/media_types/volume.rb +0 -12
- data/spec/spec_app/design/resources/instances.rb +1 -2
- data/spec/spec_helper.rb +17 -0
- data/spec/support/be_deep_equal_matcher.rb +39 -0
- data/spec/support/spec_media_types.rb +0 -73
- data/spec/support/spec_resources.rb +42 -49
- metadata +75 -40
- data/spec/praxis/handlers/xml_spec.rb +0 -177
- data/spec/praxis/links_spec.rb +0 -68
- data/spec/spec_app/app/models/person.rb +0 -3
@@ -0,0 +1,68 @@
|
|
1
|
+
# FOR AR < 6.1
|
2
|
+
module ActiveRecord
|
3
|
+
PRAXIS_JOIN_ALIAS_PREFIX = Praxis::Extensions::AttributeFiltering::ALIAS_TABLE_PREFIX
|
4
|
+
class Relation
|
5
|
+
def construct_join_dependency(associations, join_type) # :nodoc:
|
6
|
+
# Praxis: inject references into the join dependency
|
7
|
+
ActiveRecord::Associations::JoinDependency.new(
|
8
|
+
klass, table, associations, join_type, references: references_values
|
9
|
+
)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
module Associations
|
14
|
+
class JoinDependency
|
15
|
+
attr_accessor :references
|
16
|
+
|
17
|
+
private
|
18
|
+
def initialize(base, table, associations, join_type, references: nil)
|
19
|
+
tree = self.class.make_tree associations
|
20
|
+
@references = references # Save the references values into the instance (to use during build)
|
21
|
+
built = build(tree, base)
|
22
|
+
|
23
|
+
@join_root = JoinBase.new(base, table, built)
|
24
|
+
@join_type = join_type
|
25
|
+
end
|
26
|
+
|
27
|
+
# Praxis: table aliases for is shared for 5x and 6.0
|
28
|
+
def table_aliases_for(parent, node)
|
29
|
+
node.reflection.chain.map do |reflection|
|
30
|
+
is_root_reflection = reflection == node.reflection
|
31
|
+
table = alias_tracker.aliased_table_for(
|
32
|
+
reflection.table_name,
|
33
|
+
table_alias_for(reflection, parent, !is_root_reflection),
|
34
|
+
reflection.klass.type_caster
|
35
|
+
)
|
36
|
+
# through tables do not need a special alias_path alias (as they shouldn't really referenced by the client)
|
37
|
+
if is_root_reflection && node.alias_path
|
38
|
+
table = table.left if table.is_a?(Arel::Nodes::TableAlias) #un-alias it if necessary
|
39
|
+
table = table.alias(node.alias_path.join('/'))
|
40
|
+
end
|
41
|
+
table
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# Praxis: build for is shared for 5x and 6.0
|
46
|
+
def build(associations, base_klass, path: [PRAXIS_JOIN_ALIAS_PREFIX])
|
47
|
+
associations.map do |name, right|
|
48
|
+
reflection = find_reflection base_klass, name
|
49
|
+
reflection.check_validity!
|
50
|
+
reflection.check_eager_loadable!
|
51
|
+
|
52
|
+
if reflection.polymorphic?
|
53
|
+
raise EagerLoadPolymorphicError.new(reflection)
|
54
|
+
end
|
55
|
+
# Praxis: set an alias_path in the JoinAssociation if its path matches a requested reference
|
56
|
+
child_path = (path && !path.empty?) ? path + [name] : nil
|
57
|
+
association = JoinAssociation.new(reflection, build(right, reflection.klass, path: child_path))
|
58
|
+
association.alias_path = child_path if references.include?(child_path.join('/'))
|
59
|
+
association
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
end
|
64
|
+
class ActiveRecord::Associations::JoinDependency::JoinAssociation
|
65
|
+
attr_accessor :alias_path
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# FOR AR >= 6.1
|
2
|
+
module ActiveRecord
|
3
|
+
PRAXIS_JOIN_ALIAS_PREFIX = Praxis::Extensions::AttributeFiltering::ALIAS_TABLE_PREFIX
|
4
|
+
module Associations
|
5
|
+
class JoinDependency
|
6
|
+
|
7
|
+
private
|
8
|
+
def make_constraints(parent, child, join_type)
|
9
|
+
foreign_table = parent.table
|
10
|
+
foreign_klass = parent.base_klass
|
11
|
+
child.join_constraints(foreign_table, foreign_klass, join_type, alias_tracker) do |reflection|
|
12
|
+
table, terminated = @joined_tables[reflection]
|
13
|
+
root = reflection == child.reflection
|
14
|
+
|
15
|
+
if table && (!root || !terminated)
|
16
|
+
@joined_tables[reflection] = [table, root] if root
|
17
|
+
next table, true
|
18
|
+
end
|
19
|
+
|
20
|
+
table_name = @references[reflection.name.to_sym]
|
21
|
+
# Praxis: set an alias_path in the JoinAssociation if its path matches a requested reference
|
22
|
+
table_name = @references[child&.alias_path.join('/').to_sym] unless table_name
|
23
|
+
|
24
|
+
table = alias_tracker.aliased_table_for(reflection.klass.arel_table, table_name) do
|
25
|
+
name = reflection.alias_candidate(parent.table_name)
|
26
|
+
root ? name : "#{name}_join"
|
27
|
+
end
|
28
|
+
|
29
|
+
@joined_tables[reflection] ||= [table, root] if join_type == Arel::Nodes::OuterJoin
|
30
|
+
table
|
31
|
+
end.concat child.children.flat_map { |c| make_constraints(child, c, join_type) }
|
32
|
+
end
|
33
|
+
|
34
|
+
# Praxis: build for is shared for 5x and 6.0
|
35
|
+
def build(associations, base_klass, path: [PRAXIS_JOIN_ALIAS_PREFIX])
|
36
|
+
associations.map do |name, right|
|
37
|
+
reflection = find_reflection base_klass, name
|
38
|
+
reflection.check_validity!
|
39
|
+
reflection.check_eager_loadable!
|
40
|
+
|
41
|
+
if reflection.polymorphic?
|
42
|
+
raise EagerLoadPolymorphicError.new(reflection)
|
43
|
+
end
|
44
|
+
# Praxis: set an alias_path in the JoinAssociation if its path matches a requested reference
|
45
|
+
child_path = (path && !path.empty?) ? path + [name] : nil
|
46
|
+
association = JoinAssociation.new(reflection, build(right, reflection.klass, path: child_path))
|
47
|
+
#association.alias_path = child_path if references.include?(child_path.join('/'))
|
48
|
+
association.alias_path = child_path # ??? should be the line above no?
|
49
|
+
association
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
class ActiveRecord::Associations::JoinDependency::JoinAssociation
|
55
|
+
attr_accessor :alias_path
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module Praxis
|
2
|
+
module Extensions
|
3
|
+
module AttributeFiltering
|
4
|
+
class FilterTreeNode
|
5
|
+
attr_reader :path, :conditions, :children
|
6
|
+
# # parsed_filters is an Array of {name: X, op: , value: } ... exactly the format of the FilteringParams.load method
|
7
|
+
def initialize(parsed_filters, path: [])
|
8
|
+
@path = path # Array that marks the tree 'path' to this node (with respect to the absolute root)
|
9
|
+
@conditions = [] # Conditions to apply directly to this node
|
10
|
+
@children = {} # Hash with a new NodeTree object value, keyed by name
|
11
|
+
children_data = {} # Hash with keys as names of the first level component of the children nodes (and values as array of matching filters)
|
12
|
+
parsed_filters.map do |hash|
|
13
|
+
*components = hash[:name].to_s.split('.')
|
14
|
+
if components.empty?
|
15
|
+
return
|
16
|
+
elsif components.size == 1
|
17
|
+
@conditions << hash.slice(:name, :op, :value)
|
18
|
+
else
|
19
|
+
children_data[components.first] ||= []
|
20
|
+
children_data[components.first] << hash
|
21
|
+
end
|
22
|
+
end
|
23
|
+
# An array of FilterTreeNodes corresponding to each children
|
24
|
+
@children = children_data.each_with_object({}) do |(name, arr), hash|
|
25
|
+
sub_filters = arr.map do |item|
|
26
|
+
_parent, *rest = item[:name].to_s.split('.')
|
27
|
+
item.merge(name: rest.join('.'))
|
28
|
+
end
|
29
|
+
hash[name] = self.class.new(sub_filters, path: path + [name] )
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -26,7 +26,6 @@ module Praxis
|
|
26
26
|
include Attributor::Type
|
27
27
|
include Attributor::Dumpable
|
28
28
|
|
29
|
-
# This DSL allows to define which attributes are allowed in the filters, and with which operators
|
30
29
|
class DSLCompiler < Attributor::DSLCompiler
|
31
30
|
# "account.id": { operators: ["=", "!="] },
|
32
31
|
# name: { operators: ["=", "!="], fuzzy_match: true },
|
@@ -38,8 +37,8 @@ module Praxis
|
|
38
37
|
end
|
39
38
|
|
40
39
|
VALUE_REGEX = /[^,&]*/
|
41
|
-
AVAILABLE_OPERATORS = Set.new(['!=', '>=', '<=', '=', '<', '>']).freeze
|
42
|
-
FILTER_REGEX = /(?<attribute>([^=!><])+)(?<operator
|
40
|
+
AVAILABLE_OPERATORS = Set.new(['!=', '>=', '<=', '=', '<', '>','!','!!']).freeze
|
41
|
+
FILTER_REGEX = /(?<attribute>([^=!><])+)(?<operator>!=|>=|<=|!!|=|<|>|!)(?<value>#{VALUE_REGEX}(,#{VALUE_REGEX})*)/
|
43
42
|
|
44
43
|
# Abstract class, which needs to be used by subclassing it through the .for method, to set the allowed filters
|
45
44
|
# definition should be a hash, keyed by field name, which contains a hash that can have two pieces of metadata
|
@@ -62,6 +61,10 @@ module Praxis
|
|
62
61
|
end
|
63
62
|
end
|
64
63
|
|
64
|
+
def json_schema_type
|
65
|
+
:string
|
66
|
+
end
|
67
|
+
|
65
68
|
def add_filter(name, operators:, fuzzy:)
|
66
69
|
components = name.to_s.split('.').map(&:to_sym)
|
67
70
|
attribute, enclosing_type = find_filter_attribute(components, media_type)
|
@@ -176,7 +179,7 @@ module Praxis
|
|
176
179
|
else
|
177
180
|
value
|
178
181
|
end
|
179
|
-
arr.push(name: attr_name,
|
182
|
+
arr.push(name: attr_name, op: match[:operator], value: coerced )
|
180
183
|
end
|
181
184
|
new(parsed)
|
182
185
|
end
|
@@ -204,28 +207,27 @@ module Praxis
|
|
204
207
|
def validate(_context = Attributor::DEFAULT_ROOT_CONTEXT)
|
205
208
|
parsed_array.each_with_object([]) do |item, errors|
|
206
209
|
attr_name = item[:name]
|
207
|
-
specs = item[:specs]
|
208
210
|
attr_filters = allowed_filters[attr_name]
|
209
211
|
unless attr_filters
|
210
212
|
errors << "Filtering by #{attr_name} is not allowed. You can filter by #{allowed_filters.keys.map(&:to_s).join(', ')}"
|
211
213
|
next
|
212
214
|
end
|
213
215
|
allowed_operators = attr_filters[:operators]
|
214
|
-
unless allowed_operators.include?(
|
215
|
-
errors << "Operator #{
|
216
|
+
unless allowed_operators.include?(item[:op])
|
217
|
+
errors << "Operator #{item[:op]} not allowed for filter #{attr_name}"
|
216
218
|
end
|
217
219
|
value_type = attr_filters[:value_type]
|
218
|
-
value =
|
220
|
+
value = item[:value]
|
219
221
|
if value_type && !value_type.valid_type?(value)
|
220
222
|
# Allow a collection of values of the right type for multimatch (if operators are = or !=)
|
221
|
-
if ['=','!='].include?(
|
223
|
+
if ['=','!='].include?(item[:op])
|
222
224
|
coll_type = Attributor::Collection.of(value_type)
|
223
225
|
if !coll_type.valid_type?(value)
|
224
226
|
errors << "Invalid type in filter/s value for #{attr_name} " +\
|
225
227
|
"(one or more of the multiple matches in #{value} are not a #{value_type.name.split('::').last})"
|
226
228
|
end
|
227
229
|
else
|
228
|
-
errors << "Invalid type in filter value for #{attr_name} (#{value} using '#{
|
230
|
+
errors << "Invalid type in filter value for #{attr_name} (#{value} using '#{item[:op]}' is not a #{value_type.name.split('::').last})"
|
229
231
|
end
|
230
232
|
end
|
231
233
|
|
@@ -243,8 +245,7 @@ module Praxis
|
|
243
245
|
def dump
|
244
246
|
parsed_array.each_with_object([]) do |item, arr|
|
245
247
|
field = item[:name]
|
246
|
-
|
247
|
-
arr << "#{field}#{spec[:op]}#{spec[:value]}"
|
248
|
+
arr << "#{field}#{item[:op]}#{item[:value]}"
|
248
249
|
end.join('&')
|
249
250
|
end
|
250
251
|
|
@@ -34,7 +34,8 @@ module Praxis
|
|
34
34
|
|
35
35
|
# By default we'll simply use the incoming op and value, and will map
|
36
36
|
# the attribute based on what's on the `attr_to_column` hash
|
37
|
-
def
|
37
|
+
def generate(filters)
|
38
|
+
raise "Not refactored yet!"
|
38
39
|
seen_associations = Set.new
|
39
40
|
filters.each do |(attr, spec)|
|
40
41
|
column_name = attr_to_column[attr]
|
@@ -68,7 +69,7 @@ module Praxis
|
|
68
69
|
self.class.attr_to_column
|
69
70
|
end
|
70
71
|
|
71
|
-
# Private to try to funnel all column names through `
|
72
|
+
# Private to try to funnel all column names through `generate` that restricts
|
72
73
|
# the attribute names better (to allow more difficult SQL injections )
|
73
74
|
private def add_clause(attr:, op:, value:)
|
74
75
|
# TODO: partial matching
|
@@ -3,51 +3,45 @@ 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:, debug: false)
|
9
9
|
@selector = selectors
|
10
10
|
@query = query
|
11
|
-
@
|
12
|
-
@resolved = resolved
|
13
|
-
@seen = Set.new
|
14
|
-
@root = model.table_name
|
15
|
-
end
|
16
|
-
|
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
|
11
|
+
@logger = debug ? Logger.new(STDOUT) : nil
|
25
12
|
end
|
26
13
|
|
27
14
|
def generate
|
28
15
|
# TODO: unfortunately, I think we can only control the select clauses for the top model
|
29
16
|
# (as I'm not sure ActiveRecord supports expressing it in the join...)
|
30
|
-
@query = add_select(query: query,
|
17
|
+
@query = add_select(query: query, selector_node: selector)
|
18
|
+
eager_hash = _eager(selector)
|
31
19
|
|
32
|
-
@query.includes(
|
20
|
+
@query = @query.includes(eager_hash)
|
21
|
+
explain_query(query, eager_hash) if @logger
|
22
|
+
|
23
|
+
@query
|
33
24
|
end
|
34
25
|
|
35
|
-
def
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
dataset << { track => _eager(assoc_model, resolved[track]) }
|
42
|
-
end
|
26
|
+
def add_select(query:, selector_node:)
|
27
|
+
# We're gonna always require the PK of the model, as it is a special case for AR, and the app itself
|
28
|
+
# might assume it is always there and not be surprised by the fact that if it isn't, it won't blow up
|
29
|
+
# in the same way as any other attribute not being loaded...i.e., ActiveModel::MissingAttributeError: missing attribute: xyz
|
30
|
+
select_fields = selector_node.select + [selector_node.resource.model.primary_key.to_sym]
|
31
|
+
select_fields.empty? ? query : query.select(*select_fields)
|
43
32
|
end
|
44
33
|
|
45
|
-
def
|
46
|
-
|
34
|
+
def _eager(selector_node)
|
35
|
+
selector_node.tracks.each_with_object({}) do |(track_name, track_node), h|
|
36
|
+
h[track_name] = _eager(track_node)
|
37
|
+
end
|
47
38
|
end
|
48
39
|
|
49
|
-
def
|
50
|
-
selector
|
40
|
+
def explain_query(query, eager_hash)
|
41
|
+
@logger.debug("Query plan for ...#{selector.resource.model} with selectors: #{JSON.generate(selector.dump)}")
|
42
|
+
@logger.debug(" ActiveRecord query: #{selector.resource.model}.includes(#{eager_hash})")
|
43
|
+
query.explain
|
44
|
+
@logger.debug("Query plan end")
|
51
45
|
end
|
52
46
|
end
|
53
47
|
end
|
@@ -1,63 +1,56 @@
|
|
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:, debug: false)
|
9
12
|
@selector = selectors
|
10
|
-
@
|
11
|
-
@
|
12
|
-
@resolved = resolved
|
13
|
-
@seen = Set.new
|
14
|
-
@root = model.table_name
|
15
|
-
end
|
16
|
-
|
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
|
25
|
-
end
|
13
|
+
@query = query
|
14
|
+
@logger = debug ? Logger.new(STDOUT) : nil
|
26
15
|
end
|
27
16
|
|
28
17
|
def generate
|
29
|
-
@
|
30
|
-
|
31
|
-
|
32
|
-
|
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]))
|
18
|
+
@query = add_select(query: query, selector_node: @selector)
|
19
|
+
|
20
|
+
@query = @selector.tracks.inject(@query) do |ds, (track_name, track_node)|
|
21
|
+
ds.eager(track_name => _eager(track_node) )
|
38
22
|
end
|
23
|
+
|
24
|
+
explain_query(query) if @logger
|
25
|
+
@query
|
39
26
|
end
|
40
27
|
|
41
|
-
def _eager(
|
28
|
+
def _eager(selector_node)
|
42
29
|
lambda do |dset|
|
43
|
-
|
30
|
+
dset = add_select(query: dset, selector_node: selector_node)
|
44
31
|
|
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]))
|
32
|
+
dset = selector_node.tracks.inject(dset) do |ds, (track_name, track_node)|
|
33
|
+
ds.eager(track_name => _eager(track_node) )
|
51
34
|
end
|
35
|
+
|
52
36
|
end
|
53
37
|
end
|
54
38
|
|
55
|
-
def
|
56
|
-
|
39
|
+
def add_select(query:, selector_node:)
|
40
|
+
# We're gonna always require the PK of the model, as it is a special case for Sequel, and the app itself
|
41
|
+
# might assume it is always there and not be surprised by the fact that if it isn't, it won't blow up
|
42
|
+
# in the same way as any other attribute not being loaded...i.e., NoMethodError: undefined method `foobar' for #<...>
|
43
|
+
select_fields = selector_node.select + [selector_node.resource.model.primary_key.to_sym]
|
44
|
+
|
45
|
+
table_name = selector_node.resource.model.table_name
|
46
|
+
qualified = select_fields.map { |f| Sequel.qualify(table_name, f) }
|
47
|
+
query.select(*qualified)
|
57
48
|
end
|
58
49
|
|
59
|
-
def
|
60
|
-
selector
|
50
|
+
def explain_query(ds)
|
51
|
+
@logger.debug("Query plan for ...#{selector.resource.model} with selectors: #{JSON.generate(selector.dump)}")
|
52
|
+
ds.all
|
53
|
+
@logger.debug("Query plan end")
|
61
54
|
end
|
62
55
|
end
|
63
56
|
end
|