hub_kernel 0.24.0 → 0.26.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: fbd4a1ca059e2203955aab7a07510cd07620427086b6153455f0e84d1f4ea639
4
+ data.tar.gz: 26a9be066b129fa83177261e5f9e15cc72c206c3210dc4fc1f26c6a65ac29676
5
5
  SHA512:
6
- metadata.gz: 82a223294f8bbe74427019e44a38ef20678e8d6680d4e384e2c68be6322abe05da574dfa47dca5689d0ee31103ff903f841001906378842ed25aa81f1f6e5310
7
- data.tar.gz: bb81b8f0de934af592603666605e30349170ac35bd89fe59a6903cf51e62a5e4cf0dc36070dd1dd44d9c201999efddfc46e79c45002698787e3b9015f99c7819
6
+ metadata.gz: 80bcd028c34e3be272d7c81b81852a0814c592df1cdbfb58954095dc4f240db87d02e4cd78afebd7cec0a0a9d1212d5f0585203724eefab74e81d42901b43050
7
+ data.tar.gz: 782767a253efd77447d8be7b2d16dc7086f8715515d19189435cb4a4fc5c64663cb25e721e2056fd19356b82b84b742bfbedab434652b6030a5a9c21fb41323c
data/README.md CHANGED
@@ -102,6 +102,117 @@ 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
+
170
+ ### Has-many links through a record port
171
+ A hub's model lists the records another hub owns that point back at it with
172
+ `has_many_port`, naming its own hub and one of that hub's record ports:
173
+
174
+ ```ruby
175
+ module Sales
176
+ class Job < ApplicationRecord
177
+ has_many_port :quotes, port: Sales, record: :quote_record
178
+ end
179
+ end
180
+ ```
181
+
182
+ hub_kernel defines an ordinary `has_many :quotes` whose class is the model the app filled
183
+ the port with. `job.quotes` lists the quotes, and `includes`, `preload`, `joins`, `where`
184
+ and `order` work on the link as on any `has_many`. Every other `has_many` option, such as
185
+ `foreign_key:` or `inverse_of:`, and a scope passed before the options, reach the
186
+ association unchanged, apart from the options below that write to the listed records.
187
+ The class name comes from the port, so one passed is not used.
188
+
189
+ A has-many link is always read-only, and no option makes it writable:
190
+
191
+ - A record listed through the link, loaded, preloaded or queried, raises
192
+ `ActiveRecord::ReadOnlyRecord` when it is saved or destroyed.
193
+ - Adding a record to the list with `<<`, `push`, `append`, `concat` or `replace`, removing
194
+ one with `delete`, `destroy`, `delete_all`, `destroy_all` or `clear`, and building or
195
+ creating one with `build`, `new`, `create` or `create!` raise
196
+ `ActiveRecord::ReadOnlyRecord`, on the list and on a query made from it.
197
+ - Assigning the list with `quotes=` or `quote_ids=` raises `ActiveRecord::ReadOnlyRecord`.
198
+ - Writing the listed records all at once with `update_all`, `update_counters`, `insert`,
199
+ `insert!`, `insert_all`, `insert_all!`, `upsert` or `upsert_all` raises
200
+ `ActiveRecord::ReadOnlyRecord`.
201
+ - `autosave`, `counter_cache` and a `dependent` value that destroys, deletes or nullifies
202
+ the listed records each write to them, so passing any of them raises an `ArgumentError`
203
+ naming it.
204
+
205
+ `dependent: :restrict_with_exception` and `dependent: :restrict_with_error` write nothing
206
+ to the listed records, so the link takes them. Deleting the model while the link lists
207
+ records is refused, the first way by raising `ActiveRecord::DeleteRestrictionError` and
208
+ the second by adding an error and returning false, and deleting it when the link lists
209
+ none goes ahead.
210
+
211
+ The link is defined as soon as both the model has loaded and the port is filled, in
212
+ whichever order those happen, the same way as a `belongs_to_port` link. A boot with eager
213
+ loading on defines it, and after a code reload in development the reloaded model lists
214
+ the reloaded model the app fills the port with.
215
+
105
216
  ### The start check
106
217
  Every hub that declares a port or exposes a method is on `HubKernel::Hubs.list`. Call
107
218
  `HubKernel::Hubs.check!` after the app fills its ports, and the app refuses to start
@@ -115,7 +226,9 @@ end
115
226
  ```
116
227
 
117
228
  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
229
+ line. It also names each record port filled with something that is not an Active Record
230
+ model, such as "Fulfillment's job record is filled with something that is not an Active
231
+ Record model". Running it inside `to_prepare` checks again after every code reload, and a hub
119
232
  declared again on a reload stays on the list once.
120
233
 
121
234
  While any hub exposes a method, it also names the permission check or the account scope
@@ -136,8 +249,9 @@ class SuppliesHubTest < ActiveSupport::TestCase
136
249
  end
137
250
  ```
138
251
 
139
- The test fails and names each port the app left unfilled, and each port filled with
140
- something that cannot be called.
252
+ The test fails and names each port the app left unfilled, each port filled with
253
+ something that cannot be called, and each record port filled with something that is not
254
+ an Active Record model.
141
255
 
142
256
  ### The exposed list
143
257
  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,53 @@
1
+ module HubKernel
2
+ module PortAssociations
3
+ WRITING_OPTIONS = %i[autosave dependent touch counter_cache].freeze
4
+ LIST_WRITING_OPTIONS = %i[autosave dependent counter_cache].freeze
5
+ RESTRICTING = %i[restrict_with_error restrict_with_exception].freeze
6
+ LIST_WRITING_METHODS = %i[<< push append concat build new create create! delete destroy delete_all destroy_all clear replace update_all insert insert! insert_all insert_all! upsert upsert_all].freeze
7
+
8
+ def belongs_to_port(name, scope = nil, port:, record:, **options)
9
+ refuse_writing_options(name, options)
10
+ port.link_record(record, [ self.name, name ]) do |model|
11
+ belongs_to name, read_only(scope), **options, class_name: model.name
12
+ refuse_creating_through(name)
13
+ end
14
+ end
15
+
16
+ def has_many_port(name, scope = nil, port:, record:, **options)
17
+ refuse_writing_options(name, options.reject { |option, value| option == :dependent && RESTRICTING.include?(value) }, LIST_WRITING_OPTIONS)
18
+ refusal = "#{self.name}'s #{name} link is read-only, so no record can be written through it"
19
+ port.link_record(record, [ self.name, name ]) do |model|
20
+ has_many name, read_only(scope, refusing_list(refusal)), **options, class_name: model.name
21
+ refuse_replacing_list(name, refusal)
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def refuse_writing_options(name, options, writers = WRITING_OPTIONS)
28
+ writing = options.keys & writers
29
+ raise ArgumentError, "#{name} is a read-only record port link, so it does not take #{writing.to_sentence}" if writing.any?
30
+ end
31
+
32
+ def read_only(scope, *extensions)
33
+ return -> { readonly.extending(*extensions) } unless scope
34
+ return -> { instance_exec(&scope).readonly.extending(*extensions) } if scope.arity.zero?
35
+
36
+ ->(owner) { instance_exec(owner, &scope).readonly.extending(*extensions) }
37
+ end
38
+
39
+ def refusing_list(refusal)
40
+ Module.new { LIST_WRITING_METHODS.each { |method| define_method(method) { |*| raise ActiveRecord::ReadOnlyRecord, refusal } } }
41
+ end
42
+
43
+ def refuse_replacing_list(name, refusal)
44
+ [ :"#{name}=", :"#{name.to_s.singularize}_ids=" ].each { |method| define_method(method) { |*| raise ActiveRecord::ReadOnlyRecord, refusal } }
45
+ end
46
+
47
+ def refuse_creating_through(name)
48
+ refusal = "#{self.name}'s #{name} link is read-only, so it cannot build or create a record"
49
+ %W[build_#{name} create_#{name} create_#{name}!].each { |method| define_method(method) { |*| raise ActiveRecord::ReadOnlyRecord, refusal } }
50
+ define_method(:"#{name}=") { |linked| linked&.new_record? ? raise(ActiveRecord::ReadOnlyRecord, refusal) : super(linked) }
51
+ end
52
+ end
53
+ 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.26.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.26.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