dexkit 0.1.0 → 0.3.0

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.
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Form
5
+ module Nesting
6
+ extend Dex::Concern
7
+
8
+ module ClassMethods
9
+ def _nested_ones
10
+ @_nested_ones ||= {}
11
+ end
12
+
13
+ def _nested_manys
14
+ @_nested_manys ||= {}
15
+ end
16
+
17
+ def nested_one(name, class_name: nil, &block)
18
+ raise ArgumentError, "nested_one requires a block" unless block
19
+
20
+ name = name.to_sym
21
+ nested_class = _build_nested_class(name, class_name, &block)
22
+ _nested_ones[name] = nested_class
23
+
24
+ attr_reader name
25
+
26
+ define_method(:"#{name}=") do |value|
27
+ coerced = _coerce_nested_one(name, value)
28
+ instance_variable_set(:"@#{name}", coerced)
29
+ end
30
+
31
+ define_method(:"build_#{name}") do |attrs = {}|
32
+ instance = self.class._nested_ones[name].new(attrs)
33
+ send(:"#{name}=", instance)
34
+ instance
35
+ end
36
+
37
+ define_method(:"#{name}_attributes=") do |attrs|
38
+ send(:"#{name}=", attrs)
39
+ end
40
+ end
41
+
42
+ def nested_many(name, class_name: nil, &block)
43
+ raise ArgumentError, "nested_many requires a block" unless block
44
+
45
+ name = name.to_sym
46
+ nested_class = _build_nested_class(name, class_name, &block)
47
+ _nested_manys[name] = nested_class
48
+
49
+ attr_reader name
50
+
51
+ define_method(:"#{name}=") do |value|
52
+ coerced = _coerce_nested_many(name, value)
53
+ instance_variable_set(:"@#{name}", coerced)
54
+ end
55
+
56
+ define_method(:"build_#{name.to_s.singularize}") do |attrs = {}|
57
+ instance = self.class._nested_manys[name].new(attrs)
58
+ items = send(name) || []
59
+ items << instance
60
+ instance_variable_set(:"@#{name}", items)
61
+ instance
62
+ end
63
+
64
+ define_method(:"#{name}_attributes=") do |attrs|
65
+ send(:"#{name}=", attrs)
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ def _build_nested_class(name, class_name, &block)
72
+ klass = Class.new(Dex::Form, &block)
73
+ const_name = class_name || name.to_s.singularize.camelize
74
+ const_set(const_name, klass)
75
+ klass
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def _coerce_nested_one(name, value)
82
+ klass = self.class._nested_ones[name]
83
+ value = _unwrap_hash_like(value)
84
+ case value
85
+ when Hash
86
+ return nil if _marked_for_destroy?(value)
87
+ klass.new(value.except("_destroy", :_destroy))
88
+ when klass then value
89
+ when nil then value
90
+ else raise ArgumentError, "#{name} must be a Hash or #{klass}, got #{value.class}"
91
+ end
92
+ end
93
+
94
+ def _coerce_nested_many(name, value)
95
+ klass = self.class._nested_manys[name]
96
+ value = _unwrap_hash_like(value)
97
+ items = case value
98
+ when Array then value
99
+ when Hash then _normalize_nested_hash(value)
100
+ else raise ArgumentError, "#{name} must be an Array or Hash, got #{value.class}"
101
+ end
102
+
103
+ items.filter_map do |item|
104
+ item = _unwrap_hash_like(item)
105
+ case item
106
+ when Hash
107
+ next nil if _marked_for_destroy?(item)
108
+ klass.new(item.except("_destroy", :_destroy))
109
+ when klass then item
110
+ else raise ArgumentError, "each #{name} item must be a Hash or #{klass}"
111
+ end
112
+ end
113
+ end
114
+
115
+ def _unwrap_hash_like(value)
116
+ return value.to_unsafe_h if value.respond_to?(:to_unsafe_h)
117
+ return value if value.is_a?(Hash) || value.is_a?(Array) || value.is_a?(Dex::Form) || value.nil?
118
+
119
+ value.respond_to?(:to_h) ? value.to_h : value
120
+ end
121
+
122
+ def _normalize_nested_hash(hash)
123
+ hash.sort_by { |k, _| k.to_s.to_i }.map(&:last)
124
+ end
125
+
126
+ def _marked_for_destroy?(attrs)
127
+ destroy_val = attrs["_destroy"] || attrs[:_destroy]
128
+ ActiveModel::Type::Boolean.new.cast(destroy_val)
129
+ end
130
+
131
+ def _initialize_nested_defaults(provided_keys)
132
+ self.class._nested_ones.each_key do |name|
133
+ key = name.to_s
134
+ next if provided_keys.include?(key) || provided_keys.include?("#{key}_attributes")
135
+
136
+ send(:"#{name}=", {})
137
+ end
138
+
139
+ self.class._nested_manys.each_key do |name|
140
+ next if instance_variable_get(:"@#{name}")
141
+
142
+ instance_variable_set(:"@#{name}", [])
143
+ end
144
+ end
145
+
146
+ def _validate_nested(context)
147
+ valid = true
148
+
149
+ self.class._nested_ones.each_key do |name|
150
+ nested = send(name)
151
+ next unless nested
152
+
153
+ unless nested.valid?(context)
154
+ nested.errors.each do |error|
155
+ errors.add(:"#{name}.#{error.attribute}", error.message)
156
+ end
157
+ valid = false
158
+ end
159
+ end
160
+
161
+ self.class._nested_manys.each_key do |name|
162
+ items = send(name) || []
163
+ items.each_with_index do |item, index|
164
+ next if item.valid?(context)
165
+
166
+ item.errors.each do |error|
167
+ errors.add(:"#{name}[#{index}].#{error.attribute}", error.message)
168
+ end
169
+ valid = false
170
+ end
171
+ end
172
+
173
+ valid
174
+ end
175
+
176
+ def _nested_to_h(result)
177
+ self.class._nested_ones.each_key do |name|
178
+ nested = send(name)
179
+ result[name] = nested&.to_h
180
+ end
181
+
182
+ self.class._nested_manys.each_key do |name|
183
+ items = send(name) || []
184
+ result[name] = items.map(&:to_h)
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Form
5
+ class UniquenessValidator < ActiveModel::EachValidator
6
+ def check_validity!
7
+ if options.key?(:model) && !options[:model].is_a?(Class)
8
+ raise ArgumentError, "uniqueness :model must be a Class, got #{options[:model].inspect}"
9
+ end
10
+ if options.key?(:conditions) && !options[:conditions].respond_to?(:call)
11
+ raise ArgumentError, "uniqueness :conditions must be callable"
12
+ end
13
+ end
14
+
15
+ def validate_each(form, attribute, value)
16
+ return if value.blank?
17
+
18
+ model_class = _resolve_model_class(form)
19
+ return unless model_class
20
+
21
+ column = options[:attribute] || attribute
22
+ query = _build_query(model_class, column, value)
23
+ query = _apply_scope(query, form)
24
+ query = _apply_conditions(query, form)
25
+ query = _exclude_current_record(query, form)
26
+
27
+ form.errors.add(attribute, options[:message] || :taken) if query.exists?
28
+ end
29
+
30
+ private
31
+
32
+ def _resolve_model_class(form)
33
+ return options[:model] if options[:model]
34
+ return form.class._model_class if form.class.respond_to?(:_model_class) && form.class._model_class
35
+
36
+ _infer_model_class(form)
37
+ end
38
+
39
+ def _infer_model_class(form)
40
+ class_name = form.class.name
41
+ return unless class_name
42
+
43
+ model_name = class_name.sub(/Form\z/, "")
44
+ return if model_name == class_name
45
+
46
+ klass = Object.const_get(model_name)
47
+ klass.respond_to?(:where) ? klass : nil
48
+ rescue NameError
49
+ nil
50
+ end
51
+
52
+ def _build_query(model_class, column, value)
53
+ if options[:case_sensitive] == false && value.is_a?(String) && model_class.respond_to?(:arel_table)
54
+ model_class.where(model_class.arel_table[column].lower.eq(value.downcase))
55
+ else
56
+ model_class.where(column => value)
57
+ end
58
+ end
59
+
60
+ def _apply_scope(query, form)
61
+ Array(options[:scope]).each do |scope_attr|
62
+ query = query.where(scope_attr => form.public_send(scope_attr))
63
+ end
64
+ query
65
+ end
66
+
67
+ def _apply_conditions(query, form)
68
+ return query unless options[:conditions]
69
+
70
+ callable = options[:conditions]
71
+ if callable.arity.zero?
72
+ query.instance_exec(&callable)
73
+ else
74
+ query.instance_exec(form, &callable)
75
+ end
76
+ end
77
+
78
+ def _exclude_current_record(query, form)
79
+ return query unless form.record&.persisted?
80
+
81
+ pk = form.record.class.primary_key
82
+ query.where.not(pk => form.record.public_send(pk))
83
+ end
84
+ end
85
+ end
86
+ end
data/lib/dex/form.rb ADDED
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model"
4
+
5
+ require_relative "form/nesting"
6
+
7
+ module Dex
8
+ class Form
9
+ include ActiveModel::Model
10
+ include ActiveModel::Attributes
11
+ include ActiveModel::Validations::Callbacks
12
+
13
+ if defined?(ActiveModel::Attributes::Normalization)
14
+ include ActiveModel::Attributes::Normalization
15
+ end
16
+
17
+ include Nesting
18
+ include Match
19
+
20
+ class ValidationError < StandardError
21
+ attr_reader :form
22
+
23
+ def initialize(form)
24
+ @form = form
25
+ super("Validation failed: #{form.errors.full_messages.join(", ")}")
26
+ end
27
+ end
28
+
29
+ class << self
30
+ def model(klass = nil)
31
+ if klass
32
+ raise ArgumentError, "model must be a Class, got #{klass.inspect}" unless klass.is_a?(Class)
33
+ @_model_class = klass
34
+ end
35
+ _model_class
36
+ end
37
+
38
+ def _model_class
39
+ return @_model_class if defined?(@_model_class)
40
+ superclass._model_class if superclass.respond_to?(:_model_class)
41
+ end
42
+
43
+ def inherited(subclass)
44
+ super
45
+ subclass.instance_variable_set(:@_nested_ones, _nested_ones.dup)
46
+ subclass.instance_variable_set(:@_nested_manys, _nested_manys.dup)
47
+ end
48
+ end
49
+
50
+ silence_redefinition_of_method :model_name
51
+ def self.model_name
52
+ if _model_class
53
+ _model_class.model_name
54
+ elsif name && !name.start_with?("#")
55
+ super
56
+ else
57
+ @_model_name ||= ActiveModel::Name.new(self, nil, name&.split("::")&.last || "Form")
58
+ end
59
+ end
60
+
61
+ attr_reader :record
62
+
63
+ def initialize(attributes = {})
64
+ # Accept ActionController::Parameters without requiring .permit — the form's
65
+ # attribute declarations are the whitelist. Only declared attributes and nested
66
+ # setters are assignable; everything else is silently dropped.
67
+ attributes = attributes.to_unsafe_h if attributes.respond_to?(:to_unsafe_h)
68
+ attrs = (attributes || {}).transform_keys(&:to_s)
69
+ record = attrs.delete("record")
70
+ @record = record if record.nil? || record.respond_to?(:persisted?)
71
+ provided_keys = attrs.keys
72
+ nested_attrs = _extract_nested_attributes(attrs)
73
+ super(attrs.slice(*self.class.attribute_names))
74
+ _apply_nested_attributes(nested_attrs)
75
+ _initialize_nested_defaults(provided_keys)
76
+ end
77
+
78
+ def with_record(record)
79
+ raise ArgumentError, "record must respond to #persisted?, got #{record.inspect}" unless record.respond_to?(:persisted?)
80
+
81
+ @record = record
82
+ self
83
+ end
84
+
85
+ def persisted?
86
+ record&.persisted? || false
87
+ end
88
+
89
+ def to_key
90
+ record&.to_key
91
+ end
92
+
93
+ def to_param
94
+ record&.to_param
95
+ end
96
+
97
+ def valid?(context = nil)
98
+ super_result = super
99
+ nested_result = _validate_nested(context)
100
+ super_result && nested_result
101
+ end
102
+
103
+ def to_h
104
+ result = {}
105
+ self.class.attribute_names.each do |name|
106
+ result[name.to_sym] = public_send(name)
107
+ end
108
+ _nested_to_h(result)
109
+ result
110
+ end
111
+
112
+ alias_method :to_hash, :to_h
113
+
114
+ private
115
+
116
+ def _extract_nested_attributes(attrs)
117
+ nested_keys = self.class._nested_ones.keys.map(&:to_s) +
118
+ self.class._nested_manys.keys.map(&:to_s)
119
+
120
+ extracted = {}
121
+ nested_keys.each do |key|
122
+ attr_key = "#{key}_attributes"
123
+ if attrs.key?(attr_key)
124
+ extracted[attr_key] = attrs.delete(attr_key)
125
+ attrs.delete(key)
126
+ elsif attrs.key?(key)
127
+ extracted[key] = attrs.delete(key)
128
+ end
129
+ end
130
+ extracted
131
+ end
132
+
133
+ def _apply_nested_attributes(nested_attrs)
134
+ nested_attrs.each do |key, value|
135
+ next if value.nil?
136
+ send(:"#{key}=", value)
137
+ end
138
+ end
139
+ end
140
+ end
141
+
142
+ require_relative "form/uniqueness_validator"
@@ -9,41 +9,41 @@ module Dex
9
9
  end
10
10
 
11
11
  def call
12
- _async_ensure_active_job_loaded!
13
- if _async_use_record_strategy?
14
- _async_enqueue_record_job
12
+ ensure_active_job_loaded!
13
+ if use_record_strategy?
14
+ enqueue_record_job
15
15
  else
16
- _async_enqueue_direct_job
16
+ enqueue_direct_job
17
17
  end
18
18
  end
19
19
 
20
20
  private
21
21
 
22
- def _async_enqueue_direct_job
23
- job = _async_apply_options(Operation::DirectJob)
24
- job.perform_later(class_name: _async_operation_class_name, params: _async_serialized_params)
22
+ def enqueue_direct_job
23
+ job = apply_options(Operation::DirectJob)
24
+ job.perform_later(class_name: operation_class_name, params: serialized_params)
25
25
  end
26
26
 
27
- def _async_enqueue_record_job
27
+ def enqueue_record_job
28
28
  record = Dex.record_backend.create_record(
29
- name: _async_operation_class_name,
30
- params: _async_serialized_params,
29
+ name: operation_class_name,
30
+ params: serialized_params,
31
31
  status: "pending"
32
32
  )
33
33
  begin
34
- job = _async_apply_options(Operation::RecordJob)
35
- job.perform_later(class_name: _async_operation_class_name, record_id: record.id)
34
+ job = apply_options(Operation::RecordJob)
35
+ job.perform_later(class_name: operation_class_name, record_id: record.id)
36
36
  rescue => e
37
37
  begin
38
38
  record.destroy
39
39
  rescue => destroy_error
40
- _async_log_warning("Failed to clean up pending record #{record.id}: #{destroy_error.message}")
40
+ Dex.warn("Failed to clean up pending record #{record.id}: #{destroy_error.message}")
41
41
  end
42
42
  raise e
43
43
  end
44
44
  end
45
45
 
46
- def _async_use_record_strategy?
46
+ def use_record_strategy?
47
47
  return false unless Dex.record_backend
48
48
  return false unless @operation.class.name
49
49
 
@@ -54,54 +54,48 @@ module Dex
54
54
  true
55
55
  end
56
56
 
57
- def _async_apply_options(job_class)
57
+ def apply_options(job_class)
58
58
  options = {}
59
- options[:queue] = _async_queue if _async_queue
60
- options[:wait_until] = _async_scheduled_at if _async_scheduled_at
61
- options[:wait] = _async_scheduled_in if _async_scheduled_in
59
+ options[:queue] = queue if queue
60
+ options[:wait_until] = scheduled_at if scheduled_at
61
+ options[:wait] = scheduled_in if scheduled_in
62
62
  options.empty? ? job_class : job_class.set(**options)
63
63
  end
64
64
 
65
- def _async_ensure_active_job_loaded!
65
+ def ensure_active_job_loaded!
66
66
  return if defined?(ActiveJob::Base)
67
67
 
68
68
  raise LoadError, "ActiveJob is required for async operations. Add 'activejob' to your Gemfile."
69
69
  end
70
70
 
71
- def _async_merged_options
71
+ def merged_options
72
72
  @operation.class.settings_for(:async).merge(@runtime_options)
73
73
  end
74
74
 
75
- def _async_queue = _async_merged_options[:queue]
76
- def _async_scheduled_at = _async_merged_options[:at]
77
- def _async_scheduled_in = _async_merged_options[:in]
78
- def _async_operation_class_name = @operation.class.name
75
+ def queue = merged_options[:queue]
76
+ def scheduled_at = merged_options[:at]
77
+ def scheduled_in = merged_options[:in]
78
+ def operation_class_name = @operation.class.name
79
79
 
80
- def _async_serialized_params
81
- @_async_serialized_params ||= begin
80
+ def serialized_params
81
+ @serialized_params ||= begin
82
82
  hash = @operation._props_as_json
83
- _async_validate_serializable!(hash)
83
+ validate_serializable!(hash)
84
84
  hash
85
85
  end
86
86
  end
87
87
 
88
- def _async_log_warning(message)
89
- if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
90
- Rails.logger.warn "[Dex] #{message}"
91
- end
92
- end
93
-
94
- def _async_validate_serializable!(hash, path: "")
88
+ def validate_serializable!(hash, path: "")
95
89
  hash.each do |key, value|
96
90
  current = path.empty? ? key.to_s : "#{path}.#{key}"
97
91
  case value
98
92
  when String, Integer, Float, NilClass, TrueClass, FalseClass
99
93
  next
100
94
  when Hash
101
- _async_validate_serializable!(value, path: current)
95
+ validate_serializable!(value, path: current)
102
96
  when Array
103
97
  value.each_with_index do |v, i|
104
- _async_validate_serializable!({ i => v }, path: current)
98
+ validate_serializable!({ i => v }, path: current)
105
99
  end
106
100
  else
107
101
  raise ArgumentError,
@@ -2,33 +2,17 @@
2
2
 
3
3
  module Dex
4
4
  module AsyncWrapper
5
- ASYNC_KNOWN_OPTIONS = %i[queue in at].freeze
6
-
7
5
  module ClassMethods
8
6
  def async(**options)
9
- unknown = options.keys - AsyncWrapper::ASYNC_KNOWN_OPTIONS
10
- if unknown.any?
11
- raise ArgumentError,
12
- "unknown async option(s): #{unknown.map(&:inspect).join(", ")}. " \
13
- "Known: #{AsyncWrapper::ASYNC_KNOWN_OPTIONS.map(&:inspect).join(", ")}"
14
- end
15
-
7
+ validate_options!(options, %i[queue in at], :async)
16
8
  set(:async, **options)
17
9
  end
18
10
  end
19
11
 
20
- def self.included(base)
21
- base.extend(ClassMethods)
22
- end
12
+ extend Dex::Concern
23
13
 
24
14
  def async(**options)
25
- unknown = options.keys - AsyncWrapper::ASYNC_KNOWN_OPTIONS
26
- if unknown.any?
27
- raise ArgumentError,
28
- "unknown async option(s): #{unknown.map(&:inspect).join(", ")}. " \
29
- "Known: #{AsyncWrapper::ASYNC_KNOWN_OPTIONS.map(&:inspect).join(", ")}"
30
- end
31
-
15
+ self.class.validate_options!(options, %i[queue in at], :async)
32
16
  Operation::AsyncProxy.new(self, **options)
33
17
  end
34
18
  end
@@ -2,9 +2,7 @@
2
2
 
3
3
  module Dex
4
4
  module CallbackWrapper
5
- def self.included(base)
6
- base.extend(ClassMethods)
7
- end
5
+ extend Dex::Concern
8
6
 
9
7
  module ClassMethods
10
8
  def before(callable = nil, &block)
@@ -57,24 +55,22 @@ module Dex
57
55
  def _callback_wrap
58
56
  return yield unless self.class._callback_any?
59
57
 
60
- halted = nil
58
+ success_halt = nil
61
59
  result = _callback_run_around(self.class._callback_list(:around)) do
62
60
  _callback_run_before
63
- caught = catch(:_dex_halt) { yield }
64
- if caught.is_a?(Operation::Halt)
65
- if caught.success?
66
- halted = caught
67
- _callback_run_after
68
- caught.value
69
- else
70
- throw(:_dex_halt, caught)
71
- end
61
+ interceptor = Operation::HaltInterceptor.new { yield }
62
+ if interceptor.error?
63
+ interceptor.rethrow!
64
+ elsif interceptor.halted?
65
+ success_halt = interceptor
66
+ _callback_run_after
67
+ interceptor.result
72
68
  else
73
69
  _callback_run_after
74
- caught
70
+ interceptor.result
75
71
  end
76
72
  end
77
- throw(:_dex_halt, halted) if halted
73
+ success_halt&.rethrow!
78
74
  result
79
75
  end
80
76
 
@@ -11,7 +11,7 @@ module Dex
11
11
  const_set(:DirectJob, Class.new(ActiveJob::Base) do
12
12
  def perform(class_name:, params:)
13
13
  klass = class_name.constantize
14
- klass.new(**klass.send(:_dex_coerce_serialized_hash, params)).call
14
+ klass.new(**klass.send(:_coerce_serialized_hash, params)).call
15
15
  end
16
16
  end)
17
17
  when :RecordJob
@@ -19,39 +19,33 @@ module Dex
19
19
  def perform(class_name:, record_id:)
20
20
  klass = class_name.constantize
21
21
  record = Dex.record_backend.find_record(record_id)
22
- params = klass.send(:_dex_coerce_serialized_hash, record.params || {})
22
+ params = klass.send(:_coerce_serialized_hash, record.params || {})
23
23
 
24
24
  op = klass.new(**params)
25
25
  op.instance_variable_set(:@_dex_record_id, record_id)
26
26
 
27
- _dex_update_status(record_id, status: "running")
27
+ update_status(record_id, status: "running")
28
28
  op.call
29
29
  rescue => e
30
- _dex_handle_failure(record_id, e)
30
+ handle_failure(record_id, e)
31
31
  raise
32
32
  end
33
33
 
34
34
  private
35
35
 
36
- def _dex_update_status(record_id, **attributes)
36
+ def update_status(record_id, **attributes)
37
37
  Dex.record_backend.update_record(record_id, attributes)
38
38
  rescue => e
39
- _dex_log_warning("Failed to update record status: #{e.message}")
39
+ Dex.warn("Failed to update record status: #{e.message}")
40
40
  end
41
41
 
42
- def _dex_handle_failure(record_id, exception)
42
+ def handle_failure(record_id, exception)
43
43
  error_value = if exception.is_a?(Dex::Error)
44
44
  exception.code.to_s
45
45
  else
46
46
  exception.class.name
47
47
  end
48
- _dex_update_status(record_id, status: "failed", error: error_value)
49
- end
50
-
51
- def _dex_log_warning(message)
52
- if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
53
- Rails.logger.warn "[Dex] #{message}"
54
- end
48
+ update_status(record_id, status: "failed", error: error_value)
55
49
  end
56
50
  end)
57
51
  when :Job