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.
- checksums.yaml +7 -0
- data/README.md +28 -0
- data/Rakefile +34 -0
- data/app/assets/stripe_flow.png +0 -0
- data/app/controllers/concerns/rhino/authenticated.rb +18 -0
- data/app/controllers/concerns/rhino/error_handling.rb +58 -0
- data/app/controllers/concerns/rhino/paper_trail_whodunnit.rb +11 -0
- data/app/controllers/concerns/rhino/permit.rb +38 -0
- data/app/controllers/concerns/rhino/set_current_user.rb +13 -0
- data/app/controllers/rhino/account_controller.rb +34 -0
- data/app/controllers/rhino/active_model_extension_controller.rb +52 -0
- data/app/controllers/rhino/base_controller.rb +23 -0
- data/app/controllers/rhino/crud_controller.rb +57 -0
- data/app/controllers/rhino/simple_controller.rb +11 -0
- data/app/controllers/rhino/simple_stream_controller.rb +12 -0
- data/app/helpers/rhino/omniauth_helper.rb +67 -0
- data/app/helpers/rhino/policy_helper.rb +42 -0
- data/app/helpers/rhino/segment_helper.rb +62 -0
- data/app/models/rhino/account.rb +13 -0
- data/app/models/rhino/current.rb +7 -0
- data/app/models/rhino/user.rb +44 -0
- data/app/overrides/active_record/autosave_association_override.rb +18 -0
- data/app/overrides/active_record/delegated_type_override.rb +14 -0
- data/app/overrides/activestorage/direct_uploads_controller_override.rb +23 -0
- data/app/overrides/activestorage/redirect_controller_override.rb +21 -0
- data/app/overrides/activestorage/redirect_representation_controller_override.rb +21 -0
- data/app/overrides/devise_token_auth/confirmations_controller_override.rb +14 -0
- data/app/overrides/devise_token_auth/omniauth_callbacks_controller_override.rb +45 -0
- data/app/overrides/devise_token_auth/passwords_controller_override.rb +9 -0
- data/app/overrides/devise_token_auth/registrations_controller_override.rb +20 -0
- data/app/overrides/devise_token_auth/sessions_controller_override.rb +26 -0
- data/app/overrides/devise_token_auth/token_validations_controller_override.rb +18 -0
- data/app/policies/rhino/account_policy.rb +27 -0
- data/app/policies/rhino/active_storage_attachment_policy.rb +16 -0
- data/app/policies/rhino/admin_policy.rb +20 -0
- data/app/policies/rhino/base_policy.rb +72 -0
- data/app/policies/rhino/crud_policy.rb +109 -0
- data/app/policies/rhino/editor_policy.rb +12 -0
- data/app/policies/rhino/global_policy.rb +8 -0
- data/app/policies/rhino/resource_info_policy.rb +9 -0
- data/app/policies/rhino/user_policy.rb +20 -0
- data/app/policies/rhino/viewer_policy.rb +19 -0
- data/app/resources/rhino/info_graph.rb +41 -0
- data/app/resources/rhino/open_api_info.rb +108 -0
- data/config/routes.rb +19 -0
- data/db/migrate/20180101000000_devise_token_auth_create_users.rb +54 -0
- data/db/migrate/20180622142754_add_allow_change_password_to_users.rb +5 -0
- data/db/migrate/20191217010224_add_approved_to_users.rb +7 -0
- data/db/migrate/20200503182019_change_tokens_to_json_b.rb +9 -0
- data/lib/commands/rhino_command.rb +59 -0
- data/lib/generators/rhino/dev/setup/setup_generator.rb +175 -0
- data/lib/generators/rhino/dev/setup/templates/env.client.tt +4 -0
- data/lib/generators/rhino/dev/setup/templates/env.server.tt +35 -0
- data/lib/generators/rhino/dev/setup/templates/prepare-commit-msg +17 -0
- data/lib/generators/rhino/install/install_generator.rb +24 -0
- data/lib/generators/rhino/install/templates/account.rb +4 -0
- data/lib/generators/rhino/install/templates/rhino.rb +24 -0
- data/lib/generators/rhino/install/templates/user.rb +4 -0
- data/lib/generators/rhino/module/module_generator.rb +93 -0
- data/lib/generators/rhino/module/templates/engine.rb.tt +19 -0
- data/lib/generators/rhino/module/templates/install_generator.rb.tt +12 -0
- data/lib/generators/rhino/module/templates/module_tasks.rake.tt +17 -0
- data/lib/generators/rhino/policy/policy_generator.rb +33 -0
- data/lib/generators/rhino/policy/templates/policy.rb.tt +46 -0
- data/lib/generators/test_unit/rhino_policy_generator.rb +13 -0
- data/lib/generators/test_unit/templates/policy_test.rb.tt +57 -0
- data/lib/rhino/engine.rb +140 -0
- data/lib/rhino/omniauth/strategies/azure_o_auth2.rb +16 -0
- data/lib/rhino/resource/active_model_extension/backing_store/google_sheet.rb +89 -0
- data/lib/rhino/resource/active_model_extension/backing_store.rb +33 -0
- data/lib/rhino/resource/active_model_extension/describe.rb +38 -0
- data/lib/rhino/resource/active_model_extension/params.rb +70 -0
- data/lib/rhino/resource/active_model_extension/properties.rb +229 -0
- data/lib/rhino/resource/active_model_extension/reference.rb +50 -0
- data/lib/rhino/resource/active_model_extension/routing.rb +15 -0
- data/lib/rhino/resource/active_model_extension/serialization.rb +16 -0
- data/lib/rhino/resource/active_model_extension.rb +38 -0
- data/lib/rhino/resource/active_record_extension/describe.rb +44 -0
- data/lib/rhino/resource/active_record_extension/params.rb +213 -0
- data/lib/rhino/resource/active_record_extension/properties.rb +85 -0
- data/lib/rhino/resource/active_record_extension/properties_describe.rb +226 -0
- data/lib/rhino/resource/active_record_extension/reference.rb +50 -0
- data/lib/rhino/resource/active_record_extension/routing.rb +21 -0
- data/lib/rhino/resource/active_record_extension/search.rb +23 -0
- data/lib/rhino/resource/active_record_extension/serialization.rb +16 -0
- data/lib/rhino/resource/active_record_extension.rb +30 -0
- data/lib/rhino/resource/active_record_tree.rb +50 -0
- data/lib/rhino/resource/active_storage_extension.rb +41 -0
- data/lib/rhino/resource/describe.rb +19 -0
- data/lib/rhino/resource/owner.rb +172 -0
- data/lib/rhino/resource/params.rb +31 -0
- data/lib/rhino/resource/properties.rb +192 -0
- data/lib/rhino/resource/reference.rb +31 -0
- data/lib/rhino/resource/routing.rb +107 -0
- data/lib/rhino/resource/serialization.rb +13 -0
- data/lib/rhino/resource/sieves.rb +36 -0
- data/lib/rhino/resource.rb +54 -0
- data/lib/rhino/sieve/filter.rb +149 -0
- data/lib/rhino/sieve/helpers.rb +11 -0
- data/lib/rhino/sieve/limit.rb +20 -0
- data/lib/rhino/sieve/offset.rb +16 -0
- data/lib/rhino/sieve/order.rb +143 -0
- data/lib/rhino/sieve/search.rb +20 -0
- data/lib/rhino/sieve.rb +158 -0
- data/lib/rhino/test_case/controller.rb +134 -0
- data/lib/rhino/test_case/override.rb +19 -0
- data/lib/rhino/test_case/policy.rb +76 -0
- data/lib/rhino/test_case.rb +10 -0
- data/lib/rhino/version.rb +17 -0
- data/lib/rhino.rb +129 -0
- data/lib/tasks/rhino.rake +38 -0
- data/lib/tasks/rhino_dev.rake +17 -0
- data/lib/validators/country_validator.rb +11 -0
- data/lib/validators/email_validator.rb +8 -0
- data/lib/validators/ipv4_validator.rb +10 -0
- data/lib/validators/mac_address_validator.rb +9 -0
- 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,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,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,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
|