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,239 @@
|
|
|
1
|
+
# Chapter 7: Associations
|
|
2
|
+
|
|
3
|
+
Associations are powerful shortcuts that link your models together, allowing you to easily query and interact with related records. Instead of writing manual joins and foreign key lookups, you can navigate your data's relationships intuitively.
|
|
4
|
+
|
|
5
|
+
All associations are **lazy-loaded** by default, meaning the related data is only fetched from the database when you first access it.
|
|
6
|
+
|
|
7
|
+
## One-to-Many Relationships
|
|
8
|
+
|
|
9
|
+
This is the most common relationship type, connecting one model to many others.
|
|
10
|
+
|
|
11
|
+
### `belongs_to`
|
|
12
|
+
|
|
13
|
+
This association indicates that a model has a foreign key that points to another model. For example, a `Post` belongs to a `User`. This assumes your `posts` table has a `user_id` column.
|
|
14
|
+
|
|
15
|
+
#### **Definition**
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
Dami.model :posts do
|
|
19
|
+
fields { field :user_id, :integer; field :title, :string }
|
|
20
|
+
relationships do
|
|
21
|
+
belongs_to :user
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
#### **Usage**
|
|
27
|
+
|
|
28
|
+
When you access the association, Dami automatically runs a query to find the related record.
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
post = Dami.db(:posts).find(1)
|
|
32
|
+
|
|
33
|
+
# This triggers a new query: SELECT * FROM users WHERE id = ... LIMIT 1
|
|
34
|
+
author = post.user
|
|
35
|
+
|
|
36
|
+
puts author[:name] # => "Alice"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
✍️
|
|
40
|
+
|
|
41
|
+
-----
|
|
42
|
+
|
|
43
|
+
### `has_many`
|
|
44
|
+
|
|
45
|
+
This is the inverse of `belongs_to`. It indicates that a model can be associated with many instances of another model. A `User` has many `Posts`.
|
|
46
|
+
|
|
47
|
+
#### **Definition**
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
Dami.model :users do
|
|
51
|
+
fields { field :name, :string }
|
|
52
|
+
relationships do
|
|
53
|
+
has_many :posts
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
#### **Usage**
|
|
59
|
+
|
|
60
|
+
Accessing a `has_many` association returns a `Query::Builder` instance, allowing you to chain additional queries before fetching the data.
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
user = Dami.db(:users).find(1)
|
|
64
|
+
|
|
65
|
+
# This does NOT run a query yet. It returns a query builder.
|
|
66
|
+
user_posts_query = user.posts
|
|
67
|
+
|
|
68
|
+
# Now you can chain more conditions...
|
|
69
|
+
published_posts = user_posts_query.where(status: 'published').to_a
|
|
70
|
+
|
|
71
|
+
# Or just get them all. This executes the query.
|
|
72
|
+
all_posts = user.posts.to_a
|
|
73
|
+
puts all_posts.length # => 5
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
-----
|
|
77
|
+
|
|
78
|
+
## One-to-One Relationships
|
|
79
|
+
|
|
80
|
+
### `has_one`
|
|
81
|
+
|
|
82
|
+
A `has_one` association is a special case where a model is associated with at most one other record. For example, a `User` has one `Profile`. This assumes your `profiles` table has a `user_id` column.
|
|
83
|
+
|
|
84
|
+
#### **Definition**
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
Dami.model :users do
|
|
88
|
+
fields { field :name, :string }
|
|
89
|
+
relationships do
|
|
90
|
+
has_one :profile
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
#### **Usage**
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
user = Dami.db(:users).find(1)
|
|
99
|
+
|
|
100
|
+
# This triggers a query: SELECT * FROM profiles WHERE user_id = ... LIMIT 1
|
|
101
|
+
user_profile = user.profile
|
|
102
|
+
|
|
103
|
+
puts user_profile[:bio] # => "Loves writing about Dami."
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
-----
|
|
107
|
+
|
|
108
|
+
## Eager Loading with `.preload`
|
|
109
|
+
|
|
110
|
+
Lazy loading is convenient, but it can lead to a major performance issue known as the **N+1 Query Problem**.
|
|
111
|
+
|
|
112
|
+
⚠️ **The N+1 Problem:** Imagine you fetch 10 posts and then loop through them to print the author's name.
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
posts = Dami.db(:posts).limit(10).to_a # 1 query
|
|
116
|
+
|
|
117
|
+
posts.each do |post|
|
|
118
|
+
puts post.user[:name] # N (10) additional queries! One for each post.
|
|
119
|
+
end
|
|
120
|
+
# Total Queries: 1 + 10 = 11
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
This is incredibly inefficient. The solution is **eager loading**. Use `.preload` to tell Dami to fetch the associated data in advance using the minimum number of queries.
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
# This runs only TWO queries, no matter how many posts there are.
|
|
127
|
+
posts = Dami.db(:posts).preload(:user).limit(10).to_a
|
|
128
|
+
# Query 1: SELECT * FROM posts LIMIT 10
|
|
129
|
+
# Query 2: SELECT * FROM users WHERE id IN (1, 2, 5, ...)
|
|
130
|
+
|
|
131
|
+
posts.each do |post|
|
|
132
|
+
# No new query is run here! The user data is already loaded.
|
|
133
|
+
puts post.user[:name]
|
|
134
|
+
end
|
|
135
|
+
# Total Queries: 2
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
You can preload multiple associations at once:
|
|
139
|
+
`Dami.db(:users).preload(:posts, :profile).to_a`
|
|
140
|
+
|
|
141
|
+
-----
|
|
142
|
+
|
|
143
|
+
## Many-to-Many Relationships
|
|
144
|
+
|
|
145
|
+
### `has_many :through`
|
|
146
|
+
|
|
147
|
+
This association is used for many-to-many relationships, which require a third "join" table. For example, a `Post` can have many `Tags`, and a `Tag` can be on many `Posts`. The connection is made through a `posts_tags` table.
|
|
148
|
+
|
|
149
|
+
#### **Definition**
|
|
150
|
+
|
|
151
|
+
You must define the associations on all three models.
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
Dami.model :posts do
|
|
155
|
+
relationships do
|
|
156
|
+
has_many :posts_tags
|
|
157
|
+
has_many :tags, through: :posts_tags
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
Dami.model :tags do
|
|
162
|
+
relationships do
|
|
163
|
+
has_many :posts_tags
|
|
164
|
+
has_many :posts, through: :posts_tags
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
Dami.model :posts_tags do # The join model
|
|
169
|
+
relationships do
|
|
170
|
+
belongs_to :post
|
|
171
|
+
belongs_to :tag
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
#### **Usage**
|
|
177
|
+
|
|
178
|
+
You can now access the association directly, and Dami handles the join automatically.
|
|
179
|
+
|
|
180
|
+
```ruby
|
|
181
|
+
post = Dami.db(:posts).find(1)
|
|
182
|
+
puts post.tags.map { |t| t[:name] } # => ["ruby", "orm"]
|
|
183
|
+
|
|
184
|
+
tag = Dami.db(:tags).find(5)
|
|
185
|
+
puts tag.posts.count # => 42
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
-----
|
|
189
|
+
|
|
190
|
+
## Polymorphic Associations
|
|
191
|
+
|
|
192
|
+
A polymorphic association allows a model to belong to more than one type of other model on a single association. A classic example is a `Comment` which can belong to a `Post`, an `Article`, or a `Video`.
|
|
193
|
+
|
|
194
|
+
This requires two columns on the "belonging" model: `commentable_id` (an integer) and `commentable_type` (a string like "Post" or "Article").
|
|
195
|
+
|
|
196
|
+
#### **Definition**
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
# The polymorphic model
|
|
200
|
+
Dami.model :comments do
|
|
201
|
+
relationships do
|
|
202
|
+
belongs_to commentable: { polymorphic: true }
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# The models that can be commented on
|
|
207
|
+
Dami.model :posts do
|
|
208
|
+
relationships do
|
|
209
|
+
has_many comments: { as: :commentable, model: :comments }
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
Dami.model :articles do
|
|
214
|
+
relationships do
|
|
215
|
+
has_many comments: { as: :commentable, model: :comments }
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
#### **Usage**
|
|
221
|
+
|
|
222
|
+
Dami automatically figures out which type of record to fetch.
|
|
223
|
+
|
|
224
|
+
```ruby
|
|
225
|
+
comment = Dami.db(:comments).find(1) # This one is on a Post
|
|
226
|
+
parent = comment.commentable
|
|
227
|
+
puts parent[:title] # => "My Awesome Post"
|
|
228
|
+
|
|
229
|
+
comment2 = Dami.db(:comments).find(2) # This one is on an Article
|
|
230
|
+
parent2 = comment2.commentable
|
|
231
|
+
puts parent2[:headline] # => "A Great Article"
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
## What's Next?
|
|
237
|
+
|
|
238
|
+
* **Chapter 8: Scopes** - Create reusable query shortcuts to keep your code clean and readable.
|
|
239
|
+
* **Chapter 9: Migrations** - Learn how to manage your database schema over time.
|
data/docs/08.Scopes.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Chapter 8: Scopes
|
|
2
|
+
|
|
3
|
+
Scopes are named query shortcuts that let you encapsulate commonly used query logic directly into your model definitions. They allow you to turn complex, repetitive query chains into clean, readable, and reusable methods.
|
|
4
|
+
|
|
5
|
+
Think of scopes as custom filters for your models. Instead of writing `.where(status: 'active')` everywhere, you can simply call `.active`.
|
|
6
|
+
|
|
7
|
+
-----
|
|
8
|
+
|
|
9
|
+
## Defining Scopes
|
|
10
|
+
|
|
11
|
+
Scopes are defined within a `scopes` block in your model using the `.scope` method. Each scope takes a name and a lambda that returns a `Query::Builder` instance.
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
Dami.scopes :posts do
|
|
15
|
+
# A simple scope without arguments
|
|
16
|
+
scope :published, -> { where(status: 'published') }
|
|
17
|
+
|
|
18
|
+
# A scope that chains other query methods
|
|
19
|
+
scope :recent, -> { order(created_at: :desc).limit(10) }
|
|
20
|
+
|
|
21
|
+
end
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
-----
|
|
25
|
+
|
|
26
|
+
## Using Scopes
|
|
27
|
+
|
|
28
|
+
Once defined, a scope can be called just like any other method on your query builder.
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
# Get all published posts
|
|
32
|
+
published_posts = Dami.db(:posts).published.to_a
|
|
33
|
+
|
|
34
|
+
# Get the 10 most recent posts
|
|
35
|
+
recent_posts = Dami.db(:posts).recent.to_a
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Chaining Scopes 🔗
|
|
39
|
+
|
|
40
|
+
The real power of scopes comes from chaining them together. Dami combines them using `AND`, allowing you to compose complex queries from simple, reusable parts.
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
# Get the 10 most recent PUBLISHED posts
|
|
44
|
+
recent_published = Dami.db(:posts).published.recent.to_a
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
This is far more readable than chaining the underlying `where`, `order`, and `limit` calls every time.
|
|
48
|
+
|
|
49
|
+
-----
|
|
50
|
+
|
|
51
|
+
## Scopes with Arguments
|
|
52
|
+
|
|
53
|
+
Scopes can also accept arguments, making them even more flexible. The arguments you pass to the scope method will be passed directly to the lambda.
|
|
54
|
+
|
|
55
|
+
#### **Definition**
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
Dami.scopes :users do
|
|
59
|
+
|
|
60
|
+
# Find users by a specific status
|
|
61
|
+
scope :by_status, ->(status) { where(status: status) }
|
|
62
|
+
|
|
63
|
+
# Find users created after a certain date
|
|
64
|
+
scope :created_after, ->(date) { where('created_at > ?', date) }
|
|
65
|
+
|
|
66
|
+
end
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
#### **Usage**
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
# Get all pending users
|
|
73
|
+
pending_users = Dami.db(:users).by_status('pending').to_a
|
|
74
|
+
|
|
75
|
+
# Get users created in the last week and chain it with another scope
|
|
76
|
+
recent_active_users = Dami.db(:users)
|
|
77
|
+
.active # Assuming an :active scope exists
|
|
78
|
+
.created_after(1.week.ago)
|
|
79
|
+
.to_a
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
-----
|
|
83
|
+
|
|
84
|
+
## Best Practices & Why Use Scopes?
|
|
85
|
+
|
|
86
|
+
Scopes are a fundamental tool for writing clean and maintainable data-access code.
|
|
87
|
+
|
|
88
|
+
* **DRY (Don't Repeat Yourself)**: Scopes centralize your query logic. If the definition of a "published" post changes (e.g., to `status IN ('live', 'promoted')`), you only need to update the scope in one place, and your entire application will reflect the change.
|
|
89
|
+
|
|
90
|
+
* **Readability**: Scopes make your code self-documenting. `Dami.db(:posts).published.recent` is immediately understandable, whereas the underlying chain of `where` and `order` clauses is not.
|
|
91
|
+
|
|
92
|
+
* **Composability**: Scopes are like building blocks. You can define small, focused scopes and chain them together to build sophisticated queries without duplicating logic.
|
|
93
|
+
|
|
94
|
+
-----
|
|
95
|
+
|
|
96
|
+
## What's Next?
|
|
97
|
+
|
|
98
|
+
* **Chapter 9: Migrations** - Learn how to manage your database schema over time with versioned changes.
|
|
99
|
+
* **Chapter 10: Advanced Topics** - Explore transactions, flows, and other powerful features.
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# Chapter 9: Migrations
|
|
2
|
+
|
|
3
|
+
Migrations are the definitive history of your database schema, allowing you to evolve its structure in a safe, repeatable, and collaborative way. Dami provides a powerful migrator and an **intelligent migration generator** that automates the most tedious parts of schema management, letting you focus on your models.
|
|
4
|
+
|
|
5
|
+
-----
|
|
6
|
+
|
|
7
|
+
## The Magic Workflow: Let Dami Write Your Migrations ✨
|
|
8
|
+
|
|
9
|
+
With Dami, you rarely need to write migrations by hand. The primary workflow is simple and incredibly fast:
|
|
10
|
+
|
|
11
|
+
1. **Modify Your Models**: Make changes directly in your `Dami.model` definitions. Add a field, remove a field, or add a `belongs_to` relationship.
|
|
12
|
+
2. **Generate the Migration**: Run a single command in your terminal.
|
|
13
|
+
3. **Review and Migrate**: Inspect the auto-generated migration file, then run it.
|
|
14
|
+
|
|
15
|
+
#### **Example: Adding an `age` Column to Users**
|
|
16
|
+
|
|
17
|
+
**Step 1: Edit the Model**
|
|
18
|
+
You decide `users` need an `age` column. You simply add it to the `fields` block.
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
# app/models/user.rb
|
|
22
|
+
Dami.model :users do
|
|
23
|
+
fields do
|
|
24
|
+
field :name, :string
|
|
25
|
+
field :email, :string
|
|
26
|
+
field :age, :integer # <-- You add this line
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**Step 2: Generate the Migration**
|
|
32
|
+
Run the `generate` command from your terminal.
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
dami generate migration AddAgeToUsers
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Dami introspects your models, compares them to the last known schema (`db/schema.rb`), and detects that the `age` column is missing. It then writes the perfect migration file for you.
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
✅ New migration created: db/migrations/20251015194500_add_age_to_users.rb
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The generated file looks like this:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
# db/migrations/20251015194500_add_age_to_users.rb
|
|
48
|
+
# This file is auto-generated.
|
|
49
|
+
|
|
50
|
+
def up
|
|
51
|
+
add_column(:users, :age, :integer)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def down
|
|
55
|
+
remove_column(:users, :age)
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**Step 3: Run the Migration**
|
|
60
|
+
The generated file is perfect. Now, just apply it to your database.
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
dami db migrate
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
That's it\! Your database is now in sync with your models. This workflow also handles removing columns, adding tables, and even **automatically creating indexes** when you add a `belongs_to` relationship.
|
|
67
|
+
|
|
68
|
+
-----
|
|
69
|
+
|
|
70
|
+
## The CLI Commands 🚀
|
|
71
|
+
|
|
72
|
+
All migration and schema tasks are handled through the `dami` executable.
|
|
73
|
+
|
|
74
|
+
| Command | Description |
|
|
75
|
+
|----------------------------------|--------------------------------------------------------------------------|
|
|
76
|
+
| `dami db migrate` | Runs all pending migrations and updates `db/schema.rb`. |
|
|
77
|
+
| `dami db rollback [STEPS]` | Reverts the last migration (or the last `STEPS` migrations). |
|
|
78
|
+
| `dami generate migration NAME` | **(Your primary tool)** Generates a new migration from model changes. |
|
|
79
|
+
| `dami db schema_dump` | Manually regenerates `db/schema.rb` from the current database state. |
|
|
80
|
+
|
|
81
|
+
-----
|
|
82
|
+
|
|
83
|
+
## Manual Migrations
|
|
84
|
+
|
|
85
|
+
For complex changes that the generator can't infer—like data migrations, complex constraints, or custom SQL—you can always write a migration by hand.
|
|
86
|
+
|
|
87
|
+
1. Create a new file: `db/migrations/TIMESTAMP_do_something_special.rb`.
|
|
88
|
+
2. Write your `up` and `down` methods using the Schema DSL.
|
|
89
|
+
|
|
90
|
+
### The Schema DSL
|
|
91
|
+
|
|
92
|
+
The DSL provides simple methods for all common schema operations.
|
|
93
|
+
|
|
94
|
+
| Method | Example Usage |
|
|
95
|
+
|------------------|--------------------------------------------------|
|
|
96
|
+
| `create_table` | `create_table(:posts) { \|t\| t.field ... }` |
|
|
97
|
+
| `drop_table` | `drop_table(:posts)` |
|
|
98
|
+
| `add_column` | `add_column(:users, :status, :string)` |
|
|
99
|
+
| `remove_column` | `remove_column(:users, :status)` |
|
|
100
|
+
| `add_index` | `add_index(:posts, :user_id)` / `add_index(:users, :email, unique: true)` |
|
|
101
|
+
| `remove_index` | `remove_index(:posts, :user_id)` |
|
|
102
|
+
| `execute(sql)` | Runs a raw SQL command for special cases. |
|
|
103
|
+
|
|
104
|
+
### Column options
|
|
105
|
+
|
|
106
|
+
Every `t.field` (and `add_column`) accepts these options, which become real SQL constraints:
|
|
107
|
+
|
|
108
|
+
| Option | Effect |
|
|
109
|
+
|---------------------------|------------------------------------------------------------|
|
|
110
|
+
| `null: false` | `NOT NULL` |
|
|
111
|
+
| `unique: true` | `UNIQUE` |
|
|
112
|
+
| `default: value` | `DEFAULT value` (`default: :current_timestamp` for `CURRENT_TIMESTAMP`) |
|
|
113
|
+
| `references: :users` | `REFERENCES users(id)` — enforced, Dami turns foreign keys on |
|
|
114
|
+
| `on_delete: :cascade` | Adds `ON DELETE CASCADE` to a `references:` column |
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
create_table(:expenses) do |t|
|
|
118
|
+
t.field :id, :primary_key
|
|
119
|
+
t.field :amount_cents, :integer, null: false
|
|
120
|
+
t.field :currency, :string, null: false, default: 'EUR'
|
|
121
|
+
t.field :note, :text
|
|
122
|
+
t.references :batch, on_delete: :cascade # batch_id INTEGER REFERENCES batches(id) ON DELETE CASCADE
|
|
123
|
+
t.timestamps # created_at / updated_at, filled automatically
|
|
124
|
+
end
|
|
125
|
+
add_index(:expenses, :batch_id)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Violations raise `Dami::NotNullViolation`, `Dami::UniqueConstraintViolation` (with `.column`) and `Dami::ForeignKeyViolation`.
|
|
129
|
+
|
|
130
|
+
**SQLite limits on `add_column`:** SQLite cannot add a `UNIQUE` or `PRIMARY KEY` column to an existing table, and a `NOT NULL` column added later needs a `default:`. Put those constraints in `create_table`, or add the column and then an index with `add_index(..., unique: true)`.
|
|
131
|
+
|
|
132
|
+
### Types
|
|
133
|
+
|
|
134
|
+
| Type(s) | SQL type |
|
|
135
|
+
|----------------------------------|------------|
|
|
136
|
+
| `:primary_key` | `INTEGER PRIMARY KEY AUTOINCREMENT` |
|
|
137
|
+
| `:integer`, `:boolean` | `INTEGER` |
|
|
138
|
+
| `:float`, `:decimal` | `REAL` |
|
|
139
|
+
| `:datetime`, `:date`, `:time` | `DATETIME` |
|
|
140
|
+
| `:string`, `:text`, `:json` | `TEXT` |
|
|
141
|
+
|
|
142
|
+
Values come back from SQLite as they are stored: booleans are converted for fields declared `:boolean`, JSON is parsed for fields declared `:json`, dates and times come back as strings (`'2026-09-12'`, `'2026-09-12 18:40:00'` UTC).
|
|
143
|
+
|
|
144
|
+
-----
|
|
145
|
+
|
|
146
|
+
## The Role of `db/schema.rb`
|
|
147
|
+
|
|
148
|
+
This file is a critical part of the generator. It's an auto-generated snapshot of your database's canonical structure after the last successful migration.
|
|
149
|
+
|
|
150
|
+
* **DO NOT EDIT THIS FILE MANUALLY.**
|
|
151
|
+
* Dami uses it as the "before" picture to compare against your "after" models.
|
|
152
|
+
* It's automatically updated for you every time you run `dami db:migrate`.
|
|
153
|
+
* You should commit this file to your version control system (Git).
|
|
154
|
+
|
|
155
|
+
-----
|
|
156
|
+
|
|
157
|
+
## Best Practices
|
|
158
|
+
|
|
159
|
+
* **Trust the Generator First**: Always try to use `dami generate migration` as your primary workflow. It's faster and less error-prone.
|
|
160
|
+
* **Write Manual Migrations for Data Changes**: The generator handles structure (schema), but you should write manual migrations for data changes (e.g., backfilling a `status` column for existing users).
|
|
161
|
+
* **Never Edit a Run Migration**: Once a migration has been run on other machines, do not edit it. Create a *new* migration to make further changes.
|
|
162
|
+
|
|
163
|
+
## What's Next?
|
|
164
|
+
|
|
165
|
+
* **Chapter 10: Advanced Topics** - Explore transactions, flows, and other powerful features for building complex application logic.
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
## Chapter 10: Drafts, Flows & Commands
|
|
2
|
+
|
|
3
|
+
In most frameworks, handling business logic is an afterthought. It gets mixed into models, controllers, or scattered across service objects. Dami treats business logic as a first-class citizen with a simple, powerful philosophy: the **"Spectrum of Complexity."**
|
|
4
|
+
|
|
5
|
+
A great framework shouldn't give you a sledgehammer for every task. It should give you a full toolbox. Dami provides the perfect tool for every job, scaling gracefully from a simple one-liner to a complex, orchestrated workflow.
|
|
6
|
+
|
|
7
|
+
This chapter will guide you through the three tiers of Dami's business logic layer.
|
|
8
|
+
|
|
9
|
+
-----
|
|
10
|
+
|
|
11
|
+
### Tier 1: The Foot Soldier - Simple, Direct CRUD
|
|
12
|
+
|
|
13
|
+
**When to use it:** For trusted, internal operations where no complex validation is needed. An admin script, a database seed, or a simple counter update.
|
|
14
|
+
|
|
15
|
+
For these tasks, Dami gets out of your way. The tool is a direct, one-liner database command. There is no ceremony.
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
# Fast, simple, and explicit.
|
|
19
|
+
# No extra layers needed for trusted operations.
|
|
20
|
+
|
|
21
|
+
# Activate a user
|
|
22
|
+
db(:users).where(id: 1337).update(status: 'active')
|
|
23
|
+
|
|
24
|
+
# Log a user's last login time
|
|
25
|
+
db(:users).find(42).update(last_seen_at: Time.now)
|
|
26
|
+
|
|
27
|
+
# Delete archived records
|
|
28
|
+
db(:orders).where(status: 'archived').delete
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
This is the baseline. It's clean, performant, and the right tool for simple jobs.
|
|
32
|
+
|
|
33
|
+
-----
|
|
34
|
+
|
|
35
|
+
### Tier 2: The Elite Guard - Inline Drafts
|
|
36
|
+
|
|
37
|
+
**When to use it:** For the 80% of typical web controller actions. A user updates their profile, submits a form, or changes a setting. The input is untrusted and requires contextual validation and transformation.
|
|
38
|
+
|
|
39
|
+
For these tasks, Dami provides an **inline `draft` block** on `create`, `update`, and `delete`. This is a massive leap in power with minimal ceremony. It gives you a temporary `draft` object to prepare and verify your data within a single, atomic transaction.
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
# app/controllers/accounts_controller.rb
|
|
43
|
+
def update
|
|
44
|
+
# ...
|
|
45
|
+
# The block yields a `draft` object. The entire block is one transaction.
|
|
46
|
+
updated_account = db(:accounts).where(id: params[:id]).update(params) do |draft|
|
|
47
|
+
# --- The Inline Draft DSL ---
|
|
48
|
+
|
|
49
|
+
# 1. Use declarative helpers for common patterns.
|
|
50
|
+
draft.prevent_changes :subdomain, message: "Subdomain cannot be changed."
|
|
51
|
+
|
|
52
|
+
# 2. Apply reusable transformations.
|
|
53
|
+
draft.prepare(AccountPolicies::NORMALIZE_NAME)
|
|
54
|
+
|
|
55
|
+
# 3. Add ad-hoc, contextual validation.
|
|
56
|
+
draft.verify :name_is_unique, error: "This name is already taken." do
|
|
57
|
+
# `changed?` and `original` are available helpers.
|
|
58
|
+
!draft.changed?(:name) || db(:accounts).where(name: draft.get(:name)).none?
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
if updated_account
|
|
63
|
+
# The block passed, and the update was successful.
|
|
64
|
+
redirect_to account_path, notice: "Account updated!"
|
|
65
|
+
else
|
|
66
|
+
# The block failed validation and halted. `update` returns nil.
|
|
67
|
+
# The database was never touched.
|
|
68
|
+
render :edit, status: :unprocessable_entity
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
This is the workhorse of your application. It provides contextual validation and transformation right where it's needed, using a consistent, declarative DSL.
|
|
74
|
+
|
|
75
|
+
-----
|
|
76
|
+
|
|
77
|
+
### Tier 3: The Final Boss - Commands & Orchestration
|
|
78
|
+
|
|
79
|
+
**When to use it:** For the 20% of critical, multi-step business processes. A user signup, an e-commerce checkout, a subscription cancellation. These workflows involve multiple database tables, external API calls, and side effects that **cannot be rolled back**.
|
|
80
|
+
|
|
81
|
+
For these tasks, Dami provides two powerful tools: **Commands** and **`Dami.run`**.
|
|
82
|
+
|
|
83
|
+
#### The Command (The Brains 🧠)
|
|
84
|
+
|
|
85
|
+
A `Dami::Command` encapsulates complex validation and transformation logic into a single, reusable, and testable class. It's how you turn the logic from an inline block into a formal, named "recipe."
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
# app/commands/create_account_and_owner_command.rb
|
|
89
|
+
class CreateAccountAndOwnerCommand < Dami::Command
|
|
90
|
+
# Fails early if `signup_source` is not provided in the context.
|
|
91
|
+
requires_context :signup_source
|
|
92
|
+
|
|
93
|
+
# Declarative validation rules for the incoming data.
|
|
94
|
+
validate :account_name_is_unique, error: "Account name is taken" do
|
|
95
|
+
db(:accounts).where(name: data[:account][:name]).none?
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
validate :owner_password_confirmation, error: "Passwords do not match" do
|
|
99
|
+
data[:user][:password] == data[:user][:password_confirmation]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# A data transformation step.
|
|
103
|
+
transform :prepare_records do |data|
|
|
104
|
+
data[:user][:password_hash] = BCrypt::Password.create(data[:user][:password])
|
|
105
|
+
data[:account][:subdomain] = data[:account][:name].downcase.strip.gsub(/\s+/, '-')
|
|
106
|
+
data
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
#### The Orchestrator (`Dami.run`)
|
|
112
|
+
|
|
113
|
+
This is the final boss. It's a top-to-bottom, transaction-safe orchestrator for the entire business process. It reads like a clear, imperative script.
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
# app/flows/onboarding_flow.rb
|
|
117
|
+
Dami.run(context: { signup_source: 'web' }) do |run_context|
|
|
118
|
+
# 1. PREPARE the data. `prepare` uses the command to validate and transform.
|
|
119
|
+
# It halts automatically if validation fails.
|
|
120
|
+
clean_data = prepare CreateAccountAndOwnerCommand, with: params, context: run_context
|
|
121
|
+
|
|
122
|
+
# 2. Interact with the DB. These commands are now part of the `run` block's
|
|
123
|
+
# single, atomic transaction.
|
|
124
|
+
account = db(:accounts).create(clean_data[:account])
|
|
125
|
+
user = db(:users).create(clean_data[:user].merge(account_id: account[:id]))
|
|
126
|
+
|
|
127
|
+
# 3. Perform an external task with resilience.
|
|
128
|
+
perform "Create Billing Trial", retry: { on: [Billing::ApiError], times: 3 } do
|
|
129
|
+
BillingService.start_trial(account_id: account[:id], user_email: user[:email])
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# 4. SUCCEED. The final, declarative exit point for the entire orchestration.
|
|
133
|
+
succeed with: { account: account, user: user }, and_then: [
|
|
134
|
+
WelcomeEmailJob.with(user_id: user.id),
|
|
135
|
+
Analytics.track("Team Onboarded", account_id: account.id)
|
|
136
|
+
]
|
|
137
|
+
end
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
-----
|
|
141
|
+
|
|
142
|
+
### **The Dami Guarantee: Atomic Operations & Safe Side Effects**
|
|
143
|
+
|
|
144
|
+
You might be asking: "Why do I need `succeed with: and_then:`? Can't I just put the email job in a `call` block?" This is where Dami's most powerful feature comes into play.
|
|
145
|
+
|
|
146
|
+
#### The Problem: The "Email Sent, User Never Created" Bug
|
|
147
|
+
|
|
148
|
+
A `Dami.run` block guarantees that all **database operations** are atomic—they all succeed, or they all roll back. That includes a `Failure` halt from `prepare`: every write the flow made before the halt is rolled back. A flow that calls `run(:other_flow)` runs the inner flow in a savepoint, so an inner failure rolls back only the inner writes and hands the `Failure` back to the outer flow. But what about actions that can't be rolled back, like sending an email or enqueuing a background job?
|
|
149
|
+
|
|
150
|
+
**Consider this dangerous sequence of events:**
|
|
151
|
+
|
|
152
|
+
1. A user is created in the database transaction.
|
|
153
|
+
2. A welcome email job is immediately enqueued.
|
|
154
|
+
3. The *next step*, calling the `BillingService`, fails.
|
|
155
|
+
4. **Disaster:** The entire database transaction is **rolled back**. The user record is deleted. But it's too late—the email job is already in the queue, and a welcome email gets sent to a user who doesn't exist.
|
|
156
|
+
|
|
157
|
+
#### The Dami Solution: A Transaction-Aware Holding Area
|
|
158
|
+
|
|
159
|
+
The `succeed with: and_then:` block is not just another step. It is a **transaction-aware gatekeeper** for your irreversible side effects.
|
|
160
|
+
|
|
161
|
+
> It's a "holding area" for actions that must only run *after* the database transaction has been successfully and permanently committed.
|
|
162
|
+
|
|
163
|
+
**This is the safe, correct sequence:**
|
|
164
|
+
|
|
165
|
+
1. A user is created in the database transaction.
|
|
166
|
+
2. The call to the `BillingService` succeeds.
|
|
167
|
+
3. The `succeed with:` block is reached. The `WelcomeEmailJob` is placed in the holding area.
|
|
168
|
+
4. The `Dami.run` block finishes. Dami commits the database transaction.
|
|
169
|
+
5. **Only now**, after the commit is successful, does Dami execute the actions in the `and_then:` block.
|
|
170
|
+
|
|
171
|
+
If the `BillingService` had failed, the transaction would have rolled back, and the `and_then:` block would have been discarded. No email would ever be sent.
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
### The Dami Way vs. The Rails Way
|
|
176
|
+
|
|
177
|
+
* **The Problem: Callback Hell & Fat Models.** In Rails, a complex process like this is often a chain of `after_save` hooks. An `Account` model might trigger a user creation, which in turn triggers a welcome email. This chain of hidden actions is brittle and incredibly hard to debug. If an API call to the `BillingService` fails, the `Account` and `User` records are already in the database, leaving your system in an inconsistent, half-finished state.
|
|
178
|
+
|
|
179
|
+
* **The Dami Solution: An Explicit, Transactional Orchestrator.** `Dami.run` makes the entire business process visible and atomic. The "semantic trio" of `prepare`, `db()`, and `call` makes the intent of each line instantly clear. If the `call` to the `BillingService` fails, the entire transaction is **automatically rolled back**, and the `Account` and `User` records are never saved. The `succeed with: and_then:` block guarantees that side effects like sending an email happen **only after** everything has been successfully committed to the database, eliminating an entire class of common bugs.
|
|
180
|
+
|
|
181
|
+
This is the pinnacle of the Dami philosophy: a system that scales from the simplicity of a one-liner to the robust, transactional, and resilient power needed for mission-critical business logic, all while maintaining a consistent and joyful developer experience.
|