praxis 2.0.pre.19 → 2.0.pre.22

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 58ae0c1f92272e7fd0aaaa779fb679db4b14f6e8d2f9df08efaec211bd3cc4f0
4
- data.tar.gz: 19b73ba6239911b4b0f6d3097e58671f556f7835514b7fa52269c1a81f47d837
3
+ metadata.gz: fcf3359dc0ac9062c61efc934673d9bf5dfdda43e5abfbc9abc41d3030a7a00e
4
+ data.tar.gz: 04ffe07b27222f714b8da5e756cc9e1733b76d066a9e1bf5a3d15358dc7e6662
5
5
  SHA512:
6
- metadata.gz: f488d80d4e1495b3574bce59a20826c418ac663d6417838a24f7ef92770e73dd789fe3b9d432a5663b4e6cd5abe665be5d4fc96919c20d93dac0cd981732b4ec
7
- data.tar.gz: b8ffc24d28567d4b00a83c8b03bfd00539791af3a675ec315858a800d9e7890bf5bbcb1b811cd91ef8a13c9edc10be96eefb605e34139c789b5ec174b6975342
6
+ metadata.gz: f90af4ae85f4170a850d7d7637758c0c2d878271befa2fe437da8c994ac78657eebd28831bb0945a74ca48aa93a91d42e8b734fd70f3996984e427ad6850a7ae
7
+ data.tar.gz: b8eaa60ce2ba599b94f4b844339df8501ca295f3e1efb9a0f5dd38b9c4551aa64f108ba3a28913d06a22748dab11fee5c4bd8f9cfb8d2e85b1c456fb3284a5b3
data/.travis.yml CHANGED
@@ -3,7 +3,7 @@ language: ruby
3
3
  rvm:
4
4
  - 2.6
5
5
  - 2.7
6
- # - 3.1 # Not available in TravisCI out of the box yet
6
+ - 3.1 # Not available in TravisCI out of the box yet
7
7
  script:
8
8
  - bundle exec rspec spec
9
9
  branches:
data/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  ## next
4
4
 
5
+ ## 2.0.pre.22
6
+ * Introduced Resource callbacks (an includeable concern). Callbacks allow you to define methods or blocks to be executed `before`, `after` or `around` any existing method in the resource. Class-level callbacks are defined with `self.xxxxx`. These methods will be executed within the instance of the resource (i.e., in the same context of the original) and must be defined with the same parameter signature. For around methods, only blocks can be used, and to call the original (inner) one, one needs to yield.
7
+ * Introduced QueryMethods for resources (an includeable concern). QueryMethods expose handy querying methods (`.get`, `.get!`, `.all`, `.first` and `.last` ) which will reach into the underlying ORM (i.e., right now, only ActiveModelCompat is supported) to perform the desired loading of data (and subsequent wrapping of results in resource instances).
8
+ * For ActiveRecord `.get` takes a condition hash that will translate to `.find_by`, and `.all` gets a condition hash that will translate to `.where`.
9
+ * `.get!` is a `.get` but that will raise a `Praxis::Mapper::ResourceNotFound` exception if nothing was found.
10
+ * There is an `.including(<spec>)` function that can be use to preload the underlying associations. I.e., the `<spec>` argument will translate to `.includes(<spec>)` in ActiveRecord.
11
+ * Introduced method signatures and validations for resources.
12
+ * One can define a method signature with the `signature(<name>)` stanza, passing a block defining Attributor parameters. For instance method signatures, the `<name>` is just a symbol with the name of the method. For class level methods use a string, and prepend `self.` to it (i.e., `self.create`).
13
+ * Signatures can only work for methods that either have a single argument (taken as a whole hash), or that have only keyword arguments (i.e., no mixed args and kwargs). It would be basically impossible to validate that combo against an Attributor Struct.
14
+ * The calls to typed methods will be intercepted (using an around callback), and the incoming parameters will be validated against the Attributor Struct defined in the siguature, coerced if necessary and passed onto the original method. If the incoming parameters fail validation, a `IncompatibleTypeForMethodArguments` exception will be thrown.
15
+
16
+ ## 2.0.pre.21
17
+ * Fix nullable attribute in OpenApi generation
18
+ ## 2.0.pre.20
19
+ * Changed the behavior of dev-mode when validate_responses. Now they return a 500 status code (instead of a 400) but with the same validation error format body.
20
+ * validate_responses is meant to catch the application returning non-compliant responses for development only. As such, a 500 is much more appropriate and clear, as the validation is done on the behavior of the server, and not on the information sent by the client (i.e., it is a server problem, not reacting the way the API is defined)
21
+ * Introduced a method to reload a Resouce (.reload), which will clear the memoized values and call record.reload as well
22
+ * Open API Generation enhancements:
23
+ * Fixed type discovery (where some types wouldn't be included in the output)
24
+ * Changed the generation to output named types into components, and use `$ref` to point to them whenever appropriate
25
+ * Report nullable attributes
5
26
  ## 2.0.pre.19
6
27
  * Introduced a new DSL for the `FilteringParams` type that allows filters for common attributes in your Media Types:
7
28
  * The new `any` DSL allows you to define which final leaf attribute to always allow, and with which operators and/or fuzzy restrictions.
@@ -38,6 +38,7 @@ module Praxis
38
38
  end
39
39
  end
40
40
  include Attributor::Type
41
+ include Attributor::Container
41
42
  include Attributor::Dumpable
42
43
 
43
44
  extend Finalizable
@@ -37,7 +37,7 @@ module Praxis
37
37
  the_type = @attribute&.type || member_type
38
38
  {
39
39
  type: json_schema_type,
40
- items: { '$ref': "#/components/schemas/#{the_type.id}" }
40
+ items: the_type.as_json_schema
41
41
  }
42
42
  end
43
43
  end
@@ -27,9 +27,9 @@ module Praxis
27
27
  return {} if type.is_a? SimpleMediaType # NOTE: skip if it's a SimpleMediaType?? ... is that correct?
28
28
 
29
29
  the_schema = if type.anonymous? || !(type < Praxis::MediaType) # Avoid referencing custom/simple Types? (i.e., just MTs)
30
- SchemaObject.new(info: type).dump_schema
30
+ SchemaObject.new(info: type).dump_schema(shallow: false, allow_ref: false)
31
31
  else
32
- { '$ref': "#/components/schemas/#{type.id}" }
32
+ SchemaObject.new(info: type).dump_schema(shallow: true, allow_ref: true)
33
33
  end
34
34
 
35
35
  if example_payload
@@ -23,7 +23,6 @@ module Praxis
23
23
  all_tags = tags + action.traits
24
24
  h = {
25
25
  summary: action.name.to_s,
26
- description: action.description,
27
26
  # externalDocs: {}, # TODO/FIXME
28
27
  operationId: id,
29
28
  responses: ResponsesObject.new(responses: action.responses).dump
@@ -32,6 +31,7 @@ module Praxis
32
31
  # security: [{}]
33
32
  # servers: [{}]
34
33
  }
34
+ h[:description] = action.description if action.description
35
35
  h[:tags] = all_tags.uniq unless all_tags.empty?
36
36
  h[:parameters] = all_parameters unless all_parameters.empty?
37
37
  h[:requestBody] = RequestBodyObject.new(attribute: action.payload).dump if action.payload
@@ -5,33 +5,65 @@ module Praxis
5
5
  module OpenApi
6
6
  class SchemaObject
7
7
  # https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#schema-object
8
- attr_reader :type, :attribute
8
+ attr_reader :type
9
9
 
10
10
  def initialize(info:)
11
- # info could be an attribute ... or a type?
12
- if info.is_a? Attributor::Attribute
13
- @attribute = info
11
+ @attribute_options = {}
12
+
13
+ # info could be an attribute ... or a type
14
+ if info.is_a?(Attributor::Attribute)
15
+ @type = info.type
16
+ # Save the options that might be attached to the attribute, to add them to the type schema later
17
+ @attribute_options = info.options
14
18
  else
15
19
  @type = info
16
20
  end
21
+
22
+ # Mediatypes have the description method, lower types don't
23
+ @attribute_options[:description] = @type.description if @type.respond_to?(:description)
24
+ @collection = type.respond_to?(:member_type)
17
25
  end
18
26
 
19
27
  def dump_example
20
- ex = \
21
- if attribute
22
- attribute.example
23
- else
24
- type.example
25
- end
28
+ ex = type.example
26
29
  ex.respond_to?(:dump) ? ex.dump : ex
27
30
  end
28
31
 
29
- def dump_schema
30
- if attribute
31
- attribute.as_json_schema(shallow: true, example: nil)
32
+ def dump_schema(shallow: false, allow_ref: false)
33
+ # We will dump schemas for mediatypes by simply creating a reference to the components' section
34
+ if type < Attributor::Container
35
+ if (type < Praxis::Blueprint || type < Attributor::Model) && allow_ref && !type.anonymous?
36
+ # TODO: Do we even need a description?
37
+ h = @attribute_options[:description] ? { 'description' => @attribute_options[:description] } : {}
38
+
39
+ Praxis::Docs::OpenApiGenerator.instance.register_seen_component(type)
40
+ h.merge!('$ref' => "#/components/schemas/#{type.id}")
41
+ elsif @collection
42
+ items = OpenApi::SchemaObject.new(info: type.member_type).dump_schema(allow_ref: allow_ref, shallow: false)
43
+ h = @attribute_options[:description] ? { 'description' => @attribute_options[:description] } : {}
44
+ h.merge!(type: 'array', items: items)
45
+ else # Attributor::Struct, etc
46
+ props = type.attributes.transform_values do |definition|
47
+ OpenApi::SchemaObject.new(info: definition).dump_schema(allow_ref: true, shallow: shallow)
48
+ end
49
+ h = { type: :object, properties: props } # TODO: Example?
50
+ end
32
51
  else
33
- type.as_json_schema(shallow: true, example: nil)
52
+ # OpenApi::SchemaObject.new(info:target).dump_schema(allow_ref: allow_ref, shallow: shallow)
53
+ # TODO...we need to make sure we can use refs in the underlying components after the first level...
54
+ # ... maybe we need to loop over the attributes if it's an object/struct?...
55
+ h = type.as_json_schema(shallow: shallow, example: nil, attribute_options: @attribute_options)
34
56
  end
57
+
58
+ # Tag on OpenAPI specific requirements that aren't already added in the underlying JSON schema model
59
+ # Nullable: (it seems we need to ensure there is a null option to the enum, if there is one)
60
+ if @attribute_options[:null]
61
+ h[:nullable] = @attribute_options[:null]
62
+ h[:enum] = h[:enum] + [nil] if h[:enum] && !h[:enum].include?(nil)
63
+ end
64
+
65
+ h
66
+
35
67
  # # TODO: FIXME: return a generic object type if the passed info was weird.
36
68
  # return { type: :object } unless info
37
69
 
@@ -12,11 +12,11 @@ module Praxis
12
12
  end
13
13
 
14
14
  def dump
15
- {
16
- name: name,
17
- description: description
15
+ h = description ? { description: description } : {}
16
+ h.merge(
17
+ name: name
18
18
  # externalDocs: ???,
19
- }
19
+ )
20
20
  end
21
21
  end
22
22
  end
@@ -9,6 +9,7 @@ module Praxis
9
9
  module Docs
10
10
  class OpenApiGenerator
11
11
  require 'active_support/core_ext/enumerable' # For index_by
12
+ include Singleton
12
13
 
13
14
  API_DOCS_DIRNAME = 'docs/openapi'
14
15
  EXCLUDED_TYPES_FROM_OUTPUT = Set.new([
@@ -26,7 +27,7 @@ module Praxis
26
27
  Attributor::URI
27
28
  ]).freeze
28
29
 
29
- attr_reader :resources_by_version, :types_by_id, :infos_by_version, :doc_root_dir
30
+ attr_reader :resources_by_version, :infos_by_version, :doc_root_dir
30
31
 
31
32
  # substitutes ":params_like_so" for {params_like_so}
32
33
  def self.templatize_url(string)
@@ -34,24 +35,37 @@ module Praxis
34
35
  end
35
36
 
36
37
  def save!
38
+ raise 'You need to configure the root directory before saving (configure_root(<dir>))' unless @root
39
+
37
40
  initialize_directories
38
41
  # Restrict the versions listed in the index file to the ones for which we have at least 1 resource
39
42
  write_index_file(for_versions: resources_by_version.keys)
40
43
  resources_by_version.each_key do |version|
44
+ @seen_components_for_current_version = Set.new
41
45
  write_version_file(version)
42
46
  end
43
47
  end
44
48
 
45
- def initialize(root)
49
+ def initialize
46
50
  require 'yaml'
47
- @root = root
51
+
48
52
  @resources_by_version = Hash.new do |h, k|
49
53
  h[k] = Set.new
50
54
  end
51
-
55
+ # List of types that we have seen/marked as necessary to list in the components/schemas section
56
+ # These should contain any mediatype define in the versioned controllers plus any type
57
+ # for which we've explicitly rendered a $ref schema
58
+ @seen_components_for_current_version = Set.new
52
59
  @infos = ApiDefinition.instance.infos
53
60
  collect_resources
54
- collect_types
61
+ end
62
+
63
+ def configure_root(root)
64
+ @root = root
65
+ end
66
+
67
+ def register_seen_component(type)
68
+ @seen_components_for_current_version.add(type)
55
69
  end
56
70
 
57
71
  private
@@ -69,63 +83,16 @@ module Praxis
69
83
  end
70
84
  end
71
85
 
72
- def collect_types
73
- @types_by_id = ObjectSpace.each_object(Class).select do |obj|
74
- obj < Attributor::Type
75
- end.index_by(&:id)
76
- end
77
-
78
86
  def write_index_file(for_versions:)
79
87
  # TODO. create a simple html file that can link to the individual versions available
80
88
  end
81
89
 
82
- def scan_types_for_version(version, dumped_resources)
83
- found_media_types = resources_by_version[version].select(&:media_type).collect { |r| r.media_type.describe }
84
-
90
+ # TODO: Change this function name to scan_default_mediatypes...
91
+ def collect_default_mediatypes(endpoints)
85
92
  # We'll start by processing the rendered mediatypes
86
- processed_types = Set.new(resources_by_version[version].select do |r|
87
- r.media_type && !r.media_type.is_a?(Praxis::SimpleMediaType)
93
+ Set.new(endpoints.select do |endpoint|
94
+ endpoint.media_type && !endpoint.media_type.is_a?(Praxis::SimpleMediaType)
88
95
  end.collect(&:media_type))
89
-
90
- newfound = Set.new
91
- found_media_types.each do |mt|
92
- newfound += scan_dump_for_types({ type: mt }, processed_types)
93
- end
94
- # Then will process the rendered resources (noting)
95
- newfound += scan_dump_for_types(dumped_resources, Set.new)
96
-
97
- # At this point we've done a scan of the dumped resources and mediatypes.
98
- # In that scan we've discovered a bunch of types, however, many of those might have appeared in the JSON
99
- # rendered in just shallow mode, so it is not guaranteed that we've seen all the available types.
100
- # For that we'll do a (non-shallow) dump of all the types we found, and scan them until the scans do not
101
- # yield types we haven't seen before
102
- until newfound.empty?
103
- dumped = newfound.collect(&:describe)
104
- processed_types += newfound
105
- newfound = scan_dump_for_types(dumped, processed_types)
106
- end
107
- processed_types
108
- end
109
-
110
- def scan_dump_for_types(data, processed_types)
111
- newfound_types = Set.new
112
- case data
113
- when Array
114
- data.collect { |item| newfound_types += scan_dump_for_types(item, processed_types) }
115
- when Hash
116
- if data.key?(:type) && data[:type].is_a?(Hash) && (%i[id name family] - data[:type].keys).empty?
117
- type_id = data[:type][:id]
118
- unless type_id.nil? || type_id == Praxis::SimpleMediaType.id # SimpleTypes shouldn't be collected
119
- unless types_by_id[type_id]
120
- raise "Error! We have detected a reference to a 'Type' with id='#{type_id}' which is not derived from Attributor::Type" \
121
- ' Document generation cannot proceed.'
122
- end
123
- newfound_types << types_by_id[type_id] unless processed_types.include? types_by_id[type_id]
124
- end
125
- end
126
- data.values.map { |item| newfound_types += scan_dump_for_types(item, processed_types) }
127
- end
128
- newfound_types
129
96
  end
130
97
 
131
98
  def write_version_file(version)
@@ -133,11 +100,11 @@ module Praxis
133
100
  # # Hack, let's "inherit/copy" all traits of a version from the global definition
134
101
  # # Eventually traits should be defined for a version (and inheritable from global) so we'll emulate that here
135
102
  # version_info[:traits] = infos_by_version[:traits]
136
- dumped_resources = dump_resources(resources_by_version[version])
137
- processed_types = scan_types_for_version(version, dumped_resources)
138
103
 
104
+ # We'll for sure include any of the default mediatypes in the endpoints for this version
105
+ @seen_components_for_current_version.merge(collect_default_mediatypes(resources_by_version[version]))
139
106
  # Here we have:
140
- # processed types: which includes mediatypes and normal types...real classes
107
+ # processed types: which includes default mediatypes for the processed endpoints
141
108
  # processed resources for this version: resources_by_version[version]
142
109
 
143
110
  info_object = OpenApi::InfoObject.new(version: version, api_definition_info: @infos[version])
@@ -168,8 +135,8 @@ module Praxis
168
135
  end
169
136
  full_data[:tags] = full_data[:tags] + tags_for_traits unless tags_for_traits.empty?
170
137
 
171
- # Include only MTs (i.e., not custom types or simple types...)
172
- component_schemas = reusable_schema_objects(processed_types.select { |t| t < Praxis::MediaType })
138
+ # Include only MTs and Blueprints (i.e., no simple types...)
139
+ component_schemas = add_component_schemas(@seen_components_for_current_version.clone, {})
173
140
 
174
141
  # 3- Then adding all of the top level Mediatypes...so we can present them at the bottom, otherwise they don't show
175
142
  tags_for_mts = component_schemas.map do |(name, _info)|
@@ -251,16 +218,16 @@ module Praxis
251
218
  end
252
219
  end
253
220
 
254
- def reusable_schema_objects(types)
255
- types.each_with_object({}) do |(type), accum|
256
- the_type = \
257
- if type.respond_to? :as_json_schema
258
- type
259
- else # If it is a blueprint ... for now, it'd be through the attribute
260
- type.attribute
261
- end
262
- accum[type.id] = the_type.as_json_schema(shallow: false)
221
+ def add_component_schemas(types_to_add, components_hash)
222
+ initial = @seen_components_for_current_version.dup
223
+ types_to_add.each_with_object(components_hash) do |(type), accum|
224
+ # For components, we want the first level to be fully dumped (only references below that)
225
+ accum[type.id] = OpenApi::SchemaObject.new(info: type).dump_schema(allow_ref: false, shallow: false)
263
226
  end
227
+ newfound = @seen_components_for_current_version - initial
228
+ # Process the new types if they have discovered
229
+ add_component_schemas(newfound, components_hash) unless newfound.empty?
230
+ components_hash
264
231
  end
265
232
 
266
233
  def convert_to_parameter_object(params)
@@ -80,6 +80,27 @@ module Praxis
80
80
  end
81
81
  end
82
82
 
83
+ # Compatible reader accessors
84
+ def _get(condition)
85
+ find_by(condition)
86
+ end
87
+
88
+ def _all(conditions = {})
89
+ where(conditions)
90
+ end
91
+
92
+ def _add_includes(base, includes)
93
+ base.includes(includes) # includes(nil) seems to have no effect
94
+ end
95
+
96
+ def _first
97
+ first
98
+ end
99
+
100
+ def _last
101
+ last
102
+ end
103
+
83
104
  private
84
105
 
85
106
  def local_columns_used_for_the_association(type, assoc_reflection)
@@ -4,6 +4,15 @@
4
4
  # Once that is complete, the data set is iterated and a resultant view is generated.
5
5
  module Praxis
6
6
  module Mapper
7
+ class ResourceNotFound < RuntimeError
8
+ attr_reader :type, :id
9
+
10
+ def initialize(type:, id: nil)
11
+ @type = type
12
+ @id = id
13
+ end
14
+ end
15
+
7
16
  class Resource
8
17
  extend Praxis::Finalizable
9
18
 
@@ -13,6 +22,8 @@ module Praxis
13
22
 
14
23
  class << self
15
24
  attr_reader :model_map, :properties
25
+ # Names of the memoizable things (without the @__ prefix)
26
+ attr_accessor :memoized_variables
16
27
  end
17
28
 
18
29
  # TODO: also support an attribute of sorts on the versioned resource module. ie, V1::Resources.api_version.
@@ -32,6 +43,7 @@ module Praxis
32
43
 
33
44
  @properties = superclass.properties.clone
34
45
  @_filters_map = {}
46
+ @memoized_variables = []
35
47
  end
36
48
  end
37
49
 
@@ -55,6 +67,7 @@ module Praxis
55
67
  finalize_resource_delegates
56
68
  define_model_accessors
57
69
 
70
+ hookup_callbacks
58
71
  super
59
72
  end
60
73
 
@@ -76,6 +89,39 @@ module Praxis
76
89
  end
77
90
  end
78
91
 
92
+ def self.hookup_callbacks
93
+ return unless ancestors.include?(Praxis::Mapper::Resources::Callbacks)
94
+
95
+ instance_module = nil
96
+ class_module = nil
97
+
98
+ affected_methods = (before_callbacks.keys + after_callbacks.keys + around_callbacks.keys).uniq
99
+ affected_methods&.each do |method|
100
+ calls = {}
101
+ calls[:before] = before_callbacks[method] if before_callbacks.key?(method)
102
+ calls[:around] = around_callbacks[method] if around_callbacks.key?(method)
103
+ calls[:after] = after_callbacks[method] if after_callbacks.key?(method)
104
+
105
+ if method.start_with?('self.')
106
+ # Look for a Class method
107
+ simple_name = method.to_s.gsub(/^self./, '').to_sym
108
+ raise "Error building callback: Class-level method #{method} is not defined in class #{name}" unless methods.include?(simple_name)
109
+
110
+ class_module ||= Module.new
111
+ create_override_module(mod: class_module, method: method(simple_name), calls: calls)
112
+ else
113
+ # Look for an instance method
114
+ raise "Error building callback: Instance method #{method} is not defined in class #{name}" unless method_defined?(method)
115
+
116
+ instance_module ||= Module.new
117
+ create_override_module(mod: instance_module, method: instance_method(method), calls: calls)
118
+ end
119
+ end
120
+ # Prepend the created instance and/or class modules if there were any functions in them
121
+ prepend instance_module if instance_module
122
+ singleton_class.send(:prepend, class_module) if class_module
123
+ end
124
+
79
125
  def self.for_record(record)
80
126
  return record._resource if record._resource
81
127
 
@@ -130,10 +176,13 @@ module Praxis
130
176
 
131
177
  return unless association_resource_class
132
178
 
179
+ memoized_variables << name
133
180
  module_eval <<-RUBY, __FILE__, __LINE__ + 1
134
181
  def #{name}
182
+ return @__#{name} if instance_variable_defined?("@__#{name}")
183
+
135
184
  records = record.#{name}
136
- return nil if records.nil?
185
+ return nil if records.nil?
137
186
  @__#{name} ||= #{association_resource_class}.wrap(records)
138
187
  end
139
188
  RUBY
@@ -151,6 +200,7 @@ module Praxis
151
200
  end
152
201
 
153
202
  def self.define_delegation_for_related_attribute(resource_name, resource_attribute)
203
+ memoized_variables << resource_attribute
154
204
  module_eval <<-RUBY, __FILE__, __LINE__ + 1
155
205
  def #{resource_attribute}
156
206
  @__#{resource_attribute} ||= if (rec = self.#{resource_name})
@@ -164,6 +214,7 @@ module Praxis
164
214
  related_resource_class = model_map[related_association[:model]]
165
215
  return unless related_resource_class
166
216
 
217
+ memoized_variables << resource_attribute
167
218
  module_eval <<-RUBY, __FILE__, __LINE__ + 1
168
219
  def #{resource_attribute}
169
220
  @__#{resource_attribute} ||= if (rec = self.#{resource_name})
@@ -176,15 +227,18 @@ module Praxis
176
227
  end
177
228
 
178
229
  def self.define_accessor(name)
179
- ivar_name = if name.to_s =~ /\?/
230
+ ivar_name = case name.to_s
231
+ when /\?/
180
232
  "is_#{name.to_s[0..-2]}"
233
+ when /!/
234
+ "#{name.to_s[0..-2]}_bang"
181
235
  else
182
236
  name.to_s
183
237
  end
184
-
238
+ memoized_variables << ivar_name
185
239
  module_eval <<-RUBY, __FILE__, __LINE__ + 1
186
240
  def #{name}
187
- return @__#{ivar_name} if defined? @__#{ivar_name}
241
+ return @__#{ivar_name} if instance_variable_defined?("@__#{ivar_name}")
188
242
  @__#{ivar_name} = record.#{name}
189
243
  end
190
244
  RUBY
@@ -239,6 +293,23 @@ module Praxis
239
293
  @record = record
240
294
  end
241
295
 
296
+ def reload
297
+ clear_memoization
298
+ reload_record
299
+ self
300
+ end
301
+
302
+ def clear_memoization
303
+ self.class.memoized_variables.each do |name|
304
+ ivar = "@__#{name}"
305
+ remove_instance_variable(ivar) if instance_variable_defined?(ivar)
306
+ end
307
+ end
308
+
309
+ def reload_record
310
+ record.reload
311
+ end
312
+
242
313
  def respond_to_missing?(name, *)
243
314
  @record.respond_to?(name) || super
244
315
  end
@@ -251,6 +322,81 @@ module Praxis
251
322
  super
252
323
  end
253
324
  end
325
+
326
+ # Defines a 'proxy' method in the given module (mod), so it can then be prepended
327
+ # There are mostly 3 flavors, which dictate how to define the procs (to make sure we play nicely
328
+ # with ruby's arguments and all). Method with only args, with only kwords, and with both
329
+ # Note: if procs could be defined with the (...) syntax, this could be more DRY and simple...
330
+ def self.create_override_module(mod:, method:, calls:)
331
+ has_args = method.parameters.any? { |(type, _)| %i[req opt rest].include?(type) }
332
+ has_kwargs = method.parameters.any? { |(type, _)| %i[keyreq keyrest].include?(type) }
333
+
334
+ mod.class_eval do
335
+ if has_args && has_kwargs
336
+ # Setup the method to take both args and kwargs
337
+ define_method(method.name.to_sym) do |*args, **kwargs|
338
+ calls[:before]&.each do |target|
339
+ target.is_a?(Symbol) ? send(target, *args, **kwargs) : instance_exec(*args, **kwargs, &target)
340
+ end
341
+
342
+ orig_call = proc { |*a, **kw| super(*a, **kw) }
343
+ around_chain = calls[:around].inject(orig_call) do |inner, target|
344
+ proc { |*a, **kw| send(target, *a, **kw, &inner) }
345
+ end
346
+ result = if calls[:around].presence
347
+ around_chain.call(*args, **kwargs)
348
+ else
349
+ super(*args, **kwargs)
350
+ end
351
+ calls[:after]&.each do |target|
352
+ target.is_a?(Symbol) ? send(target, *args, **kwargs) : instance_exec(*args, **kwargs, &target)
353
+ end
354
+ result
355
+ end
356
+ elsif has_kwargs && !has_args
357
+ # Setup the method to only take kwargs
358
+ define_method(method.name.to_sym) do |**kwargs|
359
+ calls[:before]&.each do |target|
360
+ target.is_a?(Symbol) ? send(target, **kwargs) : instance_exec(**kwargs, &target)
361
+ end
362
+ orig_call = proc { |**kw| super(**kw) }
363
+ around_chain = calls[:around].inject(orig_call) do |inner, target|
364
+ proc { |**kw| send(target, **kw, &inner) }
365
+ end
366
+ result = if calls[:around].presence
367
+ around_chain.call(**kwargs)
368
+ else
369
+ super(**kwargs)
370
+ end
371
+ calls[:after]&.each do |target|
372
+ target.is_a?(Symbol) ? send(target, **kwargs) : instance_exec(**kwargs, &target)
373
+ end
374
+ result
375
+ end
376
+ else
377
+ # Setup the method to only take args
378
+ define_method(method.name.to_sym) do |*args|
379
+ calls[:before]&.each do |target|
380
+ target.is_a?(Symbol) ? send(target, *args) : instance_exec(*args, &target)
381
+ end
382
+ orig_call = proc { |*a| super(*a) }
383
+ around_chain = calls[:around].inject(orig_call) do |inner, target|
384
+ proc { |*a| send(target, *a, &inner) }
385
+ end
386
+ result = if calls[:around].presence
387
+ around_chain.call(*args)
388
+ else
389
+ super(*args)
390
+ end
391
+ calls[:after]&.each do |target|
392
+ target.is_a?(Symbol) ? send(target, *args) : instance_exec(*args, &target)
393
+ end
394
+ result
395
+ end
396
+ end
397
+ end
398
+ end
399
+ private_class_method :create_override_module
254
400
  end
255
401
  end
256
402
  end