dexkit 0.2.0 → 0.4.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.
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"
data/lib/dex/operation.rb CHANGED
@@ -139,3 +139,6 @@ require_relative "operation/jobs"
139
139
 
140
140
  # Top-level aliases (depend on Operation::Ok/Err)
141
141
  require_relative "match"
142
+
143
+ # Make Ok/Err available without prefix inside operations
144
+ Dex::Operation.include(Dex::Match)
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Query
5
+ module Backend
6
+ STRATEGIES = %i[eq not_eq contains starts_with ends_with gt gte lt lte in not_in].to_set.freeze
7
+
8
+ module ActiveRecordAdapter
9
+ module_function
10
+
11
+ def apply(scope, strategy, column, value)
12
+ table = scope.arel_table
13
+
14
+ case strategy
15
+ when :eq, :in
16
+ scope.where(column => value)
17
+ when :not_eq, :not_in
18
+ scope.where.not(column => value)
19
+ when :contains
20
+ scope.where(table[column].matches("%#{sanitize_like(value)}%", "\\"))
21
+ when :starts_with
22
+ scope.where(table[column].matches("#{sanitize_like(value)}%", "\\"))
23
+ when :ends_with
24
+ scope.where(table[column].matches("%#{sanitize_like(value)}", "\\"))
25
+ when :gt
26
+ scope.where(table[column].gt(value))
27
+ when :gte
28
+ scope.where(table[column].gteq(value))
29
+ when :lt
30
+ scope.where(table[column].lt(value))
31
+ when :lte
32
+ scope.where(table[column].lteq(value))
33
+ else
34
+ raise ArgumentError, "Unknown strategy: #{strategy.inspect}"
35
+ end
36
+ end
37
+
38
+ def sanitize_like(value)
39
+ ActiveRecord::Base.sanitize_sql_like(value.to_s)
40
+ end
41
+ end
42
+
43
+ module MongoidAdapter
44
+ module_function
45
+
46
+ def apply(scope, strategy, column, value)
47
+ case strategy
48
+ when :eq
49
+ scope.where(column => value)
50
+ when :not_eq
51
+ scope.where(column.to_sym.ne => value)
52
+ when :in
53
+ scope.where(column.to_sym.in => Array(value))
54
+ when :not_in
55
+ scope.where(column.to_sym.nin => Array(value))
56
+ when :contains
57
+ scope.where(column => /#{Regexp.escape(value.to_s)}/i)
58
+ when :starts_with
59
+ scope.where(column => /\A#{Regexp.escape(value.to_s)}/i)
60
+ when :ends_with
61
+ scope.where(column => /#{Regexp.escape(value.to_s)}\z/i)
62
+ when :gt
63
+ scope.where(column.to_sym.gt => value)
64
+ when :gte
65
+ scope.where(column.to_sym.gte => value)
66
+ when :lt
67
+ scope.where(column.to_sym.lt => value)
68
+ when :lte
69
+ scope.where(column.to_sym.lte => value)
70
+ else
71
+ raise ArgumentError, "Unknown strategy: #{strategy.inspect}"
72
+ end
73
+ end
74
+ end
75
+
76
+ module_function
77
+
78
+ def apply_strategy(scope, strategy, column, value)
79
+ adapter_for(scope).apply(scope, strategy, column, value)
80
+ end
81
+
82
+ def adapter_for(scope)
83
+ if defined?(Mongoid::Criteria) && scope.is_a?(Mongoid::Criteria)
84
+ MongoidAdapter
85
+ else
86
+ ActiveRecordAdapter
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Query
5
+ module Filtering
6
+ extend Dex::Concern
7
+
8
+ FilterDef = Data.define(:name, :strategy, :column, :block, :optional)
9
+
10
+ module ClassMethods
11
+ def _filter_registry
12
+ @_filter_registry ||= {}
13
+ end
14
+
15
+ def filter(name, strategy = :eq, column: nil, &block)
16
+ name = name.to_sym
17
+
18
+ if _filter_registry.key?(name)
19
+ raise ArgumentError, "Filter :#{name} is already declared."
20
+ end
21
+
22
+ unless respond_to?(:literal_properties) && literal_properties.any? { |p| p.name == name }
23
+ raise ArgumentError, "Filter :#{name} requires a prop with the same name."
24
+ end
25
+
26
+ optional = _prop_optional?(name)
27
+
28
+ if block
29
+ _filter_registry[name] = FilterDef.new(name: name, strategy: nil, column: nil, block: block, optional: optional)
30
+ else
31
+ unless Backend::STRATEGIES.include?(strategy)
32
+ raise ArgumentError, "Unknown filter strategy: #{strategy.inspect}. " \
33
+ "Valid strategies: #{Backend::STRATEGIES.to_a.join(", ")}"
34
+ end
35
+
36
+ _filter_registry[name] = FilterDef.new(
37
+ name: name,
38
+ strategy: strategy,
39
+ column: (column || name).to_sym,
40
+ block: nil,
41
+ optional: optional
42
+ )
43
+ end
44
+ end
45
+
46
+ def filters
47
+ _filter_registry.keys
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def _apply_filters(scope)
54
+ self.class._filter_registry.each_value do |filter_def|
55
+ value = public_send(filter_def.name)
56
+
57
+ next if value.nil? && filter_def.optional
58
+ next if (filter_def.strategy == :in || filter_def.strategy == :not_in) && value.respond_to?(:empty?) && value.empty?
59
+
60
+ result = if filter_def.block
61
+ instance_exec(scope, value, &filter_def.block)
62
+ else
63
+ Backend.apply_strategy(scope, filter_def.strategy, filter_def.column, value)
64
+ end
65
+
66
+ scope = result unless result.nil?
67
+ end
68
+
69
+ scope
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Query
5
+ module Sorting
6
+ extend Dex::Concern
7
+
8
+ SortDef = Data.define(:name, :custom, :block)
9
+
10
+ module ClassMethods
11
+ def _sort_registry
12
+ @_sort_registry ||= {}
13
+ end
14
+
15
+ def _sort_default
16
+ return @_sort_default if defined?(@_sort_default)
17
+
18
+ nil
19
+ end
20
+
21
+ def sort(*columns, default: nil, &block)
22
+ if block
23
+ raise ArgumentError, "Block sort requires exactly one column name." unless columns.size == 1
24
+
25
+ name = columns.first.to_sym
26
+
27
+ if _sort_registry.key?(name)
28
+ raise ArgumentError, "Sort :#{name} is already declared."
29
+ end
30
+
31
+ _sort_registry[name] = SortDef.new(name: name, custom: true, block: block)
32
+ else
33
+ raise ArgumentError, "sort requires at least one column name." if columns.empty?
34
+
35
+ columns.each do |col|
36
+ col = col.to_sym
37
+
38
+ if _sort_registry.key?(col)
39
+ raise ArgumentError, "Sort :#{col} is already declared."
40
+ end
41
+
42
+ _sort_registry[col] = SortDef.new(name: col, custom: false, block: nil)
43
+ end
44
+ end
45
+
46
+ if default
47
+ if defined?(@_sort_default) && @_sort_default
48
+ raise ArgumentError, "Default sort is already set to #{@_sort_default.inspect}."
49
+ end
50
+
51
+ bare = default.to_s.delete_prefix("-").to_sym
52
+ unless _sort_registry.key?(bare)
53
+ raise ArgumentError, "Default sort references unknown sort: #{bare.inspect}."
54
+ end
55
+
56
+ if default.to_s.start_with?("-") && _sort_registry[bare].custom
57
+ raise ArgumentError, "Custom sorts cannot use the \"-\" prefix."
58
+ end
59
+
60
+ @_sort_default = default.to_s
61
+ end
62
+ end
63
+
64
+ def sorts
65
+ _sort_registry.keys
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ def _apply_sort(scope)
72
+ sort_value = _current_sort
73
+ return scope unless sort_value
74
+
75
+ desc = sort_value.start_with?("-")
76
+ bare = sort_value.delete_prefix("-").to_sym
77
+
78
+ sort_def = self.class._sort_registry[bare]
79
+ unless sort_def
80
+ raise ArgumentError, "Unknown sort: #{bare.inspect}. Valid sorts: #{self.class._sort_registry.keys.join(", ")}"
81
+ end
82
+
83
+ if desc && sort_def.custom
84
+ raise ArgumentError, "Custom sorts cannot use the \"-\" prefix."
85
+ end
86
+
87
+ if sort_def.custom
88
+ instance_exec(scope, &sort_def.block)
89
+ else
90
+ scope.order(bare => desc ? :desc : :asc)
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
data/lib/dex/query.rb ADDED
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model"
4
+
5
+ require_relative "query/backend"
6
+ require_relative "query/filtering"
7
+ require_relative "query/sorting"
8
+
9
+ module Dex
10
+ class Query
11
+ RESERVED_PROP_NAMES = %i[scope sort resolve call from_params to_params param_key].to_set.freeze
12
+
13
+ include PropsSetup
14
+ include Filtering
15
+ include Sorting
16
+
17
+ extend ActiveModel::Naming
18
+ include ActiveModel::Conversion
19
+
20
+ class << self
21
+ def scope(&block)
22
+ raise ArgumentError, "scope requires a block." unless block
23
+
24
+ @_scope_block = block
25
+ end
26
+
27
+ def _scope_block
28
+ return @_scope_block if defined?(@_scope_block)
29
+
30
+ superclass._scope_block if superclass.respond_to?(:_scope_block)
31
+ end
32
+
33
+ def new(scope: nil, sort: nil, **kwargs)
34
+ instance = super(**kwargs)
35
+ instance.instance_variable_set(:@_injected_scope, scope)
36
+ sort_str = sort&.to_s
37
+ sort_str = nil if sort_str&.empty?
38
+ instance.instance_variable_set(:@_sort_value, sort_str)
39
+ instance
40
+ end
41
+
42
+ def call(scope: nil, sort: nil, **kwargs)
43
+ new(scope: scope, sort: sort, **kwargs).resolve
44
+ end
45
+
46
+ def count(...)
47
+ call(...).count
48
+ end
49
+
50
+ def exists?(...)
51
+ call(...).exists?
52
+ end
53
+
54
+ def any?(...)
55
+ call(...).any?
56
+ end
57
+
58
+ def param_key(key = nil)
59
+ if key
60
+ str = key.to_s
61
+ raise ArgumentError, "param_key must not be blank." if str.empty?
62
+
63
+ @_param_key = str
64
+ @_model_name = nil
65
+ end
66
+ defined?(@_param_key) ? @_param_key : nil
67
+ end
68
+
69
+ silence_redefinition_of_method :model_name
70
+ def model_name
71
+ return @_model_name if @_model_name
72
+
73
+ pk = param_key
74
+ @_model_name = if pk
75
+ ActiveModel::Name.new(self, nil, pk.to_s.camelize).tap do |mn|
76
+ mn.define_singleton_method(:param_key) { pk }
77
+ end
78
+ elsif name && !name.start_with?("#")
79
+ super
80
+ else
81
+ ActiveModel::Name.new(self, nil, "Query")
82
+ end
83
+ end
84
+
85
+ def _prop_optional?(name)
86
+ return false unless respond_to?(:literal_properties)
87
+
88
+ prop = literal_properties.find { |p| p.name == name }
89
+ prop&.type.is_a?(Literal::Types::NilableType) || false
90
+ end
91
+
92
+ def inherited(subclass)
93
+ super
94
+ subclass.instance_variable_set(:@_filter_registry, _filter_registry.dup)
95
+ subclass.instance_variable_set(:@_sort_registry, _sort_registry.dup)
96
+ subclass.instance_variable_set(:@_sort_default, _sort_default) if _sort_default
97
+ end
98
+
99
+ def from_params(params, scope: nil, **overrides)
100
+ pk = model_name.param_key
101
+ nested = _extract_nested_params(params, pk)
102
+
103
+ sort_value = overrides.delete(:sort)&.to_s
104
+ unless sort_value && !sort_value.empty?
105
+ sort_value = nested.delete(:sort)&.to_s
106
+ sort_value = nil if sort_value && sort_value.empty?
107
+
108
+ # Validate sort — drop invalid to fall back to default
109
+ if sort_value
110
+ bare = sort_value.delete_prefix("-").to_sym
111
+ sort_def = _sort_registry[bare]
112
+ sort_value = nil if sort_def.nil? || (sort_value.start_with?("-") && sort_def.custom)
113
+ end
114
+ end
115
+
116
+ kwargs = {}
117
+
118
+ literal_properties.each do |prop|
119
+ pname = prop.name
120
+ next if overrides.key?(pname)
121
+ next if _ref_type?(prop.type)
122
+
123
+ raw = nested[pname]
124
+
125
+ if raw.nil? || (raw.is_a?(String) && raw.empty? && _prop_optional?(pname))
126
+ kwargs[pname] = nil if _prop_optional?(pname)
127
+ next
128
+ end
129
+
130
+ kwargs[pname] = _coerce_param(prop.type, raw)
131
+ end
132
+
133
+ kwargs.merge!(overrides)
134
+ kwargs[:sort] = sort_value if sort_value
135
+ kwargs[:scope] = scope if scope
136
+
137
+ new(**kwargs)
138
+ end
139
+
140
+ private
141
+
142
+ def _extract_nested_params(params, pk)
143
+ hash = if params.respond_to?(:to_unsafe_h)
144
+ params.to_unsafe_h
145
+ elsif params.is_a?(Hash)
146
+ params
147
+ else
148
+ {}
149
+ end
150
+
151
+ nested = hash[pk] || hash[pk.to_sym] || hash
152
+ nested = nested.to_unsafe_h if nested.respond_to?(:to_unsafe_h)
153
+ return {} unless nested.is_a?(Hash)
154
+
155
+ nested.transform_keys(&:to_sym)
156
+ end
157
+
158
+ def _ref_type?(type)
159
+ return true if type.is_a?(Dex::RefType)
160
+ return _ref_type?(type.type) if type.respond_to?(:type)
161
+
162
+ false
163
+ end
164
+
165
+ def _coerce_param(type, raw)
166
+ inner = type.is_a?(Literal::Types::NilableType) ? type.type : type
167
+
168
+ if inner.is_a?(Literal::Types::ArrayType)
169
+ values = Array(raw)
170
+ values = values.reject { |v| v.is_a?(String) && v.empty? }
171
+ return values.map { |v| _coerce_single(inner.type, v) }.compact
172
+ end
173
+
174
+ _coerce_single(inner, raw)
175
+ end
176
+
177
+ def _coerce_single(type, value)
178
+ return value unless value.is_a?(String)
179
+
180
+ base = _resolve_coercion_class(type)
181
+ return value unless base
182
+
183
+ case base.name
184
+ when "Integer"
185
+ Integer(value, 10)
186
+ when "Float"
187
+ Float(value)
188
+ when "Date"
189
+ Date.parse(value)
190
+ when "Time"
191
+ Time.parse(value)
192
+ when "DateTime"
193
+ DateTime.parse(value)
194
+ when "BigDecimal"
195
+ BigDecimal(value)
196
+ else
197
+ value
198
+ end
199
+ rescue ArgumentError, TypeError
200
+ nil
201
+ end
202
+
203
+ def _resolve_coercion_class(type)
204
+ return type if type.is_a?(Class)
205
+ return _resolve_coercion_class(type.type) if type.respond_to?(:type)
206
+
207
+ nil
208
+ end
209
+ end
210
+
211
+ def resolve
212
+ base = _evaluate_scope
213
+ base = _merge_injected_scope(base)
214
+ base = _apply_filters(base)
215
+ _apply_sort(base)
216
+ end
217
+
218
+ def sort
219
+ _current_sort
220
+ end
221
+
222
+ def to_params
223
+ result = {}
224
+
225
+ self.class.literal_properties.each do |prop|
226
+ value = public_send(prop.name)
227
+ result[prop.name] = value unless value.nil?
228
+ end
229
+
230
+ s = _current_sort
231
+ result[:sort] = s if s
232
+
233
+ result
234
+ end
235
+
236
+ def persisted?
237
+ false
238
+ end
239
+
240
+ private
241
+
242
+ def _current_sort
243
+ @_sort_value || self.class._sort_default
244
+ end
245
+
246
+ def _evaluate_scope
247
+ block = self.class._scope_block
248
+ raise ArgumentError, "No scope defined. Use `scope { Model.all }` in your Query class." unless block
249
+
250
+ instance_exec(&block)
251
+ end
252
+
253
+ def _merge_injected_scope(base)
254
+ return base unless @_injected_scope
255
+
256
+ unless base.respond_to?(:klass)
257
+ raise ArgumentError, "Scope block must return a queryable scope (ActiveRecord relation or Mongoid criteria), got #{base.class}."
258
+ end
259
+
260
+ unless @_injected_scope.respond_to?(:klass)
261
+ raise ArgumentError, "Injected scope must be a queryable scope (ActiveRecord relation or Mongoid criteria), got #{@_injected_scope.class}."
262
+ end
263
+
264
+ unless base.klass == @_injected_scope.klass
265
+ raise ArgumentError, "Scope model mismatch: expected #{base.klass}, got #{@_injected_scope.klass}."
266
+ end
267
+
268
+ base.merge(@_injected_scope)
269
+ end
270
+ end
271
+ end
data/lib/dex/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dex
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/dexkit.rb CHANGED
@@ -17,6 +17,8 @@ require_relative "dex/props_setup"
17
17
  require_relative "dex/error"
18
18
  require_relative "dex/operation"
19
19
  require_relative "dex/event"
20
+ require_relative "dex/form"
21
+ require_relative "dex/query"
20
22
 
21
23
  module Dex
22
24
  class Configuration