rhino_project_core 0.20.0.beta.18

Sign up to get free protection for your applications and to get access to all the features.
Files changed (117) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +28 -0
  3. data/Rakefile +34 -0
  4. data/app/assets/stripe_flow.png +0 -0
  5. data/app/controllers/concerns/rhino/authenticated.rb +18 -0
  6. data/app/controllers/concerns/rhino/error_handling.rb +58 -0
  7. data/app/controllers/concerns/rhino/paper_trail_whodunnit.rb +11 -0
  8. data/app/controllers/concerns/rhino/permit.rb +38 -0
  9. data/app/controllers/concerns/rhino/set_current_user.rb +13 -0
  10. data/app/controllers/rhino/account_controller.rb +34 -0
  11. data/app/controllers/rhino/active_model_extension_controller.rb +52 -0
  12. data/app/controllers/rhino/base_controller.rb +23 -0
  13. data/app/controllers/rhino/crud_controller.rb +57 -0
  14. data/app/controllers/rhino/simple_controller.rb +11 -0
  15. data/app/controllers/rhino/simple_stream_controller.rb +12 -0
  16. data/app/helpers/rhino/omniauth_helper.rb +67 -0
  17. data/app/helpers/rhino/policy_helper.rb +42 -0
  18. data/app/helpers/rhino/segment_helper.rb +62 -0
  19. data/app/models/rhino/account.rb +13 -0
  20. data/app/models/rhino/current.rb +7 -0
  21. data/app/models/rhino/user.rb +44 -0
  22. data/app/overrides/active_record/autosave_association_override.rb +18 -0
  23. data/app/overrides/active_record/delegated_type_override.rb +14 -0
  24. data/app/overrides/activestorage/direct_uploads_controller_override.rb +23 -0
  25. data/app/overrides/activestorage/redirect_controller_override.rb +21 -0
  26. data/app/overrides/activestorage/redirect_representation_controller_override.rb +21 -0
  27. data/app/overrides/devise_token_auth/confirmations_controller_override.rb +14 -0
  28. data/app/overrides/devise_token_auth/omniauth_callbacks_controller_override.rb +45 -0
  29. data/app/overrides/devise_token_auth/passwords_controller_override.rb +9 -0
  30. data/app/overrides/devise_token_auth/registrations_controller_override.rb +20 -0
  31. data/app/overrides/devise_token_auth/sessions_controller_override.rb +26 -0
  32. data/app/overrides/devise_token_auth/token_validations_controller_override.rb +18 -0
  33. data/app/policies/rhino/account_policy.rb +27 -0
  34. data/app/policies/rhino/active_storage_attachment_policy.rb +16 -0
  35. data/app/policies/rhino/admin_policy.rb +20 -0
  36. data/app/policies/rhino/base_policy.rb +72 -0
  37. data/app/policies/rhino/crud_policy.rb +109 -0
  38. data/app/policies/rhino/editor_policy.rb +12 -0
  39. data/app/policies/rhino/global_policy.rb +8 -0
  40. data/app/policies/rhino/resource_info_policy.rb +9 -0
  41. data/app/policies/rhino/user_policy.rb +20 -0
  42. data/app/policies/rhino/viewer_policy.rb +19 -0
  43. data/app/resources/rhino/info_graph.rb +41 -0
  44. data/app/resources/rhino/open_api_info.rb +108 -0
  45. data/config/routes.rb +19 -0
  46. data/db/migrate/20180101000000_devise_token_auth_create_users.rb +54 -0
  47. data/db/migrate/20180622142754_add_allow_change_password_to_users.rb +5 -0
  48. data/db/migrate/20191217010224_add_approved_to_users.rb +7 -0
  49. data/db/migrate/20200503182019_change_tokens_to_json_b.rb +9 -0
  50. data/lib/commands/rhino_command.rb +59 -0
  51. data/lib/generators/rhino/dev/setup/setup_generator.rb +175 -0
  52. data/lib/generators/rhino/dev/setup/templates/env.client.tt +4 -0
  53. data/lib/generators/rhino/dev/setup/templates/env.server.tt +35 -0
  54. data/lib/generators/rhino/dev/setup/templates/prepare-commit-msg +17 -0
  55. data/lib/generators/rhino/install/install_generator.rb +24 -0
  56. data/lib/generators/rhino/install/templates/account.rb +4 -0
  57. data/lib/generators/rhino/install/templates/rhino.rb +24 -0
  58. data/lib/generators/rhino/install/templates/user.rb +4 -0
  59. data/lib/generators/rhino/module/module_generator.rb +93 -0
  60. data/lib/generators/rhino/module/templates/engine.rb.tt +19 -0
  61. data/lib/generators/rhino/module/templates/install_generator.rb.tt +12 -0
  62. data/lib/generators/rhino/module/templates/module_tasks.rake.tt +17 -0
  63. data/lib/generators/rhino/policy/policy_generator.rb +33 -0
  64. data/lib/generators/rhino/policy/templates/policy.rb.tt +46 -0
  65. data/lib/generators/test_unit/rhino_policy_generator.rb +13 -0
  66. data/lib/generators/test_unit/templates/policy_test.rb.tt +57 -0
  67. data/lib/rhino/engine.rb +140 -0
  68. data/lib/rhino/omniauth/strategies/azure_o_auth2.rb +16 -0
  69. data/lib/rhino/resource/active_model_extension/backing_store/google_sheet.rb +89 -0
  70. data/lib/rhino/resource/active_model_extension/backing_store.rb +33 -0
  71. data/lib/rhino/resource/active_model_extension/describe.rb +38 -0
  72. data/lib/rhino/resource/active_model_extension/params.rb +70 -0
  73. data/lib/rhino/resource/active_model_extension/properties.rb +229 -0
  74. data/lib/rhino/resource/active_model_extension/reference.rb +50 -0
  75. data/lib/rhino/resource/active_model_extension/routing.rb +15 -0
  76. data/lib/rhino/resource/active_model_extension/serialization.rb +16 -0
  77. data/lib/rhino/resource/active_model_extension.rb +38 -0
  78. data/lib/rhino/resource/active_record_extension/describe.rb +44 -0
  79. data/lib/rhino/resource/active_record_extension/params.rb +213 -0
  80. data/lib/rhino/resource/active_record_extension/properties.rb +85 -0
  81. data/lib/rhino/resource/active_record_extension/properties_describe.rb +226 -0
  82. data/lib/rhino/resource/active_record_extension/reference.rb +50 -0
  83. data/lib/rhino/resource/active_record_extension/routing.rb +21 -0
  84. data/lib/rhino/resource/active_record_extension/search.rb +23 -0
  85. data/lib/rhino/resource/active_record_extension/serialization.rb +16 -0
  86. data/lib/rhino/resource/active_record_extension.rb +30 -0
  87. data/lib/rhino/resource/active_record_tree.rb +50 -0
  88. data/lib/rhino/resource/active_storage_extension.rb +41 -0
  89. data/lib/rhino/resource/describe.rb +19 -0
  90. data/lib/rhino/resource/owner.rb +172 -0
  91. data/lib/rhino/resource/params.rb +31 -0
  92. data/lib/rhino/resource/properties.rb +192 -0
  93. data/lib/rhino/resource/reference.rb +31 -0
  94. data/lib/rhino/resource/routing.rb +107 -0
  95. data/lib/rhino/resource/serialization.rb +13 -0
  96. data/lib/rhino/resource/sieves.rb +36 -0
  97. data/lib/rhino/resource.rb +54 -0
  98. data/lib/rhino/sieve/filter.rb +149 -0
  99. data/lib/rhino/sieve/helpers.rb +11 -0
  100. data/lib/rhino/sieve/limit.rb +20 -0
  101. data/lib/rhino/sieve/offset.rb +16 -0
  102. data/lib/rhino/sieve/order.rb +143 -0
  103. data/lib/rhino/sieve/search.rb +20 -0
  104. data/lib/rhino/sieve.rb +158 -0
  105. data/lib/rhino/test_case/controller.rb +134 -0
  106. data/lib/rhino/test_case/override.rb +19 -0
  107. data/lib/rhino/test_case/policy.rb +76 -0
  108. data/lib/rhino/test_case.rb +10 -0
  109. data/lib/rhino/version.rb +17 -0
  110. data/lib/rhino.rb +129 -0
  111. data/lib/tasks/rhino.rake +38 -0
  112. data/lib/tasks/rhino_dev.rake +17 -0
  113. data/lib/validators/country_validator.rb +11 -0
  114. data/lib/validators/email_validator.rb +8 -0
  115. data/lib/validators/ipv4_validator.rb +10 -0
  116. data/lib/validators/mac_address_validator.rb +9 -0
  117. metadata +178 -0
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rhino
4
+ module Resource
5
+ module Routing
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ class_attribute :_route_key, default: nil
10
+ class_attribute :_route_path, default: nil
11
+ class_attribute :_route_singular, default: false
12
+
13
+ class_attribute :_rhino_routes, default: nil
14
+ class_attribute :_rhino_routes_except, default: []
15
+
16
+ class_attribute :controller_name, default: 'rhino/crud'
17
+
18
+ delegate :routes, to: :class
19
+ end
20
+
21
+ # rubocop:disable Style/RedundantSelf, Metrics/BlockLength
22
+ class_methods do
23
+ def route_key
24
+ self._route_key ||= if route_singular?
25
+ name.demodulize.underscore
26
+ else
27
+ name.demodulize.underscore.pluralize
28
+ end
29
+ end
30
+
31
+ def route_path
32
+ self._route_path ||= route_key
33
+ end
34
+
35
+ def route_singular?
36
+ self._route_singular
37
+ end
38
+
39
+ def route_path_frontend
40
+ route_path.camelize(:lower)
41
+ end
42
+
43
+ def route_frontend
44
+ "/#{route_path_frontend}"
45
+ end
46
+
47
+ def route_api
48
+ "/#{Rhino.namespace}/#{route_path}"
49
+ end
50
+
51
+ def routes
52
+ unless self._rhino_routes
53
+ if global_owner? # rubocop:disable Style/ConditionalAssignment
54
+ self._rhino_routes = %i[index show]
55
+ else
56
+ self._rhino_routes = %i[index create show update destroy]
57
+ end
58
+ end
59
+ self._rhino_routes - self._rhino_routes_except
60
+ end
61
+
62
+ def rhino_routing(**options)
63
+ self._route_key = options.delete(:key) if options.key?(:key)
64
+ self._route_path = options.delete(:path) if options.key?(:path)
65
+ self._route_singular = options.delete(:singular) if options.key?(:singular)
66
+
67
+ self._rhino_routes = options.delete(:only) if options.key?(:only)
68
+ self._rhino_routes_except = options.delete(:except) if options.key?(:except)
69
+ end
70
+
71
+ def rhino_controller(controller)
72
+ self.controller_name = "rhino/#{controller}"
73
+ end
74
+ end
75
+ # rubocop:enable Style/RedundantSelf, Metrics/BlockLength
76
+ end
77
+
78
+ def route_frontend # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
79
+ base_owner_pk = "#{Rhino.base_owner.table_name}.#{Rhino.base_owner.primary_key}"
80
+
81
+ joins = joins_for_base_owner
82
+ base_owner_id = if joins.empty?
83
+ # if this is Model is the base owner, we don't to include it in frontend url
84
+ nil
85
+ else
86
+ base_owner_ids = self.class.joins(joins).where(id:).pluck(base_owner_pk)
87
+ if base_owner_ids.length == 1
88
+ base_owner_ids.first
89
+ else
90
+ # if this Model doesn't have a clear single path to the base owner Model,
91
+ # we shouldn't include it in the frontend url
92
+ nil
93
+ end
94
+ end
95
+
96
+ if base_owner_id.nil?
97
+ "#{self.class.route_frontend}/#{id}"
98
+ else
99
+ "/#{base_owner_id}#{self.class.route_frontend}/#{id}"
100
+ end
101
+ end
102
+
103
+ def route_api
104
+ "#{self.class.route_api}/#{id}"
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rhino
4
+ module Resource
5
+ module Serialization
6
+ extend ActiveSupport::Concern
7
+
8
+ def to_caching_json
9
+ raise NotImplementedError, '#to_caching_json is not implemented'
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rhino
4
+ module Resource
5
+ module Sieves
6
+ extend ActiveSupport::Concern
7
+ include ArelHelpers::ArelTable
8
+
9
+ included do
10
+ class_attribute :_sieves
11
+ class_attribute :_built_sieves
12
+
13
+ delegate :sieves, to: :class
14
+ end
15
+
16
+ # rubocop:disable Style/RedundantSelf
17
+ class_methods do
18
+ def rhino_sieves
19
+ self._sieves = Rhino.sieves.dup unless self._sieves
20
+ self._sieves
21
+ end
22
+
23
+ def sieves
24
+ self._built_sieves = rhino_sieves.build(self) unless self._built_sieves
25
+
26
+ self._built_sieves
27
+ end
28
+
29
+ def resolve(scope, _params)
30
+ scope
31
+ end
32
+ end
33
+ # rubocop:enable Style/RedundantSelf
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'resource/owner'
4
+ require_relative 'resource/properties'
5
+ require_relative 'resource/reference'
6
+ require_relative 'resource/describe'
7
+ require_relative 'resource/routing'
8
+ require_relative 'resource/params'
9
+ require_relative 'resource/serialization'
10
+ require_relative 'resource/sieves'
11
+
12
+ require_relative '../../app/policies/rhino/crud_policy'
13
+
14
+ module Rhino
15
+ module Resource
16
+ extend ActiveSupport::Concern
17
+
18
+ include Rhino::Resource::Owner
19
+ include Rhino::Resource::Properties
20
+ include Rhino::Resource::Reference
21
+ include Rhino::Resource::Describe
22
+ include Rhino::Resource::Routing
23
+ include Rhino::Resource::Params
24
+ include Rhino::Resource::Serialization
25
+ include Rhino::Resource::Sieves
26
+
27
+ included do
28
+ class_attribute :_policy_class, default: Rhino::CrudPolicy
29
+
30
+ def owner
31
+ send self.class.resource_owned_by
32
+ end
33
+
34
+ def display_name
35
+ return name if respond_to? :name
36
+ return title if respond_to? :title
37
+
38
+ nil
39
+ end
40
+ end
41
+
42
+ class_methods do
43
+ def policy_class
44
+ _policy_class
45
+ end
46
+
47
+ def rhino_policy(policy)
48
+ self._policy_class = "rhino/#{policy}_policy".classify.safe_constantize || "#{policy}_policy".classify.safe_constantize
49
+
50
+ raise "Policy #{policy} not found for #{name}" unless _policy_class
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rhino
4
+ module Sieve
5
+ class Filter # rubocop:disable Metrics/ClassLength
6
+ def initialize(app)
7
+ @app = app
8
+ end
9
+
10
+ # filter[blog_id]=1
11
+ def resolve(scope, params)
12
+ return @app.resolve(scope, params) unless params.key?(:filter)
13
+
14
+ filter = params[:filter].permit!.to_h
15
+ scope = scope.joins(get_joins(scope.klass, filter))
16
+ query = apply_filters(scope, scope.klass, filter).distinct(:id)
17
+ @app.resolve(query, params)
18
+ end
19
+
20
+ private
21
+ def get_joins_hash(base, filter)
22
+ res = []
23
+ filter.each do |key, val|
24
+ value_is_hash = val.is_a? Hash
25
+ # only consider a key for the join clause if it is a relationship a.k.a reflection
26
+ key_is_reflection = base.reflections.key? key
27
+
28
+ next unless value_is_hash && key_is_reflection
29
+
30
+ # in the next recursion, the associations will have to be checked against this current key, not the initial model
31
+ reflection_model = base.reflections[key.to_s].klass
32
+ res << { key => get_joins_hash(reflection_model, val) }
33
+ end
34
+ res
35
+ end
36
+
37
+ def get_joins(base, filter)
38
+ joins_hash = get_joins_hash(base, filter)
39
+ result = []
40
+ joins_hash.each do |entry|
41
+ entry.each do |key, value|
42
+ result << ArelHelpers.join_association(base, { key => value }, Arel::Nodes::OuterJoin, {})
43
+ end
44
+ end
45
+ result
46
+ end
47
+
48
+ def apply_filters(scope, base, filter)
49
+ filter.each do |key, val|
50
+ key = Sieve::Helpers.real_column_name(scope, key)
51
+ key_is_reflection = base.reflections.key? key
52
+ key_is_attribute = base.attribute_names.include? key
53
+ key_is_association_operator = ASSOCIATION_OPS.include? key
54
+ if key_is_association_operator
55
+ scope = apply_association_operator(base, scope, key, val)
56
+ elsif key_is_reflection || key_is_attribute
57
+ scope = apply_filter(scope, base, key, val)
58
+ end
59
+ end
60
+ scope
61
+ end
62
+
63
+ def apply_filter(scope, base, key, val)
64
+ reflection = base.reflections[key.to_s]
65
+ value_is_hash = val.is_a? Hash
66
+ if value_is_hash && reflection
67
+ # joined table filter, continue recursion, e.g. ?filter[blog][...]
68
+ association_base = reflection.klass
69
+ apply_filters(scope, association_base, val)
70
+ elsif reflection
71
+ # direct association id filter, e.g. ?filter[blog]=1
72
+ apply_association_filter(base, scope, key, val)
73
+ else
74
+ # column filter, e.g. ?filter[name]=...
75
+ apply_column_filter(base, scope, key, val)
76
+ end
77
+ end
78
+
79
+ def apply_association_filter(base, scope, key, value)
80
+ scope.where(base.arel_table[base.reflections[key].foreign_key].eq(value))
81
+ end
82
+
83
+ ASSOCIATION_OPS = %w[_is_empty].freeze
84
+ def apply_association_operator(base, scope, key, value)
85
+ if key == '_is_empty' && truthy?(value)
86
+ arel_node = base.arel_table['id']
87
+
88
+ where_clause = arel_node.eq(nil)
89
+ scope.where(where_clause)
90
+ else
91
+ scope
92
+ end
93
+ end
94
+
95
+ def apply_column_filter(base, scope, column_name, column_value)
96
+ if column_value.is_a?(Hash)
97
+ # it is a more complex query, possibly using operators like gt, lt, etc.
98
+ # more than one operator per-field is allowed
99
+ column_value.each do |operation, value|
100
+ scope = merge_where_clause(base, scope, column_name, value, operation)
101
+ end
102
+ else
103
+ scope = merge_where_clause(base, scope, column_name, column_value)
104
+ end
105
+ scope
106
+ end
107
+
108
+ BASIC_AREL_OPS = %w[eq gt lt gteq lteq].freeze
109
+ BASIC_AREL_COALESCE_OPS = BASIC_AREL_OPS.map { |op| "#{op}_coalesce" }.freeze
110
+ def merge_where_clause(base, scope, column_name, value, operation = nil) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
111
+ arel_node = base.arel_table[column_name]
112
+ where_clause = case operation
113
+ when *BASIC_AREL_OPS then arel_node.send(operation, value)
114
+ when *BASIC_AREL_COALESCE_OPS
115
+ coalesce = Arel::Nodes::NamedFunction.new('COALESCE', [arel_node, Arel::Nodes.build_quoted(value)])
116
+ coalesce.send(operation.split('_').first, value)
117
+ when 'diff' then arel_node.not_eq(value)
118
+ when 'is_null' then apply_is_null(arel_node, value)
119
+ when /^tree_(.*)/ then apply_tree(Regexp.last_match(1), base, column_name, arel_node, value)
120
+ else
121
+ if value.is_a? Array
122
+ arel_node.in(value)
123
+ else
124
+ arel_node.eq(value)
125
+ end
126
+ end
127
+ scope.where(where_clause)
128
+ end
129
+
130
+ def apply_is_null(arel_node, value)
131
+ if ActiveModel::Type::Boolean::FALSE_VALUES.include?(value)
132
+ arel_node.not_eq(nil)
133
+ else
134
+ arel_node.eq(nil)
135
+ end
136
+ end
137
+
138
+ def apply_tree(operation, base, column_name, arel_node, value)
139
+ subquery = base.where(column_name => value).map(&operation.to_sym).flatten.map(&column_name.to_sym)
140
+
141
+ arel_node.in(subquery)
142
+ end
143
+
144
+ def truthy?(value)
145
+ value.present? && ActiveModel::Type::Boolean::FALSE_VALUES.exclude?(value)
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rhino
4
+ module Sieve
5
+ class Helpers
6
+ def self.real_column_name(scope, column_name)
7
+ scope.attribute_aliases[column_name] || column_name
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rhino
4
+ module Sieve
5
+ class Limit
6
+ attr_accessor :default_limit
7
+
8
+ def initialize(app, default_limit: 20)
9
+ @app = app
10
+
11
+ @default_limit = default_limit
12
+ end
13
+
14
+ # limit=20
15
+ def resolve(scope, params)
16
+ @app.resolve(scope.limit(params[:limit] || default_limit), params)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rhino
4
+ module Sieve
5
+ class Offset
6
+ def initialize(app)
7
+ @app = app
8
+ end
9
+
10
+ # offset=20
11
+ def resolve(scope, params)
12
+ @app.resolve(scope.offset(params[:offset]), params)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rhino
4
+ module Sieve
5
+ class Order
6
+ def initialize(app)
7
+ @app = app
8
+ end
9
+
10
+ # order=name
11
+ def resolve(scope, params)
12
+ @scope = scope
13
+ @param = params[:order]
14
+
15
+ # Always append id to the end of the order clause to ensure a stable sort for pagination
16
+ result = apply_order.order(:id)
17
+ @app.resolve(result, params)
18
+ end
19
+
20
+ private
21
+ def parse(param)
22
+ direction = parse_direction(param)
23
+ column_name = parse_column_name(param, direction)
24
+ [direction, column_name]
25
+ end
26
+
27
+ def parse_direction(param)
28
+ if param[0] == '-'
29
+ :desc
30
+ else
31
+ :asc
32
+ end
33
+ end
34
+
35
+ def parse_column_name(param, direction)
36
+ param = if direction == :asc
37
+ param
38
+ else
39
+ param[1..]
40
+ end
41
+ Sieve::Helpers.real_column_name(@scope, param)
42
+ end
43
+
44
+ def string?(param)
45
+ param.is_a? String
46
+ end
47
+
48
+ def get_joins(base, path, _last_base)
49
+ return {} if path.empty?
50
+
51
+ # Convert to nested hash
52
+ join_tables = path.reverse.inject({}) { |assigned_value, key| { key => assigned_value } }
53
+
54
+ # If InnerJoin was used instead of OuterJoin, a simple ORDER clause targeting an association's column
55
+ # would incur in a join being made, which in turn would exclude records with no association. This would
56
+ # be conceptually wrong, as an ORDER clause would be excluding records from the final result. In order to
57
+ # restore the previous behavior and exclude these records without parent, one could simply add a WHERE
58
+ # clause with something like `WHERE association.id IS NOT NULL`.
59
+ ArelHelpers.join_association(base, join_tables, Arel::Nodes::OuterJoin, {})
60
+ rescue StandardError
61
+ nil
62
+ end
63
+
64
+ def build_select_clause(model, path, field)
65
+ return model.arel_table[Arel.star] if path.empty?
66
+
67
+ model.arel_table[field]
68
+ end
69
+
70
+ def split_field_path(column_name)
71
+ chunks = column_name.split('.')
72
+ field = chunks.last
73
+ path = chunks.slice(0, chunks.length - 1)
74
+ [path, field]
75
+ end
76
+
77
+ def analyze(column_name)
78
+ path, field = split_field_path(column_name)
79
+
80
+ last_base = if path.last.blank?
81
+ @scope
82
+ else
83
+ path.last.classify.safe_constantize
84
+ end
85
+ return nil unless last_base&.attribute_names&.include? field
86
+
87
+ join_clauses = get_joins(@scope.klass, path, last_base)
88
+ select_clause = build_select_clause(last_base, path, field)
89
+
90
+ [join_clauses, last_base.arel_table[field], select_clause]
91
+ end
92
+
93
+ NULL_ORDERING = {
94
+ asc: :nulls_last,
95
+ desc: :nulls_first
96
+ }.freeze
97
+ def order(direction, column_name)
98
+ # nulls_last should generally be the desired user experience
99
+ join_clauses, order_clause, select_clause = analyze(column_name)
100
+ return nil unless join_clauses && order_clause
101
+
102
+ final_clause = order_clause
103
+ .send(direction)
104
+ .send(NULL_ORDERING[direction])
105
+ [join_clauses, final_clause, select_clause]
106
+ end
107
+
108
+ def build_clause(param)
109
+ return nil unless string?(param)
110
+
111
+ direction, column_name = parse(param)
112
+ order(direction, column_name)
113
+ end
114
+
115
+ def group_clauses(clauses)
116
+ join_clauses = clauses.map { |joins, _node, _select| joins }
117
+ order_clauses = clauses.map { |_joins, node, _select| node }
118
+ select_clauses = clauses.map { |_joins, _node, select| select }
119
+ [order_clauses, join_clauses, select_clauses]
120
+ end
121
+
122
+ def build_clauses
123
+ clauses = @param.split(',')
124
+ .map { |el| build_clause(el) }
125
+ .filter { |joins, node, select| joins && node && select }
126
+ group_clauses(clauses)
127
+ end
128
+
129
+ def apply_order
130
+ return @scope unless string?(@param)
131
+
132
+ order_clauses, join_clauses, select_clauses = build_clauses
133
+ if order_clauses.empty?
134
+ @scope
135
+ else
136
+ select_clauses << "#{@scope.klass.table_name}.*"
137
+ @scope = @scope.reorder(nil).select(select_clauses.uniq).joins(join_clauses)
138
+ order_clauses.inject(@scope) { |acc, el| acc.order(el) }
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rhino
4
+ module Sieve
5
+ class Search
6
+ def initialize(app)
7
+ @app = app
8
+ end
9
+
10
+ # search='test'
11
+ def resolve(scope, params)
12
+ return @app.resolve(scope, params) unless scope.respond_to?(:search_text_fields) && params[:search].present?
13
+
14
+ # Re-order so that rank isn't the default
15
+ # FIXME: Should look at the order param for 'rank'
16
+ @app.resolve(scope.search_text_fields(params[:search]).reorder(''), params)
17
+ end
18
+ end
19
+ end
20
+ end