dami 1.0.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 +7 -0
- data/CHANGELOG.md +306 -0
- data/LICENSE +21 -0
- data/README.md +106 -0
- data/bin/dami +10 -0
- data/docs/01.Getting_Started.md +220 -0
- data/docs/02.Models_and_Fields.md +244 -0
- data/docs/03.Querying.md +387 -0
- data/docs/04.Creating_Updating_Deleting.md +226 -0
- data/docs/05.Validation.md +259 -0
- data/docs/06.Protection.md +160 -0
- data/docs/07.Associations.md +239 -0
- data/docs/08.Scopes.md +99 -0
- data/docs/09.Migrations.md +165 -0
- data/docs/10.Flows_and_Commands.md +181 -0
- data/docs/11.Localization.md +435 -0
- data/docs/12.TheDamiWay.md +227 -0
- data/docs/Manifesto.md +305 -0
- data/docs/site.md +477 -0
- data/lib/dami/actions/command.rb +80 -0
- data/lib/dami/actions/context.rb +63 -0
- data/lib/dami/actions/draft.rb +36 -0
- data/lib/dami/actions/flow.rb +42 -0
- data/lib/dami/adapters/base.rb +40 -0
- data/lib/dami/adapters/sqlite/connection.rb +122 -0
- data/lib/dami/adapters/sqlite/core.rb +17 -0
- data/lib/dami/adapters/sqlite/query.rb +353 -0
- data/lib/dami/adapters/sqlite/schema.rb +135 -0
- data/lib/dami/cli.rb +117 -0
- data/lib/dami/configuration.rb +246 -0
- data/lib/dami/core.rb +98 -0
- data/lib/dami/dsl_guardrails.rb +45 -0
- data/lib/dami/errors.rb +89 -0
- data/lib/dami/inflector.rb +245 -0
- data/lib/dami/localization.rb +131 -0
- data/lib/dami/migration.rb +84 -0
- data/lib/dami/migrator.rb +105 -0
- data/lib/dami/plugins/associations.rb +284 -0
- data/lib/dami/plugins/nested_attributes.rb +180 -0
- data/lib/dami/plugins/protection.rb +30 -0
- data/lib/dami/plugins/validations.rb +90 -0
- data/lib/dami/query/builder.rb +132 -0
- data/lib/dami/query/enumerable.rb +62 -0
- data/lib/dami/query/persistence.rb +133 -0
- data/lib/dami/record_proxy.rb +32 -0
- data/lib/dami/result.rb +27 -0
- data/lib/dami/schema/diff.rb +84 -0
- data/lib/dami/schema/dumper.rb +60 -0
- data/lib/dami/schema/generator.rb +69 -0
- data/lib/dami/schema/introspector.rb +34 -0
- data/lib/dami/schema/loader.rb +58 -0
- data/lib/dami/schema.rb +13 -0
- data/lib/dami/validation_rules.rb +72 -0
- data/lib/dami/version.rb +3 -0
- data/lib/dami.rb +32 -0
- metadata +218 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# Chapter 4: Creating, Updating, and Deleting
|
|
2
|
+
|
|
3
|
+
This chapter covers all operations that modify data in your database. Dami provides a simple yet secure API for creating, updating, and deleting records, with built-in protections against common vulnerabilities.
|
|
4
|
+
|
|
5
|
+
## Creating Records
|
|
6
|
+
|
|
7
|
+
### Creating a Single Record
|
|
8
|
+
|
|
9
|
+
The most common way to insert a new record is with the `.create` method. It accepts a hash of attributes and, if successful, returns a `RecordProxy` instance of the newly created record.
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
# Create a new user
|
|
13
|
+
new_user = Dami.db(:users).create(
|
|
14
|
+
name: 'Carol',
|
|
15
|
+
email: 'carol@example.com',
|
|
16
|
+
status: 'active'
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
puts new_user[:id] # => 43
|
|
20
|
+
puts new_user[:name] # => "Carol"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
If the data fails validation, `.create` will raise a `Dami::ValidationError` instead of creating the record.
|
|
24
|
+
|
|
25
|
+
### Creating Multiple Records (Bulk Inserts) ✨
|
|
26
|
+
|
|
27
|
+
For inserting multiple records at once, always use `.create_many`. It is significantly more performant as it inserts all records with a single SQL statement. It accepts an array of hashes.
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
# Create two new posts in a single database query
|
|
31
|
+
posts_to_create = [
|
|
32
|
+
{ user_id: 1, title: 'My First Post' },
|
|
33
|
+
{ user_id: 1, title: 'My Second Post' }
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
Dami.db(:posts).create_many(posts_to_create)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Updating Records
|
|
40
|
+
|
|
41
|
+
The `.update` method is called on a query chain and modifies all records that match the preceding `where` conditions. It returns the first matching record (re-read after the update), so for multi-record updates treat the return value as a sample, not a summary. Validations run against the attributes you pass; conditional rules (`if:` / `unless:` / `when:`) see the first matching record as context.
|
|
42
|
+
|
|
43
|
+
### Updating a Single Record
|
|
44
|
+
|
|
45
|
+
The most common pattern is to find a record by its ID and then update it.
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
# Find user with ID 1 and update their name
|
|
49
|
+
Dami.db(:users).where(id: 1).update(name: 'Alice Smith')
|
|
50
|
+
|
|
51
|
+
# You can also update multiple attributes at once
|
|
52
|
+
Dami.db(:users).where(id: 1).update(
|
|
53
|
+
name: 'Alice Smith',
|
|
54
|
+
email: 'alicesmith@example.com'
|
|
55
|
+
)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Updating Multiple Records
|
|
59
|
+
|
|
60
|
+
You can efficiently update many records that match a condition in a single query.
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
# Promote all 'pending' users to 'active'
|
|
64
|
+
Dami.db(:users).where(status: 'pending').update(status: 'active')
|
|
65
|
+
|
|
66
|
+
# Archive all posts older than one year
|
|
67
|
+
Dami.db(:posts)
|
|
68
|
+
.where('published_at < ?', 1.year.ago)
|
|
69
|
+
.update(status: 'archived')
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Deleting Records
|
|
73
|
+
|
|
74
|
+
Similar to `update`, the `.delete` method is called on a query chain and removes all records that match the conditions.
|
|
75
|
+
|
|
76
|
+
### Deleting a Single Record
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
# Delete the user with ID 42
|
|
80
|
+
Dami.db(:users).where(id: 42).delete
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Deleting Multiple Records
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
# Clean up all spam comments in one query
|
|
87
|
+
Dami.db(:comments).where(is_spam: true).delete
|
|
88
|
+
|
|
89
|
+
# Delete all posts by a specific user
|
|
90
|
+
Dami.db(:posts).where(user_id: 15).delete
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Security: Attribute Protection 🛡️
|
|
94
|
+
|
|
95
|
+
A critical feature of any ORM is protection against **mass-assignment vulnerabilities**. This happens when a user submits extra data in a form (e.g., `role: 'admin'`) and the application blindly saves it.
|
|
96
|
+
|
|
97
|
+
Dami protects you by default. You must explicitly define which attributes are sensitive using a `protection` block in your model.
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
# In your behavior definition
|
|
101
|
+
Dami.behavrior :users do
|
|
102
|
+
# ... fields ...
|
|
103
|
+
protection do
|
|
104
|
+
protect :role, :account_balance # These fields cannot be changed by default
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# An attacker tries to make themselves an admin
|
|
109
|
+
malicious_params = { name: 'Eve', email: 'eve@hacker.com', role: 'admin' }
|
|
110
|
+
|
|
111
|
+
# This will fail with an error!
|
|
112
|
+
Dami.db(:users).where(id: 10).update(malicious_params)
|
|
113
|
+
# => raises Dami::ProtectionError: Protected fields not permitted: role
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
To safely update protected attributes in controlled parts of your code (like an admin panel), you must use the `permit:` option.
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
# In an admin controller, you can safely permit the role change.
|
|
120
|
+
admin_params = { role: 'moderator' }
|
|
121
|
+
Dami.db(:users).where(id: 10).update(admin_params, permit: [:role]) # This now works
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
⚠️ For rare cases where you need to bypass all protections, you can use `protect: false`. Use this with extreme caution.
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
# Disables all protections for this single operation
|
|
128
|
+
Dami.db(:users).create(name: 'Root', role: 'super_admin', protect: false)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Handling Validations
|
|
132
|
+
|
|
133
|
+
If `create` or `update` is called with data that violates a model's validation rules, a `Dami::ValidationError` is raised. You can rescue this error to handle the failure gracefully, for example, by re-rendering a form with error messages.
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
begin
|
|
137
|
+
# Attempt to create a user with a missing name
|
|
138
|
+
user_params = { email: 'test@example.com', status: 'active' }
|
|
139
|
+
Dami.db(:users).create(user_params)
|
|
140
|
+
rescue Dami::ValidationError => e
|
|
141
|
+
# The operation failed, and e.errors contains the details
|
|
142
|
+
puts e.errors # => { name: ["is required"] }
|
|
143
|
+
|
|
144
|
+
# You can now use this hash to display errors to the user
|
|
145
|
+
render_form_with_errors(e.errors)
|
|
146
|
+
end
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Nested Attributes
|
|
150
|
+
|
|
151
|
+
Dami provides a powerful way to create, update, and destroy associated records through a parent record in a single, atomic operation. This is particularly useful for complex forms where a user might edit a blog post and its comments at the same time.
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
### Enabling Nested Attributes
|
|
155
|
+
|
|
156
|
+
To enable this feature, you must explicitly declare it on the parent model using the `nests` keyword. This is a security measure to prevent accidental modification of associations.
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
Dami.model :posts do
|
|
160
|
+
fields { field :title, :string }
|
|
161
|
+
relationships { has_many :comments }
|
|
162
|
+
|
|
163
|
+
# Enable nested attributes for the :comments association
|
|
164
|
+
nests :comments, allow_destroy: true
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
Dami.model :comments do
|
|
168
|
+
fields { field :post_id, :integer; field :content, :text }
|
|
169
|
+
relationships { belongs_to :post }
|
|
170
|
+
end
|
|
171
|
+
````
|
|
172
|
+
|
|
173
|
+
The `allow_destroy: true` option is required if you want to allow nested records to be deleted.
|
|
174
|
+
|
|
175
|
+
### Usage
|
|
176
|
+
|
|
177
|
+
You can now modify the parent and its children by passing a special `_attributes` key in your `create` or `update` call.
|
|
178
|
+
|
|
179
|
+
#### **Creating Records**
|
|
180
|
+
|
|
181
|
+
To create a parent with new children, provide an array of hashes.
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
@db[:posts].create(
|
|
185
|
+
title: 'My New Post',
|
|
186
|
+
comments_attributes: [
|
|
187
|
+
{ content: 'This is the first comment.' },
|
|
188
|
+
{ content: 'And this is the second.' }
|
|
189
|
+
]
|
|
190
|
+
)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
#### **Updating and Destroying Records**
|
|
194
|
+
|
|
195
|
+
To update existing children or destroy them, you must provide their `id`. To mark a record for destruction, add a `_destroy: '1'` key.
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
@post = @db[:posts].find(1)
|
|
199
|
+
@comment_to_update = @post.comments.first
|
|
200
|
+
@comment_to_destroy = @post.comments.last
|
|
201
|
+
|
|
202
|
+
@db[:posts].where(id: @post[:id]).update(
|
|
203
|
+
title: 'An Updated Title',
|
|
204
|
+
comments_attributes: [
|
|
205
|
+
# To UPDATE, provide the ID
|
|
206
|
+
{ id: @comment_to_update[:id], content: 'This comment has been edited.' },
|
|
207
|
+
|
|
208
|
+
# To CREATE, omit the ID
|
|
209
|
+
{ content: 'This is a brand new comment.' },
|
|
210
|
+
|
|
211
|
+
# To DESTROY, provide the ID and _destroy flag
|
|
212
|
+
{ id: @comment_to_destroy[:id], _destroy: '1' }
|
|
213
|
+
]
|
|
214
|
+
)
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### Key Guarantees
|
|
218
|
+
|
|
219
|
+
* **Atomicity**: The entire operation—saving the parent and all children—is wrapped in a single database transaction. If *any* part fails (e.g., a single child comment fails its validation), the **entire operation is rolled back**, and no records are saved or changed.
|
|
220
|
+
* **Validation**: All nested records are validated against their own model's rules. If any fail, a single `Dami::ValidationError` is raised with a nested hash of all errors from all records, perfect for displaying in a form.
|
|
221
|
+
* **Protection**: Nested records respect their own `protection` rules. You cannot change a protected attribute on a child model unless you use the `permit:` option on the top-level `update` call.
|
|
222
|
+
|
|
223
|
+
## What's Next?
|
|
224
|
+
|
|
225
|
+
* **Chapter 5: Validation** - A deep dive into defining data quality rules.
|
|
226
|
+
* **Chapter 6: Protection** - Develop permit, protect, field-level security
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# Chapter 5: Validation
|
|
2
|
+
|
|
3
|
+
Validation is the cornerstone of data integrity. Dami provides a powerful and declarative DSL to ensure that only valid data is saved to your database. If a record fails its validation rules, Dami will raise a `Dami::ValidationError` and prevent the `create` or `update` operation from proceeding.
|
|
4
|
+
|
|
5
|
+
**Validations live in `Dami.behavior`**, keeping write-side rules separate from structure.
|
|
6
|
+
|
|
7
|
+
## Defining Validations
|
|
8
|
+
|
|
9
|
+
Validations are defined within a `validate` block in your `Dami.behavior` block. You use the `rule` method to apply one or more validation rules to a field.
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
Dami.model :users do
|
|
13
|
+
fields do
|
|
14
|
+
field :name, :string
|
|
15
|
+
field :email, :string
|
|
16
|
+
field :status, :string
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
Dami.behavior :users do
|
|
21
|
+
validate do
|
|
22
|
+
rule :name, :required
|
|
23
|
+
rule :email, :required, :email
|
|
24
|
+
rule :status, inclusion: %w[active inactive pending]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Registering Validation Rules
|
|
32
|
+
|
|
33
|
+
Before you can use validation rules, register them in your initializer:
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
# config/initializers/dami.rb
|
|
37
|
+
Dami.connect(:default, adapter: :sqlite, path: 'db.sqlite')
|
|
38
|
+
Dami.register_default_rules!
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
This registers these common rules:
|
|
42
|
+
|
|
43
|
+
| Rule | Description |
|
|
44
|
+
|---------------|----------------------------------------------------|
|
|
45
|
+
| `:required`, `:presence` | Ensures the value is not `nil` or an empty string. |
|
|
46
|
+
| `:email` | Checks for a valid email format. |
|
|
47
|
+
| `:inclusion` | Ensures the value is within the given list. |
|
|
48
|
+
| `:format` | Checks that the value matches a regex. |
|
|
49
|
+
| `:min_length` | Ensures the string has at least N characters. |
|
|
50
|
+
| `:max_length` | Ensures the string has at most N characters. |
|
|
51
|
+
| `:min` | Ensures the number is at least N. |
|
|
52
|
+
| `:max` | Ensures the number is at most N. |
|
|
53
|
+
|
|
54
|
+
### Customizing Error Messages
|
|
55
|
+
|
|
56
|
+
**Global Message Overrides:**
|
|
57
|
+
|
|
58
|
+
Change error messages for all validations without modifying rules:
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
# config/initializers/dami.rb
|
|
62
|
+
Dami.register_default_rules!
|
|
63
|
+
Dami.override_messages :default, {
|
|
64
|
+
required: "cannot be blank",
|
|
65
|
+
email: "must be a valid email address",
|
|
66
|
+
min_length: ->(min) { "too short (minimum: #{min} characters)" }
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Messages are checked in this priority order:
|
|
71
|
+
1. Custom rule's message (when registering a new rule)
|
|
72
|
+
2. Global override (via `override_messages`)
|
|
73
|
+
3. Default message (from `register_default_rules!`)
|
|
74
|
+
|
|
75
|
+
### Custom Validation Rules
|
|
76
|
+
|
|
77
|
+
Register your own rules for domain-specific validations:
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
Dami.rules :default, {
|
|
81
|
+
phone: {
|
|
82
|
+
check: ->(v) { v.to_s =~ /\A\d{3}-\d{3}-\d{4}\z/ },
|
|
83
|
+
message: "must be a valid phone number (XXX-XXX-XXXX)"
|
|
84
|
+
},
|
|
85
|
+
url: {
|
|
86
|
+
check: ->(v) { v.to_s =~ /\Ahttps?:\/\// },
|
|
87
|
+
message: "must be a valid URL"
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**Parameterized Rules:**
|
|
93
|
+
|
|
94
|
+
For rules that take options, return a hash from a lambda:
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
Dami.rules :default, {
|
|
98
|
+
exact_length: ->(len) {
|
|
99
|
+
{
|
|
100
|
+
check: ->(v) { v.to_s.length == len },
|
|
101
|
+
message: "must be exactly #{len} characters"
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
# Usage
|
|
109
|
+
Dami.behavior :users do
|
|
110
|
+
validate do
|
|
111
|
+
rule :zip_code, exact_length: 5
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Validating Multiple Fields
|
|
119
|
+
|
|
120
|
+
The `all` helper applies the same rules to multiple fields at once:
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
Dami.behavior :users do
|
|
124
|
+
validate do
|
|
125
|
+
# Apply :required to all fields except :age
|
|
126
|
+
on :create do
|
|
127
|
+
all except: [:age] do
|
|
128
|
+
rule :required
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
**Options:**
|
|
136
|
+
- `only: [:field1, :field2]` - Apply rules only to these fields
|
|
137
|
+
- `except: [:field3]` - Apply rules to all fields except these
|
|
138
|
+
- `**kwargs` - Apply rules directly: `all presence: true, min_length: 3`
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
Dami.behavior :users do
|
|
142
|
+
validate do
|
|
143
|
+
# Apply presence validation to specific fields
|
|
144
|
+
all only: [:name, :email], presence: true
|
|
145
|
+
|
|
146
|
+
# Apply multiple rules to all fields except one
|
|
147
|
+
all except: [:bio] do
|
|
148
|
+
rule :required
|
|
149
|
+
rule :min_length, 3
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## Contextual Validation
|
|
158
|
+
|
|
159
|
+
Sometimes a rule should only apply during a specific operation. You can scope rules to `:create` or `:update` using an `on` block.
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
Dami.behavior :users do
|
|
163
|
+
validate do
|
|
164
|
+
rule :name, :required
|
|
165
|
+
|
|
166
|
+
on :create do
|
|
167
|
+
rule :email, :required
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Conditional Validation
|
|
176
|
+
|
|
177
|
+
For more complex logic, you can make a validation conditional based on the values of other attributes.
|
|
178
|
+
|
|
179
|
+
### Using `if:` and `unless:`
|
|
180
|
+
|
|
181
|
+
Use `if:` or `unless:` with a lambda (`->`) for dynamic conditions. The lambda receives a hash of all the attributes being updated.
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
Dami.behavior :users do
|
|
185
|
+
validate do
|
|
186
|
+
rule :ssn, :required, if: ->(attrs) { attrs[:account_type] == 'business' }
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Using `when:`
|
|
192
|
+
|
|
193
|
+
For simple equality checks, `when:` provides a convenient shortcut. The rule only runs if the attributes in the `when` hash match the record's attributes.
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
Dami.behavior :orders do
|
|
197
|
+
validate do
|
|
198
|
+
rule :shipping_address, :required, when: { delivery_type: 'physical' }
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## Validating Virtual Fields
|
|
206
|
+
|
|
207
|
+
A common use case for validation is to check fields that you don't actually save to the database, like a password confirmation. These are called **virtual fields**.
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
Dami.model :users do
|
|
211
|
+
fields do
|
|
212
|
+
field :password_hash, :string
|
|
213
|
+
end
|
|
214
|
+
virtual do
|
|
215
|
+
field :password, :string
|
|
216
|
+
field :password_confirmation, :string
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
Dami.behavior :users do
|
|
221
|
+
validate do
|
|
222
|
+
rule :password, :required, min_length: 8
|
|
223
|
+
rule :password_confirmation do |confirmation, attrs|
|
|
224
|
+
confirmation == attrs[:password]
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
In this example, `password` and `password_confirmation` are accepted as input and validated, but only the `password_hash` (which you would generate from the password) is saved to the database.
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## Handling Errors
|
|
235
|
+
|
|
236
|
+
When a validation fails, Dami raises a `Dami::ValidationError`. You can `rescue` this error to access a hash of all the validation failures.
|
|
237
|
+
|
|
238
|
+
```ruby
|
|
239
|
+
begin
|
|
240
|
+
Dami.db(:users).create(name: '', email: 'invalid-email')
|
|
241
|
+
rescue Dami::ValidationError => e
|
|
242
|
+
puts e.message # => "Validation failed"
|
|
243
|
+
|
|
244
|
+
puts e.errors
|
|
245
|
+
# => {
|
|
246
|
+
# :name=>["is required"],
|
|
247
|
+
# :email=>["must be valid email"]
|
|
248
|
+
# }
|
|
249
|
+
end
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
This error hash is designed to be easily used to display feedback to a user in a web form or API response.
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
## What's Next?
|
|
257
|
+
|
|
258
|
+
* **Chapter 6: Protection** - Learn how to secure your models from mass-assignment vulnerabilities
|
|
259
|
+
* **Chapter 7: Associations** - Define and query relationships between your models
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# Chapter 6: Protection
|
|
2
|
+
|
|
3
|
+
Data security is not optional. A common web application vulnerability is **mass-assignment**, where a malicious user submits unexpected fields in a form—like `role: 'admin'`—and the application blindly saves them. Dami prevents this by default through a simple and powerful attribute protection system.
|
|
4
|
+
|
|
5
|
+
**Protection rules live in `Dami.behavior`**, keeping security policies separate from structure.
|
|
6
|
+
|
|
7
|
+
## The Problem: Mass-Assignment
|
|
8
|
+
|
|
9
|
+
Imagine a user signs up with the following data:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
malicious_params = {
|
|
13
|
+
name: 'Eve',
|
|
14
|
+
email: 'eve@hacker.com',
|
|
15
|
+
role: 'admin' # Uh oh!
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
# If the app is not secure, this might create an admin user!
|
|
19
|
+
Dami.db(:users).create(malicious_params)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Without protection, this simple action could grant unauthorized administrative access. Dami's protection layer is designed to stop this.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Protecting Attributes
|
|
27
|
+
|
|
28
|
+
You declare which attributes are sensitive using the `protect` method inside a `protection` block in your `Dami.behavior` block.
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
Dami.model :users do
|
|
32
|
+
fields do
|
|
33
|
+
field :name, :string
|
|
34
|
+
field :email, :string
|
|
35
|
+
field :role, :string
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
Dami.behavior :users do
|
|
40
|
+
protection do
|
|
41
|
+
protect :role # Cannot be mass-assigned
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Now, if someone attempts the same malicious action, Dami will raise an error and the operation will fail, keeping your application secure.
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
malicious_params = { name: 'Eve', role: 'admin' }
|
|
50
|
+
|
|
51
|
+
Dami.db(:users).create(malicious_params)
|
|
52
|
+
# => raises Dami::ProtectionError: Protected fields not permitted: role
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Permitting Attributes
|
|
58
|
+
|
|
59
|
+
In addition to `protect`, you can use `permit` to explicitly allow certain fields. This is useful for fields that are safe for users to modify:
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
Dami.behavior :users do
|
|
63
|
+
protection do
|
|
64
|
+
protect :role # Always blocked
|
|
65
|
+
permit :status # Always allowed
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
# This works - status is permitted
|
|
72
|
+
Dami.db(:users).create(name: 'Alice', status: 'active')
|
|
73
|
+
|
|
74
|
+
# This fails - role is protected
|
|
75
|
+
Dami.db(:users).create(name: 'Eve', role: 'admin')
|
|
76
|
+
# => Dami::ProtectionError
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Safely Updating Protected Fields
|
|
82
|
+
|
|
83
|
+
There are legitimate times when you need to change a protected attribute, such as in an admin panel or a background job. To do this safely, you must explicitly grant permission for the operation using the `permit:` option.
|
|
84
|
+
|
|
85
|
+
The `permit:` option takes an array of symbols representing the protected fields you are allowing to be changed for this **single operation**.
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
# In an admin controller, an authorized user is changing another user's role
|
|
89
|
+
user_id = 42
|
|
90
|
+
update_params = { role: 'moderator' }
|
|
91
|
+
|
|
92
|
+
# The permit option grants temporary permission
|
|
93
|
+
Dami.db(:users)
|
|
94
|
+
.where(id: user_id)
|
|
95
|
+
.update(update_params, permit: [:role]) # ✅ This is safe and will succeed
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
This makes your security intentions explicit. You are telling Dami, "For this one call, I know what I'm doing and I am intentionally modifying a protected field."
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Bypassing All Protections (Use with Caution)
|
|
103
|
+
|
|
104
|
+
In very rare circumstances, like in a seed script or a system migration, you may need to bypass all protections entirely. For this, you can use the `protect: false` option.
|
|
105
|
+
|
|
106
|
+
⚠️ **This is the nuclear option.** It disables all `protect` rules for the operation. Use it with extreme care and never with parameters coming directly from a user.
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
# Creating a system-level user during initial setup
|
|
110
|
+
Dami.db(:users).create(
|
|
111
|
+
name: 'Root',
|
|
112
|
+
role: 'super_admin',
|
|
113
|
+
status: 'active',
|
|
114
|
+
protect: false # Disables all protections for this one call
|
|
115
|
+
)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## Protection in Batch Operations
|
|
121
|
+
|
|
122
|
+
Protection is enforced in batch operations like `create_many`:
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
Dami.behavior :users do
|
|
126
|
+
protection do
|
|
127
|
+
protect :role
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# This will raise Dami::ProtectionError
|
|
132
|
+
Dami.db(:users).create_many([
|
|
133
|
+
{ name: 'Alice', role: 'admin' },
|
|
134
|
+
{ name: 'Bob', role: 'admin' }
|
|
135
|
+
])
|
|
136
|
+
|
|
137
|
+
# To allow it, use permit:
|
|
138
|
+
Dami.db(:users).create_many([
|
|
139
|
+
{ name: 'Alice', role: 'admin' },
|
|
140
|
+
{ name: 'Bob', role: 'admin' }
|
|
141
|
+
], permit: [:role])
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
**Important:** `create_many` validates all records before any database writes. If any record fails protection or validation, the entire batch is rejected atomically.
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## Best Practices
|
|
149
|
+
|
|
150
|
+
* **Protect by Default**: Be aggressive with the `protect` rule. If an attribute should not be changed by a regular user, protect it. Common examples include `role`, `is_admin`, `account_balance`, `verified`
|
|
151
|
+
* **Use `permit` for Safe Fields**: Explicitly mark fields that are always safe to modify, like `status` or user preferences
|
|
152
|
+
* **Permit Explicitly in Code**: Use the `permit: [...]` option in the specific, secure parts of your code (like admin controllers or service objects) where you intend to modify sensitive data
|
|
153
|
+
* **Avoid `protect: false`**: Reserve `protect: false` for trusted, internal scripts where you have full control over the input data
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## What's Next?
|
|
158
|
+
|
|
159
|
+
* **Chapter 7: Associations** - Learn how to define, query, and manage relationships between your models
|
|
160
|
+
* **Chapter 8: Scopes** - Create reusable query shortcuts to keep your code clean and readable
|