yaml_exporter 0.1.0 → 0.2.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.
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,810 @@ 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 :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) |
116
+ | `many :reviewers, through: :book_reviewers, find_by: :slug do … end` | list of hashes describing join-model attributes |
117
+
118
+ ### Lifecycle rules
119
+
120
+ These apply uniformly to every `yaml_import`:
121
+
122
+ 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.
123
+ 2. **One import = one transaction.** If anything raises, the whole import rolls back and the record is left untouched.
124
+ 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.
125
+
126
+ ## Working with relations
127
+
128
+ The examples below walk through each row of the combination table above, using the same bookstore domain.
129
+
130
+ ### `many` with a block (positional)
131
+
132
+ Your book consists of multiple parts and on the website you want to display a bit more information about each part:
133
+
134
+ ```mermaid
135
+ classDiagram
136
+ class Book {
137
+ - String title
138
+ - String author
139
+ - Decimal price
140
+ }
141
+ class BookPart {
142
+ - String title
143
+ - String content
144
+ - Integer position
145
+ }
146
+ Book "1" -- "*" BookPart
147
+ ```
148
+
149
+ 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:
150
+
151
+ ```yaml
152
+ title: Ruby on Rails Tutorial
153
+ author: Michael Hartl
154
+ price: 100
155
+ book_parts:
156
+ - title: Chapter 1
157
+ content: |-
158
+ This is the first chapter of the book
159
+ position: 1
160
+ - title: Chapter 2
161
+ content: |-
162
+ This is the second chapter of the book
163
+ position: 2
164
+ ```
165
+
166
+ To do so, configure your book model to have a `has_many` relation to book parts:
167
+
168
+ ```ruby
169
+ class Book < ActiveRecord::Base
170
+ has_many :book_parts, dependent: :destroy
171
+
172
+ include YamlExporter
173
+
174
+ yaml_structure do
175
+ attributes :title, :author, :price
176
+ many :book_parts do
177
+ attributes :title, :content, :position
178
+ end
179
+ end
180
+ end
181
+ ```
182
+
183
+ **Edge cases**:
184
+ * If there are missing book parts in the database, they will be created.
185
+ * If the database has more book parts than in the yaml file, the extra book parts will be deleted.
186
+ * Existing rows are matched by array index — so re-ordering the list silently overwrites each row with another row's data.
187
+
188
+ 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:
189
+
190
+ ### `many` with `find_by` and a block
191
+
192
+ Let us now identify the book parts by a stable column, for example, let's add a `slug` column to the book parts:
193
+
194
+ ```mermaid
195
+ classDiagram
196
+ class Book {
197
+ - String title
198
+ - String author
199
+ - Decimal price
200
+ }
201
+ class BookPart {
202
+ - String title
203
+ - String content
204
+ - Integer position
205
+ - String slug
206
+ }
207
+ Book "1" -- "*" BookPart
208
+ ```
209
+
210
+ Now you can configure your book model to identify the book parts by the `slug` column:
211
+
212
+ ```ruby
213
+ class Book < ActiveRecord::Base
214
+ has_many :book_parts, dependent: :destroy
215
+
216
+ include YamlExporter
217
+
218
+ yaml_structure do
219
+ attributes :title, :author, :price
220
+ many :book_parts, find_by: :slug do
221
+ attributes :title, :content, :position
222
+ end
223
+ end
224
+ end
225
+ ```
226
+
227
+ The corresponding yaml file would look like this:
228
+
229
+ ```yaml
230
+ title: Ruby on Rails Tutorial
231
+ author: Michael Hartl
232
+ price: 100
233
+ book_parts:
234
+ - slug: chapter-1
235
+ title: Chapter 1
236
+ content: |-
237
+ This is the first chapter of the book
238
+ position: 1
239
+ - slug: chapter-2
240
+ title: Chapter 2
241
+ content: |-
242
+ This is the second chapter of the book
243
+ position: 2
244
+ ```
245
+
246
+ **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.
247
+
248
+ **Edge cases**:
249
+ * Missing book parts in the database will be created.
250
+ * Extra book parts in the database (absent from the yaml) will be deleted.
251
+ * 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.
252
+
253
+ The next section shows how to make the YAML order itself drive the `position` column.
254
+
255
+ ### `many` with `positioned_by:` (derived position)
256
+
257
+ 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.
258
+
259
+ `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).
260
+
261
+ ```ruby
262
+ class Book < ActiveRecord::Base
263
+ has_many :book_parts, dependent: :destroy
264
+
265
+ include YamlExporter
266
+
267
+ yaml_structure do
268
+ attributes :title, :author, :price
269
+ many :book_parts, find_by: :slug, positioned_by: :position do
270
+ attributes :title, :content
271
+ end
272
+ end
273
+ end
274
+ ```
275
+
276
+ The YAML gets tidier — no `position:` keys inside the entries:
277
+
278
+ ```yaml
279
+ title: Ruby on Rails Tutorial
280
+ author: Michael Hartl
281
+ price: 100
282
+ book_parts:
283
+ - slug: chapter-1
284
+ title: Chapter 1
285
+ content: |-
286
+ This is the first chapter of the book
287
+ - slug: chapter-2
288
+ title: Chapter 2
289
+ content: |-
290
+ This is the second chapter of the book
291
+ ```
292
+
293
+ Reordering is now a single-line move in the YAML file, and the `position` column in the database tracks it automatically.
294
+
295
+ **Edge cases**:
296
+ * 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.
297
+ * A `position:` key (or whatever column you named) inside a YAML entry is rejected on import, for the same reason.
298
+ * 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.
299
+ * `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.
300
+
301
+ ### `many` with `find_by` and no block (reference list)
302
+
303
+ 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:
304
+
305
+ ```mermaid
306
+ classDiagram
307
+ class Book {
308
+ - String title
309
+ - Decimal price
310
+ }
311
+ class Author {
312
+ - String name
313
+ - String slug
314
+ }
315
+ Book "*" -- "*" Author
316
+ ```
317
+
318
+ 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:
319
+
320
+ ```ruby
321
+ class Book < ActiveRecord::Base
322
+ has_and_belongs_to_many :authors
323
+
324
+ include YamlExporter
325
+
326
+ yaml_structure do
327
+ attributes :title, :price
328
+ many :authors, find_by: :slug
329
+ end
330
+ end
331
+ ```
332
+
333
+ Because the block is missing, each author appears in the YAML as a bare string (the slug), not a hash:
334
+
335
+ ```yaml
336
+ title: Ruby on Rails Tutorial
337
+ price: 38
338
+ authors:
339
+ - michael-hartl
340
+ - another-author
341
+ ```
342
+
343
+ 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.
344
+
345
+ **Identification**: YamlExporter uses `Author.find_by(slug: "michael-hartl")` to resolve each entry.
346
+
347
+ **Edge cases**:
348
+ * If a referenced author does not exist in the database, `ActiveRecord::RecordNotFound` is raised — the library never auto-creates referenced records.
349
+ * 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.
350
+ * The order of the entries in the yaml file doesn't matter.
351
+
352
+ ### `many` with `through:` (has_many :through with join attributes)
353
+
354
+ `has_many :through` is not a separate DSL method — how you expose it depends on whether the join model carries its own attributes.
355
+
356
+ **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:
357
+
358
+ ```ruby
359
+ yaml_structure do
360
+ attributes :title, :price
361
+ many :reviewers, through: :book_reviewers, find_by: :slug
362
+ end
363
+ ```
364
+
365
+ ```yaml
366
+ title: Ruby on Rails Tutorial
367
+ price: 100
368
+ reviewers:
369
+ - alice
370
+ - bob
371
+ ```
372
+
373
+ 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:
39
374
 
40
375
  ```ruby
376
+ many :reviewers, through: :book_reviewers, find_by: :slug, positioned_by: :position
377
+ ```
378
+
379
+ **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**:
380
+
381
+ ```mermaid
382
+ classDiagram
383
+ class Book {
384
+ - String title
385
+ - Decimal price
386
+ }
387
+ class BookReviewer {
388
+ - String slug
389
+ - Boolean finished
390
+ }
391
+ class Reviewer {
392
+ - String name
393
+ - String slug
394
+ }
395
+ Book "*" -- "*" BookReviewer
396
+ BookReviewer "*" -- "1" Reviewer
397
+ ```
398
+
399
+ ```ruby
400
+ class Book < ActiveRecord::Base
401
+ has_many :book_reviewers, dependent: :destroy
402
+ has_many :reviewers, through: :book_reviewers
41
403
 
42
- class Quiz < ApplicationRecord
43
404
  include YamlExporter
44
405
 
45
406
  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
407
+ attributes :title, :price
408
+ many :reviewers, through: :book_reviewers, find_by: :slug do
409
+ attributes :finished # lives on book_reviewers, the join model
52
410
  end
53
411
  end
54
412
  end
55
413
  ```
56
414
 
57
- ### Serializing to YAML
415
+ ```yaml
416
+ title: Ruby on Rails Tutorial
417
+ price: 100
418
+ reviewers:
419
+ - slug: alice
420
+ finished: true
421
+ - slug: bob
422
+ finished: false
423
+ ```
424
+
425
+ On import, each entry is processed like this:
58
426
 
59
- To serialize a model instance to YAML:
427
+ 1. Find the `Reviewer` by slug. (`Reviewer.find_by(slug: "alice")`)
428
+ 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)`)
429
+ 3. Apply the block's attributes (`finished`) to the join row.
430
+ 4. Destroy any join rows whose reviewer is no longer listed.
431
+
432
+ `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`.
433
+
434
+ 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.
435
+
436
+ ### `one` with a block (owned)
437
+
438
+ This is the `has_one` pattern. Suppose every book has exactly one `book_detail` containing extra information like a summary and publication year:
439
+
440
+ ```mermaid
441
+ classDiagram
442
+ class Book {
443
+ - String title
444
+ - String author
445
+ - Decimal price
446
+ }
447
+ class BookDetail {
448
+ - String summary
449
+ - Integer publication_year
450
+ }
451
+ Book "1" -- "1" BookDetail
452
+ ```
453
+
454
+ Let's configure your models for this:
60
455
 
61
456
  ```ruby
62
- quiz = Quiz.find(1)
63
- yaml_string = quiz.yaml_export
457
+ class Book < ActiveRecord::Base
458
+ has_one :book_detail, dependent: :destroy
459
+
460
+ include YamlExporter
461
+
462
+ yaml_structure do
463
+ attributes :title, :author, :price
464
+ one :book_detail do
465
+ attributes :summary, :publication_year
466
+ end
467
+ end
468
+ end
469
+
470
+ class BookDetail < ActiveRecord::Base
471
+ belongs_to :book
472
+ end
64
473
  ```
65
474
 
66
- ### Deserializing from YAML
475
+ Your YAML file would look like:
476
+
477
+ ```yaml
478
+ title: Ruby on Rails Tutorial
479
+ author: Michael Hartl
480
+ price: 100
481
+ book_detail:
482
+ summary: |-
483
+ A practical introduction to Ruby on Rails development.
484
+ publication_year: 2022
485
+ ```
486
+
487
+ **Identification**: YamlExporter uses `book.book_detail` to follow the association. There can only be one book detail per book.
488
+
489
+ **Edge cases**:
490
+ * If there is no book detail in the database, it will be created.
491
+ * 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.
492
+
493
+ ### `one` with `find_by` (reference)
494
+
495
+ 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.
496
+
497
+ ```mermaid
498
+ classDiagram
499
+ class Book {
500
+ - String title
501
+ - String author
502
+ - Decimal price
503
+ }
504
+ class Publisher {
505
+ - String name
506
+ - String slug
507
+ }
508
+ Book "*" -- "1" Publisher
509
+ ```
67
510
 
68
- To deserialize YAML back to a model instance:
511
+ 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:
69
512
 
70
513
  ```ruby
71
- quiz = Quiz.new
72
- quiz.yaml_import(yaml_string)
514
+ class Book < ActiveRecord::Base
515
+ belongs_to :publisher
516
+
517
+ include YamlExporter
518
+
519
+ yaml_structure do
520
+ attributes :title, :author, :price
521
+ one :publisher, find_by: :slug
522
+ end
523
+ end
524
+ ```
525
+
526
+ Our yaml file now looks like this:
527
+
528
+ ```yaml
529
+ title: Ruby on Rails Tutorial
530
+ author: Michael Hartl
531
+ price: 100
532
+ publisher: addison-wesley
73
533
  ```
74
534
 
75
- ### Generating JSON Schema
535
+ **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.
536
+
537
+ **Edge cases**:
538
+ * If no publisher matches the slug in the database, `ActiveRecord::RecordNotFound` is raised.
539
+
540
+ **Two restrictions apply**, both following from the ownership model:
541
+
542
+ * `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.
543
+ * 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.
544
+
545
+ ### `one` with `find_by` and `of:` (indirect reference)
546
+
547
+ 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:
548
+
549
+ ```mermaid
550
+ classDiagram
551
+ class User {
552
+ - String name
553
+ - String slug
554
+ }
555
+ class CorporateUser {
556
+ - String name
557
+ }
558
+ class Book {
559
+ - String title
560
+ }
561
+ CorporateUser "*" -- "1" User
562
+ Book "*" -- "0..1" CorporateUser : responsible_editor
563
+ ```
76
564
 
77
- To generate a JSON schema for validation:
565
+ `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.
566
+
567
+ The `of:` keyword lets you do exactly that:
78
568
 
79
569
  ```ruby
80
- schema = Quiz.yaml_schema
570
+ class Book < ActiveRecord::Base
571
+ belongs_to :responsible_editor, class_name: 'CorporateUser',
572
+ foreign_key: :responsible_editor_id, optional: true
573
+
574
+ include YamlExporter
575
+
576
+ yaml_structure do
577
+ attributes :title
578
+ one :responsible_editor, find_by: :slug, of: :user
579
+ end
580
+ end
581
+
582
+ class CorporateUser < ActiveRecord::Base
583
+ belongs_to :user
584
+ end
585
+
586
+ class User < ActiveRecord::Base
587
+ has_one :corporate_user
588
+ end
81
589
  ```
82
590
 
83
- ## Configuration
591
+ The YAML stores the user's slug:
592
+
593
+ ```yaml
594
+ title: Ruby on Rails Tutorial
595
+ responsible_editor: alice
596
+ ```
597
+
598
+ On **import**, YamlExporter:
599
+ 1. Looks up `User.find_by(slug: "alice")`.
600
+ 2. Navigates back to `CorporateUser` by reversing the FK (`CorporateUser.find_by(user_id: alice.id)`).
601
+ 3. Assigns the result to `book.responsible_editor`.
602
+
603
+ On **export**, `book.responsible_editor.user.slug` is emitted.
84
604
 
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.
605
+ **Restrictions**:
87
606
 
88
- ## Contributing
607
+ * `of:` requires `find_by:` — the two always appear together.
608
+ * A block is not allowed when `of:` is used (same ownership rule as plain `find_by:`).
609
+ * 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.
610
+ * 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.
89
611
 
90
- Bug reports and pull requests are welcome on GitHub at https://github.com/itadventurer/yaml_exporter.
612
+ ## Putting it all together
91
613
 
92
- ## License
614
+ Let us now put all together:
93
615
 
94
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
616
+ ```ruby
617
+ class Book < ActiveRecord::Base
618
+ has_many :book_parts, dependent: :destroy
619
+ has_one :book_detail, dependent: :destroy
620
+ has_and_belongs_to_many :authors
621
+ belongs_to :publisher
622
+ has_many :book_reviewers, dependent: :destroy
623
+ has_many :reviewers, through: :book_reviewers
624
+ belongs_to :responsible_editor, class_name: 'CorporateUser',
625
+ foreign_key: :responsible_editor_id, optional: true
626
+
627
+ include YamlExporter
628
+
629
+ yaml_structure do
630
+ attributes :title, :price
631
+ many :book_parts, find_by: :slug, positioned_by: :position do
632
+ attributes :title, :content
633
+ end
634
+ one :book_detail do
635
+ attributes :summary, :publication_year
636
+ end
637
+ many :authors, find_by: :slug
638
+ one :publisher, find_by: :slug
639
+ many :reviewers, through: :book_reviewers, find_by: :slug do
640
+ attributes :finished
641
+ end
642
+ one :responsible_editor, find_by: :slug, of: :user
643
+ end
644
+ end
645
+
646
+ class CorporateUser < ActiveRecord::Base
647
+ belongs_to :user
648
+ end
649
+
650
+ class User < ActiveRecord::Base
651
+ has_one :corporate_user
652
+ end
653
+ ```
654
+
655
+ Our yaml file now looks like this:
656
+
657
+ ```yaml
658
+ title: Ruby on Rails Tutorial
659
+ price: 100
660
+ book_parts:
661
+ - slug: chapter-1
662
+ title: Chapter 1
663
+ content: |-
664
+ This is the first chapter of the book
665
+ - slug: chapter-2
666
+ title: Chapter 2
667
+ content: |-
668
+ This is the second chapter of the book
669
+ book_detail:
670
+ summary: |-
671
+ A practical introduction to Ruby on Rails development.
672
+ publication_year: 2022
673
+ authors:
674
+ - michael-hartl
675
+ - another-author
676
+ publisher: addison-wesley
677
+ reviewers:
678
+ - slug: alice
679
+ finished: true
680
+ - slug: bob
681
+ finished: false
682
+ responsible_editor: alice
683
+ ```
684
+
685
+ And the resulting object graph:
686
+
687
+ ```mermaid
688
+ classDiagram
689
+ class Book {
690
+ - String title
691
+ - Decimal price
692
+ }
693
+ class BookPart {
694
+ - String slug
695
+ - String title
696
+ - String content
697
+ - Integer position
698
+ }
699
+ class BookDetail {
700
+ - String summary
701
+ - Integer publication_year
702
+ }
703
+ class Author {
704
+ - String name
705
+ - String slug
706
+ }
707
+ class Publisher {
708
+ - String name
709
+ - String slug
710
+ }
711
+ class BookReviewer {
712
+ - Boolean finished
713
+ }
714
+ class Reviewer {
715
+ - String name
716
+ - String slug
717
+ }
718
+ class CorporateUser {
719
+ - String name
720
+ }
721
+ class User {
722
+ - String name
723
+ - String slug
724
+ }
725
+ Book "1" -- "*" BookPart
726
+ Book "1" -- "1" BookDetail
727
+ Book "*" -- "*" Author
728
+ Book "*" -- "1" Publisher
729
+ Book "1" -- "*" BookReviewer
730
+ BookReviewer "*" -- "1" Reviewer
731
+ Book "*" -- "0..1" CorporateUser : responsible_editor
732
+ CorporateUser "*" -- "1" User
733
+ ```
734
+
735
+ ## Behaviors of `yaml_import` and `yaml_export`
736
+
737
+ `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:
738
+
739
+ ```ruby
740
+ book = Book.new
741
+ book.yaml_import(File.read('ruby-on-rails.yaml'))
742
+ ```
743
+
744
+ And of course you can export the data from your ActiveRecord model to the file:
745
+
746
+ ```ruby
747
+ yaml_string = book.yaml_export
748
+ ```
749
+
750
+ 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:
751
+
752
+ ```ruby
753
+ Dir.glob('books/*.yaml').each do |file|
754
+ book = Book.find_or_create_by(slug: File.basename(file, '.yaml'))
755
+ book.yaml_import(File.read(file))
756
+ end
757
+ ```
758
+
759
+ **Import edge cases** (apply to every `yaml_import`, in addition to the lifecycle rules in the [mental model](#lifecycle-rules)):
760
+
761
+ * 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.
762
+ * 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).
763
+
764
+ **Export behavior** (mirrors the import rules):
765
+
766
+ * 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.
767
+ * `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.
768
+ * The output is a plain YAML document, without a leading `---` marker.
769
+ * Lists are written in a stable, diff-friendly order. The rule is: take the first rule that applies, top to bottom:
770
+ 1. `many` with `positioned_by:` → sorted by the position column ASC; the column itself is omitted from each entry's hash.
771
+ 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.
772
+ 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).
773
+
774
+ ## API reference
775
+
776
+ 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`).
777
+
778
+ ### `attributes(*names)`
779
+
780
+ Columns of the model that are serialized and deserialized. Missing keys in the YAML reset the corresponding columns to `nil` on import.
781
+
782
+ ### `one(name, find_by: nil, of: nil, &block)`
783
+
784
+ A single related record. Exactly one of `find_by:` or a block must be given:
785
+
786
+ | Call | YAML shape | Meaning |
787
+ | --------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------- |
788
+ | `one :child do … end` | nested hash | Owned child (the `has_one` pattern). |
789
+ | `one :child, find_by: :slug` | bare string | Reference to a record managed elsewhere (the `belongs_to` pattern). |
790
+ | `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. |
791
+
792
+ `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.
793
+
794
+ Passing both a block and `find_by:` is rejected — see the ownership reasoning in [`one` with `find_by`](#one-with-find_by-reference).
795
+
796
+ ### `many(name, positioned_by: nil, find_by: nil, through: nil, &block)`
797
+
798
+ A list of related records. The flavor is picked from the combination of `positioned_by:`, `find_by:`, `through:` and a block:
799
+
800
+ | Call | YAML shape | Meaning |
801
+ | -------------------------------------------------------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
802
+ | `many :children do … end` | list of hashes, matched by order | Children fully managed, identity by position. |
803
+ | `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). |
804
+ | `many :children, find_by: :slug do … end` | list of hashes containing the `slug:` key | Children fully managed, identity by a stable column. |
805
+ | `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). |
806
+ | `many :children, find_by: :slug` | list of bare strings | Children referenced by key, managed elsewhere (HABTM pattern). |
807
+ | `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**. |
808
+ | `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**. |
809
+
810
+ `positioned_by:` requires a block – there must be an owned record to write the column onto – and therefore cannot be used with the reference-list flavor of `many`.
811
+
812
+ ### Import / export
813
+
814
+ * `instance.yaml_import(yaml_string)` — updates `instance` in place from the YAML, inside a single transaction. Returns the instance.
815
+ * `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.
816
+
817
+ 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).
818
+
819
+ ### `ModelClass.yaml_schema`
820
+
821
+ 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.
822
+
823
+ ## Development
824
+
825
+ Run the test suite with:
826
+
827
+ ```
828
+ $ bundle exec rake test
829
+ ```