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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -0
- data/README.md +105 -2
- data/Untitled +12 -0
- data/guides/llm/FORM.md +520 -0
- data/guides/llm/OPERATION.md +1 -1
- data/guides/llm/QUERY.md +348 -0
- data/lib/dex/form/nesting.rb +189 -0
- data/lib/dex/form/uniqueness_validator.rb +86 -0
- data/lib/dex/form.rb +142 -0
- data/lib/dex/operation.rb +3 -0
- data/lib/dex/query/backend.rb +91 -0
- data/lib/dex/query/filtering.rb +73 -0
- data/lib/dex/query/sorting.rb +95 -0
- data/lib/dex/query.rb +271 -0
- data/lib/dex/version.rb +1 -1
- data/lib/dexkit.rb +2 -0
- metadata +41 -3
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
|
@@ -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
data/lib/dexkit.rb
CHANGED