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

Sign up to get free protection for your applications and to get access to all the features.
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