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 +4 -4
- data/README.md +44 -0
- data/Untitled +12 -0
- data/guides/llm/QUERY.md +348 -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 +1 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bc994e44218cf9063af69ad1d01929d86b91fe35b6f30769dd068832fb04fc37
|
|
4
|
+
data.tar.gz: b4a078fb25780824cd2a876f5d668196ba2f3a2094ce63a1b9731dfeab1c7aa6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
+
|
data/guides/llm/QUERY.md
ADDED
|
@@ -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
data/lib/dexkit.rb
CHANGED
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.
|
|
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
|