wallaby-active_record 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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