yaml_exporter 0.1.0 → 0.2.1

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.
data/README.md CHANGED
@@ -1,15 +1,10 @@
1
1
  # YamlExporter
2
2
 
3
- YamlExporter is a Ruby gem that provides YAML serialization and deserialization functionality for ActiveRecord models,
4
- with JSON schema generation for validation.
3
+ **Manage ActiveRecord data in YAML files, track it in Git.**
5
4
 
6
- ## Features
5
+ YamlExporter serializes a record — and everything it owns — to a human-readable YAML file, and imports the file back in a single transaction. A three-method DSL (`attributes`, `one`, `many`) declares the mapping; `yaml_export` and `yaml_import` round-trip the data.
7
6
 
8
- - Serialize ActiveRecord models to YAML
9
- - Deserialize YAML back to ActiveRecord models
10
- - Generate JSON schemas for model validation
11
- - Support for nested associations (has_many and has_one)
12
- - Automatic type inference based on database column types
7
+ Useful whenever a record is really *content*: seed data, CMS-style entries, training material, quiz banks, fixtures — anywhere you'd rather review changes in a pull request than in the database.
13
8
 
14
9
  ## Installation
15
10
 
@@ -25,70 +20,823 @@ And then execute:
25
20
  $ bundle install
26
21
  ```
27
22
 
28
- Or install it yourself as:
23
+ or install it yourself as:
29
24
 
30
25
  ```
31
26
  $ gem install yaml_exporter
32
27
  ```
33
28
 
34
- ## Usage
29
+ ## Hello Yaml
35
30
 
36
- ### Setting up your model
31
+ Imagine you are a bookstore and want to manage your books in Git.
37
32
 
38
- Include the `YamlExporter` module in your ActiveRecord model and define the YAML structure:
33
+ Here is our model:
34
+
35
+ ```mermaid
36
+ classDiagram
37
+ class Book {
38
+ - String title
39
+ - String author
40
+ - Decimal price
41
+ }
42
+ ```
43
+
44
+ Why not create such a file here:
45
+
46
+ `ruby-on-rails.yaml`
47
+ ```yaml
48
+ title: Ruby on Rails Tutorial
49
+ author: Michael Hartl
50
+ price: 100
51
+ ```
52
+
53
+ Then, you can configure your ActiveRecord model to be able to export and import this data from and to this file:
54
+
55
+ ```ruby
56
+ class Book < ActiveRecord::Base
57
+ include YamlExporter
58
+
59
+ yaml_structure do
60
+ attributes :title, :author, :price
61
+ end
62
+ end
63
+ ```
64
+
65
+ Now you can simply import the data from the file into your ActiveRecord model:
66
+
67
+ ```ruby
68
+ book = Book.new
69
+ yaml_string = File.read('ruby-on-rails.yaml')
70
+ book.yaml_import(yaml_string)
71
+ ```
72
+
73
+ And of course you can export the data from your ActiveRecord model to the file:
74
+
75
+ ```ruby
76
+ yaml_string = book.yaml_export
77
+ ```
78
+
79
+ ## Mental model
80
+
81
+ The whole DSL fits in three methods: `attributes`, `one`, and `many`. Everything else is governed by two orthogonal axes and one lifecycle rule.
82
+
83
+ **A YAML file describes one record and everything it owns.** References (via `find_by`) are how you reach outside that ownership boundary to point at records that live in other trees — other files, or records managed elsewhere in code.
84
+
85
+ ### Axis 1 — cardinality: pick the method
86
+
87
+ | Method | What it describes |
88
+ | ------------------------- | ----------------------------------- |
89
+ | `attributes :col1, :col2` | Plain columns on the current record |
90
+ | `one :relation, …` | Exactly one related record |
91
+ | `many :relations, …` | A list of related records |
92
+
93
+ ### Axis 2 — ownership: block vs. `find_by`
94
+
95
+ The arguments you pass determine both the YAML shape and who owns the record:
96
+
97
+ | Pattern | YAML shape | Ownership |
98
+ | ------------------------------ | --------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
99
+ | **block** | hash | **You own it.** YamlExporter creates, updates, and destroys records to match the YAML. |
100
+ | **`find_by:` only** (no block) | bare string | **Someone else owns it.** YamlExporter only resolves the reference — it never creates or destroys referenced records. |
101
+ | **block + `find_by:`** | hash containing the `find_by` key | You own it; identity is a stable column rather than position (for `many`). |
102
+
103
+ ### The combination table
104
+
105
+ | DSL call | YAML shape |
106
+ | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
107
+ | `attributes :title, :price` | `title: …`, `price: …` |
108
+ | `one :book_detail do … end` | nested hash |
109
+ | `one :publisher, find_by: :slug` | bare string: `publisher: some-slug` |
110
+ | `one :responsible_editor, find_by: :slug, of: :user` | bare string: `responsible_editor: some-slug` (resolved via a nested association on the target) |
111
+ | `many :book_parts do … end` | list of hashes, matched by order |
112
+ | `many :book_parts, find_by: :slug do … end` | list of hashes, matched by `slug` |
113
+ | `many :book_parts, find_by: :slug, positioned_by: :position do … end` | list of hashes, matched by `slug`; no `position:` key — the column is derived from the array index |
114
+ | `many :authors, find_by: :slug` | list of bare strings |
115
+ | `many :editorial_editors, find_by: :slug, of: :user` | list of bare strings, each resolved via a nested association on the target |
116
+ | `many :reviewers, through: :book_reviewers, find_by: :slug` | list of bare strings (join rows managed for you; add `positioned_by:` to derive a join column from order) |
117
+ | `many :editorial_editors, through: :editor_assignments, find_by: :slug, of: :user` | list of bare strings, each resolved via a nested association on the target |
118
+ | `many :reviewers, through: :book_reviewers, find_by: :slug do … end` | list of hashes describing join-model attributes |
119
+
120
+ ### Lifecycle rules
121
+
122
+ These apply uniformly to every `yaml_import`:
123
+
124
+ 1. **Missing key = `null` = clear it.** Absent attributes are reset to `nil`; absent owned records are destroyed. To tell the importer to ignore a field, omit it from `yaml_structure` — don't leave it blank in the YAML.
125
+ 2. **One import = one transaction.** If anything raises, the whole import rolls back and the record is left untouched.
126
+ 3. **Validation failures raise.** All writes go through `save!`, so `ActiveRecord::RecordInvalid` aborts the import. Unresolvable references raise `ActiveRecord::RecordNotFound`. The library deliberately never swallows these.
127
+
128
+ ## Working with relations
129
+
130
+ The examples below walk through each row of the combination table above, using the same bookstore domain.
131
+
132
+ ### `many` with a block (positional)
133
+
134
+ Your book consists of multiple parts and on the website you want to display a bit more information about each part:
135
+
136
+ ```mermaid
137
+ classDiagram
138
+ class Book {
139
+ - String title
140
+ - String author
141
+ - Decimal price
142
+ }
143
+ class BookPart {
144
+ - String title
145
+ - String content
146
+ - Integer position
147
+ }
148
+ Book "1" -- "*" BookPart
149
+ ```
150
+
151
+ Of course, you could create a separate yaml file for each book part and pass the book id, but actually it would be nicer to configure the parts in the book yaml file:
152
+
153
+ ```yaml
154
+ title: Ruby on Rails Tutorial
155
+ author: Michael Hartl
156
+ price: 100
157
+ book_parts:
158
+ - title: Chapter 1
159
+ content: |-
160
+ This is the first chapter of the book
161
+ position: 1
162
+ - title: Chapter 2
163
+ content: |-
164
+ This is the second chapter of the book
165
+ position: 2
166
+ ```
167
+
168
+ To do so, configure your book model to have a `has_many` relation to book parts:
169
+
170
+ ```ruby
171
+ class Book < ActiveRecord::Base
172
+ has_many :book_parts, dependent: :destroy
173
+
174
+ include YamlExporter
175
+
176
+ yaml_structure do
177
+ attributes :title, :author, :price
178
+ many :book_parts do
179
+ attributes :title, :content, :position
180
+ end
181
+ end
182
+ end
183
+ ```
184
+
185
+ **Edge cases**:
186
+ * If there are missing book parts in the database, they will be created.
187
+ * If the database has more book parts than in the yaml file, the extra book parts will be deleted.
188
+ * Existing rows are matched by array index — so re-ordering the list silently overwrites each row with another row's data.
189
+
190
+ Be aware that this way you need to be very careful with the order of the book parts in the yaml file. To avoid this, you can use the `find_by` option:
191
+
192
+ ### `many` with `find_by` and a block
193
+
194
+ Let us now identify the book parts by a stable column, for example, let's add a `slug` column to the book parts:
195
+
196
+ ```mermaid
197
+ classDiagram
198
+ class Book {
199
+ - String title
200
+ - String author
201
+ - Decimal price
202
+ }
203
+ class BookPart {
204
+ - String title
205
+ - String content
206
+ - Integer position
207
+ - String slug
208
+ }
209
+ Book "1" -- "*" BookPart
210
+ ```
211
+
212
+ Now you can configure your book model to identify the book parts by the `slug` column:
213
+
214
+ ```ruby
215
+ class Book < ActiveRecord::Base
216
+ has_many :book_parts, dependent: :destroy
217
+
218
+ include YamlExporter
219
+
220
+ yaml_structure do
221
+ attributes :title, :author, :price
222
+ many :book_parts, find_by: :slug do
223
+ attributes :title, :content, :position
224
+ end
225
+ end
226
+ end
227
+ ```
228
+
229
+ The corresponding yaml file would look like this:
230
+
231
+ ```yaml
232
+ title: Ruby on Rails Tutorial
233
+ author: Michael Hartl
234
+ price: 100
235
+ book_parts:
236
+ - slug: chapter-1
237
+ title: Chapter 1
238
+ content: |-
239
+ This is the first chapter of the book
240
+ position: 1
241
+ - slug: chapter-2
242
+ title: Chapter 2
243
+ content: |-
244
+ This is the second chapter of the book
245
+ position: 2
246
+ ```
247
+
248
+ **Identification**: YamlExporter uses `book.book_parts.find_by(slug: "chapter-1")` to identify each book part. Slugs do not need to be unique across all books; and if multiple book parts for the same book share a slug, only the first is matched and the rest are deleted.
249
+
250
+ **Edge cases**:
251
+ * Missing book parts in the database will be created.
252
+ * Extra book parts in the database (absent from the yaml) will be deleted.
253
+ * The order of the book parts in the yaml file doesn't matter for *identity* — but `position: 1`/`position: 2` is still being managed by hand.
254
+
255
+ The next section shows how to make the YAML order itself drive the `position` column.
256
+
257
+ ### `many` with `positioned_by:` (derived position)
258
+
259
+ The previous example stores `position` as a regular column and requires the user to keep the numbers in sync with the list order. That's both tedious and a constant source of merge conflicts.
260
+
261
+ `positioned_by:` hands that column to YamlExporter: on import, each entry's position is set from its 1-based array index; on export, the list is sorted by the position column ASC and the column is omitted from the YAML (the order of the list **is** the position).
39
262
 
40
263
  ```ruby
264
+ class Book < ActiveRecord::Base
265
+ has_many :book_parts, dependent: :destroy
41
266
 
42
- class Quiz < ApplicationRecord
43
267
  include YamlExporter
44
268
 
45
269
  yaml_structure do
46
- yaml_attribute :title, :quiz_type
47
- yaml_has_many :questions do
48
- yaml_attribute :text, :question_type, :feedback
49
- yaml_has_many :answers do
50
- yaml_attribute :text, :is_correct, :impact
51
- end
270
+ attributes :title, :author, :price
271
+ many :book_parts, find_by: :slug, positioned_by: :position do
272
+ attributes :title, :content
52
273
  end
53
274
  end
54
275
  end
55
276
  ```
56
277
 
57
- ### Serializing to YAML
278
+ The YAML gets tidier — no `position:` keys inside the entries:
279
+
280
+ ```yaml
281
+ title: Ruby on Rails Tutorial
282
+ author: Michael Hartl
283
+ price: 100
284
+ book_parts:
285
+ - slug: chapter-1
286
+ title: Chapter 1
287
+ content: |-
288
+ This is the first chapter of the book
289
+ - slug: chapter-2
290
+ title: Chapter 2
291
+ content: |-
292
+ This is the second chapter of the book
293
+ ```
294
+
295
+ Reordering is now a single-line move in the YAML file, and the `position` column in the database tracks it automatically.
296
+
297
+ **Edge cases**:
298
+ * The `positioned_by:` column may **not** also appear in the block's `attributes` list — the DSL owns it for the owned record. Declaring both raises at load time.
299
+ * A `position:` key (or whatever column you named) inside a YAML entry is rejected on import, for the same reason.
300
+ * If the database has gaps, duplicates, or `NULL`s in the position column from earlier code paths, a full import rewrites them 1..N cleanly. The YAML is always the source of truth.
301
+ * `positioned_by:` is available wherever there is an owned record to write the column onto — so with or without `find_by:`, and also in combination with `through:` (where the column lives on the join model, same as other block attributes). It is **not** available on `many :authors, find_by: :slug` (no block), since there is no owned record.
302
+
303
+ ### `many` with `find_by` and no block (reference list)
304
+
305
+ Sometimes the children of a `has_many` are not owned by the parent at all — they are managed elsewhere (in their own yaml files, or by the user directly), and the parent only needs to reference them. The prime example is a many-to-many relation. Say authors are their own records and a book just points at them:
306
+
307
+ ```mermaid
308
+ classDiagram
309
+ class Book {
310
+ - String title
311
+ - Decimal price
312
+ }
313
+ class Author {
314
+ - String name
315
+ - String slug
316
+ }
317
+ Book "*" -- "*" Author
318
+ ```
319
+
320
+ Since `has_and_belongs_to_many` has no join-model attributes to manage, it fits this pattern exactly — so the example below uses HABTM to demonstrate. Drop the block entirely and use `find_by` to declare the key:
321
+
322
+ ```ruby
323
+ class Book < ActiveRecord::Base
324
+ has_and_belongs_to_many :authors
325
+
326
+ include YamlExporter
327
+
328
+ yaml_structure do
329
+ attributes :title, :price
330
+ many :authors, find_by: :slug
331
+ end
332
+ end
333
+ ```
334
+
335
+ Because the block is missing, each author appears in the YAML as a bare string (the slug), not a hash:
336
+
337
+ ```yaml
338
+ title: Ruby on Rails Tutorial
339
+ price: 38
340
+ authors:
341
+ - michael-hartl
342
+ - another-author
343
+ ```
344
+
345
+ This same flavor works for `has_and_belongs_to_many` (as above) and for any `has_many` where the parent shouldn't manage the children's attributes.
346
+
347
+ **Identification**: YamlExporter uses `Author.find_by(slug: "michael-hartl")` to resolve each entry.
348
+
349
+ **Edge cases**:
350
+ * If a referenced author does not exist in the database, `ActiveRecord::RecordNotFound` is raised — the library never auto-creates referenced records.
351
+ * If the database has more associations than the yaml file, the extra associations are removed — for HABTM only the join rows are removed, the referenced records themselves are left untouched.
352
+ * The order of the entries in the yaml file doesn't matter.
353
+
354
+ `of:` works on reference lists too, exactly as it does for [`one`](#one-with-find_by-and-of-indirect-reference) — the YAML value is a column on a companion record (`of:`) instead of on the target. It applies to both the plain and the `through:` reference list:
355
+
356
+ ```ruby
357
+ many :corporate_reviewers, find_by: :slug, of: :user
358
+ many :editorial_editors, through: :editor_assignments, find_by: :slug, of: :user
359
+ ```
360
+
361
+ ### `many` with `through:` (has_many :through with join attributes)
362
+
363
+ `has_many :through` is not a separate DSL method — how you expose it depends on whether the join model carries its own attributes.
58
364
 
59
- To serialize a model instance to YAML:
365
+ **If the join has no extra attributes** drop the block entirely — `many :reviewers, through: :book_reviewers, find_by: :slug` is a bare reference list, identical in shape to `many :authors, find_by: :slug` (a flat list of `find_by` values). The distinction is the *presence* of a block, not its contents: omit it for bare strings; pass one (even an empty one) to opt into hash-shaped entries. You don't have to expose the join association separately; YamlExporter still creates and destroys the join rows for you, it just doesn't ask the YAML for any join attributes:
60
366
 
61
367
  ```ruby
62
- quiz = Quiz.find(1)
63
- yaml_string = quiz.yaml_export
368
+ yaml_structure do
369
+ attributes :title, :price
370
+ many :reviewers, through: :book_reviewers, find_by: :slug
371
+ end
64
372
  ```
65
373
 
66
- ### Deserializing from YAML
374
+ ```yaml
375
+ title: Ruby on Rails Tutorial
376
+ price: 100
377
+ reviewers:
378
+ - alice
379
+ - bob
380
+ ```
67
381
 
68
- To deserialize YAML back to a model instance:
382
+ Add `positioned_by:` when the join carries an order column but nothing else — the column is derived from the YAML order, so the list stays a flat list of strings:
69
383
 
70
384
  ```ruby
71
- quiz = Quiz.new
72
- quiz.yaml_import(yaml_string)
385
+ many :reviewers, through: :book_reviewers, find_by: :slug, positioned_by: :position
73
386
  ```
74
387
 
75
- ### Generating JSON Schema
388
+ **If the join carries its own attributes** (e.g. `finished` on a `book_reviewers` join), declare `many` on the `:through` target and pass both `through:` and `find_by:`. The block then describes attributes of the **join model**:
76
389
 
77
- To generate a JSON schema for validation:
390
+ ```mermaid
391
+ classDiagram
392
+ class Book {
393
+ - String title
394
+ - Decimal price
395
+ }
396
+ class BookReviewer {
397
+ - String slug
398
+ - Boolean finished
399
+ }
400
+ class Reviewer {
401
+ - String name
402
+ - String slug
403
+ }
404
+ Book "*" -- "*" BookReviewer
405
+ BookReviewer "*" -- "1" Reviewer
406
+ ```
78
407
 
79
408
  ```ruby
80
- schema = Quiz.yaml_schema
409
+ class Book < ActiveRecord::Base
410
+ has_many :book_reviewers, dependent: :destroy
411
+ has_many :reviewers, through: :book_reviewers
412
+
413
+ include YamlExporter
414
+
415
+ yaml_structure do
416
+ attributes :title, :price
417
+ many :reviewers, through: :book_reviewers, find_by: :slug do
418
+ attributes :finished # lives on book_reviewers, the join model
419
+ end
420
+ end
421
+ end
81
422
  ```
82
423
 
83
- ## Configuration
424
+ ```yaml
425
+ title: Ruby on Rails Tutorial
426
+ price: 100
427
+ reviewers:
428
+ - slug: alice
429
+ finished: true
430
+ - slug: bob
431
+ finished: false
432
+ ```
433
+
434
+ On import, each entry is processed like this:
435
+
436
+ 1. Find the `Reviewer` by slug. (`Reviewer.find_by(slug: "alice")`)
437
+ 2. Find-or-build the `book_reviewer` join row linking this book and that reviewer — the identity of the join is `(book_id, reviewer_id)`, so no positional matching is needed. (`book.book_reviewers.find_or_create_by(reviewer_id: reviewer.id)`)
438
+ 3. Apply the block's attributes (`finished`) to the join row.
439
+ 4. Destroy any join rows whose reviewer is no longer listed.
440
+
441
+ `positioned_by:` works here too — the column lives on the join model, so e.g. `many :reviewers, through: :book_reviewers, find_by: :slug, positioned_by: :position do … end` makes the YAML order drive `book_reviewers.position`.
84
442
 
85
- The YamlExporter automatically infers types based on the database column types. JSON and JSONB columns are treated as
86
- objects in the generated schema.
443
+ The alternative declaring `many` directly on the **join model itself** is also supported and useful when you want to expose the join row as its own first-class concept rather than hiding it behind the target association.
87
444
 
88
- ## Contributing
445
+ ### `one` with a block (owned)
89
446
 
90
- Bug reports and pull requests are welcome on GitHub at https://github.com/itadventurer/yaml_exporter.
447
+ This is the `has_one` pattern. Suppose every book has exactly one `book_detail` containing extra information like a summary and publication year:
91
448
 
92
- ## License
449
+ ```mermaid
450
+ classDiagram
451
+ class Book {
452
+ - String title
453
+ - String author
454
+ - Decimal price
455
+ }
456
+ class BookDetail {
457
+ - String summary
458
+ - Integer publication_year
459
+ }
460
+ Book "1" -- "1" BookDetail
461
+ ```
462
+
463
+ Let's configure your models for this:
464
+
465
+ ```ruby
466
+ class Book < ActiveRecord::Base
467
+ has_one :book_detail, dependent: :destroy
93
468
 
94
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
469
+ include YamlExporter
470
+
471
+ yaml_structure do
472
+ attributes :title, :author, :price
473
+ one :book_detail do
474
+ attributes :summary, :publication_year
475
+ end
476
+ end
477
+ end
478
+
479
+ class BookDetail < ActiveRecord::Base
480
+ belongs_to :book
481
+ end
482
+ ```
483
+
484
+ Your YAML file would look like:
485
+
486
+ ```yaml
487
+ title: Ruby on Rails Tutorial
488
+ author: Michael Hartl
489
+ price: 100
490
+ book_detail:
491
+ summary: |-
492
+ A practical introduction to Ruby on Rails development.
493
+ publication_year: 2022
494
+ ```
495
+
496
+ **Identification**: YamlExporter uses `book.book_detail` to follow the association. There can only be one book detail per book.
497
+
498
+ **Edge cases**:
499
+ * If there is no book detail in the database, it will be created.
500
+ * If the `book_detail:` key is missing from the YAML or set to `null`, the existing book detail is destroyed — omission and `null` are treated the same. If that violates a model-level validation (e.g. `book_detail` is required), the resulting `ActiveRecord` error propagates and the whole import is rolled back.
501
+
502
+ ### `one` with `find_by` (reference)
503
+
504
+ This is the `belongs_to` pattern. A book is published by a publisher — the book has the `publisher_id` foreign key, and the publisher is a first-class record managed in its own YAML file elsewhere.
505
+
506
+ ```mermaid
507
+ classDiagram
508
+ class Book {
509
+ - String title
510
+ - String author
511
+ - Decimal price
512
+ }
513
+ class Publisher {
514
+ - String name
515
+ - String slug
516
+ }
517
+ Book "*" -- "1" Publisher
518
+ ```
519
+
520
+ We could dump the `publisher_id` column to the book yaml, but that's brittle and not human-friendly. Instead we reference the publisher by a stable column:
521
+
522
+ ```ruby
523
+ class Book < ActiveRecord::Base
524
+ belongs_to :publisher
525
+
526
+ include YamlExporter
527
+
528
+ yaml_structure do
529
+ attributes :title, :author, :price
530
+ one :publisher, find_by: :slug
531
+ end
532
+ end
533
+ ```
534
+
535
+ Our yaml file now looks like this:
536
+
537
+ ```yaml
538
+ title: Ruby on Rails Tutorial
539
+ author: Michael Hartl
540
+ price: 100
541
+ publisher: addison-wesley
542
+ ```
543
+
544
+ **Identification**: YamlExporter uses `Publisher.find_by(slug: "addison-wesley")` to resolve the publisher. If several publishers share the slug, only the first match is used.
545
+
546
+ **Edge cases**:
547
+ * If no publisher matches the slug in the database, `ActiveRecord::RecordNotFound` is raised.
548
+
549
+ **Two restrictions apply**, both following from the ownership model:
550
+
551
+ * `find_by` is **required** for a reference-flavored `one`. Without it, the only alternative would be exposing the raw foreign key (`publisher_id`) in the YAML using the `attributes` method.
552
+ * A block is **not allowed** when `find_by` is given. The publisher owns itself — a book is just one of many records pointing at it — so the book's YAML has no business defining the publisher's attributes. Manage the publisher from its own YAML file instead.
553
+
554
+ ### `one` with `find_by` and `of:` (indirect reference)
555
+
556
+ Sometimes the record you point at does not carry the identifying column directly — a companion record does. A classic case is a two-level identity hierarchy:
557
+
558
+ ```mermaid
559
+ classDiagram
560
+ class User {
561
+ - String name
562
+ - String slug
563
+ }
564
+ class CorporateUser {
565
+ - String name
566
+ }
567
+ class Book {
568
+ - String title
569
+ }
570
+ CorporateUser "*" -- "1" User
571
+ Book "*" -- "0..1" CorporateUser : responsible_editor
572
+ ```
573
+
574
+ `Book belongs_to :responsible_editor` (a `CorporateUser`), but `CorporateUser` has no slug of its own. The slug lives on the associated `User`. Storing an opaque database id in the YAML is brittle; referencing the user's slug is human-readable and stable.
575
+
576
+ The `of:` keyword lets you do exactly that:
577
+
578
+ ```ruby
579
+ class Book < ActiveRecord::Base
580
+ belongs_to :responsible_editor, class_name: 'CorporateUser',
581
+ foreign_key: :responsible_editor_id, optional: true
582
+
583
+ include YamlExporter
584
+
585
+ yaml_structure do
586
+ attributes :title
587
+ one :responsible_editor, find_by: :slug, of: :user
588
+ end
589
+ end
590
+
591
+ class CorporateUser < ActiveRecord::Base
592
+ belongs_to :user
593
+ end
594
+
595
+ class User < ActiveRecord::Base
596
+ has_one :corporate_user
597
+ end
598
+ ```
599
+
600
+ The YAML stores the user's slug:
601
+
602
+ ```yaml
603
+ title: Ruby on Rails Tutorial
604
+ responsible_editor: alice
605
+ ```
606
+
607
+ On **import**, YamlExporter:
608
+ 1. Looks up `User.find_by(slug: "alice")`.
609
+ 2. Navigates back to `CorporateUser` by reversing the FK (`CorporateUser.find_by(user_id: alice.id)`).
610
+ 3. Assigns the result to `book.responsible_editor`.
611
+
612
+ On **export**, `book.responsible_editor.user.slug` is emitted.
613
+
614
+ **Restrictions**:
615
+
616
+ * `of:` requires `find_by:` — the two always appear together.
617
+ * A block is not allowed when `of:` is used (same ownership rule as plain `find_by:`).
618
+ * The `of:` association must be a **1:[0,1]** relation (`belongs_to` or `has_one`). Using a `has_many` association raises at class-load time.
619
+ * If the `of:` record exists but no target record is linked to it (e.g. a `User` with no `CorporateUser`), `ActiveRecord::RecordNotFound` is raised on import.
620
+
621
+ ## Putting it all together
622
+
623
+ Let us now put all together:
624
+
625
+ ```ruby
626
+ class Book < ActiveRecord::Base
627
+ has_many :book_parts, dependent: :destroy
628
+ has_one :book_detail, dependent: :destroy
629
+ has_and_belongs_to_many :authors
630
+ belongs_to :publisher
631
+ has_many :book_reviewers, dependent: :destroy
632
+ has_many :reviewers, through: :book_reviewers
633
+ belongs_to :responsible_editor, class_name: 'CorporateUser',
634
+ foreign_key: :responsible_editor_id, optional: true
635
+
636
+ include YamlExporter
637
+
638
+ yaml_structure do
639
+ attributes :title, :price
640
+ many :book_parts, find_by: :slug, positioned_by: :position do
641
+ attributes :title, :content
642
+ end
643
+ one :book_detail do
644
+ attributes :summary, :publication_year
645
+ end
646
+ many :authors, find_by: :slug
647
+ one :publisher, find_by: :slug
648
+ many :reviewers, through: :book_reviewers, find_by: :slug do
649
+ attributes :finished
650
+ end
651
+ one :responsible_editor, find_by: :slug, of: :user
652
+ end
653
+ end
654
+
655
+ class CorporateUser < ActiveRecord::Base
656
+ belongs_to :user
657
+ end
658
+
659
+ class User < ActiveRecord::Base
660
+ has_one :corporate_user
661
+ end
662
+ ```
663
+
664
+ Our yaml file now looks like this:
665
+
666
+ ```yaml
667
+ title: Ruby on Rails Tutorial
668
+ price: 100
669
+ book_parts:
670
+ - slug: chapter-1
671
+ title: Chapter 1
672
+ content: |-
673
+ This is the first chapter of the book
674
+ - slug: chapter-2
675
+ title: Chapter 2
676
+ content: |-
677
+ This is the second chapter of the book
678
+ book_detail:
679
+ summary: |-
680
+ A practical introduction to Ruby on Rails development.
681
+ publication_year: 2022
682
+ authors:
683
+ - michael-hartl
684
+ - another-author
685
+ publisher: addison-wesley
686
+ reviewers:
687
+ - slug: alice
688
+ finished: true
689
+ - slug: bob
690
+ finished: false
691
+ responsible_editor: alice
692
+ ```
693
+
694
+ And the resulting object graph:
695
+
696
+ ```mermaid
697
+ classDiagram
698
+ class Book {
699
+ - String title
700
+ - Decimal price
701
+ }
702
+ class BookPart {
703
+ - String slug
704
+ - String title
705
+ - String content
706
+ - Integer position
707
+ }
708
+ class BookDetail {
709
+ - String summary
710
+ - Integer publication_year
711
+ }
712
+ class Author {
713
+ - String name
714
+ - String slug
715
+ }
716
+ class Publisher {
717
+ - String name
718
+ - String slug
719
+ }
720
+ class BookReviewer {
721
+ - Boolean finished
722
+ }
723
+ class Reviewer {
724
+ - String name
725
+ - String slug
726
+ }
727
+ class CorporateUser {
728
+ - String name
729
+ }
730
+ class User {
731
+ - String name
732
+ - String slug
733
+ }
734
+ Book "1" -- "*" BookPart
735
+ Book "1" -- "1" BookDetail
736
+ Book "*" -- "*" Author
737
+ Book "*" -- "1" Publisher
738
+ Book "1" -- "*" BookReviewer
739
+ BookReviewer "*" -- "1" Reviewer
740
+ Book "*" -- "0..1" CorporateUser : responsible_editor
741
+ CorporateUser "*" -- "1" User
742
+ ```
743
+
744
+ ## Behaviors of `yaml_import` and `yaml_export`
745
+
746
+ `yaml_import` and `yaml_export` are instance methods included in the model. So if you want to import a model from a yaml file, you first find or create an object and then call `yaml_import` on it:
747
+
748
+ ```ruby
749
+ book = Book.new
750
+ book.yaml_import(File.read('ruby-on-rails.yaml'))
751
+ ```
752
+
753
+ And of course you can export the data from your ActiveRecord model to the file:
754
+
755
+ ```ruby
756
+ yaml_string = book.yaml_export
757
+ ```
758
+
759
+ We prefer to identify the objects by the filename of the file and a slug field. So if you have a directory `books` you can import all files in the directory with:
760
+
761
+ ```ruby
762
+ Dir.glob('books/*.yaml').each do |file|
763
+ book = Book.find_or_create_by(slug: File.basename(file, '.yaml'))
764
+ book.yaml_import(File.read(file))
765
+ end
766
+ ```
767
+
768
+ **Import edge cases** (apply to every `yaml_import`, in addition to the lifecycle rules in the [mental model](#lifecycle-rules)):
769
+
770
+ * Duplicate keys inside the same YAML list — e.g. two `slug: chapter-1` entries under one `book_parts:` — raise an error. Each `find_by` key must be unique within its list.
771
+ * Missing / `null` clears not just plain `attributes` columns, but also `one` references (the foreign key is cleared) and `one` owned children (the child is destroyed).
772
+
773
+ **Export behavior** (mirrors the import rules):
774
+
775
+ * Empty values are omitted by default. A `nil` column, a missing `one` reference, an absent owned `one`, and an empty `many` list are all left out of the document entirely. This is round-trip safe: import treats a missing key, an explicit `null`, and an empty list the same way. To keep explicit `null`s in the file — e.g. so reviewers can discover optional fields — call `yaml_export(omit_nil: false)`. An owned `one` child that exists but has only `nil` attributes is still emitted (as `{}`) — dropping it would destroy the child on re-import.
776
+ * `text` columns are written as YAML literal block scalars (`|`), so multi-line and long-form content stays readable and diff-friendly. `string`/varchar columns stay inline regardless of length, and non-string scalars (numbers, booleans, …) are unaffected. The choice follows the column type, not the value, so a model's files always look the same. Trailing whitespace on a line (and carriage returns) is stripped on export — it would otherwise force YAML to fall back to an inline double-quoted scalar, and it is almost always an accidental typo. Emoji and other astral-plane characters (codepoints ≥ U+10000) are emitted literally and stay in block style too, despite a libyaml quirk that would otherwise escape them into an inline scalar; ordinary accented and umlaut characters were never affected.
777
+ * The output is a plain YAML document, without a leading `---` marker.
778
+ * Lists are written in a stable, diff-friendly order. The rule is: take the first rule that applies, top to bottom:
779
+ 1. `many` with `positioned_by:` → sorted by the position column ASC; the column itself is omitted from each entry's hash.
780
+ 2. `many` with `find_by:` (with or without a block, including the `through:` variant, without `positioned_by:`) → sorted by the `find_by` column so adding or removing an entry only touches its own line in Git diffs.
781
+ 3. `many` with a block only (no `find_by:`) → the collection's SQL order, which is the order the children were inserted (this is also the order positional matching relies on).
782
+
783
+ ## API reference
784
+
785
+ All DSL methods are declared inside a `yaml_structure do … end` block on the model. Inside the block, `attributes`, `one` and `many` are resolved against a builder — they never clash with ActiveRecord's own class-level methods (e.g. `attribute`, `has_one`).
786
+
787
+ ### `attributes(*names)`
788
+
789
+ Columns of the model that are serialized and deserialized. Missing keys in the YAML reset the corresponding columns to `nil` on import.
790
+
791
+ ### `one(name, find_by: nil, of: nil, &block)`
792
+
793
+ A single related record. Exactly one of `find_by:` or a block must be given:
794
+
795
+ | Call | YAML shape | Meaning |
796
+ | --------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------- |
797
+ | `one :child do … end` | nested hash | Owned child (the `has_one` pattern). |
798
+ | `one :child, find_by: :slug` | bare string | Reference to a record managed elsewhere (the `belongs_to` pattern). |
799
+ | `one :child, find_by: :slug, of: :companion` | bare string | Indirect reference: the slug lives on a companion record reachable via the `companion` association on the target. |
800
+
801
+ `of:` requires `find_by:` and cannot be combined with a block. The `of:` association must be a 1:[0,1] relation (`belongs_to` or `has_one`). See [`one` with `find_by` and `of:`](#one-with-find_by-and-of-indirect-reference) for a worked example.
802
+
803
+ Passing both a block and `find_by:` is rejected — see the ownership reasoning in [`one` with `find_by`](#one-with-find_by-reference).
804
+
805
+ ### `many(name, positioned_by: nil, find_by: nil, through: nil, of: nil, &block)`
806
+
807
+ A list of related records. The flavor is picked from the combination of `positioned_by:`, `find_by:`, `through:`, `of:` and a block:
808
+
809
+ | Call | YAML shape | Meaning |
810
+ | -------------------------------------------------------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
811
+ | `many :children do … end` | list of hashes, matched by order | Children fully managed, identity by position. |
812
+ | `many :children, positioned_by: :position do … end` | same as above, without `position:` in each hash | Like the previous row, but the position column is derived from the 1-based array index (and omitted on export). |
813
+ | `many :children, find_by: :slug do … end` | list of hashes containing the `slug:` key | Children fully managed, identity by a stable column. |
814
+ | `many :children, find_by: :slug, positioned_by: :position do … end` | same as above, without `position:` in each hash | Like the previous row, but the named column is derived from the 1-based array index (and omitted on export). |
815
+ | `many :children, find_by: :slug` | list of bare strings | Children referenced by key, managed elsewhere (HABTM pattern). |
816
+ | `many :children, find_by: :slug, of: :companion` | list of bare strings | Reference list keyed indirectly: each value lives on a `companion` (1:[0,1]) association of the target. |
817
+ | `many :children, through: :joins, find_by: :slug do … end` | list of hashes containing the `slug:` key | `has_many :through` where the block describes attributes of the **join model**. |
818
+ | `many :children, through: :joins, find_by: :slug, positioned_by: :position do …` | same as above, without `position:` in each hash | As above, with the position column derived on the **join model**. |
819
+ | `many :children, through: :joins, find_by: :slug, of: :companion` | list of bare strings | Join-model reference list keyed indirectly via the target's `companion` association (`positioned_by:` optional).|
820
+
821
+ `positioned_by:` requires a block – there must be an owned record to write the column onto – and therefore cannot be used with the plain reference-list flavor of `many` (it *is* allowed on the `through:` reference list, where it drives the join's position column).
822
+
823
+ `of:` requires `find_by:` and cannot be combined with a block, and the `of:` association must be a 1:[0,1] relation (`belongs_to` or `has_one`) — same rules and semantics as [`one … of:`](#one-with-find_by-and-of-indirect-reference).
824
+
825
+ ### Import / export
826
+
827
+ * `instance.yaml_import(yaml_string)` — updates `instance` in place from the YAML, inside a single transaction. Returns the instance.
828
+ * `instance.yaml_export(omit_nil: true)` — returns a YAML string for `instance` following its `yaml_structure`. With `omit_nil: true` (the default) keys whose value is empty — a `nil` attribute/reference, an absent owned child, or an empty `many` list — are left out. Pass `omit_nil: false` to keep them as explicit `null`s, which is handy when you want optional fields to stay visible in the file.
829
+
830
+ Whether a string is written inline or as a literal block scalar (`|`) is decided by the database column type, not by `yaml_export` arguments: `text` columns always use block style, `string`/varchar columns always stay inline. See [Export behavior](#behaviors-of-yaml_import-and-yaml_export).
831
+
832
+ ### `ModelClass.yaml_schema`
833
+
834
+ Returns a JSON-schema-like hash describing the YAML shape declared by `yaml_structure`. Useful for generating editor support or validating YAML out-of-band. Invalid YAML passed to `yaml_import` raises with a message pointing at the violated part of the schema.
835
+
836
+ ## Development
837
+
838
+ Run the test suite with:
839
+
840
+ ```
841
+ $ bundle exec rake test
842
+ ```