praxis 2.0.pre.8 → 2.0.pre.13

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.
Files changed (242) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/.ruby-version +1 -1
  4. data/.travis.yml +1 -3
  5. data/CHANGELOG.md +33 -0
  6. data/TODO.md +1 -4
  7. data/bin/praxis +67 -12
  8. data/lib/praxis.rb +10 -3
  9. data/lib/praxis/action_definition.rb +15 -13
  10. data/lib/praxis/action_definition/headers_dsl_compiler.rb +0 -7
  11. data/lib/praxis/api_general_info.rb +1 -1
  12. data/lib/praxis/application.rb +6 -2
  13. data/lib/praxis/blueprint.rb +357 -0
  14. data/lib/praxis/bootloader.rb +9 -3
  15. data/lib/praxis/bootloader_stages/environment.rb +16 -13
  16. data/lib/praxis/collection.rb +1 -11
  17. data/lib/praxis/config_hash.rb +44 -0
  18. data/lib/praxis/docs/{openapi → open_api}/info_object.rb +18 -10
  19. data/lib/praxis/docs/{openapi → open_api}/media_type_object.rb +0 -0
  20. data/lib/praxis/docs/{openapi → open_api}/operation_object.rb +0 -0
  21. data/lib/praxis/docs/{openapi → open_api}/parameter_object.rb +0 -0
  22. data/lib/praxis/docs/{openapi → open_api}/paths_object.rb +0 -0
  23. data/lib/praxis/docs/{openapi → open_api}/request_body_object.rb +0 -0
  24. data/lib/praxis/docs/{openapi → open_api}/response_object.rb +0 -0
  25. data/lib/praxis/docs/{openapi → open_api}/responses_object.rb +0 -0
  26. data/lib/praxis/docs/{openapi → open_api}/schema_object.rb +0 -0
  27. data/lib/praxis/docs/{openapi → open_api}/server_object.rb +0 -0
  28. data/lib/praxis/docs/{openapi → open_api}/tag_object.rb +0 -0
  29. data/lib/praxis/docs/open_api_generator.rb +91 -6
  30. data/lib/praxis/endpoint_definition.rb +273 -0
  31. data/lib/praxis/extensions/attribute_filtering/active_record_filter_query_builder.rb +182 -58
  32. data/lib/praxis/extensions/attribute_filtering/filter_tree_node.rb +3 -2
  33. data/lib/praxis/extensions/attribute_filtering/filtering_params.rb +47 -56
  34. data/lib/praxis/extensions/attribute_filtering/filters_parser.rb +153 -0
  35. data/lib/praxis/extensions/attribute_filtering/sequel_filter_query_builder.rb +20 -8
  36. data/lib/praxis/extensions/field_expansion.rb +3 -36
  37. data/lib/praxis/extensions/pagination.rb +5 -32
  38. data/lib/praxis/extensions/pagination/ordering_params.rb +1 -1
  39. data/lib/praxis/extensions/pagination/pagination_params.rb +6 -4
  40. data/lib/praxis/field_expander.rb +90 -0
  41. data/lib/praxis/finalizable.rb +34 -0
  42. data/lib/praxis/mapper/active_model_compat.rb +4 -0
  43. data/lib/praxis/mapper/resource.rb +18 -2
  44. data/lib/praxis/mapper/selector_generator.rb +2 -1
  45. data/lib/praxis/mapper/sequel_compat.rb +7 -0
  46. data/lib/praxis/media_type.rb +3 -68
  47. data/lib/praxis/plugin_concern.rb +1 -1
  48. data/lib/praxis/plugins/mapper_plugin.rb +24 -15
  49. data/lib/praxis/plugins/pagination_plugin.rb +34 -4
  50. data/lib/praxis/renderer.rb +88 -0
  51. data/lib/praxis/request.rb +1 -1
  52. data/lib/praxis/resource_definition.rb +2 -311
  53. data/lib/praxis/response_definition.rb +2 -10
  54. data/lib/praxis/response_template.rb +3 -3
  55. data/lib/praxis/router.rb +2 -2
  56. data/lib/praxis/routing_config.rb +1 -1
  57. data/lib/praxis/tasks/api_docs.rb +17 -64
  58. data/lib/praxis/tasks/routes.rb +1 -1
  59. data/lib/praxis/types/media_type_common.rb +1 -11
  60. data/lib/praxis/version.rb +1 -1
  61. data/praxis.gemspec +0 -1
  62. data/spec/functional_spec.rb +5 -9
  63. data/spec/praxis/action_definition_spec.rb +12 -20
  64. data/spec/praxis/blueprint_spec.rb +373 -0
  65. data/spec/praxis/bootloader_spec.rb +10 -2
  66. data/spec/praxis/collection_spec.rb +0 -13
  67. data/spec/praxis/config_hash_spec.rb +64 -0
  68. data/spec/praxis/{resource_definition_spec.rb → endpoint_definition_spec.rb} +37 -64
  69. data/spec/praxis/extensions/attribute_filtering/active_record_filter_query_builder_spec.rb +249 -168
  70. data/spec/praxis/extensions/attribute_filtering/filter_tree_node_spec.rb +25 -6
  71. data/spec/praxis/extensions/attribute_filtering/filtering_params_spec.rb +190 -8
  72. data/spec/praxis/extensions/attribute_filtering/filters_parser_spec.rb +140 -0
  73. data/spec/praxis/extensions/field_expansion_spec.rb +5 -24
  74. data/spec/praxis/extensions/field_selection/active_record_query_selector_spec.rb +1 -1
  75. data/spec/praxis/extensions/field_selection/sequel_query_selector_spec.rb +1 -1
  76. data/spec/praxis/extensions/support/spec_resources_active_model.rb +1 -1
  77. data/spec/praxis/field_expander_spec.rb +149 -0
  78. data/spec/praxis/mapper/selector_generator_spec.rb +1 -1
  79. data/spec/praxis/media_type_identifier_spec.rb +5 -4
  80. data/spec/praxis/media_type_spec.rb +4 -93
  81. data/spec/praxis/renderer_spec.rb +188 -0
  82. data/spec/praxis/response_definition_spec.rb +0 -31
  83. data/spec/praxis/response_spec.rb +1 -1
  84. data/spec/praxis/router_spec.rb +8 -8
  85. data/spec/praxis/routing_config_spec.rb +3 -3
  86. data/spec/spec_app/app/controllers/instances.rb +13 -7
  87. data/spec/spec_app/design/media_types/instance.rb +1 -19
  88. data/spec/spec_app/design/media_types/volume.rb +1 -1
  89. data/spec/spec_app/design/media_types/volume_snapshot.rb +2 -14
  90. data/spec/spec_app/design/resources/instances.rb +5 -8
  91. data/spec/spec_app/design/resources/volume_snapshots.rb +1 -1
  92. data/spec/spec_app/design/resources/volumes.rb +1 -1
  93. data/spec/support/spec_authorization_plugin.rb +1 -1
  94. data/spec/support/spec_blueprints.rb +72 -0
  95. data/spec/support/{spec_resource_definitions.rb → spec_endpoint_definitions.rb} +2 -2
  96. data/spec/support/spec_media_types.rb +6 -26
  97. data/tasks/thor/app.rb +8 -34
  98. data/tasks/thor/example.rb +51 -285
  99. data/tasks/thor/model.rb +40 -0
  100. data/tasks/thor/scaffold.rb +117 -0
  101. data/tasks/thor/templates/generator/empty_app/.gitignore +0 -1
  102. data/tasks/thor/templates/generator/empty_app/Gemfile +7 -23
  103. data/tasks/thor/templates/generator/empty_app/README.md +1 -1
  104. data/tasks/thor/templates/generator/empty_app/Rakefile +4 -13
  105. data/tasks/thor/templates/generator/empty_app/{design/response_templates → app/v1/resources}/.empty_directory +0 -0
  106. data/tasks/thor/templates/generator/empty_app/{design/response_templates → app/v1/resources}/.gitkeep +0 -0
  107. data/tasks/thor/templates/generator/empty_app/config/environment.rb +26 -17
  108. data/tasks/thor/templates/generator/empty_app/{design/v1/resources → config/initializers}/.empty_directory +0 -0
  109. data/tasks/thor/templates/generator/empty_app/{design/v1/resources → config/initializers}/.gitkeep +0 -0
  110. data/tasks/thor/templates/generator/empty_app/design/v1/endpoints/.empty_directory +0 -0
  111. data/tasks/thor/templates/generator/empty_app/design/v1/endpoints/.gitkeep +0 -0
  112. data/tasks/thor/templates/generator/empty_app/docs/.empty_directory +0 -0
  113. data/tasks/thor/templates/generator/empty_app/docs/.gitkeep +0 -0
  114. data/tasks/thor/templates/generator/empty_app/spec/spec_helper.rb +14 -9
  115. data/tasks/thor/templates/generator/example_app/.gitignore +1 -0
  116. data/tasks/thor/templates/generator/example_app/Gemfile +19 -0
  117. data/tasks/thor/templates/generator/example_app/Rakefile +61 -0
  118. data/tasks/thor/templates/generator/example_app/app/models/user.rb +6 -0
  119. data/tasks/thor/templates/generator/example_app/app/v1/concerns/controller_base.rb +24 -0
  120. data/tasks/thor/templates/generator/example_app/app/v1/controllers/users.rb +17 -0
  121. data/tasks/thor/templates/generator/example_app/app/v1/resources/base.rb +11 -0
  122. data/tasks/thor/templates/generator/example_app/app/v1/resources/user.rb +25 -0
  123. data/tasks/thor/templates/generator/example_app/config.ru +30 -0
  124. data/tasks/thor/templates/generator/example_app/config/environment.rb +41 -0
  125. data/tasks/thor/templates/generator/example_app/db/migrate/20201010101010_create_users_table.rb +12 -0
  126. data/tasks/thor/templates/generator/example_app/db/seeds.rb +6 -0
  127. data/tasks/thor/templates/generator/example_app/design/api.rb +18 -0
  128. data/tasks/thor/templates/generator/example_app/design/v1/endpoints/users.rb +37 -0
  129. data/tasks/thor/templates/generator/example_app/design/v1/media_types/user.rb +21 -0
  130. data/tasks/thor/templates/generator/example_app/spec/helpers/database_helper.rb +20 -0
  131. data/tasks/thor/templates/generator/example_app/spec/spec_helper.rb +42 -0
  132. data/tasks/thor/templates/generator/example_app/spec/v1/controllers/users_spec.rb +37 -0
  133. data/tasks/thor/templates/generator/scaffold/design/endpoints/collection.rb +98 -0
  134. data/tasks/thor/templates/generator/scaffold/design/media_types/item.rb +18 -0
  135. data/tasks/thor/templates/generator/scaffold/implementation/controllers/collection.rb +77 -0
  136. data/tasks/thor/templates/generator/scaffold/implementation/resources/base.rb +11 -0
  137. data/tasks/thor/templates/generator/scaffold/implementation/resources/item.rb +45 -0
  138. data/tasks/thor/templates/generator/scaffold/models/active_record.rb +6 -0
  139. data/tasks/thor/templates/generator/scaffold/models/sequel.rb +6 -0
  140. metadata +64 -136
  141. data/lib/api_browser/.bowerrc +0 -3
  142. data/lib/api_browser/.editorconfig +0 -21
  143. data/lib/api_browser/Gruntfile.js +0 -581
  144. data/lib/api_browser/app/index.html +0 -59
  145. data/lib/api_browser/app/js/app.js +0 -48
  146. data/lib/api_browser/app/js/controllers/action.js +0 -47
  147. data/lib/api_browser/app/js/controllers/controller.js +0 -10
  148. data/lib/api_browser/app/js/controllers/menu.js +0 -93
  149. data/lib/api_browser/app/js/controllers/trait.js +0 -10
  150. data/lib/api_browser/app/js/controllers/type.js +0 -24
  151. data/lib/api_browser/app/js/directives/attribute_description.js +0 -56
  152. data/lib/api_browser/app/js/directives/attribute_table.js +0 -28
  153. data/lib/api_browser/app/js/directives/conditional_requirements.js +0 -13
  154. data/lib/api_browser/app/js/directives/fixed_if_fits.js +0 -38
  155. data/lib/api_browser/app/js/directives/highlight.js +0 -14
  156. data/lib/api_browser/app/js/directives/menu_item.js +0 -59
  157. data/lib/api_browser/app/js/directives/no_container.js +0 -8
  158. data/lib/api_browser/app/js/directives/readable_list.js +0 -87
  159. data/lib/api_browser/app/js/directives/request_examples.js +0 -31
  160. data/lib/api_browser/app/js/directives/type_placeholder.js +0 -30
  161. data/lib/api_browser/app/js/directives/url.js +0 -15
  162. data/lib/api_browser/app/js/factories/Configuration.js +0 -12
  163. data/lib/api_browser/app/js/factories/Documentation.js +0 -61
  164. data/lib/api_browser/app/js/factories/Example.js +0 -51
  165. data/lib/api_browser/app/js/factories/PageInfo.js +0 -9
  166. data/lib/api_browser/app/js/factories/normalize_attributes.js +0 -20
  167. data/lib/api_browser/app/js/factories/prepare_template.js +0 -15
  168. data/lib/api_browser/app/js/factories/template_for.js +0 -128
  169. data/lib/api_browser/app/js/filters/attribute_name.js +0 -10
  170. data/lib/api_browser/app/js/filters/friendly_json.js +0 -5
  171. data/lib/api_browser/app/js/filters/has_requirement.js +0 -14
  172. data/lib/api_browser/app/js/filters/header_info.js +0 -9
  173. data/lib/api_browser/app/js/filters/is_empty.js +0 -8
  174. data/lib/api_browser/app/js/filters/markdown.js +0 -6
  175. data/lib/api_browser/app/js/filters/resource_name.js +0 -5
  176. data/lib/api_browser/app/js/filters/tag_requirement.js +0 -13
  177. data/lib/api_browser/app/sass/modules/_body.scss +0 -40
  178. data/lib/api_browser/app/sass/modules/_cloke.scss +0 -8
  179. data/lib/api_browser/app/sass/modules/_header.scss +0 -10
  180. data/lib/api_browser/app/sass/modules/_nav.scss +0 -7
  181. data/lib/api_browser/app/sass/modules/_sidebar.scss +0 -134
  182. data/lib/api_browser/app/sass/modules/_switch.scss +0 -55
  183. data/lib/api_browser/app/sass/modules/_table.scss +0 -13
  184. data/lib/api_browser/app/sass/praxis.scss +0 -70
  185. data/lib/api_browser/app/sass/variables/_bootstrap-variables.scss +0 -774
  186. data/lib/api_browser/app/views/action.html +0 -97
  187. data/lib/api_browser/app/views/builtin/field-selector.html +0 -24
  188. data/lib/api_browser/app/views/controller.html +0 -55
  189. data/lib/api_browser/app/views/directives/attribute_description.html +0 -2
  190. data/lib/api_browser/app/views/directives/attribute_description/default.html +0 -2
  191. data/lib/api_browser/app/views/directives/attribute_description/example.html +0 -13
  192. data/lib/api_browser/app/views/directives/attribute_description/headers.html +0 -8
  193. data/lib/api_browser/app/views/directives/attribute_description/member_options.html +0 -4
  194. data/lib/api_browser/app/views/directives/attribute_description/values.html +0 -14
  195. data/lib/api_browser/app/views/directives/attribute_table.html +0 -17
  196. data/lib/api_browser/app/views/directives/menu_item.html +0 -8
  197. data/lib/api_browser/app/views/directives/url.html +0 -3
  198. data/lib/api_browser/app/views/examples/general.html +0 -26
  199. data/lib/api_browser/app/views/home.html +0 -5
  200. data/lib/api_browser/app/views/layout.html +0 -8
  201. data/lib/api_browser/app/views/menu.html +0 -42
  202. data/lib/api_browser/app/views/navbar.html +0 -9
  203. data/lib/api_browser/app/views/trait.html +0 -13
  204. data/lib/api_browser/app/views/type.html +0 -6
  205. data/lib/api_browser/app/views/type/details.html +0 -33
  206. data/lib/api_browser/app/views/types/embedded/array.html +0 -2
  207. data/lib/api_browser/app/views/types/embedded/default.html +0 -12
  208. data/lib/api_browser/app/views/types/embedded/field-selector.html +0 -13
  209. data/lib/api_browser/app/views/types/embedded/links.html +0 -11
  210. data/lib/api_browser/app/views/types/embedded/requirements.html +0 -6
  211. data/lib/api_browser/app/views/types/embedded/single_req.html +0 -9
  212. data/lib/api_browser/app/views/types/embedded/struct.html +0 -14
  213. data/lib/api_browser/app/views/types/label/link.html +0 -1
  214. data/lib/api_browser/app/views/types/label/primitive.html +0 -1
  215. data/lib/api_browser/app/views/types/label/primitive_collection.html +0 -1
  216. data/lib/api_browser/app/views/types/label/type.html +0 -1
  217. data/lib/api_browser/app/views/types/label/type_collection.html +0 -1
  218. data/lib/api_browser/app/views/types/main/array.html +0 -22
  219. data/lib/api_browser/app/views/types/main/default.html +0 -23
  220. data/lib/api_browser/app/views/types/main/hash.html +0 -23
  221. data/lib/api_browser/app/views/types/standalone/array.html +0 -3
  222. data/lib/api_browser/app/views/types/standalone/default.html +0 -18
  223. data/lib/api_browser/app/views/types/standalone/struct.html +0 -2
  224. data/lib/api_browser/bower_template.json +0 -41
  225. data/lib/api_browser/package-lock.json +0 -7110
  226. data/lib/api_browser/package.json +0 -43
  227. data/lib/praxis/docs/generator.rb +0 -243
  228. data/lib/praxis/docs/link_builder.rb +0 -30
  229. data/lib/praxis/links.rb +0 -135
  230. data/lib/praxis/types/multipart.rb +0 -109
  231. data/spec/api_browser/directives/type_placeholder_spec.js +0 -134
  232. data/spec/api_browser/factories/configuration_spec.js +0 -32
  233. data/spec/api_browser/factories/documentation_spec.js +0 -100
  234. data/spec/api_browser/factories/normalize_attributes_spec.js +0 -92
  235. data/spec/api_browser/factories/template_for_spec.js +0 -67
  236. data/spec/api_browser/filters/attribute_name_spec.js +0 -23
  237. data/spec/praxis/types/multipart_spec.rb +0 -112
  238. data/tasks/thor/templates/generator/empty_app/.rspec +0 -1
  239. data/tasks/thor/templates/generator/empty_app/Guardfile +0 -3
  240. data/tasks/thor/templates/generator/empty_app/config/rainbows.rb +0 -57
  241. data/tasks/thor/templates/generator/empty_app/docs/app.js +0 -1
  242. data/tasks/thor/templates/generator/empty_app/docs/styles.scss +0 -3
@@ -5,58 +5,166 @@ 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
+ attr_reader :model, :filters_map
11
27
 
12
28
  # Base query to build upon
13
29
  def initialize(query: , model:, filters_map:, debug: false)
14
- @query = query
30
+ # 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
31
+ @initial_query = query
15
32
  @model = model
16
- @attr_to_column = filters_map
33
+ @filters_map = filters_map
17
34
  @logger = debug ? Logger.new(STDOUT) : nil
35
+ @active_record_version_maj = ActiveRecord.gem_version.segments[0]
18
36
  end
19
37
 
20
- def debug(msg)
21
- @logger && @logger.puts(msg)
38
+ def debug_query(msg, query)
39
+ @logger.info(msg + query.to_sql) if @logger
22
40
  end
23
41
 
24
42
  def generate(filters)
25
43
  # Resolve the names and values first, based on filters_map
26
44
  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
45
+ crafted = craft_filter_query(root_node, for_model: @model)
46
+ debug_query("SQL due to filters: ", crafted.all)
47
+ crafted
30
48
  end
31
49
 
32
50
  def craft_filter_query(nodetree, for_model:)
33
51
  result = _compute_joins_and_conditions_data(nodetree, model: for_model)
34
- @query = query.joins(result[:associations_hash]) unless result[:associations_hash].empty?
52
+ return @initial_query if result[:conditions].empty?
53
+
54
+
55
+ # Find the root group (usually an AND group) but can be an OR group, or nil if there's only 1 condition
56
+ root_parent_group = result[:conditions].first[:node_object].parent_group || result[:conditions].first[:node_object]
57
+ while root_parent_group.parent_group != nil
58
+ root_parent_group = root_parent_group.parent_group
59
+ end
35
60
 
36
- result[:conditions].each do |condition|
37
- filter_name = condition[:name]
38
- filter_value = condition[:value]
61
+ # Process the joins
62
+ query_with_joins = result[:associations_hash].empty? ? @initial_query : @initial_query.joins(result[:associations_hash])
63
+
64
+ # Proc to apply a single condition
65
+ apply_single_condition = Proc.new do |condition, associated_query|
66
+ colo = condition[:model].columns_hash[condition[:name].to_s]
39
67
  column_prefix = condition[:column_prefix]
68
+ #Mark where clause referencing the appropriate alias
69
+ associated_query = associated_query.references(build_reference_value(column_prefix, query: associated_query))
70
+ self.class.add_clause(
71
+ query: associated_query,
72
+ column_prefix: column_prefix,
73
+ column_object: colo,
74
+ op: condition[:op],
75
+ value: condition[:value]
76
+ )
77
+ end
40
78
 
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)
79
+ if @active_record_version_maj < 6
80
+ # ActiveRecord < 6 does not support '.and' so no nested things can be done
81
+ # But we can still support the case of 1+ flat conditions of the same AND/OR type
82
+ if root_parent_group.is_a?(FilteringParams::Condition)
83
+ # A Single condition it is easy to handle
84
+ apply_single_condition.call(result[:conditions].first, query_with_joins)
85
+ elsif root_parent_group.items.all?{|i| i.is_a?(FilteringParams::Condition)}
86
+ # Only 1 top level root, with only with simple condition items
87
+ if root_parent_group.type == :and
88
+ result[:conditions].reverse.inject(query_with_joins) do |accum, condition|
89
+ apply_single_condition.call(condition, accum)
90
+ end
91
+ else
92
+ # To do a flat OR, we need to apply the first condition to the incoming query
93
+ # and then apply any extra ORs to it. Otherwise Book.or(X).or(X) still matches all books
94
+ cond1, *rest = result[:conditions].reverse
95
+ start_query = apply_single_condition.call(cond1, query_with_joins)
96
+ rest.inject(start_query) do |accum, condition|
97
+ accum.or(apply_single_condition.call(condition, query_with_joins))
98
+ end
99
+ end
100
+ else
101
+ raise "Mixing AND and OR conditions is not supported for ActiveRecord <6."
102
+ end
103
+ else # ActiveRecord 6+
104
+ # Process the conditions in a depth-first order, and return the resulting query
105
+ _depth_first_traversal(
106
+ root_query: query_with_joins,
107
+ root_node: root_parent_group,
108
+ conditions: result[:conditions],
109
+ &apply_single_condition
110
+ )
43
111
  end
44
112
  end
45
113
 
46
114
  private
115
+ def _depth_first_traversal(root_query:, root_node:, conditions:, &block)
116
+ # Save the associated query for non-leaves
117
+ root_node.associated_query = root_query if root_node.is_a?(FilteringParams::ConditionGroup)
118
+
119
+ if root_node.is_a?(FilteringParams::Condition)
120
+ matching_condition = conditions.find {|cond| cond[:node_object] == root_node }
121
+
122
+ # The simplified case of a single top level condition (without a wrapping group)
123
+ # will need to pass the root query itself
124
+ associated_query = root_node.parent_group ? root_node.parent_group.associated_query : root_query
125
+ return yield matching_condition, associated_query
126
+ else
127
+ first_query, *rest_queries = root_node.items.map do |child|
128
+ _depth_first_traversal(root_query: root_query, root_node: child, conditions: conditions, &block)
129
+ end
130
+
131
+ rest_queries.each.inject(first_query) do |q, a_query|
132
+ root_node.type == :and ? q.and(a_query) : q.or(a_query)
133
+ end
134
+ end
135
+ end
136
+
137
+ def _mapped_filter(name)
138
+ target = @filters_map[name]
139
+ unless target
140
+ if @model.attribute_names.include?(name.to_s)
141
+ # Cache it in the filters mapping (to avoid later lookups), and return it.
142
+ @filters_map[name] = name
143
+ target = name
144
+ end
145
+ end
146
+ return target
147
+ end
47
148
 
48
149
  # Resolve and convert from filters, to a more manageable and param-type-independent structure
49
150
  def _convert_to_treenode(filters)
50
151
  # Resolve the names and values first, based on filters_map
51
152
  resolved_array = []
52
153
  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
154
+ mapped_value = _mapped_filter(filter[:name])
155
+ unless mapped_value
156
+ msg = "Filtering by #{filter[:name]} is not allowed. No implementation mapping defined for it has been found \
157
+ and there is not a model attribute with this name either.\n" \
158
+ "Please add a mapping for #{filter[:name]} in the `filters_mapping` method of the appropriate Resource class"
159
+ raise msg
160
+ end
55
161
  bindings_array = \
56
162
  if mapped_value.is_a?(Proc)
57
163
  result = mapped_value.call(filter)
58
164
  # Result could be an array of hashes (each hash has name/op/value to identify a condition)
59
- result.is_a?(Array) ? result : [result]
165
+ result_from_proc = result.is_a?(Array) ? result : [result]
166
+ # Make sure we tack on the node object associated with the filter
167
+ result_from_proc.map{|hash| hash.merge(node_object: filter[:node_object])}
60
168
  else
61
169
  # For non-procs there's only 1 filter and 1 value (we're just overriding the mapped value)
62
170
  [filter.merge( name: mapped_value)]
@@ -84,51 +192,51 @@ module Praxis
84
192
  {associations_hash: h, conditions: conditions}
85
193
  end
86
194
 
87
- def add_clause(column_prefix:, column_object:, op:, value:)
88
- @query = @query.references(column_prefix) #Mark where clause referencing the appropriate alias
195
+ def self.add_clause(query:, column_prefix:, column_object:, op:, value:)
89
196
  likeval = get_like_value(value)
90
197
  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)
198
+ when '!' # name! means => name IS NOT NULL (and the incoming value is nil)
199
+ op = '!='
200
+ value = nil # Enforce it is indeed nil (should be)
201
+ when '!!'
202
+ op = '='
203
+ value = nil # Enforce it is indeed nil (should be)
204
+ end
205
+
206
+ case op
207
+ when '='
208
+ if likeval
209
+ add_safe_where(query: query, tab: column_prefix, col: column_object, op: 'LIKE', value: likeval)
210
+ else
211
+ quoted_right = quote_right_part(query: query, value: value, column_object: column_object, negative: false)
212
+ query.where("#{quote_column_path(query: query, prefix: column_prefix, column_object: column_object)} #{quoted_right}")
213
+ end
214
+ when '!='
215
+ if likeval
216
+ add_safe_where(query: query, tab: column_prefix, col: column_object, op: 'NOT LIKE', value: likeval)
217
+ else
218
+ quoted_right = quote_right_part(query: query, value: value, column_object: column_object, negative: true)
219
+ query.where("#{quote_column_path(query: query, prefix: column_prefix, column_object: column_object)} #{quoted_right}")
97
220
  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:)
221
+ when '>'
222
+ add_safe_where(query: query, tab: column_prefix, col: column_object, op: '>', value: value)
223
+ when '<'
224
+ add_safe_where(query: query, tab: column_prefix, col: column_object, op: '<', value: value)
225
+ when '>='
226
+ add_safe_where(query: query, tab: column_prefix, col: column_object, op: '>=', value: value)
227
+ when '<='
228
+ add_safe_where(query: query, tab: column_prefix, col: column_object, op: '<=', value: value)
229
+ else
230
+ raise "Unsupported Operator!!! #{op}"
231
+ end
232
+ end
233
+
234
+ def self.add_safe_where(query:, tab:, col:, op:, value:)
127
235
  quoted_value = query.connection.quote_default_expression(value,col)
128
- query.where("#{quote_column_path(tab, col)} #{op} #{quoted_value}")
236
+ query.where("#{self.quote_column_path(query: query, prefix: tab, column_object: col)} #{op} #{quoted_value}")
129
237
  end
130
238
 
131
- def quote_column_path(prefix, column_object)
239
+ def self.quote_column_path(query:, prefix:, column_object:)
132
240
  c = query.connection
133
241
  quoted_column = c.quote_column_name(column_object.name)
134
242
  if prefix
@@ -139,7 +247,7 @@ module Praxis
139
247
  end
140
248
  end
141
249
 
142
- def quote_right_part(value:, column_object:, negative:)
250
+ def self.quote_right_part(query:, value:, column_object:, negative:)
143
251
  conn = query.connection
144
252
  if value.nil?
145
253
  no = negative ? ' NOT' : ''
@@ -157,7 +265,7 @@ module Praxis
157
265
  end
158
266
 
159
267
  # Returns nil if the value was not a fuzzzy pattern
160
- def get_like_value(value)
268
+ def self.get_like_value(value)
161
269
  if value.is_a?(String) && (value[-1] == '*' || value[0] == '*')
162
270
  likeval = value.dup
163
271
  likeval[-1] = '%' if value[-1] == '*'
@@ -165,6 +273,22 @@ module Praxis
165
273
  likeval
166
274
  end
167
275
  end
276
+
277
+ # The value that we need to stick in the references method is different in the latest Rails
278
+ maj, min, _ = ActiveRecord.gem_version.segments
279
+ if maj == 5 || (maj == 6 && min == 0)
280
+ # In AR 6 (and 6.0) the references are simple strings
281
+ def build_reference_value(column_prefix, query: nil)
282
+ column_prefix
283
+ end
284
+ else
285
+ # The latest AR versions discard passing references to joins when they're not SqlLiterals ... so let's wrap it
286
+ # with our class, so that it is a literal (already quoted), but that can still provide the expected "symbol" without quotes
287
+ # so that our aliasing code can match it.
288
+ def build_reference_value(column_prefix, query:)
289
+ QuasiSqlLiteral.new(quoted: query.connection.quote_table_name(column_prefix), symbolized: column_prefix.to_sym)
290
+ end
291
+ end
168
292
  end
169
293
  end
170
294
  end
@@ -3,7 +3,8 @@ module Praxis
3
3
  module AttributeFiltering
4
4
  class FilterTreeNode
5
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
6
+ # Parsed_filters is an Array of {name: X, op: Y, value: Z} ... exactly the format of the FilteringParams.load method
7
+ # It can also contain a :node_object
7
8
  def initialize(parsed_filters, path: [])
8
9
  @path = path # Array that marks the tree 'path' to this node (with respect to the absolute root)
9
10
  @conditions = [] # Conditions to apply directly to this node
@@ -14,7 +15,7 @@ module Praxis
14
15
  if components.empty?
15
16
  return
16
17
  elsif components.size == 1
17
- @conditions << hash.slice(:name, :op, :value)
18
+ @conditions << hash.slice(:name, :op, :value, :node_object)
18
19
  else
19
20
  children_data[components.first] ||= []
20
21
  children_data[components.first] << hash
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
- # rubocop:disable all
2
+ require 'praxis/extensions/attribute_filtering/filters_parser'
3
+
3
4
  #
4
- # Attributor type to define and handlea simple language to express filtering attributes in listings.
5
+ # Attributor type to define and handle the language to express filtering attributes in listings.
5
6
  # Commonly used in a query string parameter value for listing calls.
6
7
  #
7
8
  # The type allows you to restrict the allowable fields (and their types) based on an existing Mediatype.
@@ -14,7 +15,7 @@
14
15
  # attribute :filters,
15
16
  # Types::FilteringParams.for(MediaTypes::MyType) do
16
17
  # filter 'user.id', using: ['=', '!=']
17
- # filter 'name', using: ['=', '!=']
18
+ # filter 'name', using: ['=', '!=', '!', '!!]
18
19
  # filter 'children.created_at', using: ['>', '>=', '<', '<=']
19
20
  # filter 'display_name', using: ['=', '!='], fuzzy: true
20
21
  # end
@@ -26,6 +27,8 @@ module Praxis
26
27
  include Attributor::Type
27
28
  include Attributor::Dumpable
28
29
 
30
+ attr_reader :parsed_array
31
+
29
32
  class DSLCompiler < Attributor::DSLCompiler
30
33
  # "account.id": { operators: ["=", "!="] },
31
34
  # name: { operators: ["=", "!="], fuzzy_match: true },
@@ -36,9 +39,9 @@ module Praxis
36
39
  end
37
40
  end
38
41
 
39
- VALUE_REGEX = /[^,&]*/
40
- AVAILABLE_OPERATORS = Set.new(['!=', '>=', '<=', '=', '<', '>','!','!!']).freeze
41
- FILTER_REGEX = /(?<attribute>([^=!><])+)(?<operator>!=|>=|<=|!!|=|<|>|!)(?<value>#{VALUE_REGEX}(,#{VALUE_REGEX})*)/
42
+ VALUE_OPERATORS = Set.new(['!=', '>=', '<=', '=', '<', '>']).freeze
43
+ NOVALUE_OPERATORS = Set.new(['!','!!']).freeze
44
+ AVAILABLE_OPERATORS = Set.new(VALUE_OPERATORS+NOVALUE_OPERATORS).freeze
42
45
 
43
46
  # Abstract class, which needs to be used by subclassing it through the .for method, to set the allowed filters
44
47
  # definition should be a hash, keyed by field name, which contains a hash that can have two pieces of metadata
@@ -52,7 +55,7 @@ module Praxis
52
55
  def for(media_type, **_opts)
53
56
  unless media_type < Praxis::MediaType
54
57
  raise ArgumentError, "Invalid type: #{media_type.name} for Filters. " \
55
- 'Must be a subclass of MediaType'
58
+ 'Using the .for method for defining a filter, requires passing a subclass of a MediaType'
56
59
  end
57
60
 
58
61
  ::Class.new(self) do
@@ -77,9 +80,7 @@ module Praxis
77
80
  }
78
81
  end
79
82
  end
80
-
81
- attr_reader :parsed_array
82
-
83
+
83
84
  def self.native_type
84
85
  self
85
86
  end
@@ -103,7 +104,7 @@ module Praxis
103
104
  def self.construct(definition, **options)
104
105
  return self if definition.nil?
105
106
 
106
- DSLCompiler.new(self, options).parse(*definition)
107
+ DSLCompiler.new(self, **options).parse(*definition)
107
108
  self
108
109
  end
109
110
 
@@ -134,11 +135,15 @@ module Praxis
134
135
  raise "filter with name #{filter_name} does not correspond to an existing field inside " \
135
136
  " MediaType #{media_type.name}"
136
137
  end
137
- attr_example = filter_components.inject(mt_example) do |last, name|
138
- # we can safely do sends, since we've verified the components are valid
139
- last.send(name)
140
- end
141
- arr << "#{filter_name}#{op}#{attr_example}"
138
+ if NOVALUE_OPERATORS.include?(op)
139
+ arr << "#{filter_name}#{op}" # Do not add a value for the operators that don't take it
140
+ else
141
+ attr_example = filter_components.inject(mt_example) do |last, name|
142
+ # we can safely do sends, since we've verified the components are valid
143
+ last.send(name)
144
+ end
145
+ arr << "#{filter_name}#{op}#{attr_example}"
146
+ end
142
147
  end.join('&')
143
148
  else
144
149
  'name=Joe&date>2017-01-01'
@@ -153,35 +158,33 @@ module Praxis
153
158
 
154
159
  def self.load(filters, _context = Attributor::DEFAULT_ROOT_CONTEXT, **_options)
155
160
  return filters if filters.is_a?(native_type)
156
- return new if filters.nil?
157
- parsed = filters.split('&').each_with_object([]) do |filter_string, arr|
158
- match = FILTER_REGEX.match(filter_string)
159
- values = CGI.unescape(match[:value]).split(',')
160
- value = if values.size > 1
161
- multimatch = true
162
- values
163
- else
164
- multimatch = false
165
- values.first
166
- end
167
-
168
- attr_name = match[:attribute].to_sym
169
- # TODO: we should coerce values if there's a mediatype defined?
170
- coerced = if media_type
171
- filter_components = attr_name.to_s.split('.').map(&:to_sym)
172
- attr, _enclosing_type = find_filter_attribute(filter_components, media_type)
173
- if multimatch
174
- attr_coll = Attributor::Collection.of(attr.type)
175
- attr_coll.load(value)
161
+ return new if filters.nil? || filters.blank?
162
+
163
+ parsed = Parser.new.parse(filters)
164
+
165
+ tree = ConditionGroup.load(parsed)
166
+
167
+ rr = tree.flattened_conditions
168
+ accum = []
169
+ rr.each do |spec|
170
+ attr_name = spec[:name]
171
+ # TODO: Do we need to CGI.unescape things? here or even before??...
172
+ coerced = \
173
+ if media_type
174
+ filter_components = attr_name.to_s.split('.').map(&:to_sym)
175
+ attr, _enclosing_type = find_filter_attribute(filter_components, media_type)
176
+ if spec[:values].is_a?(Array)
177
+ attr_coll = Attributor::Collection.of(attr.type)
178
+ attr_coll.load(spec[:values])
179
+ else
180
+ attr.load(spec[:values])
181
+ end
176
182
  else
177
- attr.load(value)
183
+ spec[:values]
178
184
  end
179
- else
180
- value
181
- end
182
- arr.push(name: attr_name, op: match[:operator], value: coerced )
185
+ accum.push(name: attr_name, op: spec[:op], value: coerced , node_object: spec[:node_object])
183
186
  end
184
- new(parsed)
187
+ new(accum)
185
188
  end
186
189
 
187
190
  def self.dump(value, **_opts)
@@ -217,21 +220,9 @@ module Praxis
217
220
  errors << "Operator #{item[:op]} not allowed for filter #{attr_name}"
218
221
  end
219
222
  value_type = attr_filters[:value_type]
220
- value = item[:value]
221
- if value_type && !value_type.valid_type?(value)
222
- # Allow a collection of values of the right type for multimatch (if operators are = or !=)
223
- if ['=','!='].include?(item[:op])
224
- coll_type = Attributor::Collection.of(value_type)
225
- if !coll_type.valid_type?(value)
226
- errors << "Invalid type in filter/s value for #{attr_name} " +\
227
- "(one or more of the multiple matches in #{value} are not a #{value_type.name.split('::').last})"
228
- end
229
- else
230
- errors << "Invalid type in filter value for #{attr_name} (#{value} using '#{item[:op]}' is not a #{value_type.name.split('::').last})"
231
- end
232
- end
233
-
234
223
  next unless value_type == Attributor::String
224
+
225
+ value = item[:value]
235
226
  unless value.empty?
236
227
  fuzzy_match = attr_filters[:fuzzy_match]
237
228
  if (value[-1] == '*' || value[0] == '*') && !fuzzy_match