plutonium 0.50.0 → 0.51.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/.claude/skills/plutonium/SKILL.md +85 -102
- data/.claude/skills/plutonium-app/SKILL.md +572 -0
- data/.claude/skills/plutonium-auth/SKILL.md +163 -300
- data/.claude/skills/plutonium-behavior/SKILL.md +838 -0
- data/.claude/skills/plutonium-resource/SKILL.md +1176 -0
- data/.claude/skills/plutonium-tenancy/SKILL.md +655 -0
- data/.claude/skills/plutonium-testing/SKILL.md +6 -5
- data/.claude/skills/plutonium-ui/SKILL.md +900 -0
- data/CHANGELOG.md +27 -2
- data/Rakefile +2 -1
- data/app/assets/plutonium.css +1 -11
- data/app/assets/plutonium.js +1009 -1214
- data/app/assets/plutonium.js.map +3 -3
- data/app/assets/plutonium.min.js +52 -51
- data/app/assets/plutonium.min.js.map +3 -3
- data/docs/.vitepress/config.ts +37 -27
- data/docs/getting-started/index.md +22 -29
- data/docs/getting-started/installation.md +37 -80
- data/docs/getting-started/tutorial/index.md +4 -5
- data/docs/guides/adding-resources.md +66 -377
- data/docs/guides/authentication.md +94 -463
- data/docs/guides/authorization.md +124 -370
- data/docs/guides/creating-packages.md +94 -296
- data/docs/guides/custom-actions.md +121 -441
- data/docs/guides/index.md +22 -42
- data/docs/guides/multi-tenancy.md +116 -187
- data/docs/guides/nested-resources.md +103 -431
- data/docs/guides/search-filtering.md +123 -240
- data/docs/guides/testing.md +5 -4
- data/docs/guides/theming.md +157 -407
- data/docs/guides/troubleshooting.md +5 -3
- data/docs/guides/user-invites.md +106 -425
- data/docs/guides/user-profile.md +76 -243
- data/docs/index.md +1 -1
- data/docs/reference/app/generators.md +517 -0
- data/docs/reference/app/index.md +158 -0
- data/docs/reference/app/packages.md +146 -0
- data/docs/reference/app/portals.md +377 -0
- data/docs/reference/auth/accounts.md +230 -0
- data/docs/reference/auth/index.md +88 -0
- data/docs/reference/auth/profile.md +185 -0
- data/docs/reference/behavior/controllers.md +395 -0
- data/docs/reference/behavior/index.md +22 -0
- data/docs/reference/behavior/interactions.md +341 -0
- data/docs/reference/behavior/policies.md +417 -0
- data/docs/reference/index.md +56 -49
- data/docs/reference/resource/actions.md +423 -0
- data/docs/reference/resource/definition.md +508 -0
- data/docs/reference/resource/index.md +50 -0
- data/docs/reference/resource/model.md +348 -0
- data/docs/reference/resource/query.md +305 -0
- data/docs/reference/tenancy/entity-scoping.md +361 -0
- data/docs/reference/tenancy/index.md +36 -0
- data/docs/reference/tenancy/invites.md +393 -0
- data/docs/reference/tenancy/nested-resources.md +267 -0
- data/docs/reference/testing/index.md +287 -0
- data/docs/reference/ui/assets.md +400 -0
- data/docs/reference/ui/components.md +165 -0
- data/docs/reference/ui/displays.md +104 -0
- data/docs/reference/ui/forms.md +284 -0
- data/docs/reference/ui/index.md +30 -0
- data/docs/reference/ui/layouts.md +106 -0
- data/docs/reference/ui/pages.md +189 -0
- data/docs/reference/ui/tables.md +117 -0
- data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
- data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
- data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/core/update/update_generator.rb +0 -20
- data/lib/generators/pu/invites/install_generator.rb +1 -0
- data/lib/plutonium/definition/base.rb +1 -1
- data/lib/plutonium/definition/{views.rb → index_views.rb} +21 -20
- data/lib/plutonium/helpers/turbo_helper.rb +11 -0
- data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
- data/lib/plutonium/resource/controller.rb +1 -0
- data/lib/plutonium/resource/controllers/crud_actions.rb +19 -1
- data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
- data/lib/plutonium/resource/policy.rb +7 -0
- data/lib/plutonium/routing/mapper_extensions.rb +15 -0
- data/lib/plutonium/ui/component/methods.rb +4 -0
- data/lib/plutonium/ui/form/base.rb +6 -2
- data/lib/plutonium/ui/form/components/json.rb +58 -0
- data/lib/plutonium/ui/form/components/resource_select.rb +62 -8
- data/lib/plutonium/ui/form/components/secure_association.rb +98 -22
- data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
- data/lib/plutonium/ui/form/resource.rb +0 -4
- data/lib/plutonium/ui/grid/resource.rb +1 -1
- data/lib/plutonium/ui/layout/base.rb +1 -0
- data/lib/plutonium/ui/page/base.rb +0 -7
- data/lib/plutonium/ui/page/index.rb +4 -4
- data/lib/plutonium/ui/table/resource.rb +1 -1
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium.rb +8 -0
- data/lib/tasks/release.rake +15 -1
- data/package.json +10 -10
- data/src/css/slim_select.css +4 -0
- data/src/js/controllers/slim_select_controller.js +61 -0
- data/src/js/turbo/turbo_actions.js +33 -0
- data/yarn.lock +553 -543
- metadata +44 -33
- data/.claude/skills/plutonium-assets/SKILL.md +0 -512
- data/.claude/skills/plutonium-controller/SKILL.md +0 -396
- data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
- data/.claude/skills/plutonium-definition/SKILL.md +0 -1223
- data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
- data/.claude/skills/plutonium-forms/SKILL.md +0 -465
- data/.claude/skills/plutonium-installation/SKILL.md +0 -331
- data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
- data/.claude/skills/plutonium-invites/SKILL.md +0 -408
- data/.claude/skills/plutonium-model/SKILL.md +0 -440
- data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
- data/.claude/skills/plutonium-package/SKILL.md +0 -198
- data/.claude/skills/plutonium-policy/SKILL.md +0 -456
- data/.claude/skills/plutonium-portal/SKILL.md +0 -410
- data/.claude/skills/plutonium-views/SKILL.md +0 -651
- data/docs/reference/assets/index.md +0 -496
- data/docs/reference/controller/index.md +0 -412
- data/docs/reference/definition/actions.md +0 -462
- data/docs/reference/definition/fields.md +0 -383
- data/docs/reference/definition/index.md +0 -326
- data/docs/reference/definition/query.md +0 -351
- data/docs/reference/generators/index.md +0 -648
- data/docs/reference/interaction/index.md +0 -449
- data/docs/reference/model/features.md +0 -248
- data/docs/reference/model/index.md +0 -218
- data/docs/reference/policy/index.md +0 -456
- data/docs/reference/portal/index.md +0 -379
- data/docs/reference/views/forms.md +0 -411
- data/docs/reference/views/index.md +0 -544
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
# Model
|
|
2
|
+
|
|
3
|
+
The model layer of a resource. Includes the `Plutonium::Resource::Record` module (via inheritance from `ResourceRecord`) on top of standard `ApplicationRecord`.
|
|
4
|
+
|
|
5
|
+
## Base class
|
|
6
|
+
|
|
7
|
+
All resource models inherit from `ResourceRecord` (created by `pu:core:install`):
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# Main app
|
|
11
|
+
class Post < ResourceRecord
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Inside a feature package — uses the package's ResourceRecord
|
|
15
|
+
module Blogging
|
|
16
|
+
class Post < Blogging::ResourceRecord
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`ResourceRecord` is abstract and inherits from `ApplicationRecord`. Standard ActiveRecord features (associations, validations, scopes, callbacks, attribute macros) all work — Plutonium adds capabilities on top.
|
|
22
|
+
|
|
23
|
+
## What `Plutonium::Resource::Record` adds
|
|
24
|
+
|
|
25
|
+
| Module | Purpose | Section |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| `HasCents` | Money handling — cents column ↔ decimal accessor | [has_cents](#has-cents) |
|
|
28
|
+
| `Routes` | URL parameter customization (slugs, dynamic params) | [URL routing](#url-routing) |
|
|
29
|
+
| `Labeling` | `to_label` for human-readable record names | [Labeling](#labeling) |
|
|
30
|
+
| `FieldNames` | Field introspection by category | [Field introspection](#field-introspection) |
|
|
31
|
+
| `Associations` | Auto-generated SGID accessors on every association | [SGID accessors](#sgid-accessors) |
|
|
32
|
+
| `AssociatedWith` | Multi-tenant scoping — `Model.associated_with(entity)` | [Tenancy](/reference/tenancy/entity-scoping) |
|
|
33
|
+
|
|
34
|
+
## Section layout
|
|
35
|
+
|
|
36
|
+
Scaffolded models follow a strict ordering. Keep new code in the right section so files stay scannable:
|
|
37
|
+
|
|
38
|
+
1. Concerns (`include`)
|
|
39
|
+
2. Constants (`TYPES = {...}.freeze`)
|
|
40
|
+
3. Enums
|
|
41
|
+
4. Model configurations (`has_cents`)
|
|
42
|
+
5. `belongs_to`
|
|
43
|
+
6. `has_one`
|
|
44
|
+
7. `has_many`
|
|
45
|
+
8. Attachments (`has_one_attached`, `has_many_attached`)
|
|
46
|
+
9. Scopes
|
|
47
|
+
10. Validations
|
|
48
|
+
11. Callbacks
|
|
49
|
+
12. Delegations
|
|
50
|
+
13. Misc macros (`has_rich_text`, `has_secure_token`, `has_secure_password`)
|
|
51
|
+
14. Public methods, then `private`, then private methods
|
|
52
|
+
|
|
53
|
+
Example:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
class Property < ResourceRecord
|
|
57
|
+
TYPES = {apartment: "Apartment", house: "House"}.freeze
|
|
58
|
+
|
|
59
|
+
enum :state, archived: 0, active: 1
|
|
60
|
+
|
|
61
|
+
has_cents :market_value_cents
|
|
62
|
+
|
|
63
|
+
belongs_to :company
|
|
64
|
+
has_one :address
|
|
65
|
+
has_many :units
|
|
66
|
+
|
|
67
|
+
has_one_attached :photo
|
|
68
|
+
|
|
69
|
+
scope :active, -> { where(state: :active) }
|
|
70
|
+
|
|
71
|
+
validates :name, presence: true
|
|
72
|
+
validates :property_code, presence: true, uniqueness: {scope: :company_id}
|
|
73
|
+
|
|
74
|
+
before_validation :generate_code, on: :create
|
|
75
|
+
|
|
76
|
+
has_rich_text :description
|
|
77
|
+
|
|
78
|
+
def full_address
|
|
79
|
+
address&.to_s
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def generate_code
|
|
85
|
+
self.property_code ||= SecureRandom.hex(4).upcase
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## `has_cents`
|
|
91
|
+
|
|
92
|
+
Stores monetary values as integer cents and exposes a decimal virtual accessor. Use this for money — never store decimals directly.
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
class Product < ResourceRecord
|
|
96
|
+
has_cents :price_cents # column: price_cents (integer); accessor: price (decimal)
|
|
97
|
+
has_cents :cost_cents, name: :wholesale # custom accessor name
|
|
98
|
+
has_cents :tax_cents, rate: 1000 # 3 decimal places (e.g. for fractional currencies)
|
|
99
|
+
has_cents :amount_yen, rate: 1 # currencies with no subunit (JPY)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
product = Product.new
|
|
103
|
+
product.price = 19.99
|
|
104
|
+
product.price_cents # => 1999
|
|
105
|
+
product.price # => 19.99
|
|
106
|
+
|
|
107
|
+
# Truncates, never rounds
|
|
108
|
+
product.price = 10.999
|
|
109
|
+
product.price_cents # => 1099
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
::: danger Use the virtual accessor in policies and definitions
|
|
113
|
+
Reference `:price`, NOT `:price_cents`:
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
# Policy
|
|
117
|
+
def permitted_attributes_for_create
|
|
118
|
+
%i[name price] # ✅ virtual name
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Definition
|
|
122
|
+
field :price, as: :decimal # ✅ virtual name
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Generators sometimes emit the `_cents` name in the policy — fix by hand (and verify `has_cents` is declared on the model).
|
|
126
|
+
:::
|
|
127
|
+
|
|
128
|
+
### Options
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
has_cents :field_cents,
|
|
132
|
+
name: :custom_name, # accessor name (default: field with _cents stripped)
|
|
133
|
+
rate: 100, # conversion rate (default: 100 for 2 decimal places)
|
|
134
|
+
suffix: "amount" # suffix for generated name when name pattern matches
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Validation propagation
|
|
138
|
+
|
|
139
|
+
Validations on the cents column automatically mark the virtual accessor invalid too:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
class Product < ResourceRecord
|
|
143
|
+
has_cents :price_cents
|
|
144
|
+
validates :price_cents, numericality: {greater_than: 0}
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
product = Product.new(price: -10)
|
|
148
|
+
product.valid? # => false
|
|
149
|
+
product.errors[:price_cents] # => ["must be greater than 0"]
|
|
150
|
+
product.errors[:price] # => ["is invalid"]
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The framework adds an `after_validation` hook that copies `:invalid` from `price_cents` → `price` automatically — no manual wiring needed.
|
|
154
|
+
|
|
155
|
+
### Introspection
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
Product.has_cents_attributes
|
|
159
|
+
# => { price_cents: { name: :price, rate: 100 } }
|
|
160
|
+
|
|
161
|
+
Product.has_cents_attribute?(:price_cents) # => true
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## URL routing
|
|
165
|
+
|
|
166
|
+
### Default
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
post.to_param # => "1" (numeric id)
|
|
170
|
+
# URL: /posts/1
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### `path_parameter` — use a stable column
|
|
174
|
+
|
|
175
|
+
Use a column that's unique and human-readable instead of the numeric id:
|
|
176
|
+
|
|
177
|
+
```ruby
|
|
178
|
+
class User < ResourceRecord
|
|
179
|
+
path_parameter :username
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
user = User.create(username: "john_doe")
|
|
183
|
+
user.to_param # => "john_doe"
|
|
184
|
+
# URL: /users/john_doe
|
|
185
|
+
|
|
186
|
+
User.from_path_param("john_doe") # finds by username
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
`path_parameter` is a class-level macro (private class method). The column you pass MUST be unique — Plutonium uses it for lookup.
|
|
190
|
+
|
|
191
|
+
### `dynamic_path_parameter` — SEO-friendly id + slug
|
|
192
|
+
|
|
193
|
+
Combines the id (for stable lookup) with a slug from another column (for SEO):
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
class Article < ResourceRecord
|
|
197
|
+
dynamic_path_parameter :title
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
article = Article.create(id: 42, title: "Hello World")
|
|
201
|
+
article.to_param # => "42-hello-world"
|
|
202
|
+
# URL: /articles/42-hello-world
|
|
203
|
+
|
|
204
|
+
Article.from_path_param("42-hello-world") # extracts "42", finds by id
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
The slug is informational — only the id portion is used for lookup, so changing the title doesn't break old URLs.
|
|
208
|
+
|
|
209
|
+
## Labeling
|
|
210
|
+
|
|
211
|
+
`to_label` provides a human-readable name for dropdowns, breadcrumbs, and display fallbacks.
|
|
212
|
+
|
|
213
|
+
### Default resolution
|
|
214
|
+
|
|
215
|
+
1. Returns `name` if the model has a `name` attribute.
|
|
216
|
+
2. Returns `title` if the model has a `title` attribute.
|
|
217
|
+
3. Falls back to `"ModelName #id"` (e.g. `"Post #42"`).
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
post = Post.new(title: "Hello World")
|
|
221
|
+
post.to_label # => "Hello World"
|
|
222
|
+
|
|
223
|
+
post.title = nil
|
|
224
|
+
post.to_label # => "Post #42"
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### Override
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
class Product < ResourceRecord
|
|
231
|
+
def to_label
|
|
232
|
+
"#{name} (#{sku})"
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## SGID accessors
|
|
238
|
+
|
|
239
|
+
Every association on a resource model gets Signed Global ID accessors automatically — for secure form submission, API payloads, and hidden fields without exposing database ids.
|
|
240
|
+
|
|
241
|
+
### Singular associations (`belongs_to`, `has_one`)
|
|
242
|
+
|
|
243
|
+
```ruby
|
|
244
|
+
class Post < ResourceRecord
|
|
245
|
+
belongs_to :user
|
|
246
|
+
has_one :featured_image
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
post.user_sgid # get SGID
|
|
250
|
+
post.user_sgid = "BAh7..." # set: locates and assigns user from SGID
|
|
251
|
+
|
|
252
|
+
post.featured_image_sgid
|
|
253
|
+
post.featured_image_sgid = "..."
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### Collection associations (`has_many`, `has_and_belongs_to_many`)
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
class User < ResourceRecord
|
|
260
|
+
has_many :posts
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
user.post_sgids # => ["...", "..."]
|
|
264
|
+
user.post_sgids = [sgid1, sgid2] # bulk replace
|
|
265
|
+
user.add_post_sgid(sgid) # append
|
|
266
|
+
user.remove_post_sgid(sgid) # remove
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
These are what `secure_association_tag` uses in forms — see [UI › Forms](/reference/ui/forms).
|
|
270
|
+
|
|
271
|
+
## Field introspection
|
|
272
|
+
|
|
273
|
+
```ruby
|
|
274
|
+
User.resource_field_names # all fields suitable for UI
|
|
275
|
+
User.content_column_field_names # database columns
|
|
276
|
+
User.belongs_to_association_field_names
|
|
277
|
+
User.has_one_association_field_names # excludes attachments
|
|
278
|
+
User.has_many_association_field_names # excludes attachments
|
|
279
|
+
User.has_one_attached_field_names # ActiveStorage single
|
|
280
|
+
User.has_many_attached_field_names # ActiveStorage multiple
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Used internally by definitions for auto-detection. You rarely call these directly, but they're useful when writing dynamic UI in `customize_fields` / custom Phlex pages.
|
|
284
|
+
|
|
285
|
+
Results are cached outside development (so changing the schema in dev hot-reloads correctly).
|
|
286
|
+
|
|
287
|
+
## Nested attributes introspection
|
|
288
|
+
|
|
289
|
+
```ruby
|
|
290
|
+
Post.all_nested_attributes_options
|
|
291
|
+
# => {
|
|
292
|
+
# comments: { allow_destroy: true, limit: 10, macro: :has_many, class: Comment },
|
|
293
|
+
# metadata: { update_only: true, macro: :has_one, class: PostMetadata }
|
|
294
|
+
# }
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Returns the configuration for all associations declared with `accepts_nested_attributes_for`. Used internally by `nested_input` in the definition.
|
|
298
|
+
|
|
299
|
+
## Multi-tenancy: `associated_with`
|
|
300
|
+
|
|
301
|
+
`Plutonium::Resource::Record` provides `Model.associated_with(entity)` for multi-tenant queries:
|
|
302
|
+
|
|
303
|
+
```ruby
|
|
304
|
+
Comment.associated_with(post)
|
|
305
|
+
# => Comment.where(post: post)
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
Resolution order, association path requirements, three model shapes, and custom scopes are all covered in [Tenancy › Entity scoping](/reference/tenancy/entity-scoping).
|
|
309
|
+
|
|
310
|
+
## Standard ActiveRecord features
|
|
311
|
+
|
|
312
|
+
Everything you'd expect works — associations, validations, scopes, callbacks, delegations, `has_rich_text`, `has_secure_token`, `has_one_attached`, etc. Where Plutonium adds twists:
|
|
313
|
+
|
|
314
|
+
- **Section ordering** is by convention, not enforcement — pick the right slot in the [layout above](#section-layout) so the file stays scannable.
|
|
315
|
+
- **Compound uniqueness for tenant-scoped resources:** `validates :code, uniqueness: {scope: :organization_id}` — without the scope, uniqueness leaks across tenants.
|
|
316
|
+
- **Keep models thin** — business logic that touches multiple records or has multi-step state changes belongs in [interactions](/reference/behavior/interactions), not model methods.
|
|
317
|
+
|
|
318
|
+
## Nested resources
|
|
319
|
+
|
|
320
|
+
Plutonium auto-generates nested routes from `has_many` and `has_one` associations. No model-side change needed beyond the association itself:
|
|
321
|
+
|
|
322
|
+
```ruby
|
|
323
|
+
class Comment < ResourceRecord
|
|
324
|
+
belongs_to :post
|
|
325
|
+
end
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
When both `Post` and `Comment` are registered in a portal, `/posts/:post_id/nested_comments` exists automatically. See [Tenancy › Nested resources](/reference/tenancy/nested-resources).
|
|
329
|
+
|
|
330
|
+
## Table naming in packages
|
|
331
|
+
|
|
332
|
+
Namespaced models use prefixed tables by default:
|
|
333
|
+
|
|
334
|
+
```ruby
|
|
335
|
+
module Blogging
|
|
336
|
+
class Post < ResourceRecord
|
|
337
|
+
# table: blogging_posts
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
Override with `self.table_name = "posts"` if you need a shared table.
|
|
343
|
+
|
|
344
|
+
## Related
|
|
345
|
+
|
|
346
|
+
- [Definition](./definition) — controls how the model's fields render
|
|
347
|
+
- [Tenancy › Entity scoping](/reference/tenancy/entity-scoping) — `associated_with`, three model shapes
|
|
348
|
+
- [App › Generators](/reference/app/generators) — `pu:res:scaffold` field syntax
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
# Query
|
|
2
|
+
|
|
3
|
+
Search, filters, scopes, and sorting for a resource's index page. All declared in the definition.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
class PostDefinition < Plutonium::Resource::Definition
|
|
9
|
+
search do |scope, query|
|
|
10
|
+
scope.where("title ILIKE ?", "%#{query}%")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
filter :title, with: :text, predicate: :contains
|
|
14
|
+
filter :status, with: :select, choices: %w[draft published archived]
|
|
15
|
+
filter :published, with: :boolean
|
|
16
|
+
filter :created_at, with: :date_range
|
|
17
|
+
|
|
18
|
+
scope :published
|
|
19
|
+
default_scope :published
|
|
20
|
+
|
|
21
|
+
sort :title
|
|
22
|
+
sort :created_at
|
|
23
|
+
default_sort :created_at, :desc
|
|
24
|
+
end
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Search
|
|
28
|
+
|
|
29
|
+
`search` defines global free-text search. The block receives the scope and the query string; return a filtered relation.
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
search do |scope, query|
|
|
33
|
+
scope.where("title ILIKE ?", "%#{query}%")
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Multi-field
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
search do |scope, query|
|
|
41
|
+
scope.where(
|
|
42
|
+
"title ILIKE :q OR content ILIKE :q OR author_name ILIKE :q",
|
|
43
|
+
q: "%#{query}%"
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Across associations
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
search do |scope, query|
|
|
52
|
+
scope.joins(:author).where(
|
|
53
|
+
"posts.title ILIKE :q OR users.name ILIKE :q",
|
|
54
|
+
q: "%#{query}%"
|
|
55
|
+
).distinct
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Split terms
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
search do |scope, query|
|
|
63
|
+
query.split(/\s+/).reduce(scope) do |s, term|
|
|
64
|
+
s.where("title ILIKE ?", "%#{term}%")
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Search powers typeahead too
|
|
70
|
+
|
|
71
|
+
The same `search` block drives **typeahead lookups** on association inputs that target this resource — when you write `input :author, …` for an association, the dropdown's autocomplete calls the target resource's `search` block.
|
|
72
|
+
|
|
73
|
+
::: tip Typeahead fallback when there's no search block
|
|
74
|
+
A resource without a `search` block still gets typeahead — the framework runs a case-insensitive `LIKE` against the first column that exists, in priority order:
|
|
75
|
+
|
|
76
|
+
1. The input's `label_method:` option, if it names a real column on the model.
|
|
77
|
+
2. Otherwise the first match from `[name, title, label, slug, display_name, email]`.
|
|
78
|
+
3. If none exist, the relation is returned unfiltered (capped).
|
|
79
|
+
|
|
80
|
+
For large tables, write an explicit `search` block backed by a trigram or full-text index. The fallback's leading-wildcard `LIKE '%q%'` can't use a b-tree index and gets slow past a few thousand rows.
|
|
81
|
+
:::
|
|
82
|
+
|
|
83
|
+
## Filters
|
|
84
|
+
|
|
85
|
+
Six built-in filter types. Use the shorthand symbol or the full class name.
|
|
86
|
+
|
|
87
|
+
| Type | Symbol | Params in URL | Options |
|
|
88
|
+
|---|---|---|---|
|
|
89
|
+
| Text | `:text` | `query` | `predicate:` |
|
|
90
|
+
| Boolean | `:boolean` | `value` | `true_label:`, `false_label:` |
|
|
91
|
+
| Date | `:date` | `value` | `predicate:` |
|
|
92
|
+
| Date range | `:date_range` | `from`, `to` | `from_label:`, `to_label:` |
|
|
93
|
+
| Select | `:select` | `value` | `choices:`, `multiple:` |
|
|
94
|
+
| Association | `:association` | `value` | `class_name:`, `multiple:` |
|
|
95
|
+
|
|
96
|
+
### Text predicates
|
|
97
|
+
|
|
98
|
+
`:eq`, `:not_eq`, `:contains`, `:not_contains`, `:starts_with`, `:ends_with`, `:matches`, `:not_matches`
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
filter :title, with: :text, predicate: :contains
|
|
102
|
+
filter :status, with: :text, predicate: :eq
|
|
103
|
+
filter :title, with: Plutonium::Query::Filters::Text, predicate: :contains # full class form
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Boolean
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
filter :active, with: :boolean
|
|
110
|
+
filter :published, with: :boolean, true_label: "Published", false_label: "Draft"
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Date
|
|
114
|
+
|
|
115
|
+
Predicates: `:eq`, `:not_eq`, `:lt`, `:lteq`, `:gt`, `:gteq`.
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
filter :created_at, with: :date, predicate: :gteq
|
|
119
|
+
filter :due_date, with: :date, predicate: :lt
|
|
120
|
+
filter :published_at, with: :date, predicate: :eq
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Date range
|
|
124
|
+
|
|
125
|
+
Two inputs (`from` + `to`):
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
filter :created_at, with: :date_range
|
|
129
|
+
filter :published_at, with: :date_range,
|
|
130
|
+
from_label: "Published from",
|
|
131
|
+
to_label: "Published to"
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Select
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
filter :status, with: :select, choices: %w[draft published archived]
|
|
138
|
+
filter :category, with: :select, choices: -> { Category.pluck(:name) }
|
|
139
|
+
filter :tags, with: :select, choices: %w[ruby rails js], multiple: true
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Association
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
filter :category, with: :association
|
|
146
|
+
filter :author, with: :association, class_name: User
|
|
147
|
+
filter :tags, with: :association, class_name: Tag, multiple: true
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Custom filter (lambda)
|
|
151
|
+
|
|
152
|
+
For simple one-offs:
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
filter :published, with: ->(scope, value) {
|
|
156
|
+
value == "true" ? scope.where.not(published_at: nil) : scope.where(published_at: nil)
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Custom filter class
|
|
161
|
+
|
|
162
|
+
For anything reusable or with multiple inputs:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
class PriceRangeFilter < Plutonium::Query::Filter
|
|
166
|
+
def apply(scope, min: nil, max: nil)
|
|
167
|
+
scope = scope.where("price >= ?", min) if min.present?
|
|
168
|
+
scope = scope.where("price <= ?", max) if max.present?
|
|
169
|
+
scope
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def customize_inputs
|
|
173
|
+
input :min, as: :number
|
|
174
|
+
input :max, as: :number
|
|
175
|
+
field :min, placeholder: "Min price..."
|
|
176
|
+
field :max, placeholder: "Max price..."
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
filter :price, with: PriceRangeFilter
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Scopes
|
|
184
|
+
|
|
185
|
+
Scopes appear as quick-filter buttons across the top of the table.
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
class PostDefinition < ResourceDefinition
|
|
189
|
+
scope :published # uses Post.published
|
|
190
|
+
scope :draft # uses Post.draft
|
|
191
|
+
|
|
192
|
+
# Inline scope — block runs with the scope as argument
|
|
193
|
+
scope(:recent) { |s| s.where('created_at > ?', 1.week.ago) }
|
|
194
|
+
scope(:this_month) { |s| s.where(created_at: Time.current.all_month) }
|
|
195
|
+
end
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Named scopes reference a model scope of the same name. Inline (block) scopes have access to controller context (`current_user`, `current_parent`, etc.):
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
scope(:mine) { |s| s.where(author: current_user) }
|
|
202
|
+
scope(:my_team) { |s| s.where(team: current_user.team) }
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Default scope
|
|
206
|
+
|
|
207
|
+
```ruby
|
|
208
|
+
default_scope :published
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
When a default is set:
|
|
212
|
+
|
|
213
|
+
- It applies on initial page load.
|
|
214
|
+
- The default scope button is highlighted (not "All").
|
|
215
|
+
- Clicking "All" shows the unscoped collection.
|
|
216
|
+
|
|
217
|
+
## Sorting
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
sort :title
|
|
221
|
+
sort :created_at
|
|
222
|
+
|
|
223
|
+
sorts :title, :created_at, :view_count # shorthand for several at once
|
|
224
|
+
|
|
225
|
+
default_sort :created_at, :desc
|
|
226
|
+
|
|
227
|
+
# Complex default sort with a block
|
|
228
|
+
default_sort { |scope| scope.order(featured: :desc, created_at: :desc) }
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
The framework default (no `default_sort` declared, no user sort) is `id DESC`.
|
|
232
|
+
|
|
233
|
+
## URL parameters
|
|
234
|
+
|
|
235
|
+
Query parameters are namespaced under `q`:
|
|
236
|
+
|
|
237
|
+
```
|
|
238
|
+
/posts?q[search]=rails
|
|
239
|
+
/posts?q[title][query]=widget
|
|
240
|
+
/posts?q[status][value]=published
|
|
241
|
+
/posts?q[created_at][from]=2024-01-01&q[created_at][to]=2024-12-31
|
|
242
|
+
/posts?q[scope]=recent
|
|
243
|
+
/posts?q[sort_fields][]=created_at&q[sort_directions][created_at]=desc
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Combined:
|
|
247
|
+
|
|
248
|
+
```
|
|
249
|
+
/posts?q[search]=rails&q[scope]=published&q[sort_fields][]=created_at&q[sort_directions][created_at]=desc
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## Common patterns
|
|
253
|
+
|
|
254
|
+
### Full-text search with `pg_search`
|
|
255
|
+
|
|
256
|
+
```ruby
|
|
257
|
+
# Model
|
|
258
|
+
class Post < ResourceRecord
|
|
259
|
+
include PgSearch::Model
|
|
260
|
+
pg_search_scope :search_content, against: %i[title content]
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Definition
|
|
264
|
+
search do |scope, query|
|
|
265
|
+
scope.search_content(query)
|
|
266
|
+
end
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
### Status filter + scopes
|
|
270
|
+
|
|
271
|
+
```ruby
|
|
272
|
+
filter :status, with: :select, choices: %w[draft published archived]
|
|
273
|
+
scope :draft
|
|
274
|
+
scope :published
|
|
275
|
+
scope :archived
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
### Date-based scopes
|
|
279
|
+
|
|
280
|
+
```ruby
|
|
281
|
+
# Model
|
|
282
|
+
scope :today, -> { where(created_at: Time.current.all_day) }
|
|
283
|
+
scope :this_week, -> { where(created_at: Time.current.all_week) }
|
|
284
|
+
scope :this_month, -> { where(created_at: Time.current.all_month) }
|
|
285
|
+
|
|
286
|
+
# Definition
|
|
287
|
+
scope :today
|
|
288
|
+
scope :this_week
|
|
289
|
+
scope :this_month
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
## Performance
|
|
293
|
+
|
|
294
|
+
- **Add indexes** for filtered and sorted columns.
|
|
295
|
+
- **Use `.distinct`** when joining associations in search to avoid duplicate rows.
|
|
296
|
+
- **Prefer scopes over filters** for queries used often (faster, no input parsing).
|
|
297
|
+
- **`pg_search` / FTS** for complex search — write an explicit `search` block.
|
|
298
|
+
- **`LIKE '%q%'` can't use a b-tree index** — the typeahead fallback and naive search blocks get slow on large tables. Plan a trigram or full-text index when scaling.
|
|
299
|
+
|
|
300
|
+
## Related
|
|
301
|
+
|
|
302
|
+
- [Definition](./definition) — field/input/display configuration
|
|
303
|
+
- [Actions](./actions) — custom and bulk actions
|
|
304
|
+
- [Behavior › Policy](/reference/behavior/policies) — `relation_scope` (filters records to what the user can see)
|
|
305
|
+
- [Tenancy › Entity scoping](/reference/tenancy/entity-scoping) — multi-tenant filtering
|