dexkit 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ec8b561633fbdf9989f8bc2476fe84ad2cd0c32177990d03380d63f59a8368a9
4
- data.tar.gz: a348e6b1090374bfda6281e6a58fe16af3e285bb009fad38be60a3ba2c510720
3
+ metadata.gz: bc994e44218cf9063af69ad1d01929d86b91fe35b6f30769dd068832fb04fc37
4
+ data.tar.gz: b4a078fb25780824cd2a876f5d668196ba2f3a2094ce63a1b9731dfeab1c7aa6
5
5
  SHA512:
6
- metadata.gz: 49d44b87657f65927b88c7fc1296ccab5d5246637a1887a126b0949e11bd452ecc0deb5fea90ef7f3329051b7bc13895f8f0ef73286705de44f7d21dccc0802a
7
- data.tar.gz: 43182a4b6b6c904afe87fb385cff3a42057975363b5fd53e10744264f3af8cd5d48306b0ade26fd195b0d4fca696cbd85ac6c06e4434f48c280fb42a959beb80
6
+ metadata.gz: b4bb8dbbe3c9acd66e5c3ed6a6b203408f66c7455544c36de4e60e755a9cfa9a2fa3405a39648e77e5bd42b2593a053d5d517e16ce1cd5236225083bd24891b3
7
+ data.tar.gz: c050bd2dc477e19efcebbe6bfa6fe7d46dbfa4dea5367f38c098b6c82750439211d1b4a28224c21fd66e98c8971b9532f341f4e17b4d2e836383470ccf778913
data/README.md CHANGED
@@ -196,6 +196,49 @@ def save
196
196
  end
197
197
  ```
198
198
 
199
+ ## Queries
200
+
201
+ Declarative query objects for filtering and sorting ActiveRecord relations.
202
+
203
+ ```ruby
204
+ class UserSearch < Dex::Query
205
+ scope { User.all }
206
+
207
+ prop? :name, String
208
+ prop? :role, _Array(String)
209
+ prop? :age_min, Integer
210
+
211
+ filter :name, :contains
212
+ filter :role, :in
213
+ filter :age_min, :gte, column: :age
214
+
215
+ sort :name, :created_at, default: "-created_at"
216
+ end
217
+
218
+ users = UserSearch.call(name: "ali", role: %w[admin], sort: "name")
219
+ ```
220
+
221
+ ### What you get out of the box
222
+
223
+ **11 built-in filter strategies** — `:eq`, `:not_eq`, `:contains`, `:starts_with`, `:ends_with`, `:gt`, `:gte`, `:lt`, `:lte`, `:in`, `:not_in`. Custom blocks for complex logic.
224
+
225
+ **Sorting** with ascending/descending column sorts, custom sort blocks, and defaults.
226
+
227
+ **`from_params`** — HTTP boundary handling with automatic coercion, blank stripping, and invalid value fallback:
228
+
229
+ ```ruby
230
+ class UsersController < ApplicationController
231
+ def index
232
+ query = UserSearch.from_params(params, scope: policy_scope(User))
233
+ @users = pagy(query.resolve)
234
+ end
235
+ end
236
+ ```
237
+
238
+ **Form binding** — works with `form_with` for search forms. Queries respond to `model_name`, `param_key`, `persisted?`, and `to_params`.
239
+
240
+ **Scope injection** — narrow the base scope at call time without modifying the query class.
241
+
199
242
  ## Installation
200
243
 
201
244
  ```ruby
@@ -214,6 +257,7 @@ Dexkit ships LLM-optimized guides. Copy them into your project so AI agents auto
214
257
  cp $(bundle show dexkit)/guides/llm/OPERATION.md app/operations/CLAUDE.md
215
258
  cp $(bundle show dexkit)/guides/llm/EVENT.md app/event_handlers/CLAUDE.md
216
259
  cp $(bundle show dexkit)/guides/llm/FORM.md app/forms/CLAUDE.md
260
+ cp $(bundle show dexkit)/guides/llm/QUERY.md app/queries/CLAUDE.md
217
261
  ```
218
262
 
219
263
  ## License
data/Untitled ADDED
@@ -0,0 +1,12 @@
1
+ P2
2
+ Preserve explicit nils in `from_params` initialization
3
+ from_params explicitly assigns nil for blank optional values, but new(**kwargs.compact) removes those keys before initialization. This breaks the documented "blank/invalid to nil" behavior whenever an optional prop has a non-nil default (prop? ... default: ...), because blank or uncoercible input silently falls back to the default and applies unintended filters.
4
+
5
+
6
+
7
+ /Users/razorjack/Projects/OpenSource/dexkit/lib/dex/query.rb:134-134
8
+ P3
9
+ Validate `param_key` input before caching model name
10
+ param_key accepts any truthy value and stores key.to_s without validation, so param_key "" is accepted but later crashes in model_name when ActiveModel::Name.new receives a blank class name. This turns a declaration-time DSL error into a runtime failure in form binding/from_params, which is harder to diagnose.
11
+ /Users/razorjack/Projects/OpenSource/dexkit/lib/dex/query.rb:58-60
12
+
@@ -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,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.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/dexkit.rb CHANGED
@@ -18,6 +18,7 @@ require_relative "dex/error"
18
18
  require_relative "dex/operation"
19
19
  require_relative "dex/event"
20
20
  require_relative "dex/form"
21
+ require_relative "dex/query"
21
22
 
22
23
  module Dex
23
24
  class Configuration
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dexkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jacek Galanciak
@@ -132,9 +132,11 @@ files:
132
132
  - CHANGELOG.md
133
133
  - LICENSE.txt
134
134
  - README.md
135
+ - Untitled
135
136
  - guides/llm/EVENT.md
136
137
  - guides/llm/FORM.md
137
138
  - guides/llm/OPERATION.md
139
+ - guides/llm/QUERY.md
138
140
  - lib/dex/concern.rb
139
141
  - lib/dex/error.rb
140
142
  - lib/dex/event.rb
@@ -168,6 +170,10 @@ files:
168
170
  - lib/dex/operation/transaction_adapter.rb
169
171
  - lib/dex/operation/transaction_wrapper.rb
170
172
  - lib/dex/props_setup.rb
173
+ - lib/dex/query.rb
174
+ - lib/dex/query/backend.rb
175
+ - lib/dex/query/filtering.rb
176
+ - lib/dex/query/sorting.rb
171
177
  - lib/dex/ref_type.rb
172
178
  - lib/dex/test_helpers.rb
173
179
  - lib/dex/test_helpers/assertions.rb