rails_ops 1.0.0.beta1

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 (57) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +11 -0
  3. data/.rubocop.yml +84 -0
  4. data/.travis.yml +5 -0
  5. data/Gemfile +4 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +1216 -0
  8. data/RUBY_VERSION +1 -0
  9. data/Rakefile +39 -0
  10. data/VERSION +1 -0
  11. data/lib/rails_ops.rb +96 -0
  12. data/lib/rails_ops/authorization_backend/abstract.rb +7 -0
  13. data/lib/rails_ops/authorization_backend/can_can_can.rb +14 -0
  14. data/lib/rails_ops/configuration.rb +4 -0
  15. data/lib/rails_ops/context.rb +35 -0
  16. data/lib/rails_ops/controller_mixin.rb +105 -0
  17. data/lib/rails_ops/exceptions.rb +19 -0
  18. data/lib/rails_ops/hooked_job.rb +25 -0
  19. data/lib/rails_ops/hookup.rb +80 -0
  20. data/lib/rails_ops/hookup/dsl.rb +29 -0
  21. data/lib/rails_ops/hookup/dsl_validator.rb +45 -0
  22. data/lib/rails_ops/hookup/hook.rb +11 -0
  23. data/lib/rails_ops/log_subscriber.rb +24 -0
  24. data/lib/rails_ops/mixins.rb +2 -0
  25. data/lib/rails_ops/mixins/authorization.rb +83 -0
  26. data/lib/rails_ops/mixins/log_settings.rb +20 -0
  27. data/lib/rails_ops/mixins/model.rb +4 -0
  28. data/lib/rails_ops/mixins/model/authorization.rb +64 -0
  29. data/lib/rails_ops/mixins/model/nesting.rb +180 -0
  30. data/lib/rails_ops/mixins/policies.rb +42 -0
  31. data/lib/rails_ops/mixins/require_context.rb +33 -0
  32. data/lib/rails_ops/mixins/routes.rb +35 -0
  33. data/lib/rails_ops/mixins/schema_validation.rb +25 -0
  34. data/lib/rails_ops/mixins/sub_ops.rb +35 -0
  35. data/lib/rails_ops/model_casting.rb +17 -0
  36. data/lib/rails_ops/model_mixins.rb +12 -0
  37. data/lib/rails_ops/model_mixins/ar_extension.rb +20 -0
  38. data/lib/rails_ops/model_mixins/parent_op.rb +10 -0
  39. data/lib/rails_ops/model_mixins/protected_attributes.rb +78 -0
  40. data/lib/rails_ops/model_mixins/virtual_attributes.rb +24 -0
  41. data/lib/rails_ops/model_mixins/virtual_attributes/virtual_column_wrapper.rb +9 -0
  42. data/lib/rails_ops/model_mixins/virtual_has_one.rb +32 -0
  43. data/lib/rails_ops/operation.rb +215 -0
  44. data/lib/rails_ops/operation/model.rb +168 -0
  45. data/lib/rails_ops/operation/model/create.rb +35 -0
  46. data/lib/rails_ops/operation/model/destroy.rb +26 -0
  47. data/lib/rails_ops/operation/model/load.rb +72 -0
  48. data/lib/rails_ops/operation/model/update.rb +31 -0
  49. data/lib/rails_ops/patches/active_type_patch.rb +52 -0
  50. data/lib/rails_ops/profiler.rb +47 -0
  51. data/lib/rails_ops/profiler/node.rb +64 -0
  52. data/lib/rails_ops/railtie.rb +19 -0
  53. data/lib/rails_ops/scoped_env.rb +20 -0
  54. data/lib/rails_ops/virtual_model.rb +19 -0
  55. data/rails_ops.gemspec +58 -0
  56. data/test/test_helper.rb +3 -0
  57. metadata +252 -0
@@ -0,0 +1,29 @@
1
+ class RailsOps::Hookup::DSL
2
+ attr_reader :hooks
3
+
4
+ def initialize(&block)
5
+ @current_target = nil
6
+ @validator = nil
7
+ @hooks = ActiveSupport::OrderedHash.new
8
+
9
+ RailsOps::ScopedEnv.new(self, [:run]).instance_exec(&block)
10
+ end
11
+
12
+ def run(target, &block)
13
+ @current_target = target
14
+ RailsOps::ScopedEnv.new(self, [:on]).instance_exec(&block)
15
+ @current_target = nil
16
+ end
17
+
18
+ def on(source, event = :after_run)
19
+ @hooks[source] ||= ActiveSupport::OrderedHash.new
20
+ @hooks[source][event] ||= []
21
+ @hooks[source][event] << RailsOps::Hookup::Hook.new(source, event, @current_target)
22
+ end
23
+
24
+ def validate!
25
+ @validator = RailsOps::Hookup::DSLValidator.new @hooks
26
+ @validator.validate!
27
+ @validator
28
+ end
29
+ end
@@ -0,0 +1,45 @@
1
+ class RailsOps::Hookup::DSLValidator
2
+ attr_reader :error, :trace
3
+
4
+ def initialize(hooks)
5
+ @hooks = hooks
6
+ @trace = []
7
+ end
8
+
9
+ def validate!
10
+ # Check infinity loop
11
+ if target_hooks.any? { |name, targets| recursion?(targets, name) }
12
+ fail StandardError::SystemStackError.new, "Infinite loop detected in hooks configuration: #{inspect_trace}."
13
+ end
14
+ end
15
+
16
+ def target_hooks
17
+ @target_hooks ||= @hooks.map do |name, hash|
18
+ [name, drilldown(hash)]
19
+ end.to_h
20
+ end
21
+
22
+ private
23
+
24
+ def inspect_trace
25
+ @trace.map(&:to_s).join(' ~> ')
26
+ end
27
+
28
+ def recursion?(targets, name)
29
+ if targets.include? name
30
+ @trace << name.to_s
31
+ return true
32
+ end
33
+
34
+ return targets.any? do |target|
35
+ if @hooks[target]
36
+ @trace |= [name, target]
37
+ recursion? drilldown(@hooks[target]), name
38
+ end
39
+ end
40
+ end
41
+
42
+ def drilldown(hash)
43
+ hash.values.flatten.map(&:target_operation)
44
+ end
45
+ end
@@ -0,0 +1,11 @@
1
+ class RailsOps::Hookup::Hook
2
+ attr_reader :on_operation
3
+ attr_reader :on_event
4
+ attr_reader :target_operation
5
+
6
+ def initialize(on_operation, on_event, target_operation)
7
+ @on_operation = on_operation
8
+ @on_event = on_event
9
+ @target_operation = target_operation
10
+ end
11
+ end
@@ -0,0 +1,24 @@
1
+ module RailsOps
2
+ # This class subscribes to Rails Ops events via the ActiveSupport
3
+ # instrumentation API.
4
+ class LogSubscriber < ActiveSupport::LogSubscriber
5
+ # This gets called whenever an operation has been performed and logs the
6
+ # operation via Rails' `debug` logging method.
7
+ def run(event)
8
+ op = event.payload[:operation]
9
+
10
+ return if op.class.logging_skipped?
11
+
12
+ message = 'OP'
13
+
14
+ profile = ::RailsOps::Profiler.node(op.object_id)
15
+ message += " (#{profile.t_self_ms.round(1)}ms / #{profile.t_kids_ms.round(1)}ms)"
16
+ profile.free
17
+
18
+ message = color(message, YELLOW, true)
19
+ message += color(" #{op.class.name}", YELLOW, false)
20
+
21
+ debug " #{message}"
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,2 @@
1
+ module RailsOps::Mixins
2
+ end
@@ -0,0 +1,83 @@
1
+ # Mixin for the {RailsOps::Operation} class that provides basic authorization
2
+ # methods based on the cancan(can) ability that can be provided using an
3
+ # operation's context.
4
+ module RailsOps::Mixins::Authorization
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ class_attribute :_authorization_disabled
9
+ class_attribute :_authorization_disabled_for_sub_ops
10
+
11
+ policy :after_perform do
12
+ ensure_authorize_called!
13
+ end
14
+ end
15
+
16
+ module ClassMethods
17
+ # Disables authorization (and authorization check) for this
18
+ # operation. If `include_sub_ops` is `true` (default `false`),
19
+ # this setting also applies to sub operations called by this
20
+ # operation (either via run_sub(!) or via a hook).
21
+ def without_authorization(include_sub_ops: false)
22
+ fail 'Option include_sub_ops is not yet supported.' if include_sub_ops
23
+
24
+ self._authorization_disabled = true
25
+ self._authorization_disabled_for_sub_ops = include_sub_ops
26
+ end
27
+ end
28
+
29
+ # Checks whether authorization is currently enabled and possible
30
+ # (an ability is present).
31
+ def authorization_enabled?
32
+ # Do not perform authorization if it is disabled globally
33
+ return false unless RailsOps.authorization_enabled?
34
+
35
+ # Do not perform authorization if it is disabled for this operation
36
+ return false if self.class._authorization_disabled
37
+
38
+ # Do not perform authorization if no ability is present
39
+ return false unless context.ability
40
+
41
+ # Perform authorization
42
+ return true
43
+ end
44
+
45
+ def authorization_enabled_for_sub_ops?
46
+ !@_authorization_disabled_for_sub_ops
47
+ end
48
+
49
+ def authorize_called?
50
+ @_authorize_called ||= false
51
+ end
52
+
53
+ # Operations within the given block will have disabled authorization.
54
+ # This only applies to the current thread.
55
+ def without_authorization(&block)
56
+ RailsOps.without_authorization(&block)
57
+ end
58
+
59
+ # Checks authorization against the configured authentication backend. Fails if
60
+ # authentication is not successfull or could not be performed. Does not
61
+ # perform anything if authorization is disabled.
62
+ def authorize!(action, *args)
63
+ authorize_only!(action, *args)
64
+ @_authorize_called = true
65
+ end
66
+
67
+ # Checks authorization against the configured authentication backend. Fails if
68
+ # authentication is not successfull or could not be performed. Does not
69
+ # perform anything if authorization is disabled. Calling authorize_only! does
70
+ # not count as authorized concerning ensure_authorize_called!.
71
+ def authorize_only!(*args)
72
+ return unless authorization_enabled?
73
+ RailsOps.authorization_backend.authorize!(self, *args)
74
+ end
75
+
76
+ # Checks if an authentication check has been performed if authorization is
77
+ # enabled.
78
+ def ensure_authorize_called!
79
+ return unless authorization_enabled?
80
+ return if authorize_called?
81
+ fail RailsOps::Exceptions::NoAuthorizationPerformed, "Operation #{self.class.name} has been performed without authorization."
82
+ end
83
+ end
@@ -0,0 +1,20 @@
1
+ module RailsOps::Mixins::LogSettings
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ class_attribute :_skip_logging
6
+ self._skip_logging = false
7
+ end
8
+
9
+ module ClassMethods
10
+ # Allows to skip logging for this operation via the
11
+ # {RailsOps::LogSubscriber}.
12
+ def skip_logging(skip = true)
13
+ self._skip_logging = skip
14
+ end
15
+
16
+ def logging_skipped?
17
+ _skip_logging
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,4 @@
1
+ # Empty module used for namespacing mixins that are meant for
2
+ # {RailsOps::Operations::Model} and it's subclasses.
3
+ module RailsOps::Mixins::Model
4
+ end
@@ -0,0 +1,64 @@
1
+ # Mixin for the {RailsOps::Operation::Model} class that provides the method
2
+ # {authorize_model!} which authorizes the operation's model class or instance
3
+ # against the authorization methods of {RailsOps::Mixins::Authorization}.
4
+ module RailsOps::Mixins::Model::Authorization
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ class_attribute :_model_authorization_action
9
+ end
10
+
11
+ module ClassMethods
12
+ # Gets or sets the action verb used for authorizing models.
13
+ def model_authorization_action(*action)
14
+ if action.size == 1
15
+ self._model_authorization_action = action.first
16
+ elsif action.size > 1
17
+ fail ArgumentError, 'Too many arguments'
18
+ end
19
+
20
+ return _model_authorization_action
21
+ end
22
+ end
23
+
24
+ def model_authorization_action
25
+ self.class.model_authorization_action
26
+ end
27
+
28
+ # Performs authorization on the given model using the {authorize?} method.
29
+ # Models are casted using {cast_model_for_authorization} so that they can be
30
+ # used for authorization. If no `model_class_or_instance` is given, the
31
+ # {model} instance method will be used.
32
+ def authorize_model!(action, model_class_or_instance = model, *extra_args)
33
+ authorize! action, cast_model_for_authorization(model_class_or_instance), *extra_args
34
+ end
35
+
36
+ # Performs an authorization check like {authorize_model!}, but does not mark
37
+ # this operation instance as checked. This means that there must be at least
38
+ # one other authorization call within execution of this operation for the
39
+ # operation not to fail.
40
+ def authorize_model_with_authorize_only!(action, model_class_or_instance = model, *extra_args)
41
+ authorize_only! action, cast_model_for_authorization(model_class_or_instance), *extra_args
42
+ end
43
+
44
+ private
45
+
46
+ # Cast {ActiveType::Record} classes or instances to regular AR models in order
47
+ # for cancan(can) to work properly. Classes and instances that are no active
48
+ # type records will be returned as-is.
49
+ #
50
+ # TODO: Use ModelCasting module instead?
51
+ def cast_model_for_authorization(model_class_or_instance)
52
+ if model_class_or_instance.is_a?(Class)
53
+ if model_class_or_instance.respond_to?(:extended_record_base_class)
54
+ return model_class_or_instance.extended_record_base_class
55
+ else
56
+ return model_class_or_instance
57
+ end
58
+ elsif model_class_or_instance.class.respond_to?(:extended_record_base_class)
59
+ return ActiveType.cast(model_class_or_instance, model_class_or_instance.class.extended_record_base_class)
60
+ else
61
+ model_class_or_instance
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,180 @@
1
+ module RailsOps::Mixins::Model::Nesting
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ class_attribute :_nested_model_ops
6
+ self._nested_model_ops = {}.freeze
7
+
8
+ attr_reader :nested_model_ops
9
+ end
10
+
11
+ module ClassMethods
12
+ def nest_model_op(attribute, klass, lookup_via_id_on_update: true, &params_block)
13
+ # ---------------------------------------------------------------
14
+ # Make sure we're working with an extension / copy
15
+ # of the given model class
16
+ # ---------------------------------------------------------------
17
+ unless always_extend_model_class?
18
+ fail 'This operation class must be configured to always extend the model class as `nest_model_op` modifies it.'
19
+ end
20
+
21
+ # ---------------------------------------------------------------
22
+ # Validate association (currently, we only support belongs_to)
23
+ # ---------------------------------------------------------------
24
+ reflection = model.reflect_on_association(attribute)
25
+
26
+ if reflection.nil?
27
+ fail "Association #{attribute} could not be found for #{model.model_name}."
28
+ elsif !reflection.belongs_to?
29
+ fail 'Method nest_model_op only supports :belongs_to associations, '\
30
+ "but association #{attribute} of model #{model.model_name} is a "\
31
+ "#{reflection.macro} association."
32
+ elsif reflection.options[:autosave] != false
33
+ fail "Association #{attribute} of #{model.model_name} has :autosave turned on. "\
34
+ 'This is not supported by nest_model_op.'
35
+ elsif !reflection.options[:validate]
36
+ fail "Association #{attribute} of #{model.model_name} has :validate turned off. "\
37
+ 'This is not supported by nest_model_op.'
38
+ end
39
+
40
+ # ---------------------------------------------------------------
41
+ # Define attributes setter on model.
42
+ #
43
+ # Model nesting is not compatible with accepts_nested_attributes_for
44
+ # as it automatically enables :autosave which is not supported.
45
+ # As we can't use accepts_nested_attributes_for, no nested attributes
46
+ # setter is generated. This also means that `fields_for` in form
47
+ # generation don't detect the attribute as nested and mistakenly omit the
48
+ # _attributes suffix. Therefore, we define this method but fail if it
49
+ # ever gets called.
50
+ # ---------------------------------------------------------------
51
+ model.send(:define_method, "#{attribute}_attributes=") do |_value|
52
+ fail 'This operation model does not allow receiving nested attributes' \
53
+ "for #{attribute}, as this is saved using a nested model operation."
54
+ end
55
+
56
+ # ---------------------------------------------------------------
57
+ # Validate inverse association reflection if given
58
+ # ---------------------------------------------------------------
59
+ if (inverse_reflection = reflection.inverse_of)
60
+ if inverse_reflection.options[:autosave] != false
61
+ fail "Association #{inverse_reflection.name} of #{inverse_reflection.active_record} has :autosave turned on. "\
62
+ 'This is not supported by nest_model_op.'
63
+ end
64
+ end
65
+
66
+ # ---------------------------------------------------------------
67
+ # Store configuration
68
+ # ---------------------------------------------------------------
69
+ self._nested_model_ops = _nested_model_ops.merge(
70
+ attribute => {
71
+ klass: klass,
72
+ params_proc: params_block,
73
+ lookup_via_id_on_update: lookup_via_id_on_update
74
+ }
75
+ )
76
+ end
77
+
78
+ def nested_model_param_keys
79
+ _nested_model_ops.keys.collect do |attribute|
80
+ "#{attribute}_attributes"
81
+ end
82
+ end
83
+ end
84
+
85
+ def nested_model_ops_performed?
86
+ @nested_model_ops_performed || false
87
+ end
88
+
89
+ protected
90
+
91
+ def nested_model_op(attribute)
92
+ fail 'Nested model operations have not been built yet.' unless @nested_model_ops
93
+ return @nested_model_ops[attribute]
94
+ end
95
+
96
+ def build_nested_model_ops(action)
97
+ # Validate action
98
+ fail 'Unsupported action.' unless [:create, :update].include?(action)
99
+
100
+ # Make sure that this method can only be run once per operation
101
+ fail 'Nested model operations can only be built once.' if @nested_model_ops
102
+ @nested_model_ops = {}
103
+
104
+ self.class._nested_model_ops.each do |attribute, config|
105
+ op_params = extract_attributes_from_params["#{attribute}_attributes"] || {}
106
+
107
+ # Remove id field as this is commonly supplied by Rails' `fields_for` if
108
+ # the nested model is persisted. We don't usually need this.
109
+ op_params = op_params.except(:id)
110
+
111
+ # Apply custom params processing callback if given
112
+ if config[:params_proc]
113
+ op_params = instance_exec(op_params, &config[:params_proc])
114
+ end
115
+
116
+ # Wrap parameters for nested model operation
117
+ if action == :create
118
+ wrapped_params = {
119
+ attribute => op_params
120
+ }
121
+ elsif action == :update
122
+ if config[:lookup_via_id_on_update]
123
+ foreign_key = model.class.reflect_on_association(attribute).foreign_key
124
+ id = model.send(foreign_key)
125
+ else
126
+ id = model.send(attribute).id
127
+ end
128
+
129
+ wrapped_params = {
130
+ :id => id,
131
+ attribute => op_params
132
+ }
133
+ else
134
+ fail "Unsupported action #{action}."
135
+ end
136
+
137
+ # Instantiate nested operation
138
+ @nested_model_ops[attribute] = sub_op(config[:klass], wrapped_params)
139
+
140
+ # Inject model of nested operation to our own model
141
+ nested_model = @nested_model_ops[attribute].model
142
+ model.send("#{attribute}=", nested_model)
143
+
144
+ # Inject our own model to model of nested operation (if the inverse
145
+ # reflection can be resolved)
146
+ if (inverse_reflection = model.class.reflect_on_association(attribute).inverse_of)
147
+ nested_model.send("#{inverse_reflection.name}=", model)
148
+ end
149
+ end
150
+ end
151
+
152
+ # Tries to save nested models using their respective operations.
153
+ def perform_nested_model_ops!
154
+ fail 'Nested model operations can only be performed once.' if nested_model_ops_performed?
155
+
156
+ # Validate the whole model hierarchy. Since we're calling 'model' here, this
157
+ # line also makes sure a model is built.
158
+ model.validate!
159
+
160
+ # Make sure nested model operations are build
161
+ fail 'Nested model operations are not built yet. Make sure the model is built.' unless @nested_model_ops
162
+
163
+ @nested_model_ops.each do |attribute, op|
164
+ # Run the nested model operation and fail hard if a validation error
165
+ # arises. This should generally not happen as the whole hierarchy has been
166
+ # validated before. It is vital that the transaction gets rolled back if
167
+ # an exception happens here.
168
+ begin
169
+ op.run!
170
+ rescue op.validation_errors => e
171
+ fail RailsOps::Exceptions::SubOpValidationFailed, e
172
+ end
173
+
174
+ # Assign model again so that the ID gets updated
175
+ model.send("#{attribute}=", op.model)
176
+ end
177
+
178
+ @nested_model_ops_performed = true
179
+ end
180
+ end