hub_kernel 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 278e7929cc3994d1e1149a5fa9ce387922748cb829f615e9bf56e52d960f1d06
4
- data.tar.gz: b60a8992e16825e54d1c8f211e9ce09a814bfe4d0215d94bc40d8da77f74a281
3
+ metadata.gz: 3e8e24b52cfa8070ea79fc420ec714555b3e81377725ac9ea8e8a3a20ac280bb
4
+ data.tar.gz: 130ba409bb6db19a6cd0d1c34ffc535407ba03255f8e282bd2f333c53bef6000
5
5
  SHA512:
6
- metadata.gz: 82a223294f8bbe74427019e44a38ef20678e8d6680d4e384e2c68be6322abe05da574dfa47dca5689d0ee31103ff903f841001906378842ed25aa81f1f6e5310
7
- data.tar.gz: bb81b8f0de934af592603666605e30349170ac35bd89fe59a6903cf51e62a5e4cf0dc36070dd1dd44d9c201999efddfc46e79c45002698787e3b9015f99c7819
6
+ metadata.gz: d159cfc7e9b03980d03de84433ca09d3f1b5a11d758b10a3412a54eff96b8636b1187d9b2e4d54d2582ef7610d43c8df0e63c8a99de1553f17cb698175b34e9a
7
+ data.tar.gz: 7e075af8fa7fbe2cc6b7952a45c5a710c7702a39686cf38b0b121166c39f89f06f53e3e9114e3543e606cb22e9628c42ca09f96ba70ceafe31922b74bf491dce
data/README.md CHANGED
@@ -102,6 +102,71 @@ returns its answer. An error it raises reaches the caller unchanged. Calling a p
102
102
  app left unfilled raises `HubKernel::UnwiredPortError`, such as "Supplies' spend
103
103
  recorder is not wired".
104
104
 
105
+ ### Record ports
106
+ A record port is a port the app fills with an Active Record model class, so a hub can
107
+ name a model another part of the app owns without naming its class. The hub declares it
108
+ by name:
109
+
110
+ ```ruby
111
+ module Fulfillment
112
+ extend HubKernel::Ports
113
+
114
+ record_port :job_record
115
+ end
116
+ ```
117
+
118
+ The app fills it with the model, in the same setup as its other ports:
119
+
120
+ ```ruby
121
+ Rails.application.config.to_prepare do
122
+ Fulfillment.job_record = Job
123
+ end
124
+ ```
125
+
126
+ `Fulfillment.job_record` then answers `Job`. A record port is on the hub's
127
+ `port_names`, so the start check, the hub check and kept ports all read it.
128
+
129
+ ### Links through a record port
130
+ A hub's model links to a record another hub owns with `belongs_to_port`, naming its own
131
+ hub and one of that hub's record ports instead of the other model's class:
132
+
133
+ ```ruby
134
+ module Sales
135
+ class Quote < ApplicationRecord
136
+ belongs_to_port :job, port: Sales, record: :job_record
137
+ end
138
+ end
139
+ ```
140
+
141
+ hub_kernel defines an ordinary `belongs_to :job` whose class is the model the app filled
142
+ the port with. `quote.job` reads the job, and `includes`, `preload`, `joins`, `where` and
143
+ `order` work on the link as on any `belongs_to`. Every other `belongs_to` option, such as
144
+ `foreign_key:` or `optional:`, and a scope passed before the options, reach the
145
+ association unchanged. The class name comes from the port, so one passed is not used.
146
+
147
+ A link is always read-only, and no option makes it writable:
148
+
149
+ - A record read through the link, loaded or preloaded, raises
150
+ `ActiveRecord::ReadOnlyRecord` when it is saved or destroyed.
151
+ - `build_job`, `create_job` and `create_job!` raise `ActiveRecord::ReadOnlyRecord`, and so
152
+ does assigning an unsaved record to `job`.
153
+ - The records a scope loads are read-only too.
154
+ - `autosave`, `dependent`, `touch` and `counter_cache` each write to the linked record, so
155
+ passing any of them raises an `ArgumentError` naming it.
156
+
157
+ The model still sets which record it links to, by assigning a saved record or its id, and
158
+ saves itself. A change to the linked record goes through the hub that owns it.
159
+
160
+ The link is defined as soon as both the model has loaded and the port is filled, in
161
+ whichever order those happen. A model that loads after its port is filled has the link
162
+ when it loads. A model that loads first waits on its hub, and filling the port defines the
163
+ link. A boot with eager loading on defines it the same way. After a code reload in
164
+ development, the reloaded model has the link again, and it points at the reloaded model
165
+ the app fills the port with.
166
+
167
+ The model names only its own hub, so the crossing check passes it. The app's
168
+ `to_prepare` block that fills the port is the one place both models are named.
169
+
105
170
  ### The start check
106
171
  Every hub that declares a port or exposes a method is on `HubKernel::Hubs.list`. Call
107
172
  `HubKernel::Hubs.check!` after the app fills its ports, and the app refuses to start
@@ -115,7 +180,9 @@ end
115
180
  ```
116
181
 
117
182
  It raises `HubKernel::UnwiredPortError` naming every unfilled port with its hub, one per
118
- line. Running it inside `to_prepare` checks again after every code reload, and a hub
183
+ line. It also names each record port filled with something that is not an Active Record
184
+ model, such as "Fulfillment's job record is filled with something that is not an Active
185
+ Record model". Running it inside `to_prepare` checks again after every code reload, and a hub
119
186
  declared again on a reload stays on the list once.
120
187
 
121
188
  While any hub exposes a method, it also names the permission check or the account scope
@@ -136,8 +203,9 @@ class SuppliesHubTest < ActiveSupport::TestCase
136
203
  end
137
204
  ```
138
205
 
139
- The test fails and names each port the app left unfilled, and each port filled with
140
- something that cannot be called.
206
+ The test fails and names each port the app left unfilled, each port filled with
207
+ something that cannot be called, and each record port filled with something that is not
208
+ an Active Record model.
141
209
 
142
210
  ### The exposed list
143
211
  The exposed list, calling by name, the permission check, the account scope and the errors
@@ -13,7 +13,7 @@ module HubKernel
13
13
 
14
14
  included do
15
15
  test "every port of the hub is filled with something that can be called" do
16
- problems = the_hub.unwired_ports + the_hub.uncallable_ports
16
+ problems = the_hub.unwired_ports + the_hub.uncallable_ports + the_hub.unmodelled_ports
17
17
  flunk problems.join("\n") if problems.any?
18
18
  end
19
19
  end
@@ -1,4 +1,5 @@
1
1
  require "hub_kernel/hub_folders"
2
+ require "hub_kernel/port_associations"
2
3
 
3
4
  module HubKernel
4
5
  class Engine < ::Rails::Engine
@@ -16,6 +17,10 @@ module HubKernel
16
17
  ActiveSupport.on_load(:action_controller) { append_view_path(views) }
17
18
  end
18
19
 
20
+ initializer "hub_kernel.port_associations" do
21
+ ActiveSupport.on_load(:active_record) { extend HubKernel::PortAssociations }
22
+ end
23
+
19
24
  config.to_prepare { HubFolders.hub_modules(Rails.root).each(&:constantize) }
20
25
  end
21
26
  end
@@ -14,7 +14,7 @@ module HubKernel
14
14
  def self.list = (registered + Interface::ExposingHubs.list).uniq { |hub| hub.name || hub }
15
15
 
16
16
  def self.check!
17
- unwired = list.select { |hub| hub.respond_to?(:unwired_ports) }.flat_map(&:unwired_ports) + unfilled_host_answers
17
+ unwired = list.select { |hub| hub.respond_to?(:unwired_ports) }.flat_map { |hub| hub.unwired_ports + hub.unmodelled_ports } + unfilled_host_answers
18
18
  raise UnwiredPortError, unwired.join("\n") if unwired.any?
19
19
  end
20
20
 
@@ -0,0 +1,33 @@
1
+ module HubKernel
2
+ module PortAssociations
3
+ WRITING_OPTIONS = %i[autosave dependent touch counter_cache].freeze
4
+
5
+ def belongs_to_port(name, scope = nil, port:, record:, **options)
6
+ refuse_writing_options(name, options)
7
+ port.link_record(record, [ self.name, name ]) do |model|
8
+ belongs_to name, read_only(scope), **options, class_name: model.name
9
+ refuse_creating_through(name)
10
+ end
11
+ end
12
+
13
+ private
14
+
15
+ def refuse_writing_options(name, options)
16
+ writing = options.keys & WRITING_OPTIONS
17
+ raise ArgumentError, "#{name} is a read-only record port link, so it does not take #{writing.to_sentence}" if writing.any?
18
+ end
19
+
20
+ def read_only(scope)
21
+ return -> { readonly } unless scope
22
+ return -> { instance_exec(&scope).readonly } if scope.arity.zero?
23
+
24
+ ->(owner) { instance_exec(owner, &scope).readonly }
25
+ end
26
+
27
+ def refuse_creating_through(name)
28
+ refusal = "#{self.name}'s #{name} link is read-only, so it cannot build or create a record"
29
+ %W[build_#{name} create_#{name} create_#{name}!].each { |method| define_method(method) { |*| raise ActiveRecord::ReadOnlyRecord, refusal } }
30
+ define_method(:"#{name}=") { |linked| linked&.new_record? ? raise(ActiveRecord::ReadOnlyRecord, refusal) : super(linked) }
31
+ end
32
+ end
33
+ end
@@ -13,6 +13,20 @@ module HubKernel
13
13
  define_singleton_method(as) { |*args, **options| public_send(name).call(*args, **options) }
14
14
  end
15
15
 
16
+ def record_port(name)
17
+ Hubs.add(self)
18
+ declared_ports << name
19
+ record_ports << name
20
+ singleton_class.attr_reader name
21
+ define_singleton_method(:"#{name}=") { |model| fill_record_port(name, model) }
22
+ end
23
+
24
+ def link_record(name, model_and_link, &link)
25
+ links_to(name)[model_and_link] = link
26
+ filling = public_send(name)
27
+ link.call(filling) if active_record_model?(filling)
28
+ end
29
+
16
30
  def port_names = declared_ports.dup
17
31
 
18
32
  def unwired_ports
@@ -20,14 +34,31 @@ module HubKernel
20
34
  end
21
35
 
22
36
  def uncallable_ports
23
- declared_ports.select { |name| instance_variable_get(:"@#{name}")&.respond_to?(:call) == false }
37
+ (declared_ports - record_ports).select { |name| instance_variable_get(:"@#{name}")&.respond_to?(:call) == false }
24
38
  .map { |name| "#{hub_name} #{name.to_s.tr("_", " ")} is filled with something that cannot be called" }
25
39
  end
26
40
 
41
+ def unmodelled_ports
42
+ record_ports.select { |name| instance_variable_get(:"@#{name}") }
43
+ .reject { |name| active_record_model?(instance_variable_get(:"@#{name}")) }
44
+ .map { |name| "#{hub_name} #{name.to_s.tr("_", " ")} is filled with something that is not an Active Record model" }
45
+ end
46
+
27
47
  private
28
48
 
29
49
  def declared_ports = @declared_ports ||= []
30
50
 
51
+ def record_ports = @record_ports ||= []
52
+
53
+ def links_to(name) = (@record_links ||= {})[name] ||= {}
54
+
55
+ def fill_record_port(name, model)
56
+ instance_variable_set(:"@#{name}", model)
57
+ links_to(name).each_value { |link| link.call(model) } if active_record_model?(model)
58
+ end
59
+
60
+ def active_record_model?(filling) = filling.is_a?(Class) && filling < ActiveRecord::Base
61
+
31
62
  def unwired(name)
32
63
  ->(*, **) { raise UnwiredPortError, unwired_message(name) }
33
64
  end
@@ -1,3 +1,3 @@
1
1
  module HubKernel
2
- VERSION = "0.24.0"
2
+ VERSION = "0.25.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hub_kernel
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.24.0
4
+ version: 0.25.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - tylercschneider
@@ -66,6 +66,7 @@ files:
66
66
  - lib/hub_kernel/hubs.rb
67
67
  - lib/hub_kernel/kept_ports.rb
68
68
  - lib/hub_kernel/markup.rb
69
+ - lib/hub_kernel/port_associations.rb
69
70
  - lib/hub_kernel/ports.rb
70
71
  - lib/hub_kernel/records.rb
71
72
  - lib/hub_kernel/version.rb