rails_ops 1.0.0.beta1

Sign up to get free protection for your applications and to get access to all the features.
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