praxis 2.0.pre.10 → 2.0.pre.15
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.ruby-version +1 -1
- data/.travis.yml +1 -3
- data/CHANGELOG.md +26 -0
- data/bin/praxis +65 -2
- data/lib/praxis/api_definition.rb +8 -4
- data/lib/praxis/bootloader_stages/environment.rb +1 -0
- data/lib/praxis/collection.rb +11 -0
- data/lib/praxis/docs/open_api/response_object.rb +21 -6
- data/lib/praxis/docs/open_api_generator.rb +1 -1
- data/lib/praxis/extensions/attribute_filtering.rb +14 -1
- data/lib/praxis/extensions/attribute_filtering/active_record_filter_query_builder.rb +206 -66
- data/lib/praxis/extensions/attribute_filtering/filter_tree_node.rb +3 -2
- data/lib/praxis/extensions/attribute_filtering/filtering_params.rb +45 -41
- data/lib/praxis/extensions/attribute_filtering/filters_parser.rb +193 -0
- data/lib/praxis/extensions/attribute_filtering/sequel_filter_query_builder.rb +20 -8
- data/lib/praxis/extensions/pagination.rb +5 -32
- data/lib/praxis/mapper/active_model_compat.rb +4 -0
- data/lib/praxis/mapper/resource.rb +18 -2
- data/lib/praxis/mapper/selector_generator.rb +1 -0
- data/lib/praxis/mapper/sequel_compat.rb +7 -0
- data/lib/praxis/media_type_identifier.rb +11 -1
- data/lib/praxis/plugins/mapper_plugin.rb +22 -13
- data/lib/praxis/plugins/pagination_plugin.rb +34 -4
- data/lib/praxis/response_definition.rb +46 -66
- data/lib/praxis/responses/http.rb +3 -1
- data/lib/praxis/tasks/api_docs.rb +4 -1
- data/lib/praxis/tasks/routes.rb +6 -6
- data/lib/praxis/version.rb +1 -1
- data/spec/praxis/action_definition_spec.rb +3 -1
- data/spec/praxis/extensions/attribute_filtering/active_record_filter_query_builder_spec.rb +267 -167
- data/spec/praxis/extensions/attribute_filtering/filter_tree_node_spec.rb +25 -6
- data/spec/praxis/extensions/attribute_filtering/filtering_params_spec.rb +100 -17
- data/spec/praxis/extensions/attribute_filtering/filters_parser_spec.rb +148 -0
- data/spec/praxis/extensions/field_selection/active_record_query_selector_spec.rb +1 -1
- data/spec/praxis/extensions/field_selection/sequel_query_selector_spec.rb +1 -1
- data/spec/praxis/extensions/support/spec_resources_active_model.rb +1 -1
- data/spec/praxis/mapper/selector_generator_spec.rb +1 -1
- data/spec/praxis/media_type_identifier_spec.rb +15 -1
- data/spec/praxis/response_definition_spec.rb +37 -129
- data/tasks/thor/example.rb +12 -6
- data/tasks/thor/model.rb +40 -0
- data/tasks/thor/scaffold.rb +117 -0
- data/tasks/thor/templates/generator/empty_app/config/environment.rb +1 -0
- data/tasks/thor/templates/generator/example_app/Rakefile +9 -2
- data/tasks/thor/templates/generator/example_app/app/v1/concerns/controller_base.rb +24 -0
- data/tasks/thor/templates/generator/example_app/app/v1/concerns/href.rb +33 -0
- data/tasks/thor/templates/generator/example_app/app/v1/controllers/users.rb +2 -2
- data/tasks/thor/templates/generator/example_app/app/v1/resources/base.rb +15 -0
- data/tasks/thor/templates/generator/example_app/app/v1/resources/user.rb +7 -28
- data/tasks/thor/templates/generator/example_app/config.ru +1 -2
- data/tasks/thor/templates/generator/example_app/config/environment.rb +3 -2
- data/tasks/thor/templates/generator/example_app/db/migrate/20201010101010_create_users_table.rb +3 -2
- data/tasks/thor/templates/generator/example_app/db/seeds.rb +6 -0
- data/tasks/thor/templates/generator/example_app/design/v1/endpoints/users.rb +4 -4
- data/tasks/thor/templates/generator/example_app/design/v1/media_types/user.rb +1 -6
- data/tasks/thor/templates/generator/example_app/spec/helpers/database_helper.rb +4 -2
- data/tasks/thor/templates/generator/example_app/spec/spec_helper.rb +2 -2
- data/tasks/thor/templates/generator/example_app/spec/v1/controllers/users_spec.rb +2 -2
- data/tasks/thor/templates/generator/scaffold/design/endpoints/collection.rb +98 -0
- data/tasks/thor/templates/generator/scaffold/design/media_types/item.rb +18 -0
- data/tasks/thor/templates/generator/scaffold/implementation/controllers/collection.rb +77 -0
- data/tasks/thor/templates/generator/scaffold/implementation/resources/base.rb +11 -0
- data/tasks/thor/templates/generator/scaffold/implementation/resources/item.rb +45 -0
- data/tasks/thor/templates/generator/scaffold/models/active_record.rb +6 -0
- data/tasks/thor/templates/generator/scaffold/models/sequel.rb +6 -0
- metadata +21 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 9391f2cd6fb2c5a605a4b4c9f492371ef1d7f01849e94a9d17fdc51b95c211a9
|
4
|
+
data.tar.gz: 70c9f00cd12c7b3e99cebd10f86a98ac913433b8b96a41b658893b63ddc4b31b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: edfc42021b347aea6bd7b84853ebd901f55e4963612da2a6870e00b956b3352e51efd8cd3c360123625af48f89553bff6fa0ad7766e076bc164ba8ccf770ba7b
|
7
|
+
data.tar.gz: da2df887a849f03be390f7218f931807670a58addc8eb3d101b293e0827a522680ca1824f47bafcff2c3f26bfb45bd13e8ad0c6f02577edad12c5c8cfca60904
|
data/.ruby-version
CHANGED
@@ -1 +1 @@
|
|
1
|
-
2.
|
1
|
+
2.7.1
|
data/.travis.yml
CHANGED
data/CHANGELOG.md
CHANGED
@@ -2,6 +2,32 @@
|
|
2
2
|
|
3
3
|
## next
|
4
4
|
|
5
|
+
## 2.0.pre.14
|
6
|
+
|
7
|
+
* More encoding/decoding robustness for filters.
|
8
|
+
* Specs for how to encode filters are now properly defined by:
|
9
|
+
* The "value" of the filters query string needs to be URI encoded (like any other query string value). This encoding is subject to the normal rules, and therefore "could" leave some of the URI unreserved characters (i.e., 'markers') unencoded depending on the client (Section 2.2 of https://tools.ietf.org/html/rfc2396).
|
10
|
+
* The "values" for any of the conditions in the contents of the filters, however, will need to be properly "escaped" as well (prior to URL-encoding the whole syntax string itself like described above). This means that any match value needs to ensure that it has (at least) "(",")","|","&" and "," escaped as they are reserved characters for the filter expression syntax. For example, if I want to search for a name with value "Rocket&(Pants)", I need to first compose the syntax by: "name=<escaped Rocket&(Pants)>, which is "name=Rocket%26%28Pants%29" and then, just URI encode that query string value for the filters parameter in the URL like any other. For example: "filters=name%3DRocket%2526%2528Pants%2529"
|
11
|
+
* When using a multi-match (csv-separated) list of values, you need to escape each of the values as well, leaving the 'comma' unescape, as that's part of the syntax. Then uri-encode it all for the filters query string parameter value like above.
|
12
|
+
* Now, one can properly differentiate between fuzzy query prefix/postfix, and the literal data to search for (which can be or include '*'). Report that multi-matches (i.e., csv separated values for a single field, which translate into "IN" clauses) is not allowed if fuzzy matches are received (need to use multiple OR clauses for it).
|
13
|
+
|
14
|
+
## 2.0.pre.13
|
15
|
+
|
16
|
+
* Fix filters parser regression, which would incorrectly decode url-encoded values
|
17
|
+
|
18
|
+
## 2.0.pre.12
|
19
|
+
|
20
|
+
* Rebuilt API filters to support a much richer syntax. One can now use ANDs and ORs (with ANDs having order precedence), as well as group them with parenthesis. The same individual filter operands are supported. For example: 'email=*@gmail.com&(friends.first_name=Joe*,Patty|friends.last_name=Smith)
|
21
|
+
|
22
|
+
## 2.0.pre.11
|
23
|
+
|
24
|
+
- Remove MapperPlugin's `set_selectors` (made `selector_generator` lazy instead), and ensure it includes the rendering extensions to the Controllers. Less things to configure if you opt into the Mapper way.
|
25
|
+
- Built scaffolding generator for quickly creating a new API endpoint in the praxis binary (it builds endpoint+mediatype+controller+resource at one, with useful base code and comments)
|
26
|
+
- Dropped support for Ruby 2.4 and 2.5 as some of the newest dependent gems are dropping it as well.
|
27
|
+
- Simplify filters_mapping definition, by not requiring to define same-name mappings if the underlying model has an attribute with the same exact name. i.e., a `name: :name` entry is not necessary if the model has a `:name` attribute.
|
28
|
+
|
29
|
+
## 2.0.pre.10
|
30
|
+
|
5
31
|
- Simple, but pervasive breaking change: Rename `ResourceDefinition` to `EndpointDefinition` (but same functionality).
|
6
32
|
- Remove all deprecated features (and raise error describing it's not supported yet)
|
7
33
|
- Remove `Links` and `LinkBuilder`. Those seem unnecessary from a Framework point of view as they aren't clear most
|
data/bin/praxis
CHANGED
@@ -9,6 +9,12 @@ rescue Bundler::GemfileNotFound
|
|
9
9
|
# no-op: we might be installed as a system gem
|
10
10
|
end
|
11
11
|
|
12
|
+
if ARGV[0] == "version"
|
13
|
+
require 'praxis/version'
|
14
|
+
puts "Praxis version #{Praxis::VERSION}"
|
15
|
+
exit 0
|
16
|
+
end
|
17
|
+
|
12
18
|
if ["routes","docs","console"].include? ARGV[0]
|
13
19
|
require 'rake'
|
14
20
|
require 'praxis'
|
@@ -46,7 +52,7 @@ class PraxisGenerator < Thor
|
|
46
52
|
def routes
|
47
53
|
end
|
48
54
|
|
49
|
-
desc "docs [generate|browser|package]",
|
55
|
+
desc "docs [generate|browser|package]", <<~EOF
|
50
56
|
Generates API documentation and a Web App to inspect it
|
51
57
|
generate - Generates the JSON docs
|
52
58
|
browser - (default) Generates JSON docs, and automatically starts a Web app to browse them.
|
@@ -81,7 +87,64 @@ class PraxisGenerator < Thor
|
|
81
87
|
gen = ::PraxisGen::Example.new([app_name])
|
82
88
|
gen.destination_root = app_name
|
83
89
|
gen.invoke(:example)
|
84
|
-
end
|
90
|
+
end
|
91
|
+
|
92
|
+
desc_for "g COLLECTION_NAME", ::PraxisGen::Scaffold, :g
|
93
|
+
# Cannot use the argument below or it will apply to all commands (the action in the class has it)
|
94
|
+
# argument :collection_name, required: false
|
95
|
+
# The options, however, since they're optional are fine (But need to be duplicated from the class :( )
|
96
|
+
option :version, required: false, default: '1',
|
97
|
+
desc: 'Version string for the API endpoint. This also dictates the directory structure (i.e., v1/endpoints/...))'
|
98
|
+
option :design, type: :boolean, default: true,
|
99
|
+
desc: 'Include the Endpoint and MediaType files for the collection'
|
100
|
+
option :implementation, type: :boolean, default: true,
|
101
|
+
desc: 'Include the Controller and (possibly the) Resource files for the collection (see --no-resource)'
|
102
|
+
option :resource, type: :boolean, default: true,
|
103
|
+
desc: 'Disable (or enable) the creation of the Resource files when generating implementation'
|
104
|
+
option :model, type: :string, enum: ['activerecord','sequel'],
|
105
|
+
desc: 'It also generates a model for the given ORM. An empty --model flag will default to activerecord'
|
106
|
+
option :actions, type: :string, default: 'crud', enum: ['cr','cru','crud','u','ud','d'],
|
107
|
+
desc: 'Specifies the actions to generate for the API. cr=create, u=update, d=delete. Index and show actions are always generated'
|
108
|
+
def g(*args)
|
109
|
+
# Because we cannot share the :collection_name argument, we need to do this check here, before
|
110
|
+
# we "parse" it and pass it to the g command
|
111
|
+
unless args.size == 1
|
112
|
+
::PraxisGen::Scaffold.command_help(shell,:g)
|
113
|
+
exit 1
|
114
|
+
end
|
115
|
+
|
116
|
+
collection_name,_ = args
|
117
|
+
::PraxisGen::Scaffold.new([collection_name],options).invoke(:g)
|
118
|
+
if options[:model]
|
119
|
+
# Make it easy to be able to both enable or not enable the creation of the model, by passing --model=...
|
120
|
+
# but also make it easy so that if there is no value for it, it default to activerecord
|
121
|
+
opts = {orm: options[:model] }
|
122
|
+
opts[:orm] = 'activerecord' if opts[:orm] == 'model' # value is model param passed by no value
|
123
|
+
::PraxisGen::Model.new([collection_name.singularize],opts).invoke(:g)
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
# Initially, the idea was to build some quick model generator, but I think it's better to keep it
|
128
|
+
# simple and just use the scaffold generator with `--no-implementation --no-design --model` instead
|
129
|
+
# Left here in case we want to rescue it
|
130
|
+
# desc_for "gmodel MODEL_NAME", ::PraxisGen::Model, :g
|
131
|
+
# # Cannot use the argument below or it will apply to all commands (the action in the class has it)
|
132
|
+
# # argument :collection_name, required: false
|
133
|
+
# # The options, however, since they're optional are fine (But need to be duplicated from the class :( )
|
134
|
+
# option :orm, required: false, default: 'activerecord', enum: ['activerecord','sequel'],
|
135
|
+
# desc: 'Type of ORM model to create.'
|
136
|
+
# def gmodel(*args)
|
137
|
+
# # Because we cannot share the :collection_name argument, we need to do this check here, before
|
138
|
+
# # we "parse" it and pass it to the g command
|
139
|
+
# unless args.size == 1
|
140
|
+
# ::PraxisGen::Model.command_help(shell,:g)
|
141
|
+
# exit 1
|
142
|
+
# end
|
143
|
+
|
144
|
+
# model_name,_ = args
|
145
|
+
# ::PraxisGen::Model.new([model_name],options).invoke(:g)
|
146
|
+
# end
|
147
|
+
|
85
148
|
end
|
86
149
|
|
87
150
|
PraxisGenerator.start(ARGV)
|
@@ -97,8 +97,10 @@ module Praxis
|
|
97
97
|
description( description || 'Standard response for successful HTTP requests.' )
|
98
98
|
|
99
99
|
media_type media_type
|
100
|
-
location location
|
101
|
-
headers
|
100
|
+
location if location
|
101
|
+
headers&.each do |(name, value)|
|
102
|
+
header(name: name, value: value)
|
103
|
+
end
|
102
104
|
end
|
103
105
|
|
104
106
|
api.response_template :created do |media_type: nil, location: nil, headers: nil, description: nil|
|
@@ -106,8 +108,10 @@ module Praxis
|
|
106
108
|
description( description || 'The request has been fulfilled and resulted in a new resource being created.' )
|
107
109
|
|
108
110
|
media_type media_type if media_type
|
109
|
-
location location
|
110
|
-
headers
|
111
|
+
location if location
|
112
|
+
headers&.each do |(name, value)|
|
113
|
+
header(name: name, value: value)
|
114
|
+
end
|
111
115
|
end
|
112
116
|
end
|
113
117
|
|
data/lib/praxis/collection.rb
CHANGED
@@ -32,5 +32,16 @@ module Praxis
|
|
32
32
|
@member_type.domain_model
|
33
33
|
end
|
34
34
|
|
35
|
+
def self.json_schema_type
|
36
|
+
:array
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.as_json_schema(**args)
|
40
|
+
the_type = @attribute && @attribute.type || member_type
|
41
|
+
{
|
42
|
+
type: json_schema_type,
|
43
|
+
items: { '$ref': "#/components/schemas/#{the_type.id}" }
|
44
|
+
}
|
45
|
+
end
|
35
46
|
end
|
36
47
|
end
|
@@ -16,12 +16,27 @@ module Praxis
|
|
16
16
|
|
17
17
|
def dump_response_headers_object( headers )
|
18
18
|
headers.each_with_object({}) do |(name,data),accum|
|
19
|
-
#
|
20
|
-
#
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
19
|
+
# each header comes from Praxis::ResponseDefinition
|
20
|
+
# the keys are the header names, and value can be:
|
21
|
+
# "true" => means it only needs to exist
|
22
|
+
# String => which means that it has to fully match
|
23
|
+
# Regex => which means it has to regexp match it
|
24
|
+
|
25
|
+
# Get the schema from the type (defaulting to string in case the type doesn't have the as_json_schema defined)
|
26
|
+
schema = data[:attribute].type.as_json_schema rescue { type: :string }
|
27
|
+
hash = { description: data[:description] || '', schema: schema }
|
28
|
+
# Note, our Headers in response definition are not full types...they're basically only
|
29
|
+
# strings, which can either match anything, match the exact word or match a regex
|
30
|
+
# they don't even have a description...
|
31
|
+
data_value = data[:value]
|
32
|
+
if data_value.is_a? String
|
33
|
+
hash[:pattern] = "^#{data_value}$" # Exact String match
|
34
|
+
elsif data_value.is_a? Regexp
|
35
|
+
sanitized_pattern = data_value.inspect[1..-2] #inspect returns enclosing '/' characters
|
36
|
+
hash[:pattern] = sanitized_pattern
|
37
|
+
end
|
38
|
+
|
39
|
+
accum[name] = hash
|
25
40
|
end
|
26
41
|
end
|
27
42
|
|
@@ -244,7 +244,7 @@ module Praxis
|
|
244
244
|
resources_by_version.keys.each do |version|
|
245
245
|
FileUtils.mkdir_p @doc_root_dir + '/' + version
|
246
246
|
end
|
247
|
-
FileUtils.mkdir_p @doc_root_dir + '/unversioned'
|
247
|
+
FileUtils.mkdir_p @doc_root_dir + '/unversioned' if resources_by_version.keys.include?('n/a')
|
248
248
|
end
|
249
249
|
|
250
250
|
def normalize_media_types( mtis )
|
@@ -1,2 +1,15 @@
|
|
1
1
|
require 'praxis/extensions/attribute_filtering/filtering_params'
|
2
|
-
require 'praxis/extensions/attribute_filtering/filter_tree_node'
|
2
|
+
require 'praxis/extensions/attribute_filtering/filter_tree_node'
|
3
|
+
module Praxis
|
4
|
+
module Extensions
|
5
|
+
module AttributeFiltering
|
6
|
+
class MultiMatchWithFuzzyNotAllowedByAdapter < StandardError
|
7
|
+
def initialize
|
8
|
+
msg = 'Matching multiple, comma-separated values with fuzzy matches for a single field is not allowed by this DB adapter'\
|
9
|
+
'Please use multiple OR clauses instead.'
|
10
|
+
super(msg)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -5,58 +5,173 @@ module Praxis
|
|
5
5
|
module AttributeFiltering
|
6
6
|
ALIAS_TABLE_PREFIX = ''
|
7
7
|
require_relative 'active_record_patches'
|
8
|
+
# Helper class that can present an SqlLiteral string which we have already quoted
|
9
|
+
# ... but! that can properly provide a "to_sym" that has the value unquoted
|
10
|
+
# This is necessary as (the latest AR code):
|
11
|
+
# * does not carry over "references" in joins if they are not SqlLiterals
|
12
|
+
# * but, at the same time, it indexes the references using the .to_sym value (which is really expected to be the normal string, without quotes)
|
13
|
+
# If we pass a normal SqlLiteral, instead of our wrapper, without quoting the table, the current AR code will never quote it to form the
|
14
|
+
# SQL string, as it's already a literal...so our "/" type separators as names won't work without quoting.
|
15
|
+
class QuasiSqlLiteral < Arel::Nodes::SqlLiteral
|
16
|
+
def initialize(quoted:, symbolized:)
|
17
|
+
@symbolized = symbolized
|
18
|
+
super(quoted)
|
19
|
+
end
|
20
|
+
def to_sym
|
21
|
+
@symbolized
|
22
|
+
end
|
23
|
+
end
|
8
24
|
|
9
25
|
class ActiveRecordFilterQueryBuilder
|
10
|
-
|
26
|
+
REFERENCES_STRING_SEPARATOR = '/'
|
27
|
+
attr_reader :model, :filters_map
|
11
28
|
|
12
29
|
# Base query to build upon
|
13
30
|
def initialize(query: , model:, filters_map:, debug: false)
|
14
|
-
|
31
|
+
# Note: Do not make the initial_query an attr reader to make sure we don't count/leak on modifying it. Easier to mostly use class methods
|
32
|
+
@initial_query = query
|
15
33
|
@model = model
|
16
|
-
@
|
34
|
+
@filters_map = filters_map
|
17
35
|
@logger = debug ? Logger.new(STDOUT) : nil
|
36
|
+
@active_record_version_maj = ActiveRecord.gem_version.segments[0]
|
18
37
|
end
|
19
38
|
|
20
|
-
def
|
21
|
-
@logger
|
39
|
+
def debug_query(msg, query)
|
40
|
+
@logger.info(msg + query.to_sql) if @logger
|
22
41
|
end
|
23
42
|
|
24
43
|
def generate(filters)
|
25
44
|
# Resolve the names and values first, based on filters_map
|
26
45
|
root_node = _convert_to_treenode(filters)
|
27
|
-
craft_filter_query(root_node, for_model: @model)
|
28
|
-
|
29
|
-
|
46
|
+
crafted = craft_filter_query(root_node, for_model: @model)
|
47
|
+
debug_query("SQL due to filters: ", crafted.all)
|
48
|
+
crafted
|
30
49
|
end
|
31
50
|
|
32
51
|
def craft_filter_query(nodetree, for_model:)
|
33
52
|
result = _compute_joins_and_conditions_data(nodetree, model: for_model)
|
34
|
-
@
|
53
|
+
return @initial_query if result[:conditions].empty?
|
54
|
+
|
55
|
+
|
56
|
+
# Find the root group (usually an AND group) but can be an OR group, or nil if there's only 1 condition
|
57
|
+
root_parent_group = result[:conditions].first[:node_object].parent_group || result[:conditions].first[:node_object]
|
58
|
+
while root_parent_group.parent_group != nil
|
59
|
+
root_parent_group = root_parent_group.parent_group
|
60
|
+
end
|
35
61
|
|
36
|
-
|
37
|
-
|
38
|
-
|
62
|
+
# Process the joins
|
63
|
+
query_with_joins = result[:associations_hash].empty? ? @initial_query : @initial_query.joins(result[:associations_hash])
|
64
|
+
|
65
|
+
# Proc to apply a single condition
|
66
|
+
apply_single_condition = Proc.new do |condition, associated_query|
|
67
|
+
colo = condition[:model].columns_hash[condition[:name].to_s]
|
39
68
|
column_prefix = condition[:column_prefix]
|
69
|
+
|
70
|
+
# Mark where clause referencing the appropriate alias IF it's not the root table, as there is no association to reference
|
71
|
+
# If we added root table as a reference, we better make sure it is not quoted, as it actually makes AR to see it as an
|
72
|
+
# unmatched reference and eager loads the whole association (it means eager load ALL the things). Not good.
|
73
|
+
unless for_model.table_name == column_prefix
|
74
|
+
associated_query = associated_query.references(build_reference_value(column_prefix, query: associated_query))
|
75
|
+
end
|
76
|
+
self.class.add_clause(
|
77
|
+
query: associated_query,
|
78
|
+
column_prefix: column_prefix,
|
79
|
+
column_object: colo,
|
80
|
+
op: condition[:op],
|
81
|
+
value: condition[:value],
|
82
|
+
fuzzy: condition[:fuzzy]
|
83
|
+
)
|
84
|
+
end
|
40
85
|
|
41
|
-
|
42
|
-
|
86
|
+
if @active_record_version_maj < 6
|
87
|
+
# ActiveRecord < 6 does not support '.and' so no nested things can be done
|
88
|
+
# But we can still support the case of 1+ flat conditions of the same AND/OR type
|
89
|
+
if root_parent_group.is_a?(FilteringParams::Condition)
|
90
|
+
# A Single condition it is easy to handle
|
91
|
+
apply_single_condition.call(result[:conditions].first, query_with_joins)
|
92
|
+
elsif root_parent_group.items.all?{|i| i.is_a?(FilteringParams::Condition)}
|
93
|
+
# Only 1 top level root, with only with simple condition items
|
94
|
+
if root_parent_group.type == :and
|
95
|
+
result[:conditions].reverse.inject(query_with_joins) do |accum, condition|
|
96
|
+
apply_single_condition.call(condition, accum)
|
97
|
+
end
|
98
|
+
else
|
99
|
+
# To do a flat OR, we need to apply the first condition to the incoming query
|
100
|
+
# and then apply any extra ORs to it. Otherwise Book.or(X).or(X) still matches all books
|
101
|
+
cond1, *rest = result[:conditions].reverse
|
102
|
+
start_query = apply_single_condition.call(cond1, query_with_joins)
|
103
|
+
rest.inject(start_query) do |accum, condition|
|
104
|
+
accum.or(apply_single_condition.call(condition, query_with_joins))
|
105
|
+
end
|
106
|
+
end
|
107
|
+
else
|
108
|
+
raise "Mixing AND and OR conditions is not supported for ActiveRecord <6."
|
109
|
+
end
|
110
|
+
else # ActiveRecord 6+
|
111
|
+
# Process the conditions in a depth-first order, and return the resulting query
|
112
|
+
_depth_first_traversal(
|
113
|
+
root_query: query_with_joins,
|
114
|
+
root_node: root_parent_group,
|
115
|
+
conditions: result[:conditions],
|
116
|
+
&apply_single_condition
|
117
|
+
)
|
43
118
|
end
|
44
119
|
end
|
45
120
|
|
46
121
|
private
|
122
|
+
def _depth_first_traversal(root_query:, root_node:, conditions:, &block)
|
123
|
+
# Save the associated query for non-leaves
|
124
|
+
root_node.associated_query = root_query if root_node.is_a?(FilteringParams::ConditionGroup)
|
125
|
+
|
126
|
+
if root_node.is_a?(FilteringParams::Condition)
|
127
|
+
matching_condition = conditions.find {|cond| cond[:node_object] == root_node }
|
128
|
+
|
129
|
+
# The simplified case of a single top level condition (without a wrapping group)
|
130
|
+
# will need to pass the root query itself
|
131
|
+
associated_query = root_node.parent_group ? root_node.parent_group.associated_query : root_query
|
132
|
+
return yield matching_condition, associated_query
|
133
|
+
else
|
134
|
+
first_query, *rest_queries = root_node.items.map do |child|
|
135
|
+
_depth_first_traversal(root_query: root_query, root_node: child, conditions: conditions, &block)
|
136
|
+
end
|
137
|
+
|
138
|
+
rest_queries.each.inject(first_query) do |q, a_query|
|
139
|
+
root_node.type == :and ? q.and(a_query) : q.or(a_query)
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
def _mapped_filter(name)
|
145
|
+
target = @filters_map[name]
|
146
|
+
unless target
|
147
|
+
if @model.attribute_names.include?(name.to_s)
|
148
|
+
# Cache it in the filters mapping (to avoid later lookups), and return it.
|
149
|
+
@filters_map[name] = name
|
150
|
+
target = name
|
151
|
+
end
|
152
|
+
end
|
153
|
+
return target
|
154
|
+
end
|
47
155
|
|
48
156
|
# Resolve and convert from filters, to a more manageable and param-type-independent structure
|
49
157
|
def _convert_to_treenode(filters)
|
50
158
|
# Resolve the names and values first, based on filters_map
|
51
159
|
resolved_array = []
|
52
160
|
filters.parsed_array.each do |filter|
|
53
|
-
mapped_value =
|
54
|
-
|
161
|
+
mapped_value = _mapped_filter(filter[:name])
|
162
|
+
unless mapped_value
|
163
|
+
msg = "Filtering by #{filter[:name]} is not allowed. No implementation mapping defined for it has been found \
|
164
|
+
and there is not a model attribute with this name either.\n" \
|
165
|
+
"Please add a mapping for #{filter[:name]} in the `filters_mapping` method of the appropriate Resource class"
|
166
|
+
raise msg
|
167
|
+
end
|
55
168
|
bindings_array = \
|
56
169
|
if mapped_value.is_a?(Proc)
|
57
170
|
result = mapped_value.call(filter)
|
58
171
|
# Result could be an array of hashes (each hash has name/op/value to identify a condition)
|
59
|
-
result.is_a?(Array) ? result : [result]
|
172
|
+
result_from_proc = result.is_a?(Array) ? result : [result]
|
173
|
+
# Make sure we tack on the node object associated with the filter
|
174
|
+
result_from_proc.map{|hash| hash.merge(node_object: filter[:node_object])}
|
60
175
|
else
|
61
176
|
# For non-procs there's only 1 filter and 1 value (we're just overriding the mapped value)
|
62
177
|
[filter.merge( name: mapped_value)]
|
@@ -76,59 +191,58 @@ module Praxis
|
|
76
191
|
h[name] = result[:associations_hash]
|
77
192
|
conditions += result[:conditions]
|
78
193
|
end
|
79
|
-
column_prefix = nodetree.path == [ALIAS_TABLE_PREFIX] ? model.table_name : nodetree.path.join(
|
80
|
-
#column_prefix = nodetree.path == [ALIAS_TABLE_PREFIX] ? nil : nodetree.path.join('/')
|
194
|
+
column_prefix = nodetree.path == [ALIAS_TABLE_PREFIX] ? model.table_name : nodetree.path.join(REFERENCES_STRING_SEPARATOR)
|
81
195
|
nodetree.conditions.each do |condition|
|
82
196
|
conditions += [condition.merge(column_prefix: column_prefix, model: model)]
|
83
197
|
end
|
84
198
|
{associations_hash: h, conditions: conditions}
|
85
199
|
end
|
86
200
|
|
87
|
-
def add_clause(column_prefix:, column_object:, op:, value:)
|
88
|
-
|
89
|
-
|
201
|
+
def self.add_clause(query:, column_prefix:, column_object:, op:, value:,fuzzy:)
|
202
|
+
likeval = get_like_value(value,fuzzy)
|
203
|
+
case op
|
204
|
+
when '!' # name! means => name IS NOT NULL (and the incoming value is nil)
|
205
|
+
op = '!='
|
206
|
+
value = nil # Enforce it is indeed nil (should be)
|
207
|
+
when '!!'
|
208
|
+
op = '='
|
209
|
+
value = nil # Enforce it is indeed nil (should be)
|
210
|
+
end
|
211
|
+
|
90
212
|
case op
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
213
|
+
when '='
|
214
|
+
if likeval
|
215
|
+
add_safe_where(query: query, tab: column_prefix, col: column_object, op: 'LIKE', value: likeval)
|
216
|
+
else
|
217
|
+
quoted_right = quote_right_part(query: query, value: value, column_object: column_object, negative: false)
|
218
|
+
query.where("#{quote_column_path(query: query, prefix: column_prefix, column_object: column_object)} #{quoted_right}")
|
219
|
+
end
|
220
|
+
when '!='
|
221
|
+
if likeval
|
222
|
+
add_safe_where(query: query, tab: column_prefix, col: column_object, op: 'NOT LIKE', value: likeval)
|
223
|
+
else
|
224
|
+
quoted_right = quote_right_part(query: query, value: value, column_object: column_object, negative: true)
|
225
|
+
query.where("#{quote_column_path(query: query, prefix: column_prefix, column_object: column_object)} #{quoted_right}")
|
97
226
|
end
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
end
|
113
|
-
when '>'
|
114
|
-
add_safe_where(tab: column_prefix, col: column_object, op: '>', value: value)
|
115
|
-
when '<'
|
116
|
-
add_safe_where(tab: column_prefix, col: column_object, op: '<', value: value)
|
117
|
-
when '>='
|
118
|
-
add_safe_where(tab: column_prefix, col: column_object, op: '>=', value: value)
|
119
|
-
when '<='
|
120
|
-
add_safe_where(tab: column_prefix, col: column_object, op: '<=', value: value)
|
121
|
-
else
|
122
|
-
raise "Unsupported Operator!!! #{op}"
|
123
|
-
end
|
124
|
-
end
|
125
|
-
|
126
|
-
def add_safe_where(tab:, col:, op:, value:)
|
227
|
+
when '>'
|
228
|
+
add_safe_where(query: query, tab: column_prefix, col: column_object, op: '>', value: value)
|
229
|
+
when '<'
|
230
|
+
add_safe_where(query: query, tab: column_prefix, col: column_object, op: '<', value: value)
|
231
|
+
when '>='
|
232
|
+
add_safe_where(query: query, tab: column_prefix, col: column_object, op: '>=', value: value)
|
233
|
+
when '<='
|
234
|
+
add_safe_where(query: query, tab: column_prefix, col: column_object, op: '<=', value: value)
|
235
|
+
else
|
236
|
+
raise "Unsupported Operator!!! #{op}"
|
237
|
+
end
|
238
|
+
end
|
239
|
+
|
240
|
+
def self.add_safe_where(query:, tab:, col:, op:, value:)
|
127
241
|
quoted_value = query.connection.quote_default_expression(value,col)
|
128
|
-
query.where("#{quote_column_path(tab, col)} #{op} #{quoted_value}")
|
242
|
+
query.where("#{self.quote_column_path(query: query, prefix: tab, column_object: col)} #{op} #{quoted_value}")
|
129
243
|
end
|
130
244
|
|
131
|
-
def quote_column_path(prefix
|
245
|
+
def self.quote_column_path(query:, prefix:, column_object:)
|
132
246
|
c = query.connection
|
133
247
|
quoted_column = c.quote_column_name(column_object.name)
|
134
248
|
if prefix
|
@@ -139,7 +253,7 @@ module Praxis
|
|
139
253
|
end
|
140
254
|
end
|
141
255
|
|
142
|
-
def quote_right_part(value:, column_object:, negative:)
|
256
|
+
def self.quote_right_part(query:, value:, column_object:, negative:)
|
143
257
|
conn = query.connection
|
144
258
|
if value.nil?
|
145
259
|
no = negative ? ' NOT' : ''
|
@@ -157,12 +271,38 @@ module Praxis
|
|
157
271
|
end
|
158
272
|
|
159
273
|
# Returns nil if the value was not a fuzzzy pattern
|
160
|
-
def get_like_value(value)
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
274
|
+
def self.get_like_value(value,fuzzy)
|
275
|
+
is_fuzzy = fuzzy.is_a?(Array) ? !fuzzy.compact.empty? : fuzzy
|
276
|
+
if is_fuzzy
|
277
|
+
unless value.is_a?(String)
|
278
|
+
raise MultiMatchWithFuzzyNotAllowedByAdapter.new
|
279
|
+
end
|
280
|
+
case fuzzy
|
281
|
+
when :start_end
|
282
|
+
'%'+value+'%'
|
283
|
+
when :start
|
284
|
+
'%'+value
|
285
|
+
when :end
|
286
|
+
value+'%'
|
287
|
+
end
|
288
|
+
else
|
289
|
+
nil
|
290
|
+
end
|
291
|
+
end
|
292
|
+
|
293
|
+
# The value that we need to stick in the references method is different in the latest Rails
|
294
|
+
maj, min, _ = ActiveRecord.gem_version.segments
|
295
|
+
if maj == 5 || (maj == 6 && min == 0)
|
296
|
+
# In AR 6 (and 6.0) the references are simple strings
|
297
|
+
def build_reference_value(column_prefix, query: nil)
|
298
|
+
column_prefix
|
299
|
+
end
|
300
|
+
else
|
301
|
+
# The latest AR versions discard passing references to joins when they're not SqlLiterals ... so let's wrap it
|
302
|
+
# with our class, so that it is a literal (already quoted), but that can still provide the expected "symbol" without quotes
|
303
|
+
# so that our aliasing code can match it.
|
304
|
+
def build_reference_value(column_prefix, query:)
|
305
|
+
QuasiSqlLiteral.new(quoted: query.connection.quote_table_name(column_prefix), symbolized: column_prefix.to_sym)
|
166
306
|
end
|
167
307
|
end
|
168
308
|
end
|