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.
@@ -187,7 +187,7 @@ in Dex::Err(code: :email_taken) then puts "Already exists"
187
187
  end
188
188
  ```
189
189
 
190
- `include Dex::Match` to use `Ok`/`Err` without `Dex::` prefix.
190
+ `Ok`/`Err` are available inside operations without prefix. In other contexts (controllers, POROs), use `Dex::Ok`/`Dex::Err` or `include Dex::Match`.
191
191
 
192
192
  ---
193
193
 
@@ -0,0 +1,348 @@
1
+ # Dex::Query — LLM Reference
2
+
3
+ Copy this to your app's queries directory (e.g., `app/queries/AGENTS.md`) so coding agents know the full API when implementing and testing queries.
4
+
5
+ ---
6
+
7
+ ## Reference Query
8
+
9
+ All examples below build on this query unless noted otherwise:
10
+
11
+ ```ruby
12
+ class UserSearch < Dex::Query
13
+ scope { User.all }
14
+
15
+ prop? :name, String
16
+ prop? :role, _Array(String)
17
+ prop? :age_min, Integer
18
+ prop? :status, String
19
+
20
+ filter :name, :contains
21
+ filter :role, :in
22
+ filter :age_min, :gte, column: :age
23
+ filter :status
24
+
25
+ sort :name, :created_at, default: "-created_at"
26
+ sort(:relevance) { |scope| scope.order(Arel.sql("LENGTH(name)")) }
27
+ end
28
+ ```
29
+
30
+ ---
31
+
32
+ ## Defining Queries
33
+
34
+ Queries declare a base scope, typed props for filter inputs, filter strategies, and sort columns.
35
+
36
+ ```ruby
37
+ class ProjectSearch < Dex::Query
38
+ scope { Project.where(archived: false) }
39
+
40
+ prop? :name, String
41
+ filter :name, :contains
42
+
43
+ sort :name, :created_at, default: "name"
44
+ end
45
+ ```
46
+
47
+ ### `scope { ... }`
48
+
49
+ Required. Defines the base relation. The block is `instance_exec`'d so props are accessible:
50
+
51
+ ```ruby
52
+ class TaskSearch < Dex::Query
53
+ scope { project.tasks }
54
+
55
+ prop :project, _Ref(Project)
56
+ prop? :status, String
57
+ filter :status
58
+ end
59
+ ```
60
+
61
+ ### Props
62
+
63
+ Uses the same `prop`/`prop?`/`_Ref` DSL as Operation and Event (powered by Literal):
64
+
65
+ ```ruby
66
+ prop :project, _Ref(Project) # required, auto-finds by ID
67
+ prop? :name, String # optional (nil by default)
68
+ prop? :roles, _Array(String) # optional array
69
+ prop? :age_min, Integer # optional integer
70
+ ```
71
+
72
+ Reserved prop names: `scope`, `sort`, `resolve`, `call`, `from_params`, `to_params`, `param_key`.
73
+
74
+ ---
75
+
76
+ ## Filters
77
+
78
+ ### Built-in Strategies
79
+
80
+ | Strategy | SQL | Example |
81
+ |----------|-----|---------|
82
+ | `:eq` (default) | `= value` | `filter :status` |
83
+ | `:not_eq` | `!= value` | `filter :status, :not_eq` |
84
+ | `:contains` | `LIKE %value%` | `filter :name, :contains` |
85
+ | `:starts_with` | `LIKE value%` | `filter :name, :starts_with` |
86
+ | `:ends_with` | `LIKE %value` | `filter :name, :ends_with` |
87
+ | `:gt` | `> value` | `filter :age, :gt` |
88
+ | `:gte` | `>= value` | `filter :age_min, :gte, column: :age` |
89
+ | `:lt` | `< value` | `filter :age, :lt` |
90
+ | `:lte` | `<= value` | `filter :age, :lte` |
91
+ | `:in` | `IN (values)` | `filter :roles, :in, column: :role` |
92
+ | `:not_in` | `NOT IN (values)` | `filter :roles, :not_in, column: :role` |
93
+
94
+ String strategies (`:contains`, `:starts_with`, `:ends_with`) use case-insensitive matching. With ActiveRecord, this uses Arel `matches` (LIKE); with Mongoid, case-insensitive regex. Wildcards in values are auto-sanitized. The adapter is auto-detected from the scope.
95
+
96
+ ### Column Mapping
97
+
98
+ Map a prop name to a different column:
99
+
100
+ ```ruby
101
+ prop? :age_min, Integer
102
+ filter :age_min, :gte, column: :age
103
+ ```
104
+
105
+ ### Custom Filter Blocks
106
+
107
+ For complex logic, use a block. Sanitize LIKE wildcards manually (built-in strategies handle this automatically):
108
+
109
+ ```ruby
110
+ prop? :search, String
111
+ filter(:search) do |scope, value|
112
+ sanitized = ActiveRecord::Base.sanitize_sql_like(value)
113
+ scope.where("name LIKE ? OR email LIKE ?", "%#{sanitized}%", "%#{sanitized}%")
114
+ end
115
+ ```
116
+
117
+ ### Nil Skipping
118
+
119
+ - Optional props (`prop?`) skip their filter when nil
120
+ - `:in` / `:not_in` strategies also skip when value is nil or empty array
121
+
122
+ ---
123
+
124
+ ## Sorting
125
+
126
+ ### Column Sorts
127
+
128
+ ```ruby
129
+ sort :name, :created_at, :age # multiple columns at once
130
+ sort :email # or one at a time
131
+ ```
132
+
133
+ At call time, prefix with `-` for descending:
134
+
135
+ ```ruby
136
+ UserSearch.call(sort: "name") # ASC
137
+ UserSearch.call(sort: "-created_at") # DESC
138
+ ```
139
+
140
+ ### Custom Sorts
141
+
142
+ ```ruby
143
+ sort(:relevance) { |scope| scope.order(Arel.sql("LENGTH(name)")) }
144
+ ```
145
+
146
+ Custom sorts cannot use the `-` prefix (direction is baked into the block).
147
+
148
+ ### Default Sort
149
+
150
+ ```ruby
151
+ sort :name, :created_at, default: "-created_at"
152
+ ```
153
+
154
+ Only one default per class. Applied when no sort is provided.
155
+
156
+ ---
157
+
158
+ ## Calling Queries
159
+
160
+ ### `.call`
161
+
162
+ Returns an ActiveRecord relation:
163
+
164
+ ```ruby
165
+ users = UserSearch.call(name: "ali", role: %w[admin], sort: "-name")
166
+ users.each { |u| puts u.name }
167
+ ```
168
+
169
+ ### Shortcuts
170
+
171
+ ```ruby
172
+ UserSearch.count(role: %w[admin])
173
+ UserSearch.exists?(name: "Alice")
174
+ UserSearch.any?(status: "active")
175
+ ```
176
+
177
+ ### Scope Injection
178
+
179
+ Narrow the base scope without modifying the query class:
180
+
181
+ ```ruby
182
+ active_users = User.where(active: true)
183
+ UserSearch.call(scope: active_users, name: "ali")
184
+ ```
185
+
186
+ The injected scope is merged via `.merge` — model must match.
187
+
188
+ ### Instance Usage
189
+
190
+ ```ruby
191
+ query = UserSearch.new(name: "ali", sort: "-name")
192
+ query.name # => "ali"
193
+ query.sort # => "-name"
194
+ result = query.resolve
195
+ ```
196
+
197
+ ---
198
+
199
+ ## `from_params` — HTTP Boundary
200
+
201
+ Extracts, coerces, and validates params from a controller:
202
+
203
+ ```ruby
204
+ UserSearch.from_params(params)
205
+ UserSearch.from_params(params, scope: current_team.users)
206
+ UserSearch.from_params(params, scope: current_team.users, project: current_project)
207
+ ```
208
+
209
+ ### What it does
210
+
211
+ 1. Extracts the nested hash from `params[param_key]` (e.g., `params[:user_search]`); falls back to flat params if the nested key is absent
212
+ 2. Extracts `sort` from that hash
213
+ 3. Strips blank strings to nil for optional props
214
+ 4. Compacts array blanks (`["admin", ""]` → `["admin"]`)
215
+ 5. Coerces strings to typed values (Integer, Date, etc.) — drops uncoercible to nil
216
+ 6. Skips `_Ref` typed props (must be passed as keyword overrides)
217
+ 7. Drops invalid sort values (falls back to default)
218
+ 8. Applies keyword overrides (pinned values)
219
+ 9. Returns a query instance
220
+
221
+ ### Controller Pattern
222
+
223
+ ```ruby
224
+ class UsersController < ApplicationController
225
+ def index
226
+ query = UserSearch.from_params(params, scope: policy_scope(User))
227
+ @users = pagy(query.resolve)
228
+ end
229
+ end
230
+ ```
231
+
232
+ ---
233
+
234
+ ## Form Binding
235
+
236
+ Queries work with `form_with` for search forms:
237
+
238
+ ```ruby
239
+ # Controller
240
+ @query = UserSearch.from_params(params)
241
+
242
+ # View
243
+ <%= form_with model: @query, url: users_path, method: :get do |f| %>
244
+ <%= f.text_field :name %>
245
+ <%= f.select :role, %w[admin user], include_blank: true %>
246
+ <%= f.hidden_field :sort, value: @query.sort %>
247
+ <%= f.submit "Search" %>
248
+ <% end %>
249
+ ```
250
+
251
+ ### `param_key`
252
+
253
+ Override the default param key:
254
+
255
+ ```ruby
256
+ class UserSearch < Dex::Query
257
+ param_key :q
258
+ # params[:q][:name] instead of params[:user_search][:name]
259
+ end
260
+ ```
261
+
262
+ ### `model_name`
263
+
264
+ Derives from class name by default. Anonymous classes fall back to "query".
265
+
266
+ ### `to_params`
267
+
268
+ Returns a hash of non-nil prop values + current sort:
269
+
270
+ ```ruby
271
+ query = UserSearch.new(name: "ali", sort: "-name")
272
+ query.to_params # => { name: "ali", sort: "-name" }
273
+ ```
274
+
275
+ ### `persisted?`
276
+
277
+ Always returns `false` (queries are never persisted).
278
+
279
+ ---
280
+
281
+ ## Inheritance
282
+
283
+ Subclasses inherit filters, sorts, and props. They can add new ones or replace the scope:
284
+
285
+ ```ruby
286
+ class AdminUserSearch < UserSearch
287
+ scope { User.where(admin: true) } # replaces parent scope
288
+
289
+ prop? :department, String
290
+ filter :department
291
+ end
292
+ ```
293
+
294
+ ---
295
+
296
+ ## Testing
297
+
298
+ Queries return standard ActiveRecord relations. Test them with plain Minitest:
299
+
300
+ ```ruby
301
+ class UserSearchTest < Minitest::Test
302
+ def setup
303
+ User.create!(name: "Alice", role: "admin", age: 30)
304
+ User.create!(name: "Bob", role: "user", age: 25)
305
+ end
306
+
307
+ def test_filters_by_role
308
+ result = UserSearch.call(role: %w[admin])
309
+ assert_equal 1, result.count
310
+ assert_equal "Alice", result.first.name
311
+ end
312
+
313
+ def test_contains_search
314
+ result = UserSearch.call(name: "li")
315
+ assert_equal 1, result.count
316
+ end
317
+
318
+ def test_sorting
319
+ result = UserSearch.call(sort: "name")
320
+ assert_equal %w[Alice Bob], result.map(&:name)
321
+ end
322
+
323
+ def test_default_sort
324
+ result = UserSearch.call
325
+ assert_equal "Bob", result.first.name # -created_at = newest first
326
+ end
327
+
328
+ def test_scope_injection
329
+ active = User.where(active: true)
330
+ result = UserSearch.call(scope: active, role: %w[user])
331
+ assert_equal 1, result.count
332
+ end
333
+
334
+ def test_from_params
335
+ params = ActionController::Parameters.new(
336
+ user_search: { name: "ali", sort: "-name" }
337
+ )
338
+ query = UserSearch.from_params(params)
339
+ assert_equal "ali", query.name
340
+ assert_equal "-name", query.sort
341
+ end
342
+
343
+ def test_to_params
344
+ query = UserSearch.new(name: "ali", sort: "-name")
345
+ assert_equal({ name: "ali", sort: "-name" }, query.to_params)
346
+ end
347
+ end
348
+ ```
@@ -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