wallaby-active_record 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (24) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +31 -0
  4. data/lib/adapters/wallaby/active_record/cancancan_provider.rb +9 -0
  5. data/lib/adapters/wallaby/active_record/default_provider.rb +9 -0
  6. data/lib/adapters/wallaby/active_record/model_decorator/fields_builder/association_builder.rb +52 -0
  7. data/lib/adapters/wallaby/active_record/model_decorator/fields_builder/polymorphic_builder.rb +52 -0
  8. data/lib/adapters/wallaby/active_record/model_decorator/fields_builder/sti_builder.rb +50 -0
  9. data/lib/adapters/wallaby/active_record/model_decorator/fields_builder.rb +66 -0
  10. data/lib/adapters/wallaby/active_record/model_decorator/title_field_finder.rb +32 -0
  11. data/lib/adapters/wallaby/active_record/model_decorator.rb +155 -0
  12. data/lib/adapters/wallaby/active_record/model_finder.rb +45 -0
  13. data/lib/adapters/wallaby/active_record/model_pagination_provider.rb +34 -0
  14. data/lib/adapters/wallaby/active_record/model_service_provider/normalizer.rb +54 -0
  15. data/lib/adapters/wallaby/active_record/model_service_provider/permitter.rb +68 -0
  16. data/lib/adapters/wallaby/active_record/model_service_provider/querier/transformer.rb +78 -0
  17. data/lib/adapters/wallaby/active_record/model_service_provider/querier.rb +172 -0
  18. data/lib/adapters/wallaby/active_record/model_service_provider/validator.rb +37 -0
  19. data/lib/adapters/wallaby/active_record/model_service_provider.rb +166 -0
  20. data/lib/adapters/wallaby/active_record/pundit_provider.rb +19 -0
  21. data/lib/adapters/wallaby/active_record.rb +7 -0
  22. data/lib/wallaby/active_record/version.rb +7 -0
  23. data/lib/wallaby/active_record.rb +38 -0
  24. metadata +152 -0
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wallaby
4
+ class ActiveRecord
5
+ class ModelServiceProvider
6
+ # Filter the params
7
+ class Permitter
8
+ # @param model_decorator [Wallaby::ModelDecorator]
9
+ def initialize(model_decorator)
10
+ @model_decorator = model_decorator
11
+ end
12
+
13
+ # @return [Array<String>] a list of field names of general types
14
+ def simple_field_names
15
+ field_names =
16
+ non_range_fields.keys +
17
+ belongs_to_fields.map do |_, metadata|
18
+ [metadata[:foreign_key], metadata[:polymorphic_type]]
19
+ end.flatten.compact
20
+ fields = [@model_decorator.primary_key, 'created_at', 'updated_at']
21
+ field_names.reject { |field_name| fields.include? field_name }
22
+ end
23
+
24
+ # @return [Array<String>] a list of field names of range and association
25
+ def compound_hashed_fields
26
+ field_names =
27
+ range_fields.keys +
28
+ many_association_fields.map { |_, metadata| metadata[:foreign_key] }
29
+ field_names.each_with_object({}) { |name, hash| hash[name] = [] }
30
+ end
31
+
32
+ protected
33
+
34
+ # @return [Array<String>] a list of field names that ain't association
35
+ def non_association_fields
36
+ @model_decorator.fields.reject { |_, metadata| metadata[:is_association] }
37
+ end
38
+
39
+ # @return [Array<String>] a list of field names that ain't range
40
+ def non_range_fields
41
+ non_association_fields.reject { |_, metadata| /range|point/ =~ metadata[:type] }
42
+ end
43
+
44
+ # @return [Array<String>] a list of range field names
45
+ def range_fields
46
+ non_association_fields.select { |_, metadata| /range|point/ =~ metadata[:type] }
47
+ end
48
+
49
+ # @return [Array<String>] a list of association field names
50
+ def association_fields
51
+ @model_decorator.fields.select { |_, metadata| metadata[:is_association] && !metadata[:has_scope] }
52
+ end
53
+
54
+ # @return [Array<String>] a list of many association field names:
55
+ # - has_many
56
+ # - has_and_belongs_to_many
57
+ def many_association_fields
58
+ association_fields.select { |_, metadata| /many/ =~ metadata[:type] }
59
+ end
60
+
61
+ # @return [Array<String>] a list of belongs_to association field names
62
+ def belongs_to_fields
63
+ association_fields.select { |_, metadata| metadata[:type] == 'belongs_to' }
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wallaby
4
+ class ActiveRecord
5
+ class ModelServiceProvider
6
+ class Querier
7
+ # Build up query using the results
8
+ class Transformer < Parslet::Transform
9
+ SIMPLE_OPERATORS = {
10
+ ':' => :eq,
11
+ ':=' => :eq,
12
+ ':!' => :not_eq,
13
+ ':!=' => :not_eq,
14
+ ':<>' => :not_eq,
15
+ ':~' => :matches,
16
+ ':^' => :matches,
17
+ ':$' => :matches,
18
+ ':!~' => :does_not_match,
19
+ ':!^' => :does_not_match,
20
+ ':!$' => :does_not_match,
21
+ ':>' => :gt,
22
+ ':>=' => :gteq,
23
+ ':<' => :lt,
24
+ ':<=' => :lteq
25
+ }.freeze
26
+
27
+ SEQUENCE_OPERATORS = {
28
+ ':' => :in,
29
+ ':=' => :in,
30
+ ':!' => :not_in,
31
+ ':!=' => :not_in,
32
+ ':<>' => :not_in,
33
+ ':()' => :between,
34
+ ':!()' => :not_between
35
+ }.freeze
36
+
37
+ # For single keyword
38
+ rule keyword: simple(:value) do
39
+ value.try :to_str
40
+ end
41
+
42
+ # For multiple keywords
43
+ rule keyword: sequence(:value) do
44
+ value.presence.try :map, :to_str
45
+ end
46
+
47
+ # For operators
48
+ rule left: simple(:left), op: simple(:op), right: simple(:right) do
49
+ oped = op.try :to_str
50
+ operator = SIMPLE_OPERATORS[oped]
51
+ # skip if the operator is unknown
52
+ next unless operator
53
+
54
+ lefted = left.try :to_str
55
+ convert =
56
+ case oped
57
+ when ':~', ':!~' then "%#{right}%"
58
+ when ':^', ':!^' then "#{right}%"
59
+ when ':$', ':!$' then "%#{right}"
60
+ end
61
+ { left: lefted, op: operator, right: convert || right }
62
+ end
63
+
64
+ # For operators that have multiple items
65
+ rule left: simple(:left), op: simple(:op), right: sequence(:right) do
66
+ oped = op.try :to_str
67
+ operator = SEQUENCE_OPERATORS[oped]
68
+ next unless operator
69
+
70
+ lefted = left.try :to_str
71
+ convert = Range.new right.try(:first), right.try(:last) if %w(:() :!()).include?(oped)
72
+ { left: lefted, op: operator, right: convert || right }
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wallaby
4
+ class ActiveRecord
5
+ class ModelServiceProvider
6
+ # Query builder
7
+ class Querier
8
+ TEXT_FIELDS = %w(string text citext longtext tinytext mediumtext).freeze
9
+
10
+ # @param model_decorator [Wallaby::ModelDecorator]
11
+ def initialize(model_decorator)
12
+ @model_decorator = model_decorator
13
+ @model_class = @model_decorator.model_class
14
+ end
15
+
16
+ # Pull out the query expression string from the parameter `q`,
17
+ # use parser to understand the expression, then use transformer to run
18
+ # SQL arel query.
19
+ # @param params [ActionController::Parameters]
20
+ # @return [ActiveRecord::Relation]
21
+ def search(params)
22
+ filter_name, keywords, field_queries = extract params
23
+ scope = filtered_by filter_name
24
+ query = text_search keywords
25
+ query = field_search field_queries, query
26
+ scope.where query
27
+ end
28
+
29
+ private
30
+
31
+ # @see Wallaby::Parser
32
+ def parser
33
+ @parser ||= Parser.new
34
+ end
35
+
36
+ # @see Wallaby::ActiveRecord::ModelServiceProvider::Querier::Transformer
37
+ def transformer
38
+ @transformer ||= Transformer.new
39
+ end
40
+
41
+ # @return [Arel::Table] arel table
42
+ def table
43
+ @model_class.arel_table
44
+ end
45
+
46
+ # @param params [ActionController::Parameters]
47
+ # @return [Array<String, Array, Array>] a list of object for other
48
+ # method to use.
49
+ def extract(params)
50
+ expressions = to_expressions params
51
+ keywords = expressions.select { |v| v.is_a? String }
52
+ field_queries = expressions.select { |v| v.is_a? Hash }
53
+ filter_name = params[:filter]
54
+ [filter_name, keywords, field_queries]
55
+ end
56
+
57
+ # @param params [ActionController::Parameters]
58
+ # @return [Array] a list of transformed operations
59
+ def to_expressions(params)
60
+ parsed = parser.parse(params[:q] || EMPTY_STRING)
61
+ converted = transformer.apply parsed
62
+ converted.is_a?(Array) ? converted : [converted]
63
+ end
64
+
65
+ # Use the filter name to find out the scope in the following precedents:
66
+ # - scope from metadata
67
+ # - defined scope from the model
68
+ # - unscoped
69
+ # @param filter_name [String] filter name
70
+ # @return [ActiveRecord::Relation]
71
+ def filtered_by(filter_name)
72
+ valid_filter_name =
73
+ FilterUtils.filter_name_by(filter_name, @model_decorator.filters)
74
+ scope = find_scope(valid_filter_name)
75
+ if scope.blank? then unscoped
76
+ elsif scope.is_a?(Proc) then @model_class.instance_exec(&scope)
77
+ elsif @model_class.respond_to?(scope)
78
+ @model_class.public_send(scope)
79
+ else unscoped
80
+ end
81
+ end
82
+
83
+ # Find out the scope for given filter
84
+ # - from metadata
85
+ # - filter name itself
86
+ # @param filter_name [String] filter name
87
+ # @return [String]
88
+ def find_scope(filter_name)
89
+ filter = @model_decorator.filters[filter_name] || {}
90
+ filter[:scope] || filter_name
91
+ end
92
+
93
+ # Unscoped query
94
+ # @return [ActiveRecord::Relation]
95
+ def unscoped
96
+ @model_class.where nil
97
+ end
98
+
99
+ # Search text for the text columns that appear in `index_field_names`
100
+ # @param keywords [String] keywords
101
+ # @param query [ActiveRecord::Relation, nil]
102
+ # @return [ActiveRecord::Relation]
103
+ def text_search(keywords, query = nil)
104
+ return query unless keywords_check? keywords
105
+
106
+ text_fields.each do |field_name|
107
+ sub_query = nil
108
+ keywords.each do |keyword|
109
+ exp = table[field_name].matches("%#{keyword}%")
110
+ sub_query = sub_query.try(:and, exp) || exp
111
+ end
112
+ query = query.try(:or, sub_query) || sub_query
113
+ end
114
+ query
115
+ end
116
+
117
+ # Perform SQL query for the colon query (e.g. data:<2000-01-01)
118
+ # @param field_queries [Array]
119
+ # @param query [ActiveRecord::Relation]
120
+ # @return [ActiveRecord::Relation]
121
+ def field_search(field_queries, query)
122
+ return query unless field_check? field_queries
123
+
124
+ field_queries.each do |exp|
125
+ sub_query = table[exp[:left]].try(exp[:op], exp[:right])
126
+ query = query.try(:and, sub_query) || sub_query
127
+ end
128
+ query
129
+ end
130
+
131
+ # @return [Array<String>] a list of text fields from `index_field_names`
132
+ def text_fields
133
+ @text_fields ||= begin
134
+ index_field_names = @model_decorator.index_field_names.map(&:to_s)
135
+ @model_decorator.fields.select do |field_name, metadata|
136
+ index_field_names.include?(field_name) &&
137
+ TEXT_FIELDS.include?(metadata[:type].to_s)
138
+ end.keys
139
+ end
140
+ end
141
+
142
+ # @param keywords [Array<String>] a list of keywords
143
+ # @return [Boolean] false when keywords are empty
144
+ # true when text fields for query exist
145
+ # otherwise, raise exception
146
+ def keywords_check?(keywords)
147
+ return false if keywords.blank?
148
+ return true if text_fields.present?
149
+
150
+ message = I18n.t 'errors.unprocessable_entity.keyword_search'
151
+ raise UnprocessableEntity, message
152
+ end
153
+
154
+ # @param field_queries [Array]
155
+ # @return [Boolean] false when field queries are blank
156
+ # true when the fields used are valid (exist in `fields`)
157
+ # otherwise, raise exception
158
+ def field_check?(field_queries)
159
+ return false if field_queries.blank?
160
+
161
+ fields = field_queries.map { |exp| exp[:left] }
162
+ invalid_fields = fields - @model_decorator.fields.keys
163
+ return true if invalid_fields.blank?
164
+
165
+ message = I18n.t 'errors.unprocessable_entity.field_colon_search',
166
+ invalid_fields: invalid_fields.to_sentence
167
+ raise UnprocessableEntity, message
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wallaby
4
+ class ActiveRecord
5
+ class ModelServiceProvider
6
+ # Validator
7
+ class Validator
8
+ # @param model_decorator [Wallaby::ModelDecorator]
9
+ def initialize(model_decorator)
10
+ @model_decorator = model_decorator
11
+ end
12
+
13
+ # @param resource [Object] resource object
14
+ # @return [Boolean] whether the resource object is valid
15
+ def valid?(resource)
16
+ resource.attributes.each do |field_name, values|
17
+ metadata = @model_decorator.fields[field_name]
18
+ next if valid_range_type? values, metadata
19
+
20
+ resource.errors.add field_name, 'required for range data'
21
+ end
22
+ resource.errors.blank?
23
+ end
24
+
25
+ private
26
+
27
+ # @param values [Array]
28
+ # @return [Boolean] whether the values are valid range values
29
+ def valid_range_type?(values, metadata)
30
+ !metadata \
31
+ || !%w(daterange tsrange tstzrange).include?(metadata[:type]) \
32
+ || !values.try(:any?, &:blank?)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wallaby
4
+ class ActiveRecord
5
+ # Model service provider
6
+ # @see Wallaby::ModelServiceProvider
7
+ class ModelServiceProvider < ::Wallaby::ModelServiceProvider
8
+ # @param params [ActionController::Parameters]
9
+ # @param action [String, Symbol]
10
+ # @param authorizer
11
+ # @return [ActionController::Parameters] whitelisted parameters
12
+ # @see Wallaby::ModelServiceProvider#permit
13
+ def permit(params, action, authorizer)
14
+ authorized_fields = authorizer.permit_params action, @model_class
15
+ params.require(param_key).permit(authorized_fields || permitted_fields)
16
+ end
17
+
18
+ # @note Pagination free here. Since somewhere might need the collection without any pagination
19
+ # @param params [ActionController::Parameters]
20
+ # @param authorizer [Ability] for now
21
+ # @return [ActiveRecord::Relation] relation
22
+ # @see Wallaby::ModelServiceProvider#collection
23
+ def collection(params, authorizer)
24
+ query = querier.search params
25
+ query = query.order params[:sort] if params[:sort].present?
26
+ authorizer.accessible_for :index, query
27
+ end
28
+
29
+ # @param query [ActiveRecord::Relation]
30
+ # @param params [ActionController::Parameters]
31
+ # @return [ActiveRecord::Relation] paginated query
32
+ # @see Wallaby::ModelServiceProvider#paginate
33
+ def paginate(query, params)
34
+ per = params[:per] || Wallaby.configuration.pagination.page_size
35
+ query = query.page params[:page] if query.respond_to? :page
36
+ query = query.per per if query.respond_to? :per
37
+ query
38
+ end
39
+
40
+ # @note No mass assignment happens here!
41
+ # @return [Object] new resource object
42
+ # @see Wallaby::ModelServiceProvider#new
43
+ def new(_params, _authorizer)
44
+ @model_class.new
45
+ end
46
+
47
+ # @note No mass assignment happens here!
48
+ # Find the record using id.
49
+ # @param id [Integer, String]
50
+ # @return [Object] persisted resource object
51
+ # @raise [Wallaby::ResourceNotFound] when record is not found
52
+ # @see Wallaby::ModelServiceProvider#find
53
+ def find(id, _params, _authorizer)
54
+ @model_class.find id
55
+ rescue ::ActiveRecord::RecordNotFound
56
+ raise ResourceNotFound, id
57
+ end
58
+
59
+ # Assign resource with new values and store it in database as new record.
60
+ # @param resource [Object]
61
+ # @param params [ActionController::Parameters]
62
+ # @param authorizer [Wallaby::ModelAuthorizer]
63
+ # @see Wallaby::ModelServiceProvider#create
64
+ def create(resource, params, authorizer)
65
+ save __callee__, resource, params, authorizer
66
+ end
67
+
68
+ # Assign resource with new values and store it in database as an update.
69
+ # @param resource [Object]
70
+ # @param params [ActionController::Parameters]
71
+ # @param authorizer [Wallaby::ModelAuthorizer]
72
+ # @see Wallaby::ModelServiceProvider#update
73
+ def update(resource, params, authorizer)
74
+ save __callee__, resource, params, authorizer
75
+ end
76
+
77
+ # Remove a record from database
78
+ # @param resource [Object]
79
+ # @see Wallaby::ModelServiceProvider#destroy
80
+ def destroy(resource, _params, _authorizer)
81
+ resource.destroy
82
+ end
83
+
84
+ protected
85
+
86
+ # Save the record
87
+ # @param action [String] `create`, `update`
88
+ # @param resource [Object]
89
+ # @param params [ActionController::Parameters]
90
+ # @param authorizer [Wallaby::ModelAuthorizer]
91
+ # @return resource itself
92
+ # @raise [ActiveRecord::StatementInvalid, ActiveModel::UnknownAttributeError, ActiveRecord::UnknownAttributeError]
93
+ def save(action, resource, params, authorizer)
94
+ resource.assign_attributes normalize params
95
+ ensure_attributes_for authorizer, action, resource
96
+ resource.save if valid? resource
97
+ resource
98
+ rescue ::ActiveRecord::ActiveRecordError, ActiveModel::ForbiddenAttributesError, unknown_attribute_error => e
99
+ resource.errors.add :base, e.message
100
+ resource
101
+ end
102
+
103
+ # Normalize params
104
+ # @param params [ActionController::Parameters]
105
+ def normalize(params)
106
+ normalizer.normalize params
107
+ end
108
+
109
+ # See if a resource is valid
110
+ # @param resource [Object]
111
+ # @return [Boolean]
112
+ def valid?(resource)
113
+ validator.valid? resource
114
+ end
115
+
116
+ # To make sure that the record can be updated with the values that are
117
+ # allowed to.
118
+ # @param authorizer [Object]
119
+ # @param action [String]
120
+ # @param resource [Object]
121
+ def ensure_attributes_for(authorizer, action, resource)
122
+ return if authorizer.blank?
123
+
124
+ restricted_conditions = authorizer.attributes_for action, resource
125
+ resource.assign_attributes restricted_conditions
126
+ end
127
+
128
+ # @return [String] param key
129
+ def param_key
130
+ @model_class.model_name.param_key
131
+ end
132
+
133
+ # @return [Array] the list of attributes to whitelist for mass assignment
134
+ def permitted_fields
135
+ @permitted_fields ||=
136
+ permitter.simple_field_names << permitter.compound_hashed_fields
137
+ end
138
+
139
+ # @see Wallaby::ModelServiceProvider::Permitter
140
+ def permitter
141
+ @permitter ||= Permitter.new @model_decorator
142
+ end
143
+
144
+ # @see Wallaby::ModelServiceProvider::Querier
145
+ def querier
146
+ @querier ||= Querier.new @model_decorator
147
+ end
148
+
149
+ # @see Wallaby::ModelServiceProvider::Normalizer
150
+ def normalizer
151
+ @normalizer ||= Normalizer.new @model_decorator
152
+ end
153
+
154
+ # @see Wallaby::ModelServiceProvider::Validator
155
+ def validator
156
+ @validator ||= Validator.new @model_decorator
157
+ end
158
+
159
+ # @return [Class] ActiveModel::UnknownAttributeError if Rails 4
160
+ # @return [Class] ActiveRecord::UnknownAttributeError if Rails 5
161
+ def unknown_attribute_error
162
+ (defined?(::ActiveModel::UnknownAttributeError) ? ::ActiveModel : ::ActiveRecord)::UnknownAttributeError
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wallaby
4
+ class ActiveRecord
5
+ # Pundit provider for ActiveRecord
6
+ class PunditProvider < PunditAuthorizationProvider
7
+ # Filter a scope
8
+ # @param _action [Symbol, String]
9
+ # @param scope [Object]
10
+ # @return [Object]
11
+ def accessible_for(_action, scope)
12
+ Pundit.policy_scope! user, scope
13
+ rescue Pundit::NotDefinedError
14
+ Rails.logger.warn I18n.t('errors.pundit.not_found.scope_policy', scope: scope)
15
+ scope
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wallaby
4
+ # ActiveRecord mode
5
+ class ActiveRecord < Mode
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wallaby
4
+ module ActiveRecordGem
5
+ VERSION = '0.1.1'
6
+ end
7
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'kaminari'
4
+ require 'wallaby/core'
5
+
6
+ require 'wallaby/active_record/version'
7
+
8
+ # All files required for ActiveRecord ORM
9
+ require 'adapters/wallaby/active_record'
10
+ require 'adapters/wallaby/active_record/model_finder'
11
+
12
+ # ModelPaginationProvider: begin
13
+ require 'adapters/wallaby/active_record/model_pagination_provider'
14
+ # ModelPaginationProvider: end
15
+
16
+ # ModelDecorator: begin
17
+ require 'adapters/wallaby/active_record/model_decorator'
18
+ require 'adapters/wallaby/active_record/model_decorator/title_field_finder'
19
+ require 'adapters/wallaby/active_record/model_decorator/fields_builder'
20
+ require 'adapters/wallaby/active_record/model_decorator/fields_builder/sti_builder'
21
+ require 'adapters/wallaby/active_record/model_decorator/fields_builder/association_builder'
22
+ require 'adapters/wallaby/active_record/model_decorator/fields_builder/polymorphic_builder'
23
+ # ModelDecorator: end
24
+
25
+ # ModelServiceProvider: begin
26
+ require 'adapters/wallaby/active_record/model_service_provider'
27
+ require 'adapters/wallaby/active_record/model_service_provider/normalizer'
28
+ require 'adapters/wallaby/active_record/model_service_provider/permitter'
29
+ require 'adapters/wallaby/active_record/model_service_provider/querier'
30
+ require 'adapters/wallaby/active_record/model_service_provider/querier/transformer'
31
+ require 'adapters/wallaby/active_record/model_service_provider/validator'
32
+ # ModelServiceProvider: end
33
+
34
+ # AuthorizationProvider: begin
35
+ require 'adapters/wallaby/active_record/default_provider'
36
+ require 'adapters/wallaby/active_record/cancancan_provider'
37
+ require 'adapters/wallaby/active_record/pundit_provider'
38
+ # AuthorizationProvider: end