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 +4 -4
- data/README.md +117 -3
- data/lib/hub_kernel/conformance/hub.rb +1 -1
- data/lib/hub_kernel/engine.rb +5 -0
- data/lib/hub_kernel/hubs.rb +1 -1
- data/lib/hub_kernel/port_associations.rb +53 -0
- data/lib/hub_kernel/ports.rb +32 -1
- data/lib/hub_kernel/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fbd4a1ca059e2203955aab7a07510cd07620427086b6153455f0e84d1f4ea639
|
|
4
|
+
data.tar.gz: 26a9be066b129fa83177261e5f9e15cc72c206c3210dc4fc1f26c6a65ac29676
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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,
|
|
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
|
data/lib/hub_kernel/engine.rb
CHANGED
|
@@ -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
|
data/lib/hub_kernel/hubs.rb
CHANGED
|
@@ -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
|
|
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
|
data/lib/hub_kernel/ports.rb
CHANGED
|
@@ -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
|
data/lib/hub_kernel/version.rb
CHANGED
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.
|
|
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
|