yaml_exporter 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e760f89f11f40a2f5e94e6e44348da196bd2da09267ad7585070f21c3cf998da
4
- data.tar.gz: 8a2261d83c43f0a9ad8be36e3cdb425984dc42088a6cf49d839a779d820e0a80
3
+ metadata.gz: 6c15fc079f4444a43a53ea849f8ff86162074ace88cc884ed28bef127a59afc0
4
+ data.tar.gz: 6b51f04b4712e71f1840c61fb323be7127b133e561afaaafcd81f9e382f7919b
5
5
  SHA512:
6
- metadata.gz: 40c9ba2b0c249095bf6e88bed66cd13c4a37c6e3566c1f4d3b5308e19df475cc897215962840513b8de90d1298f675d5492c1f8315993840e27dbe77a4de204f
7
- data.tar.gz: 347bb621e9f981bdb61899864b70b8659f11b65691fc67c8ccf2bc8417ae1cf0c6ae6f39a091efa1c99f3314ebc1951d650d014a217a9fb2d5970db6d579ae57
6
+ metadata.gz: 4beae00561aeb8d7da659b6b3b578cb9e9aa95bfcd2d266a63bc9fb0e98a6a6f1310cd73801edc03125fb3c831b6a730458165b56549067a0b5a5eb276a114a8
7
+ data.tar.gz: 19dfc281188c17041c1d822422b1eb07f11aea06525ba042b261f016945e664ae40ac272e10246f280fbe5c70d1b0f3633a809ec4cb9ea4363eb6563ddf2dc8e
data/CHANGELOG.md CHANGED
@@ -5,6 +5,17 @@ This project follows [semantic versioning](https://semver.org).
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## v0.2.1
9
+
10
+ ### Added
11
+
12
+ - `of:` on `many` reference lists. Both `many :assoc, find_by: :col, of: :nested`
13
+ and the `through:` flavor `many :assoc, through: :join, find_by: :col, of: :nested`
14
+ now identify each target indirectly via a 1:[0,1] association on the target
15
+ (`of:`), mirroring the existing `one … find_by: of:` semantics. The YAML stays
16
+ a flat list of the related model's `find_by` values (e.g. user slugs) instead
17
+ of requiring a nested block of hashes.
18
+
8
19
  ## v0.2.0
9
20
 
10
21
  A ground-up rewrite of the export/import engine and its DSL. **Breaking: nothing
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- yaml_exporter (0.2.0)
4
+ yaml_exporter (0.2.1)
5
5
  activerecord (>= 5.2)
6
6
  activesupport (>= 5.2)
7
7
 
@@ -28,27 +28,27 @@ GEM
28
28
  tzinfo (~> 2.0, >= 2.0.5)
29
29
  uri (>= 0.13.1)
30
30
  base64 (0.3.0)
31
- bigdecimal (4.1.1)
32
- concurrent-ruby (1.3.6)
31
+ bigdecimal (4.1.2)
32
+ concurrent-ruby (1.3.7)
33
33
  connection_pool (3.0.2)
34
34
  drb (2.2.3)
35
- i18n (1.14.8)
35
+ i18n (1.15.2)
36
36
  concurrent-ruby (~> 1.0)
37
- json (2.19.3)
37
+ json (2.21.1)
38
38
  logger (1.7.0)
39
39
  minitest (5.27.0)
40
- rake (13.3.1)
40
+ rake (13.4.2)
41
41
  securerandom (0.4.1)
42
- sqlite3 (2.9.2-aarch64-linux-gnu)
43
- sqlite3 (2.9.2-aarch64-linux-musl)
44
- sqlite3 (2.9.2-arm-linux-gnu)
45
- sqlite3 (2.9.2-arm-linux-musl)
46
- sqlite3 (2.9.2-arm64-darwin)
47
- sqlite3 (2.9.2-x86-linux-gnu)
48
- sqlite3 (2.9.2-x86-linux-musl)
49
- sqlite3 (2.9.2-x86_64-darwin)
50
- sqlite3 (2.9.2-x86_64-linux-gnu)
51
- sqlite3 (2.9.2-x86_64-linux-musl)
42
+ sqlite3 (2.9.5-aarch64-linux-gnu)
43
+ sqlite3 (2.9.5-aarch64-linux-musl)
44
+ sqlite3 (2.9.5-arm-linux-gnu)
45
+ sqlite3 (2.9.5-arm-linux-musl)
46
+ sqlite3 (2.9.5-arm64-darwin)
47
+ sqlite3 (2.9.5-x86-linux-gnu)
48
+ sqlite3 (2.9.5-x86-linux-musl)
49
+ sqlite3 (2.9.5-x86_64-darwin)
50
+ sqlite3 (2.9.5-x86_64-linux-gnu)
51
+ sqlite3 (2.9.5-x86_64-linux-musl)
52
52
  timeout (0.6.1)
53
53
  tzinfo (2.0.6)
54
54
  concurrent-ruby (~> 1.0)
@@ -77,30 +77,30 @@ CHECKSUMS
77
77
  activerecord (8.1.3) sha256=8003be7b2466ba0a2a670e603eeb0a61dd66058fccecfc49901e775260ac70ab
78
78
  activesupport (8.1.3) sha256=21a5e0dfbd4c3ddd9e1317ec6a4d782fa226e7867dc70b0743acda81a1dca20e
79
79
  base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
80
- bigdecimal (4.1.1) sha256=1c09efab961da45203c8316b0cdaec0ff391dfadb952dd459584b63ebf8054ca
81
- concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab
80
+ bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
81
+ concurrent-ruby (1.3.7) sha256=4412caec3a5ea2e5fdc52076724c071a81f2c0593d83b2ac8cbb8ca63b3151b0
82
82
  connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
83
83
  drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
84
- i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5
85
- json (2.19.3) sha256=289b0bb53052a1fa8c34ab33cc750b659ba14a5c45f3fcf4b18762dc67c78646
84
+ i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
85
+ json (2.21.1) sha256=13a43df75d95641443f5702dff350f237164a9d811ff0f2c2800d4d980220583
86
86
  logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
87
87
  minitest (5.27.0) sha256=2d3b17f8a36fe7801c1adcffdbc38233b938eb0b4966e97a6739055a45fa77d5
88
- rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c
88
+ rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
89
89
  securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
90
- sqlite3 (2.9.2-aarch64-linux-gnu) sha256=eeb86db55645b85327ba75129e3614658d974bf4da8fdc87018a0d42c59f6e42
91
- sqlite3 (2.9.2-aarch64-linux-musl) sha256=4feff91fb8c2b13688da34b5627c9d1ed9cedb3ee87a7114ec82209147f07a6d
92
- sqlite3 (2.9.2-arm-linux-gnu) sha256=1ee2eb06b5301aaf5ce343a6e88d99ac932d95202d7b350f0e7b6d8d588580d7
93
- sqlite3 (2.9.2-arm-linux-musl) sha256=8ca0de6aceede968de0394e22e95d549834c4d8e318f69a92a52f049878a0057
94
- sqlite3 (2.9.2-arm64-darwin) sha256=d15bd9609a05f9d54930babe039585efc8cadd57517c15b64ec7dfa75158a5e9
95
- sqlite3 (2.9.2-x86-linux-gnu) sha256=066bc904522f8a7072236a81237c03a4a1dfe070a25107e392de03d1e4ad0e6d
96
- sqlite3 (2.9.2-x86-linux-musl) sha256=6503c76278f5e8629b12b6518ff43a9a4f6d9381de73f0b086c9fa1226db5ede
97
- sqlite3 (2.9.2-x86_64-darwin) sha256=ed691b5021674d72582d03c5a38e89634b961902735fb6225273892805421d13
98
- sqlite3 (2.9.2-x86_64-linux-gnu) sha256=dce83ffcb7e72f9f7aeb6e5404f15d277a45332fe18ccce8a8b3ed51e8d23aee
99
- sqlite3 (2.9.2-x86_64-linux-musl) sha256=e8dd906a613f13b60f6d47ae9dda376384d9de1ab3f7e3f2fdf2fd18a871a2d7
90
+ sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc
91
+ sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d
92
+ sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b
93
+ sqlite3 (2.9.5-arm-linux-musl) sha256=bae1109d12b2e9f588455967729b008e1ff4feb7761749df695019c9079913c6
94
+ sqlite3 (2.9.5-arm64-darwin) sha256=d0cf444a70fc9395d513cfbcc1e6719e224aa645314e3824cb0474c721425aa2
95
+ sqlite3 (2.9.5-x86-linux-gnu) sha256=c94b96b16f17796be6fa099d15218b52e396f55690c4760faaaefa21ebab9dd5
96
+ sqlite3 (2.9.5-x86-linux-musl) sha256=063a8c13cbadfe7f29453b1706cbdf91fca4a78d244f816ff20bac4fb259f1e4
97
+ sqlite3 (2.9.5-x86_64-darwin) sha256=8e9caae38bd7ebb29cbeee3e7ab1d12dc2327d9a1b92c7fcf0dda05589627a81
98
+ sqlite3 (2.9.5-x86_64-linux-gnu) sha256=233dbcb6714148dd23bc5aeb33e8efd6eac974969564ddd5794c23d5f52b231e
99
+ sqlite3 (2.9.5-x86_64-linux-musl) sha256=e7d3a7474e8af0f96150c21abc203fbab5437206bfcdf11deab7741c0ca516f2
100
100
  timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb
101
101
  tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b
102
102
  uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6
103
- yaml_exporter (0.2.0)
103
+ yaml_exporter (0.2.1)
104
104
 
105
105
  BUNDLED WITH
106
106
  4.0.6
data/README.md CHANGED
@@ -112,7 +112,9 @@ The arguments you pass determine both the YAML shape and who owns the record:
112
112
  | `many :book_parts, find_by: :slug do … end` | list of hashes, matched by `slug` |
113
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
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 |
115
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 |
116
118
  | `many :reviewers, through: :book_reviewers, find_by: :slug do … end` | list of hashes describing join-model attributes |
117
119
 
118
120
  ### Lifecycle rules
@@ -349,6 +351,13 @@ This same flavor works for `has_and_belongs_to_many` (as above) and for any `has
349
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.
350
352
  * The order of the entries in the yaml file doesn't matter.
351
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
+
352
361
  ### `many` with `through:` (has_many :through with join attributes)
353
362
 
354
363
  `has_many :through` is not a separate DSL method — how you expose it depends on whether the join model carries its own attributes.
@@ -793,9 +802,9 @@ A single related record. Exactly one of `find_by:` or a block must be given:
793
802
 
794
803
  Passing both a block and `find_by:` is rejected — see the ownership reasoning in [`one` with `find_by`](#one-with-find_by-reference).
795
804
 
796
- ### `many(name, positioned_by: nil, find_by: nil, through: nil, &block)`
805
+ ### `many(name, positioned_by: nil, find_by: nil, through: nil, of: nil, &block)`
797
806
 
798
- A list of related records. The flavor is picked from the combination of `positioned_by:`, `find_by:`, `through:` and a block:
807
+ A list of related records. The flavor is picked from the combination of `positioned_by:`, `find_by:`, `through:`, `of:` and a block:
799
808
 
800
809
  | Call | YAML shape | Meaning |
801
810
  | -------------------------------------------------------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
@@ -804,10 +813,14 @@ A list of related records. The flavor is picked from the combination of `positio
804
813
  | `many :children, find_by: :slug do … end` | list of hashes containing the `slug:` key | Children fully managed, identity by a stable column. |
805
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). |
806
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. |
807
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**. |
808
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).
809
822
 
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`.
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).
811
824
 
812
825
  ### Import / export
813
826
 
@@ -49,7 +49,17 @@ module YamlExporter
49
49
  end
50
50
  end
51
51
 
52
- def many(name, find_by: nil, through: nil, positioned_by: nil, &block)
52
+ def many(name, find_by: nil, through: nil, positioned_by: nil, of: nil, &block)
53
+ if of && !find_by
54
+ raise ArgumentError,
55
+ "`many #{name.inspect}`: of: requires find_by:."
56
+ end
57
+ if of && block
58
+ raise ArgumentError,
59
+ "`many #{name.inspect}`: of: cannot combine with a block — of: only applies to " \
60
+ 'reference lists (no block).'
61
+ end
62
+
53
63
  if through
54
64
  unless find_by
55
65
  raise ArgumentError,
@@ -61,7 +71,7 @@ module YamlExporter
61
71
  # the YAML order). The join row itself is always managed by the DSL.
62
72
  @nodes << Nodes::ManyThrough.new(
63
73
  name: name, owner_class: klass, through: through, find_by: find_by,
64
- positioned_by: positioned_by, &block
74
+ positioned_by: positioned_by, of: of, &block
65
75
  )
66
76
  elsif positioned_by && !block
67
77
  raise ArgumentError,
@@ -76,7 +86,7 @@ module YamlExporter
76
86
  name: name, owner_class: klass, positioned_by: positioned_by, &block
77
87
  )
78
88
  elsif find_by
79
- @nodes << Nodes::ManyReference.new(name: name, owner_class: klass, find_by: find_by)
89
+ @nodes << Nodes::ManyReference.new(name: name, owner_class: klass, find_by: find_by, of: of)
80
90
  else
81
91
  raise ArgumentError,
82
92
  "`many #{name.inspect}`: pass either find_by: (reference list), a block (owned), " \
@@ -7,19 +7,29 @@ module YamlExporter
7
7
  # an existing target record via `find_by`. Target records are never
8
8
  # created or mutated; only the association is updated.
9
9
  #
10
+ # With `of:` the value is resolved indirectly through a 1:[0,1]
11
+ # association on the target (see OfResolution), so the YAML lists the
12
+ # find_by value of a *related* model (e.g. the user slug behind each
13
+ # target) rather than a column on the target itself.
14
+ #
10
15
  # Phase: :post_save (join rows / FK need parent.id).
11
16
  class ManyReference
12
- attr_reader :name, :owner_class, :find_by
17
+ include OfResolution
18
+
19
+ attr_reader :name, :owner_class, :find_by, :of
13
20
 
14
- def initialize(name:, owner_class:, find_by:)
21
+ def initialize(name:, owner_class:, find_by:, of: nil)
15
22
  @name = name.to_sym
16
23
  @owner_class = owner_class
17
24
  @find_by = find_by.to_sym
25
+ @of = of&.to_sym
18
26
  reflection = owner_class.reflect_on_association(@name)
19
27
  unless reflection
20
28
  raise ArgumentError,
21
29
  "`many #{name.inspect}`: #{owner_class} has no association `#{name}`"
22
30
  end
31
+
32
+ validate_of_reflection!("many #{name.inspect}") if @of
23
33
  end
24
34
 
25
35
  def phase
@@ -44,9 +54,8 @@ module YamlExporter
44
54
  end
45
55
 
46
56
  targets = values.map do |value|
47
- target_class.find_by(@find_by => value) ||
48
- (raise ActiveRecord::RecordNotFound,
49
- "no #{target_class} with #{@find_by}=#{value.inspect}")
57
+ resolve_target(value) ||
58
+ (raise ActiveRecord::RecordNotFound, not_found_message(value))
50
59
  end
51
60
 
52
61
  record.public_send("#{@name}=", targets)
@@ -54,17 +63,34 @@ module YamlExporter
54
63
 
55
64
  def export(record, exporter:)
56
65
  targets = Array(record.public_send(@name))
57
- keys = targets.map { |t| t.public_send(@find_by) }.sort
66
+ keys = targets.map { |t| key_for(t) }.compact.sort
58
67
  [@name.to_s, keys]
59
68
  end
60
69
 
61
70
  def schema_fragment
62
- item_type = TypeInference.schema_type_for(target_class, @find_by)
71
+ key_class = @of ? of_class : target_class
72
+ item_type = TypeInference.schema_type_for(key_class, @find_by)
63
73
  { @name => { type: 'array', items: { type: item_type } } }
64
74
  end
65
75
 
66
76
  private
67
77
 
78
+ def resolve_target(value)
79
+ @of ? resolve_target_by_of(value) : target_class.find_by(@find_by => value)
80
+ end
81
+
82
+ def key_for(target)
83
+ @of ? of_value_for(target) : target.public_send(@find_by)
84
+ end
85
+
86
+ def not_found_message(value)
87
+ if @of
88
+ "no #{target_class} for #{of_class} with #{@find_by}=#{value.inspect} via `#{@of}`"
89
+ else
90
+ "no #{target_class} with #{@find_by}=#{value.inspect}"
91
+ end
92
+ end
93
+
68
94
  def describe_path(path)
69
95
  path.empty? ? @name.to_s : "#{path}.#{@name}"
70
96
  end
@@ -16,15 +16,18 @@ module YamlExporter
16
16
  # the reference flavor: passing a block (empty or not) opts into the
17
17
  # hash-shaped entries, same as the other `many` flavors.
18
18
  class ManyThrough < ManyBase
19
- attr_reader :through
19
+ include OfResolution
20
20
 
21
- def initialize(name:, owner_class:, through:, find_by:, positioned_by: nil, &block)
21
+ attr_reader :through, :of
22
+
23
+ def initialize(name:, owner_class:, through:, find_by:, positioned_by: nil, of: nil, &block)
22
24
  # Whether a block was passed (even an empty one) decides the YAML
23
25
  # shape: block → hash entries, no block → bare reference list.
24
26
  @reference_list = block.nil?
25
27
  block ||= proc {}
26
28
 
27
29
  @through = through.to_sym
30
+ @of = of&.to_sym
28
31
  join_reflection = owner_class.reflect_on_association(@through)
29
32
  unless join_reflection
30
33
  raise ArgumentError,
@@ -33,6 +36,8 @@ module YamlExporter
33
36
 
34
37
  super(name: name, owner_class: owner_class, find_by: find_by,
35
38
  positioned_by: positioned_by, &block)
39
+
40
+ validate_of_reflection!("many #{name.inspect}") if @of
36
41
  end
37
42
 
38
43
  # No block was passed → the YAML is a flat list of find_by values
@@ -63,10 +68,9 @@ module YamlExporter
63
68
 
64
69
  def find_or_build_child(parent, entry, _index, existing:)
65
70
  key = entry[@find_by.to_s]
66
- target = target_class.find_by(@find_by => key)
71
+ target = @of ? resolve_target_by_of(key) : target_class.find_by(@find_by => key)
67
72
  unless target
68
- raise ActiveRecord::RecordNotFound,
69
- "no #{target_class} with #{@find_by}=#{key.inspect}"
73
+ raise ActiveRecord::RecordNotFound, not_found_message(key)
70
74
  end
71
75
 
72
76
  # Match existing join rows by comparing the source association
@@ -87,7 +91,7 @@ module YamlExporter
87
91
  def default_export_order(records)
88
92
  records.sort_by do |join|
89
93
  target = join.public_send(source_association_name)
90
- target.public_send(@find_by).to_s
94
+ key_for(target).to_s
91
95
  end
92
96
  end
93
97
 
@@ -96,7 +100,7 @@ module YamlExporter
96
100
  def export(parent, exporter:)
97
101
  records = sort_for_export(Array(parent.public_send(@through)))
98
102
  if reference_list?
99
- keys = records.map { |join| join.public_send(source_association_name).public_send(@find_by) }
103
+ keys = records.map { |join| key_for(join.public_send(source_association_name)) }
100
104
  return [@name.to_s, keys]
101
105
  end
102
106
 
@@ -158,12 +162,27 @@ module YamlExporter
158
162
  def schema_fragment
159
163
  return super unless reference_list?
160
164
 
161
- item_type = TypeInference.schema_type_for(target_class, @find_by)
165
+ key_class = @of ? of_class : target_class
166
+ item_type = TypeInference.schema_type_for(key_class, @find_by)
162
167
  { @name => { type: 'array', items: { type: item_type } } }
163
168
  end
164
169
 
165
170
  private
166
171
 
172
+ # The YAML value for a resolved target: with `of:` it lives on a related
173
+ # model; otherwise it's the find_by column on the target itself.
174
+ def key_for(target)
175
+ @of ? of_value_for(target) : target.public_send(@find_by)
176
+ end
177
+
178
+ def not_found_message(key)
179
+ if @of
180
+ "no #{target_class} for #{of_class} with #{@find_by}=#{key.inspect} via `#{@of}`"
181
+ else
182
+ "no #{target_class} with #{@find_by}=#{key.inspect}"
183
+ end
184
+ end
185
+
167
186
  def source_reflection
168
187
  @source_reflection ||= @owner_class.reflect_on_association(@name).source_reflection
169
188
  end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YamlExporter
4
+ module Nodes
5
+ # Shared `of:` navigation for reference nodes that identify their target
6
+ # indirectly: the YAML value names a column on a *related* model
7
+ # (`of:`) reachable from the target, not on the target itself.
8
+ #
9
+ # Example: a reviewer target is a CorporateUser, and
10
+ # CorporateUser `belongs_to :user` (a User with a slug). Declaring
11
+ # `find_by: :slug, of: :user` stores the User's slug in the YAML and, on
12
+ # import, resolves the User first and then reverses `of:` back to the
13
+ # CorporateUser.
14
+ #
15
+ # Mixed into OneReferenceOf, ManyReference and ManyThrough. The host must
16
+ # provide `target_class` and set `@find_by` and `@of` (both symbols).
17
+ module OfResolution
18
+ # The related model that actually carries the find_by column
19
+ # (CorporateUser.user -> User).
20
+ def of_reflection
21
+ @of_reflection ||= target_class.reflect_on_association(@of)
22
+ end
23
+
24
+ # The class of the related model (CorporateUser.user -> User).
25
+ def of_class
26
+ @of_class ||= of_reflection.klass
27
+ end
28
+
29
+ # Resolve a single YAML value to a target record: find the related
30
+ # record by find_by, then reverse `of:` back to the target. Returns nil
31
+ # if either lookup misses.
32
+ def resolve_target_by_of(value)
33
+ related = of_class.find_by(@find_by => value)
34
+ return nil unless related
35
+
36
+ find_target_via(related)
37
+ end
38
+
39
+ # Navigate from a resolved related record back to the target.
40
+ #
41
+ # belongs_to (:user on CorporateUser): FK is on the target.
42
+ # target_class.find_by(user_id: related.id)
43
+ # has_one (:profile on CorporateUser): FK is on the related record.
44
+ # target_class.find_by(id: related.corporate_user_id)
45
+ def find_target_via(related)
46
+ if of_reflection.is_a?(ActiveRecord::Reflection::BelongsToReflection)
47
+ pk_val = related.public_send(of_reflection.association_primary_key)
48
+ target_class.find_by(of_reflection.foreign_key => pk_val)
49
+ else # has_one
50
+ fk_val = related.public_send(of_reflection.foreign_key)
51
+ target_class.find_by(of_reflection.association_primary_key => fk_val)
52
+ end
53
+ end
54
+
55
+ # The YAML value for a target: the find_by column on its `of:` relation.
56
+ # nil when the target has no related record (can't be represented).
57
+ def of_value_for(target)
58
+ related = target.public_send(@of)
59
+ related&.public_send(@find_by)
60
+ end
61
+
62
+ # Declaration-time check: `of:` must name a 1:[0,1] association on the
63
+ # target. Raises ArgumentError at class load otherwise.
64
+ def validate_of_reflection!(node_label)
65
+ of_ref = target_class.reflect_on_association(@of)
66
+ unless of_ref
67
+ raise ArgumentError,
68
+ "`#{node_label}, of: #{@of.inspect}`: #{target_class} has no association `#{@of}`"
69
+ end
70
+
71
+ return if singular_of_association?(of_ref)
72
+
73
+ raise ArgumentError,
74
+ "`#{node_label}, of: #{@of.inspect}`: `of:` must be a 1:[0,1] association " \
75
+ "(belongs_to or has_one); got #{of_ref.macro}"
76
+ end
77
+
78
+ def singular_of_association?(reflection)
79
+ reflection.is_a?(ActiveRecord::Reflection::BelongsToReflection) ||
80
+ reflection.is_a?(ActiveRecord::Reflection::HasOneReflection)
81
+ end
82
+ end
83
+ end
84
+ end
@@ -16,6 +16,8 @@ module YamlExporter
16
16
  # Only 1:[0,1] `of:` associations are permitted (belongs_to or has_one).
17
17
  # Phase: :pre_save (sets the FK before record.save!).
18
18
  class OneReferenceOf
19
+ include OfResolution
20
+
19
21
  attr_reader :name, :owner_class, :find_by, :of
20
22
 
21
23
  def initialize(name:, owner_class:, find_by:, of:)
@@ -30,17 +32,7 @@ module YamlExporter
30
32
  "`one #{name.inspect}`: #{owner_class} has no association `#{name}`"
31
33
  end
32
34
 
33
- of_ref = target_class.reflect_on_association(@of)
34
- unless of_ref
35
- raise ArgumentError,
36
- "`one #{name.inspect}, of: #{of.inspect}`: #{target_class} has no association `#{of}`"
37
- end
38
-
39
- unless singular_association?(of_ref)
40
- raise ArgumentError,
41
- "`one #{name.inspect}, of: #{of.inspect}`: `of:` must be a 1:[0,1] association " \
42
- "(belongs_to or has_one); got #{of_ref.macro}"
43
- end
35
+ validate_of_reflection!("one #{name.inspect}")
44
36
  end
45
37
 
46
38
  def phase
@@ -63,16 +55,16 @@ module YamlExporter
63
55
  return
64
56
  end
65
57
 
66
- through = through_class.find_by(@find_by => value)
67
- unless through
58
+ related = of_class.find_by(@find_by => value)
59
+ unless related
68
60
  raise ActiveRecord::RecordNotFound,
69
- "no #{through_class} with #{@find_by}=#{value.inspect}"
61
+ "no #{of_class} with #{@find_by}=#{value.inspect}"
70
62
  end
71
63
 
72
- target = find_target_via(through)
64
+ target = find_target_via(related)
73
65
  unless target
74
66
  raise ActiveRecord::RecordNotFound,
75
- "no #{target_class} linked to #{through_class} #{@find_by}=#{value.inspect} via `#{@of}`"
67
+ "no #{target_class} linked to #{of_class} #{@find_by}=#{value.inspect} via `#{@of}`"
76
68
  end
77
69
 
78
70
  record.public_send("#{@name}=", target)
@@ -82,46 +74,11 @@ module YamlExporter
82
74
  target = record.public_send(@name)
83
75
  return [@name.to_s, nil] if target.nil?
84
76
 
85
- through = target.public_send(@of)
86
- return [@name.to_s, nil] if through.nil?
87
-
88
- [@name.to_s, through.public_send(@find_by)]
77
+ [@name.to_s, of_value_for(target)]
89
78
  end
90
79
 
91
80
  def schema_fragment
92
- { @name => { type: TypeInference.schema_type_for(through_class, @find_by) } }
93
- end
94
-
95
- private
96
-
97
- def of_reflection
98
- @of_reflection ||= target_class.reflect_on_association(@of)
99
- end
100
-
101
- def through_class
102
- @through_class ||= of_reflection.klass
103
- end
104
-
105
- # Navigate from a resolved `through` record back to the target.
106
- #
107
- # belongs_to (:user on CorporateUser): FK is on the target.
108
- # target_class.find_by(user_id: through.id)
109
- #
110
- # has_one (:profile on CorporateUser): FK is on the through record.
111
- # target_class.find_by(id: through.corporate_user_id)
112
- def find_target_via(through)
113
- if of_reflection.is_a?(ActiveRecord::Reflection::BelongsToReflection)
114
- pk_val = through.public_send(of_reflection.association_primary_key)
115
- target_class.find_by(of_reflection.foreign_key => pk_val)
116
- else # has_one
117
- fk_val = through.public_send(of_reflection.foreign_key)
118
- target_class.find_by(of_reflection.association_primary_key => fk_val)
119
- end
120
- end
121
-
122
- def singular_association?(reflection)
123
- reflection.is_a?(ActiveRecord::Reflection::BelongsToReflection) ||
124
- reflection.is_a?(ActiveRecord::Reflection::HasOneReflection)
81
+ { @name => { type: TypeInference.schema_type_for(of_class, @find_by) } }
125
82
  end
126
83
  end
127
84
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module YamlExporter
4
- VERSION = '0.2.0'
4
+ VERSION = '0.2.1'
5
5
  end
data/lib/yaml_exporter.rb CHANGED
@@ -63,6 +63,7 @@ require 'yaml'
63
63
  require_relative 'yaml_exporter/structure'
64
64
  require_relative 'yaml_exporter/type_inference'
65
65
  require_relative 'yaml_exporter/nodes/attribute'
66
+ require_relative 'yaml_exporter/nodes/of_resolution'
66
67
  require_relative 'yaml_exporter/nodes/one_owned'
67
68
  require_relative 'yaml_exporter/nodes/one_reference'
68
69
  require_relative 'yaml_exporter/nodes/one_reference_of'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yaml_exporter
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Anatoly Zelenin
@@ -108,6 +108,7 @@ files:
108
108
  - lib/yaml_exporter/nodes/many_positional.rb
109
109
  - lib/yaml_exporter/nodes/many_reference.rb
110
110
  - lib/yaml_exporter/nodes/many_through.rb
111
+ - lib/yaml_exporter/nodes/of_resolution.rb
111
112
  - lib/yaml_exporter/nodes/one_owned.rb
112
113
  - lib/yaml_exporter/nodes/one_reference.rb
113
114
  - lib/yaml_exporter/nodes/one_reference_of.rb