dexkit 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -0
- data/README.md +105 -2
- data/Untitled +12 -0
- data/guides/llm/FORM.md +520 -0
- data/guides/llm/OPERATION.md +1 -1
- data/guides/llm/QUERY.md +348 -0
- data/lib/dex/form/nesting.rb +189 -0
- data/lib/dex/form/uniqueness_validator.rb +86 -0
- data/lib/dex/form.rb +142 -0
- data/lib/dex/operation.rb +3 -0
- data/lib/dex/query/backend.rb +91 -0
- data/lib/dex/query/filtering.rb +73 -0
- data/lib/dex/query/sorting.rb +95 -0
- data/lib/dex/query.rb +271 -0
- data/lib/dex/version.rb +1 -1
- data/lib/dexkit.rb +2 -0
- metadata +41 -3
data/guides/llm/OPERATION.md
CHANGED
|
@@ -187,7 +187,7 @@ in Dex::Err(code: :email_taken) then puts "Already exists"
|
|
|
187
187
|
end
|
|
188
188
|
```
|
|
189
189
|
|
|
190
|
-
`
|
|
190
|
+
`Ok`/`Err` are available inside operations without prefix. In other contexts (controllers, POROs), use `Dex::Ok`/`Dex::Err` or `include Dex::Match`.
|
|
191
191
|
|
|
192
192
|
---
|
|
193
193
|
|
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,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
class Form
|
|
5
|
+
module Nesting
|
|
6
|
+
extend Dex::Concern
|
|
7
|
+
|
|
8
|
+
module ClassMethods
|
|
9
|
+
def _nested_ones
|
|
10
|
+
@_nested_ones ||= {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def _nested_manys
|
|
14
|
+
@_nested_manys ||= {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def nested_one(name, class_name: nil, &block)
|
|
18
|
+
raise ArgumentError, "nested_one requires a block" unless block
|
|
19
|
+
|
|
20
|
+
name = name.to_sym
|
|
21
|
+
nested_class = _build_nested_class(name, class_name, &block)
|
|
22
|
+
_nested_ones[name] = nested_class
|
|
23
|
+
|
|
24
|
+
attr_reader name
|
|
25
|
+
|
|
26
|
+
define_method(:"#{name}=") do |value|
|
|
27
|
+
coerced = _coerce_nested_one(name, value)
|
|
28
|
+
instance_variable_set(:"@#{name}", coerced)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
define_method(:"build_#{name}") do |attrs = {}|
|
|
32
|
+
instance = self.class._nested_ones[name].new(attrs)
|
|
33
|
+
send(:"#{name}=", instance)
|
|
34
|
+
instance
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
define_method(:"#{name}_attributes=") do |attrs|
|
|
38
|
+
send(:"#{name}=", attrs)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def nested_many(name, class_name: nil, &block)
|
|
43
|
+
raise ArgumentError, "nested_many requires a block" unless block
|
|
44
|
+
|
|
45
|
+
name = name.to_sym
|
|
46
|
+
nested_class = _build_nested_class(name, class_name, &block)
|
|
47
|
+
_nested_manys[name] = nested_class
|
|
48
|
+
|
|
49
|
+
attr_reader name
|
|
50
|
+
|
|
51
|
+
define_method(:"#{name}=") do |value|
|
|
52
|
+
coerced = _coerce_nested_many(name, value)
|
|
53
|
+
instance_variable_set(:"@#{name}", coerced)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
define_method(:"build_#{name.to_s.singularize}") do |attrs = {}|
|
|
57
|
+
instance = self.class._nested_manys[name].new(attrs)
|
|
58
|
+
items = send(name) || []
|
|
59
|
+
items << instance
|
|
60
|
+
instance_variable_set(:"@#{name}", items)
|
|
61
|
+
instance
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
define_method(:"#{name}_attributes=") do |attrs|
|
|
65
|
+
send(:"#{name}=", attrs)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def _build_nested_class(name, class_name, &block)
|
|
72
|
+
klass = Class.new(Dex::Form, &block)
|
|
73
|
+
const_name = class_name || name.to_s.singularize.camelize
|
|
74
|
+
const_set(const_name, klass)
|
|
75
|
+
klass
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def _coerce_nested_one(name, value)
|
|
82
|
+
klass = self.class._nested_ones[name]
|
|
83
|
+
value = _unwrap_hash_like(value)
|
|
84
|
+
case value
|
|
85
|
+
when Hash
|
|
86
|
+
return nil if _marked_for_destroy?(value)
|
|
87
|
+
klass.new(value.except("_destroy", :_destroy))
|
|
88
|
+
when klass then value
|
|
89
|
+
when nil then value
|
|
90
|
+
else raise ArgumentError, "#{name} must be a Hash or #{klass}, got #{value.class}"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def _coerce_nested_many(name, value)
|
|
95
|
+
klass = self.class._nested_manys[name]
|
|
96
|
+
value = _unwrap_hash_like(value)
|
|
97
|
+
items = case value
|
|
98
|
+
when Array then value
|
|
99
|
+
when Hash then _normalize_nested_hash(value)
|
|
100
|
+
else raise ArgumentError, "#{name} must be an Array or Hash, got #{value.class}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
items.filter_map do |item|
|
|
104
|
+
item = _unwrap_hash_like(item)
|
|
105
|
+
case item
|
|
106
|
+
when Hash
|
|
107
|
+
next nil if _marked_for_destroy?(item)
|
|
108
|
+
klass.new(item.except("_destroy", :_destroy))
|
|
109
|
+
when klass then item
|
|
110
|
+
else raise ArgumentError, "each #{name} item must be a Hash or #{klass}"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def _unwrap_hash_like(value)
|
|
116
|
+
return value.to_unsafe_h if value.respond_to?(:to_unsafe_h)
|
|
117
|
+
return value if value.is_a?(Hash) || value.is_a?(Array) || value.is_a?(Dex::Form) || value.nil?
|
|
118
|
+
|
|
119
|
+
value.respond_to?(:to_h) ? value.to_h : value
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def _normalize_nested_hash(hash)
|
|
123
|
+
hash.sort_by { |k, _| k.to_s.to_i }.map(&:last)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def _marked_for_destroy?(attrs)
|
|
127
|
+
destroy_val = attrs["_destroy"] || attrs[:_destroy]
|
|
128
|
+
ActiveModel::Type::Boolean.new.cast(destroy_val)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def _initialize_nested_defaults(provided_keys)
|
|
132
|
+
self.class._nested_ones.each_key do |name|
|
|
133
|
+
key = name.to_s
|
|
134
|
+
next if provided_keys.include?(key) || provided_keys.include?("#{key}_attributes")
|
|
135
|
+
|
|
136
|
+
send(:"#{name}=", {})
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
self.class._nested_manys.each_key do |name|
|
|
140
|
+
next if instance_variable_get(:"@#{name}")
|
|
141
|
+
|
|
142
|
+
instance_variable_set(:"@#{name}", [])
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def _validate_nested(context)
|
|
147
|
+
valid = true
|
|
148
|
+
|
|
149
|
+
self.class._nested_ones.each_key do |name|
|
|
150
|
+
nested = send(name)
|
|
151
|
+
next unless nested
|
|
152
|
+
|
|
153
|
+
unless nested.valid?(context)
|
|
154
|
+
nested.errors.each do |error|
|
|
155
|
+
errors.add(:"#{name}.#{error.attribute}", error.message)
|
|
156
|
+
end
|
|
157
|
+
valid = false
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
self.class._nested_manys.each_key do |name|
|
|
162
|
+
items = send(name) || []
|
|
163
|
+
items.each_with_index do |item, index|
|
|
164
|
+
next if item.valid?(context)
|
|
165
|
+
|
|
166
|
+
item.errors.each do |error|
|
|
167
|
+
errors.add(:"#{name}[#{index}].#{error.attribute}", error.message)
|
|
168
|
+
end
|
|
169
|
+
valid = false
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
valid
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def _nested_to_h(result)
|
|
177
|
+
self.class._nested_ones.each_key do |name|
|
|
178
|
+
nested = send(name)
|
|
179
|
+
result[name] = nested&.to_h
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
self.class._nested_manys.each_key do |name|
|
|
183
|
+
items = send(name) || []
|
|
184
|
+
result[name] = items.map(&:to_h)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
class Form
|
|
5
|
+
class UniquenessValidator < ActiveModel::EachValidator
|
|
6
|
+
def check_validity!
|
|
7
|
+
if options.key?(:model) && !options[:model].is_a?(Class)
|
|
8
|
+
raise ArgumentError, "uniqueness :model must be a Class, got #{options[:model].inspect}"
|
|
9
|
+
end
|
|
10
|
+
if options.key?(:conditions) && !options[:conditions].respond_to?(:call)
|
|
11
|
+
raise ArgumentError, "uniqueness :conditions must be callable"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def validate_each(form, attribute, value)
|
|
16
|
+
return if value.blank?
|
|
17
|
+
|
|
18
|
+
model_class = _resolve_model_class(form)
|
|
19
|
+
return unless model_class
|
|
20
|
+
|
|
21
|
+
column = options[:attribute] || attribute
|
|
22
|
+
query = _build_query(model_class, column, value)
|
|
23
|
+
query = _apply_scope(query, form)
|
|
24
|
+
query = _apply_conditions(query, form)
|
|
25
|
+
query = _exclude_current_record(query, form)
|
|
26
|
+
|
|
27
|
+
form.errors.add(attribute, options[:message] || :taken) if query.exists?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def _resolve_model_class(form)
|
|
33
|
+
return options[:model] if options[:model]
|
|
34
|
+
return form.class._model_class if form.class.respond_to?(:_model_class) && form.class._model_class
|
|
35
|
+
|
|
36
|
+
_infer_model_class(form)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def _infer_model_class(form)
|
|
40
|
+
class_name = form.class.name
|
|
41
|
+
return unless class_name
|
|
42
|
+
|
|
43
|
+
model_name = class_name.sub(/Form\z/, "")
|
|
44
|
+
return if model_name == class_name
|
|
45
|
+
|
|
46
|
+
klass = Object.const_get(model_name)
|
|
47
|
+
klass.respond_to?(:where) ? klass : nil
|
|
48
|
+
rescue NameError
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def _build_query(model_class, column, value)
|
|
53
|
+
if options[:case_sensitive] == false && value.is_a?(String) && model_class.respond_to?(:arel_table)
|
|
54
|
+
model_class.where(model_class.arel_table[column].lower.eq(value.downcase))
|
|
55
|
+
else
|
|
56
|
+
model_class.where(column => value)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def _apply_scope(query, form)
|
|
61
|
+
Array(options[:scope]).each do |scope_attr|
|
|
62
|
+
query = query.where(scope_attr => form.public_send(scope_attr))
|
|
63
|
+
end
|
|
64
|
+
query
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def _apply_conditions(query, form)
|
|
68
|
+
return query unless options[:conditions]
|
|
69
|
+
|
|
70
|
+
callable = options[:conditions]
|
|
71
|
+
if callable.arity.zero?
|
|
72
|
+
query.instance_exec(&callable)
|
|
73
|
+
else
|
|
74
|
+
query.instance_exec(form, &callable)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def _exclude_current_record(query, form)
|
|
79
|
+
return query unless form.record&.persisted?
|
|
80
|
+
|
|
81
|
+
pk = form.record.class.primary_key
|
|
82
|
+
query.where.not(pk => form.record.public_send(pk))
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|