petra_core 0.0.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 (54) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +9 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +83 -0
  5. data/.ruby-version +1 -0
  6. data/.travis.yml +5 -0
  7. data/CODE_OF_CONDUCT.md +74 -0
  8. data/Gemfile +13 -0
  9. data/Gemfile.lock +74 -0
  10. data/MIT-LICENSE +20 -0
  11. data/README.md +726 -0
  12. data/Rakefile +8 -0
  13. data/bin/console +8 -0
  14. data/bin/setup +8 -0
  15. data/examples/continuation_error.rb +125 -0
  16. data/examples/dining_philosophers.rb +138 -0
  17. data/examples/showcase.rb +54 -0
  18. data/lib/petra/components/entries/attribute_change.rb +29 -0
  19. data/lib/petra/components/entries/attribute_change_veto.rb +37 -0
  20. data/lib/petra/components/entries/attribute_read.rb +20 -0
  21. data/lib/petra/components/entries/object_destruction.rb +22 -0
  22. data/lib/petra/components/entries/object_initialization.rb +19 -0
  23. data/lib/petra/components/entries/object_persistence.rb +26 -0
  24. data/lib/petra/components/entries/read_integrity_override.rb +42 -0
  25. data/lib/petra/components/entry_set.rb +87 -0
  26. data/lib/petra/components/log_entry.rb +342 -0
  27. data/lib/petra/components/proxy_cache.rb +209 -0
  28. data/lib/petra/components/section.rb +543 -0
  29. data/lib/petra/components/transaction.rb +405 -0
  30. data/lib/petra/components/transaction_manager.rb +214 -0
  31. data/lib/petra/configuration/base.rb +132 -0
  32. data/lib/petra/configuration/class_configurator.rb +309 -0
  33. data/lib/petra/configuration/configurator.rb +67 -0
  34. data/lib/petra/core_ext.rb +27 -0
  35. data/lib/petra/exceptions.rb +181 -0
  36. data/lib/petra/persistence_adapters/adapter.rb +154 -0
  37. data/lib/petra/persistence_adapters/file_adapter.rb +239 -0
  38. data/lib/petra/proxies/abstract_proxy.rb +149 -0
  39. data/lib/petra/proxies/enumerable_proxy.rb +44 -0
  40. data/lib/petra/proxies/handlers/attribute_read_handler.rb +45 -0
  41. data/lib/petra/proxies/handlers/missing_method_handler.rb +47 -0
  42. data/lib/petra/proxies/method_handlers.rb +213 -0
  43. data/lib/petra/proxies/module_proxy.rb +12 -0
  44. data/lib/petra/proxies/object_proxy.rb +310 -0
  45. data/lib/petra/util/debug.rb +45 -0
  46. data/lib/petra/util/extended_attribute_accessors.rb +51 -0
  47. data/lib/petra/util/field_accessors.rb +35 -0
  48. data/lib/petra/util/registrable.rb +48 -0
  49. data/lib/petra/util/test_helpers.rb +9 -0
  50. data/lib/petra/version.rb +5 -0
  51. data/lib/petra.rb +100 -0
  52. data/lib/tasks/petra_tasks.rake +5 -0
  53. data/petra.gemspec +36 -0
  54. metadata +208 -0
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Petra
4
+ module Configuration
5
+ class Base
6
+
7
+ DEFAULTS = {
8
+ persistence_adapter_name: 'file',
9
+ log_level: 'debug',
10
+ instant_read_integrity_fail: true
11
+ }.freeze
12
+
13
+ #----------------------------------------------------------------
14
+ # Configuration Keys
15
+ #----------------------------------------------------------------
16
+
17
+ #
18
+ # Configures whether a read integrity error will be automatically
19
+ # detected whenever an attribute is read.
20
+ # If this is set to +false+, the read values will only be checked during
21
+ # the commit phase.
22
+ #
23
+ def instant_read_integrity_fail(new_value = nil)
24
+ if !new_value.nil?
25
+ __configuration_hash[:instant_read_integrity_fail] = new_value
26
+ else
27
+ __config_or_default(:instant_read_integrity_fail)
28
+ end
29
+ end
30
+
31
+ alias instantly_fail_on_read_integrity_errors instant_read_integrity_fail
32
+
33
+ #
34
+ # Sets the adapter to be used as transaction persistence adapter.
35
+ # An adapter has to be registered before it may be used (see Adapter)
36
+ #
37
+ # @return [Class] the persistence adapter class used for storing transaction values.
38
+ # Defaults to use to the cache adapter
39
+ #
40
+ def persistence_adapter(name = nil)
41
+ if name
42
+ unless Petra::PersistenceAdapters::Adapter.registered_adapter?(name)
43
+ fail Petra::ConfigurationError,
44
+ "The given adapter `#{name}` hasn't been registered. " \
45
+ "Valid adapters are: #{Petra::PersistenceAdapters::Adapter.registered_adapters.keys.inspect}"
46
+ end
47
+ __configuration_hash[:persistence_adapter_name] = name
48
+ else
49
+ Petra::PersistenceAdapters::Adapter[__config_or_default(:persistence_adapter_name)]
50
+ end
51
+ end
52
+
53
+ #
54
+ # The log level for petra. Only messages which are greater or equal to this level
55
+ # will be shown in the output
56
+ #
57
+ def log_level(new_value = nil)
58
+ if new_value
59
+ __configuration_hash[:log_level] = new_value.to_s
60
+ else
61
+ __config_or_default(:log_level).to_sym
62
+ end
63
+ end
64
+
65
+ #----------------------------------------------------------------
66
+ # Helper Methods
67
+ #----------------------------------------------------------------
68
+
69
+ #
70
+ # A shortcut method to set +proxy_instances+ for multiple classes at once
71
+ # without having to +configure_class+ for each one.
72
+ #
73
+ # @example
74
+ # proxy_class_instances 'Array', 'Enumerator', Hash
75
+ #
76
+ def proxy_class_instances(*class_names)
77
+ class_names.each do |klass|
78
+ configure_class(klass) do
79
+ proxy_instances true
80
+ end
81
+ end
82
+ end
83
+
84
+ #
85
+ # Executes the given block in the context of a ClassConfigurator to
86
+ # configure petra's behaviour for a certain model/class
87
+ #
88
+ def configure_class(class_name, &proc)
89
+ configurator = class_configurator(class_name)
90
+ configurator.instance_eval(&proc)
91
+ configurator.__persist!
92
+ end
93
+
94
+ #
95
+ # Builds a ClassConfigurator for the given class or class name.
96
+ #
97
+ # @example Request the configuration for a certain model
98
+ # Notifications::Notificator.configuration.model_configurator(Subscription).__value(:recipients)
99
+ #
100
+ def class_configurator(class_name)
101
+ ClassConfigurator.for_class(class_name)
102
+ end
103
+
104
+ alias [] class_configurator
105
+
106
+ #
107
+ # @return [Hash] the complete configuration or one of its sub-namespaces.
108
+ # If a namespace does not exists yet, it will be initialized with an empty hash
109
+ #
110
+ # @example Retrieve the {:something => {:completely => {:different => 5}}} namespace
111
+ # __configuration_hash(:something, :completely)
112
+ # #=> {:different => 5}
113
+ #
114
+ def __configuration_hash(*sub_keys)
115
+ sub_keys.inject(@configuration ||= {}) do |h, k|
116
+ h[k] ||= {}
117
+ end
118
+ end
119
+
120
+ private
121
+
122
+ #
123
+ # @return [Object] a base configuration value (non-namespaced) or
124
+ # the default value (see DEFAULTS) if none was set yet.
125
+ #
126
+ def __config_or_default(name)
127
+ __configuration_hash.fetch(name.to_sym, DEFAULTS[name.to_sym])
128
+ end
129
+
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,309 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'petra/configuration/configurator'
4
+
5
+ module Petra
6
+ module Configuration
7
+ class ClassConfigurator < Configurator
8
+
9
+ DEFAULTS = {
10
+ proxy_instances: false,
11
+ mixin_module_proxies: true,
12
+ use_specialized_proxy: true,
13
+ id_method: :object_id,
14
+ lookup_method: ->(id) { ObjectSpace._id2ref(id.to_i) },
15
+ init_method: :new,
16
+ attribute_reader?: false,
17
+ attribute_writer?: ->(name) { /=$/.match(name) },
18
+ dynamic_attribute_reader?: false,
19
+ persistence_method?: false,
20
+ destruction_method?: false
21
+ }.freeze
22
+
23
+ #
24
+ # @param [String] class_name
25
+ # The name of the class to be configured.
26
+ # .new() should not be called manually, use #for_class instead
27
+ # which accepts different input types.
28
+ #
29
+ def initialize(class_name)
30
+ @class_name = class_name
31
+ super()
32
+ end
33
+
34
+ #----------------------------------------------------------------
35
+ # Configuration Keys
36
+ #----------------------------------------------------------------
37
+
38
+ # TODO: LIST:
39
+ # - Rails' routes like configurators for method handlers:
40
+ # - Allow attribute writers without parameters. These will need a value the
41
+ # write set entry is set to. An example here would be `lock!` which sets `locked = true` internally
42
+ # - Methods should get an option which will set their generated log entries
43
+ # to not be executed. This is necessary if e.g. a setter method is also a persistence method
44
+ # and would re-set the attribute - fine in many places, but think about a mutex.
45
+ # example:
46
+ # attribute_writers do
47
+ # lock! :locked => true
48
+ # - Usage of method_missing with (**options)?
49
+ #
50
+ # Sets whether instances of this class should be wrapped in an ObjectProxy
51
+ # if this is not directly done by the programmer, e.g. as return value
52
+ # from an already proxied object.
53
+ #
54
+ base_config :proxy_instances
55
+
56
+ #
57
+ # Sets whether ObjectProxies should be extended with possibly existing
58
+ # ModuleProxy modules. This is used mainly for +Enumerable+, but you may want
59
+ # to define your own helper modules.
60
+ #
61
+ base_config :mixin_module_proxies
62
+
63
+ #
64
+ # Some classes have specialized proxy classes.
65
+ # If this setting is set to +false+, they will not be used in favour of ObjectProxy
66
+ #
67
+ base_config :use_specialized_proxy
68
+
69
+ #
70
+ # Sets the method to be used to determine the unique ID of an Object.
71
+ # The ID is needed to identify an object when reloading it within a transaction,
72
+ # so basically a key for our read set.
73
+ #
74
+ # If a block is given, the (:base) object is yielded to it, otherwise,
75
+ # the given method name is assumed to be an instance method in the configured class
76
+ #
77
+ base_config :id_method
78
+
79
+ #
80
+ # Sets the method to be used to load an object with a certain unique ID
81
+ # (see +:id_method+).
82
+ #
83
+ # If a block is given, the identifier is yielded to it, otherwise,
84
+ # the given method name is assumed to be a class method accepting
85
+ # a string identifier in the configured class
86
+ #
87
+ base_config :lookup_method
88
+
89
+ #
90
+ # Method to initialize a new instance of the proxied class, e.g. `:new` for basic objects
91
+ #
92
+ base_config :init_method
93
+
94
+ #
95
+ # Expects the value (or return value of a block) to be a boolean value
96
+ # depending on whether a method name given as argument is an attribute reader
97
+ #
98
+ base_config :attribute_reader?
99
+
100
+ #
101
+ # Expects the value (or return value of a block) to be a boolean value
102
+ # depending on whether a method name given as argument is an attribute reader
103
+ #
104
+ base_config :attribute_writer?
105
+
106
+ #
107
+ # Sometimes it might be necessary to use helper methods to combine multiple attributes,
108
+ # e.g. `#name` for `"#{first_name} #{last_name}"`.
109
+ # As calling `#name` would usually be passed to the proxied objects and
110
+ # executed within the object's context instead of the proxy, these methods
111
+ # can be flagged as combined/dynamic attribute readers and will be executed within
112
+ # the proxy's binding.
113
+ # The function is expected to return a boolean value.
114
+ #
115
+ base_config :dynamic_attribute_reader?
116
+
117
+ #
118
+ # Expects the value (or return value of a block) to be a boolean value
119
+ # depending on whether a method name given as argument is a method that will persist
120
+ # the current instance.
121
+ # For normal ruby objects this would be every attribute setter (as it would be persisted in
122
+ # the process memory), for e.g. ActiveRecord::Base instances, this is only done by update/save/...
123
+ #
124
+ base_config :persistence_method?
125
+
126
+ #
127
+ # Expects the value (or return value of a block) to be a boolean value and
128
+ # be +true+ if the given method is a "destructor" of the configured class.
129
+ # This can't be easily said for plain ruby objects, but some classes
130
+ # may implement an own destruction behaviour (e.g. ActiveRecord)
131
+ #
132
+ base_config :destruction_method?
133
+
134
+ #----------------------------------------------------------------
135
+ # Helper Methods
136
+ #----------------------------------------------------------------
137
+
138
+ #
139
+ # Builds a new instance for the given class name.
140
+ # If a configuration for this class already exists, it is loaded and
141
+ # can be retrieved through the corresponding getter methods
142
+ #
143
+ # @param [String, Symbol, Class] klass
144
+ # The class (name) which will be used to initialize the configurator
145
+ # and load a possibly already existing configuration
146
+ #
147
+ def self.for_class(klass)
148
+ new(klass.to_s)
149
+ end
150
+
151
+ #
152
+ # Returns the value for a certain configuration key.
153
+ # If the configuration value is a proc, it will be called
154
+ # with the given +*args+.
155
+ #
156
+ # If no custom configuration was set for the given +name+, the default
157
+ # value is returned instead.
158
+ #
159
+ # @param [Boolean] proc_expected
160
+ # If set to +true+, the value is expected to be either a Proc object
161
+ # or a String/Symbol which is assumed to be a method name.
162
+ # If the value is something else, an Exception is thrown
163
+ #
164
+ # @param [Object] base
165
+ # The base object which is used in case +mandatory_proc+ is set to +true+.
166
+ # If the fetched value is a String or Symbol, it will be used as method
167
+ # name in a call based on the +base+ object with +*args* as actual parameters, e.g.
168
+ # base.send(:some_fetched_value, *args)
169
+ #
170
+ def __value(key, *args, proc_expected: false, base: nil)
171
+ v = __configuration.fetch(key.to_sym, DEFAULTS[key.to_sym])
172
+
173
+ # As the setting blocks are saved as Proc objects (which are run
174
+ # in their textual scope) and not lambdas (which are run in their caller's scope),
175
+ # Ruby does not allow using the `return` keyword while being inside the
176
+ # block as method the proc was defined in might have already been returned.
177
+ #
178
+ # When configuring petra using blocks, it is advised to use `next`
179
+ # instead of `return` (which will jump back to the correct position),
180
+ # a workaround is to rescue from possible LocalJumpErrors and simply
181
+ # use their exit value.
182
+ begin
183
+ case v
184
+ when Proc
185
+ # see #__send_to_base
186
+ return v.call(*[*args, base][0, v.arity]) if proc_expected
187
+ v.call(*(args[0, v.arity]))
188
+ when String, Symbol
189
+ return __send_to_base(base, method: v, args: args, key: key) if proc_expected
190
+ v
191
+ else
192
+ __fail_for_key key, 'Value has to be either a Proc or a method name (Symbol/String)' if proc_expected
193
+ v
194
+ end
195
+ rescue LocalJumpError => e
196
+ e.exit_value
197
+ end
198
+ end
199
+
200
+ #
201
+ # Tests whether this class configuration has a custom setting for the given key.
202
+ #
203
+ # @return [TrueClass, FalseClass] +true+ if there is a custom setting
204
+ #
205
+ def __value?(key)
206
+ __configuration.key?(key.to_sym)
207
+ end
208
+
209
+ #
210
+ # Much like #__value, but it searches for settings
211
+ # with the given name in the current class' ancestors if
212
+ # itself does not have a custom value set.
213
+ #
214
+ def __inherited_value(key, *args)
215
+ configurator = self
216
+
217
+ # Search for a custom configuration in the current class and its superclasses
218
+ # until we either reach Object (the lowest level ignoring BasicObject) or
219
+ # found a custom setting.
220
+ until (klass = configurator.send(:configured_class)) == Object || configurator.__value?(key)
221
+ configurator = Petra.configuration.class_configurator(klass.superclass)
222
+ end
223
+
224
+ # By now, we have either reached the Object level or found a value.
225
+ # In either case, we are save to retrieve it.
226
+ configurator.__value(key, *args)
227
+ end
228
+
229
+ private
230
+
231
+ #
232
+ # Raises a Petra::ConfigurationError with information about the key that caused it and a message
233
+ #
234
+ def __fail_for_key(key, message)
235
+ fail Petra::ConfigurationError,
236
+ "The configuration '#{key}' for class '#{@class_name}' seems to be incorrect: #{message}"
237
+ end
238
+
239
+ #
240
+ # Tries to .send() the given +method+ to the +base+ object.
241
+ # Exceptions are raised when no base was given or the given base does not respond to the given method.
242
+ #
243
+ def __send_to_base(base, method:, key:, args: [])
244
+ fail ArgumentError, "No base object to send ':#{method}' to was given" unless base
245
+
246
+ unless base.respond_to?(method.to_sym)
247
+ if base.is_a?(Class)
248
+ __fail_for_key key, ":#{method} was expected to be a class method in #{base}"
249
+ else
250
+ __fail_for_key key, ":#{method} was expected to be an instance method in #{base.class}"
251
+ end
252
+ end
253
+
254
+ # It might happen that the given method name does not accept all of the given
255
+ # arguments, most likely because they are not needed to make the necessary
256
+ # decisions anyway.
257
+ # Therefore, only the correct amount of arguments is passed to the function, e.g.
258
+ # args[0,2] for a method with arity 2
259
+ base.send(method.to_sym, *__args_for_arity(base, method, args))
260
+ end
261
+
262
+ #
263
+ # Takes as many elements from +args+ as the given method accepts
264
+ # If a method with variable arguments is given (def something(*args)),
265
+ # all arguments are returned
266
+ #
267
+ def __args_for_arity(base, method, args)
268
+ arity = base.method(method.to_sym).arity
269
+ arity >= 0 ? args[0, arity] : args
270
+ end
271
+
272
+ #
273
+ # @return [Class] the class which is configured by this ClassConfigurator
274
+ #
275
+ # Even though ruby class should only contain module separators (::) and camel case words,
276
+ # there might be (framework) class names which do not comply to this.
277
+ # An example would be ActiveRecord's Relation class which seems to be specific
278
+ # for each model class it is used on.
279
+ #
280
+ # Example: User.all #=> <User::ActiveRecord_Relation...>
281
+ #
282
+ # Therefore, we first try to camelize the given class name and if that
283
+ # does not lead us to a valid constant name, we try to pass in the
284
+ # @class_name as is and raise possible errors.
285
+ #
286
+ def configured_class
287
+ @class_name.camelize.safe_constantize || @class_name.constantize
288
+ end
289
+
290
+ #
291
+ # @return [Array<Symbol, String>] the namespaces which will be used
292
+ # when merging this class configuration into the main configuration hash
293
+ #
294
+ def __namespaces
295
+ [:models, @class_name]
296
+ end
297
+
298
+ #
299
+ # Removes options from the given arguments if the last element is a Hash
300
+ #
301
+ # @return [Hash] the options extracted from the given arguments or an empty
302
+ # hash if there were no options given
303
+ #
304
+ def extract_options!(args)
305
+ args.last.is_a?(Hash) ? args.pop : {}
306
+ end
307
+ end
308
+ end
309
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Petra
4
+ module Configuration
5
+ class Configurator
6
+
7
+ #
8
+ # Generates a very basic configuration method which accepts a
9
+ # block or input value (see +options+)
10
+ #
11
+ # @param [Object] name
12
+ # The configuration's name which will become the method name
13
+ #
14
+ # @param [Boolean] accept_value
15
+ # If set to +true+, the resulting method accepts not only a block,
16
+ # but also a direct value.
17
+ # If both, a value and a block are given, the block takes precedence
18
+ #
19
+ def self.base_config(name, accept_value: true)
20
+ if accept_value
21
+ define_method name do |value = nil, &proc|
22
+ if proc
23
+ __configuration[name.to_sym] = proc
24
+ elsif !value.nil?
25
+ __configuration[name.to_sym] = value
26
+ else
27
+ fail ArgumentError, 'Either a value or a configuration block have to be given.'
28
+ end
29
+ end
30
+ else
31
+ define_method name do |&proc|
32
+ fail(ArgumentError, 'A configuration block has to be given.') unless proc
33
+ __configuration[name.to_sym] = proc
34
+ end
35
+ end
36
+ end
37
+
38
+ def initialize
39
+ @options = Petra.configuration.__configuration_hash(*__namespaces).deep_dup
40
+ end
41
+
42
+ #
43
+ # Persists the new configuration values in the global configuration,
44
+ # meaning that it merges its options into the specific configuration hash
45
+ # under a certain key
46
+ #
47
+ def __persist!
48
+ Petra.configuration.__configuration_hash(*__namespaces).deep_merge!(__configuration)
49
+ end
50
+
51
+ protected
52
+
53
+ #
54
+ # @return [Array<Symbol>] the current configuration options within an
55
+ # optional namespace chain, mainly to be merged into a global
56
+ #
57
+ def __namespaces
58
+ not_implemented
59
+ end
60
+
61
+ def __configuration
62
+ @options
63
+ end
64
+
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Petra
4
+ module CoreExt
5
+ module Object
6
+ #
7
+ # @return [Petra::ObjectProxy, Object] A proxy object to be used instead of the
8
+ # actual object in the transactions' contexts.
9
+ #
10
+ # Some objects are frozen by default (e.g. +nil+ or the shared instances of TrueClass and FalseClass),
11
+ # for these, the resulting object proxy is not cached
12
+ #
13
+ def petra(inherited: false, configuration_args: [])
14
+ # Do not proxy inherited objects if their configuration prohibits it.
15
+ if inherited && !Petra::Proxies::ObjectProxy.inherited_config_for(self, :proxy_instances, *configuration_args)
16
+ return self
17
+ end
18
+
19
+ if frozen?
20
+ Petra::Proxies::ObjectProxy.for(self, inherited: inherited)
21
+ else
22
+ @__petra_proxy ||= Petra::Proxies::ObjectProxy.for(self, inherited: inherited)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end