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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/.travis.yml +1 -3
  4. data/CHANGELOG.md +26 -0
  5. data/bin/praxis +65 -2
  6. data/lib/praxis/api_definition.rb +8 -4
  7. data/lib/praxis/bootloader_stages/environment.rb +1 -0
  8. data/lib/praxis/collection.rb +11 -0
  9. data/lib/praxis/docs/open_api/response_object.rb +21 -6
  10. data/lib/praxis/docs/open_api_generator.rb +1 -1
  11. data/lib/praxis/extensions/attribute_filtering.rb +14 -1
  12. data/lib/praxis/extensions/attribute_filtering/active_record_filter_query_builder.rb +206 -66
  13. data/lib/praxis/extensions/attribute_filtering/filter_tree_node.rb +3 -2
  14. data/lib/praxis/extensions/attribute_filtering/filtering_params.rb +45 -41
  15. data/lib/praxis/extensions/attribute_filtering/filters_parser.rb +193 -0
  16. data/lib/praxis/extensions/attribute_filtering/sequel_filter_query_builder.rb +20 -8
  17. data/lib/praxis/extensions/pagination.rb +5 -32
  18. data/lib/praxis/mapper/active_model_compat.rb +4 -0
  19. data/lib/praxis/mapper/resource.rb +18 -2
  20. data/lib/praxis/mapper/selector_generator.rb +1 -0
  21. data/lib/praxis/mapper/sequel_compat.rb +7 -0
  22. data/lib/praxis/media_type_identifier.rb +11 -1
  23. data/lib/praxis/plugins/mapper_plugin.rb +22 -13
  24. data/lib/praxis/plugins/pagination_plugin.rb +34 -4
  25. data/lib/praxis/response_definition.rb +46 -66
  26. data/lib/praxis/responses/http.rb +3 -1
  27. data/lib/praxis/tasks/api_docs.rb +4 -1
  28. data/lib/praxis/tasks/routes.rb +6 -6
  29. data/lib/praxis/version.rb +1 -1
  30. data/spec/praxis/action_definition_spec.rb +3 -1
  31. data/spec/praxis/extensions/attribute_filtering/active_record_filter_query_builder_spec.rb +267 -167
  32. data/spec/praxis/extensions/attribute_filtering/filter_tree_node_spec.rb +25 -6
  33. data/spec/praxis/extensions/attribute_filtering/filtering_params_spec.rb +100 -17
  34. data/spec/praxis/extensions/attribute_filtering/filters_parser_spec.rb +148 -0
  35. data/spec/praxis/extensions/field_selection/active_record_query_selector_spec.rb +1 -1
  36. data/spec/praxis/extensions/field_selection/sequel_query_selector_spec.rb +1 -1
  37. data/spec/praxis/extensions/support/spec_resources_active_model.rb +1 -1
  38. data/spec/praxis/mapper/selector_generator_spec.rb +1 -1
  39. data/spec/praxis/media_type_identifier_spec.rb +15 -1
  40. data/spec/praxis/response_definition_spec.rb +37 -129
  41. data/tasks/thor/example.rb +12 -6
  42. data/tasks/thor/model.rb +40 -0
  43. data/tasks/thor/scaffold.rb +117 -0
  44. data/tasks/thor/templates/generator/empty_app/config/environment.rb +1 -0
  45. data/tasks/thor/templates/generator/example_app/Rakefile +9 -2
  46. data/tasks/thor/templates/generator/example_app/app/v1/concerns/controller_base.rb +24 -0
  47. data/tasks/thor/templates/generator/example_app/app/v1/concerns/href.rb +33 -0
  48. data/tasks/thor/templates/generator/example_app/app/v1/controllers/users.rb +2 -2
  49. data/tasks/thor/templates/generator/example_app/app/v1/resources/base.rb +15 -0
  50. data/tasks/thor/templates/generator/example_app/app/v1/resources/user.rb +7 -28
  51. data/tasks/thor/templates/generator/example_app/config.ru +1 -2
  52. data/tasks/thor/templates/generator/example_app/config/environment.rb +3 -2
  53. data/tasks/thor/templates/generator/example_app/db/migrate/20201010101010_create_users_table.rb +3 -2
  54. data/tasks/thor/templates/generator/example_app/db/seeds.rb +6 -0
  55. data/tasks/thor/templates/generator/example_app/design/v1/endpoints/users.rb +4 -4
  56. data/tasks/thor/templates/generator/example_app/design/v1/media_types/user.rb +1 -6
  57. data/tasks/thor/templates/generator/example_app/spec/helpers/database_helper.rb +4 -2
  58. data/tasks/thor/templates/generator/example_app/spec/spec_helper.rb +2 -2
  59. data/tasks/thor/templates/generator/example_app/spec/v1/controllers/users_spec.rb +2 -2
  60. data/tasks/thor/templates/generator/scaffold/design/endpoints/collection.rb +98 -0
  61. data/tasks/thor/templates/generator/scaffold/design/media_types/item.rb +18 -0
  62. data/tasks/thor/templates/generator/scaffold/implementation/controllers/collection.rb +77 -0
  63. data/tasks/thor/templates/generator/scaffold/implementation/resources/base.rb +11 -0
  64. data/tasks/thor/templates/generator/scaffold/implementation/resources/item.rb +45 -0
  65. data/tasks/thor/templates/generator/scaffold/models/active_record.rb +6 -0
  66. data/tasks/thor/templates/generator/scaffold/models/sequel.rb +6 -0
  67. metadata +21 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dd76d8bd13954d5607d009ee37ff7fc1a8ef8a2c6c8c87544dddaee332d984bf
4
- data.tar.gz: 719bffed7ea98fe42d7c620cd4377c4201c9d23e49a9777e981647d671b39ffd
3
+ metadata.gz: 9391f2cd6fb2c5a605a4b4c9f492371ef1d7f01849e94a9d17fdc51b95c211a9
4
+ data.tar.gz: 70c9f00cd12c7b3e99cebd10f86a98ac913433b8b96a41b658893b63ddc4b31b
5
5
  SHA512:
6
- metadata.gz: 18e87e4563faa7a1ffbba174634f1ebabe57320b8324e4ac23a711f5785906482412db711c83308ff87264d48cc496f8bd118b5a5ac77f1d6bd8f09e543f90ed
7
- data.tar.gz: 9da869a6acecf7c04273ef7dc0519be0cf8b3d00c29df16c851df04f5ea52d7cf4d62123e25533b8887c0a30b940155abfbd812f4fe2b347d1139df91e3e124f
6
+ metadata.gz: edfc42021b347aea6bd7b84853ebd901f55e4963612da2a6870e00b956b3352e51efd8cd3c360123625af48f89553bff6fa0ad7766e076bc164ba8ccf770ba7b
7
+ data.tar.gz: da2df887a849f03be390f7218f931807670a58addc8eb3d101b293e0827a522680ca1824f47bafcff2c3f26bfb45bd13e8ad0c6f02577edad12c5c8cfca60904
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 2.6.3
1
+ 2.7.1
data/.travis.yml CHANGED
@@ -1,10 +1,8 @@
1
1
  sudo: false
2
2
  language: ruby
3
3
  rvm:
4
- - 2.4
5
- - 2.5
6
4
  - 2.6
7
- - 2.7
5
+ - 2.7
8
6
  script:
9
7
  - bundle exec rspec spec
10
8
  branches:
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]", <<-EOF
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 headers if 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 headers if 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
 
@@ -33,6 +33,7 @@ module Praxis
33
33
  map :models, 'models/**/*'
34
34
  map :responses, '**/responses/**/*'
35
35
  map :exceptions, '**/exceptions/**/*'
36
+ map :concerns, '**/concerns/**/*'
36
37
  map :resources, '**/resources/**/*'
37
38
  map :controllers, '**/controllers/**/*'
38
39
  end
@@ -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
- # data is a hash with :value and :type keys
20
- # How did we say in that must match a value in json schema again??
21
- accum[name] = {
22
- schema: SchemaObject.new(info: data[:type])
23
- # allowed values: [ data[:value] ] ??? is this the right json schema way?
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
- attr_reader :query, :model, :attr_to_column
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
- @query = query
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
- @attr_to_column = filters_map
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 debug(msg)
21
- @logger && @logger.info(msg)
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
- debug("SQL due to filters: #{@query.all.to_sql}")
29
- @query
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
- @query = query.joins(result[:associations_hash]) unless result[:associations_hash].empty?
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
- result[:conditions].each do |condition|
37
- filter_name = condition[:name]
38
- filter_value = condition[:value]
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
- colo = condition[:model].columns_hash[filter_name.to_s]
42
- add_clause(column_prefix: column_prefix, column_object: colo, op: condition[:op], value: filter_value)
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 = attr_to_column[filter[:name]]
54
- raise "Filtering by #{filter[:name]} not allowed (no mapping found)" unless mapped_value
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
- @query = @query.references(column_prefix) #Mark where clause referencing the appropriate alias
89
- likeval = get_like_value(value)
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
- when '!' # name! means => name IS NOT NULL (and the incoming value is nil)
92
- op = '!='
93
- value = nil # Enforce it is indeed nil (should be)
94
- when '!!'
95
- op = '='
96
- value = nil # Enforce it is indeed nil (should be)
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
- @query = case op
99
- when '='
100
- if likeval
101
- add_safe_where(tab: column_prefix, col: column_object, op: 'LIKE', value: likeval)
102
- else
103
- quoted_right = quote_right_part(value: value, column_object: column_object, negative: false)
104
- query.where("#{quote_column_path(column_prefix, column_object)} #{quoted_right}")
105
- end
106
- when '!='
107
- if likeval
108
- add_safe_where(tab: column_prefix, col: column_object, op: 'NOT LIKE', value: likeval)
109
- else
110
- quoted_right = quote_right_part(value: value, column_object: column_object, negative: true)
111
- query.where("#{quote_column_path(column_prefix, column_object)} #{quoted_right}")
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, column_object)
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
- if value.is_a?(String) && (value[-1] == '*' || value[0] == '*')
162
- likeval = value.dup
163
- likeval[-1] = '%' if value[-1] == '*'
164
- likeval[0] = '%' if value[0] == '*'
165
- likeval
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