dexkit 0.3.0 → 0.4.1

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: c6f6f437fc7f7726b58232820e6f1afd65c5adf674f51c4be07944a43d92a58b
4
+ data.tar.gz: 13046423e606ff6e000b6d557ce468efa94d0f13d8aa36530dc5ae69a688e097
5
5
  SHA512:
6
- metadata.gz: 49d44b87657f65927b88c7fc1296ccab5d5246637a1887a126b0949e11bd452ecc0deb5fea90ef7f3329051b7bc13895f8f0ef73286705de44f7d21dccc0802a
7
- data.tar.gz: 43182a4b6b6c904afe87fb385cff3a42057975363b5fd53e10744264f3af8cd5d48306b0ade26fd195b0d4fca696cbd85ac6c06e4434f48c280fb42a959beb80
6
+ metadata.gz: d48748c521d0e0c298ab78230797f7a22e45ae194fee6669f0f99669ea0ac1333b59158a8068211319694cbd073660f298babdb0a784b251444b579b898ba1c0
7
+ data.tar.gz: 4a574717aa7d2f5f5de9d192b86d7a6151d5b1a8cd2bba04c79e933cb2768e5c4d7cd05d91e22cf3ca9973cfca59467cd1a6972df8626ce90971ca9fec2a365d
data/README.md CHANGED
@@ -18,7 +18,12 @@ class CreateUser < Dex::Operation
18
18
 
19
19
  def perform
20
20
  error!(:email_taken) if User.exists?(email: email)
21
- User.create!(name: name, email: email)
21
+
22
+ user = User.create!(name: name, email: email)
23
+
24
+ after_commit { WelcomeMailer.with(user: user).deliver_later }
25
+
26
+ user
22
27
  end
23
28
  end
24
29
 
@@ -196,6 +201,49 @@ def save
196
201
  end
197
202
  ```
198
203
 
204
+ ## Queries
205
+
206
+ Declarative query objects for filtering and sorting ActiveRecord relations.
207
+
208
+ ```ruby
209
+ class UserSearch < Dex::Query
210
+ scope { User.all }
211
+
212
+ prop? :name, String
213
+ prop? :role, _Array(String)
214
+ prop? :age_min, Integer
215
+
216
+ filter :name, :contains
217
+ filter :role, :in
218
+ filter :age_min, :gte, column: :age
219
+
220
+ sort :name, :created_at, default: "-created_at"
221
+ end
222
+
223
+ users = UserSearch.call(name: "ali", role: %w[admin], sort: "name")
224
+ ```
225
+
226
+ ### What you get out of the box
227
+
228
+ **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.
229
+
230
+ **Sorting** with ascending/descending column sorts, custom sort blocks, and defaults.
231
+
232
+ **`from_params`** — HTTP boundary handling with automatic coercion, blank stripping, and invalid value fallback:
233
+
234
+ ```ruby
235
+ class UsersController < ApplicationController
236
+ def index
237
+ query = UserSearch.from_params(params, scope: policy_scope(User))
238
+ @users = pagy(query.resolve)
239
+ end
240
+ end
241
+ ```
242
+
243
+ **Form binding** — works with `form_with` for search forms. Queries respond to `model_name`, `param_key`, `persisted?`, and `to_params`.
244
+
245
+ **Scope injection** — narrow the base scope at call time without modifying the query class.
246
+
199
247
  ## Installation
200
248
 
201
249
  ```ruby
@@ -214,6 +262,7 @@ Dexkit ships LLM-optimized guides. Copy them into your project so AI agents auto
214
262
  cp $(bundle show dexkit)/guides/llm/OPERATION.md app/operations/CLAUDE.md
215
263
  cp $(bundle show dexkit)/guides/llm/EVENT.md app/event_handlers/CLAUDE.md
216
264
  cp $(bundle show dexkit)/guides/llm/FORM.md app/forms/CLAUDE.md
265
+ cp $(bundle show dexkit)/guides/llm/QUERY.md app/queries/CLAUDE.md
217
266
  ```
218
267
 
219
268
  ## 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
+
@@ -20,7 +20,12 @@ class CreateUser < Dex::Operation
20
20
  def perform
21
21
  error!(:invalid_email) unless email.include?("@")
22
22
  error!(:email_taken) if User.exists?(email: email)
23
- User.create!(email: email, name: name, role: role)
23
+
24
+ user = User.create!(email: email, name: name, role: role)
25
+
26
+ after_commit { WelcomeMailer.with(user: user).deliver_later }
27
+
28
+ user
24
29
  end
25
30
  end
26
31
  ```
@@ -255,6 +260,25 @@ transaction :mongoid # adapter override (default: auto-detect AR → Mongoid
255
260
 
256
261
  Child classes can re-enable: `transaction true`.
257
262
 
263
+ ### after_commit
264
+
265
+ Register blocks to run after the transaction commits. Use for side effects that should only happen on success (emails, webhooks, cache invalidation):
266
+
267
+ ```ruby
268
+ def perform
269
+ user = User.create!(name: name, email: email)
270
+ after_commit { WelcomeMailer.with(user: user).deliver_later }
271
+ after_commit { Analytics.track(:user_created, user_id: user.id) }
272
+ user
273
+ end
274
+ ```
275
+
276
+ On rollback (`error!` or exception), callbacks are discarded. When no transaction is open anywhere, executes immediately. Multiple blocks run in registration order.
277
+
278
+ **ActiveRecord:** fully nesting-aware — callbacks are deferred until the outermost transaction commits, even across nested operations or ambient `ActiveRecord::Base.transaction` blocks. Requires Rails 7.2+.
279
+
280
+ **Mongoid:** callbacks are deferred across nested Dex operations. Ambient `Mongoid.transaction` blocks opened outside Dex are not detected — callbacks will fire immediately in that case.
281
+
258
282
  ---
259
283
 
260
284
  ## Advisory Locking
@@ -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
+ ```
@@ -32,17 +32,63 @@ module Dex
32
32
  ActiveRecord::Base.transaction(&block)
33
33
  end
34
34
 
35
+ def self.after_commit(&block)
36
+ unless defined?(ActiveRecord) && ActiveRecord.respond_to?(:after_all_transactions_commit)
37
+ raise LoadError, "after_commit requires Rails 7.2+"
38
+ end
39
+
40
+ ActiveRecord.after_all_transactions_commit(&block)
41
+ end
42
+
35
43
  def self.rollback_exception_class
36
44
  ActiveRecord::Rollback
37
45
  end
38
46
  end
39
47
 
40
48
  module MongoidAdapter
49
+ AFTER_COMMIT_KEY = :_dex_mongoid_after_commit
50
+
41
51
  def self.wrap(&block)
42
52
  unless defined?(Mongoid)
43
53
  raise LoadError, "Mongoid is required for transactions"
44
54
  end
45
- Mongoid.transaction(&block)
55
+
56
+ outermost = !Thread.current[AFTER_COMMIT_KEY]
57
+ Thread.current[AFTER_COMMIT_KEY] ||= []
58
+ snapshot = Thread.current[AFTER_COMMIT_KEY].length
59
+
60
+ block_completed = false
61
+ result = Mongoid.transaction do
62
+ value = block.call
63
+ block_completed = true
64
+ value
65
+ end
66
+
67
+ if outermost && block_completed
68
+ Thread.current[AFTER_COMMIT_KEY].each(&:call)
69
+ elsif !block_completed
70
+ # Mongoid swallowed a Rollback exception — discard callbacks from this level
71
+ Thread.current[AFTER_COMMIT_KEY].slice!(snapshot..)
72
+ end
73
+
74
+ result
75
+ rescue StandardError # rubocop:disable Style/RescueStandardError
76
+ Thread.current[AFTER_COMMIT_KEY]&.slice!(snapshot..) unless outermost
77
+ raise
78
+ ensure
79
+ Thread.current[AFTER_COMMIT_KEY] = nil if outermost
80
+ end
81
+
82
+ # NOTE: Only detects transactions opened via MongoidAdapter.wrap (i.e. Dex operations).
83
+ # Ambient Mongoid.transaction blocks opened outside Dex are invisible here —
84
+ # the callback will fire immediately instead of deferring to the outer commit.
85
+ def self.after_commit(&block)
86
+ callbacks = Thread.current[AFTER_COMMIT_KEY]
87
+ if callbacks
88
+ callbacks << block
89
+ else
90
+ block.call
91
+ end
46
92
  end
47
93
 
48
94
  def self.rollback_exception_class
@@ -18,6 +18,17 @@ module Dex
18
18
  result
19
19
  end
20
20
 
21
+ def after_commit(&block)
22
+ raise ArgumentError, "after_commit requires a block" unless block
23
+
24
+ adapter = _transaction_adapter
25
+ if adapter
26
+ adapter.after_commit(&block)
27
+ else
28
+ block.call
29
+ end
30
+ end
31
+
21
32
  TRANSACTION_KNOWN_ADAPTERS = %i[active_record mongoid].freeze
22
33
 
23
34
  module ClassMethods
@@ -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.1"
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.1
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