hub_kernel 0.25.0 → 0.27.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 +77 -0
- data/lib/hub_kernel/conformance/unlinked_ids.rb +41 -0
- data/lib/hub_kernel/port_associations.rb +32 -6
- data/lib/hub_kernel/ports.rb +7 -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: d5519634e5f01317012e931057dc2caa508996a18ee0f952ee6e4de867f285ff
|
|
4
|
+
data.tar.gz: 5477988282902fb115811f3f08f6a7eebe8547eee1f7a5ec9c56a3b8f758272c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bf9219c9a2027f881df22d5aca5149f34408fb6c91156f550ab9fbd992c572d4bc3bfb31f944722fbb73154c37646ef3a3386e9049f3d5aefaf82a5962727f3c
|
|
7
|
+
data.tar.gz: 70344238486a35ed0bff5707898f24f3ee807647cb03f95e0b9426fd640a1506a968fb13dc561d18ab13e1262d203c887c0ae83e2aad7db070e8be095412943e
|
data/README.md
CHANGED
|
@@ -164,9 +164,62 @@ link. A boot with eager loading on defines it the same way. After a code reload
|
|
|
164
164
|
development, the reloaded model has the link again, and it points at the reloaded model
|
|
165
165
|
the app fills the port with.
|
|
166
166
|
|
|
167
|
+
A link that names a port its hub does not declare, or one of the hub's call ports, raises
|
|
168
|
+
an `ArgumentError` when the model loads, naming the model, the hub and the port. A link
|
|
169
|
+
whose `port:` is not a hub module raises an `ArgumentError` naming the model.
|
|
170
|
+
|
|
167
171
|
The model names only its own hub, so the crossing check passes it. The app's
|
|
168
172
|
`to_prepare` block that fills the port is the one place both models are named.
|
|
169
173
|
|
|
174
|
+
### Has-many links through a record port
|
|
175
|
+
A hub's model lists the records another hub owns that point back at it with
|
|
176
|
+
`has_many_port`, naming its own hub and one of that hub's record ports:
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
module Sales
|
|
180
|
+
class Job < ApplicationRecord
|
|
181
|
+
has_many_port :quotes, port: Sales, record: :quote_record
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
hub_kernel defines an ordinary `has_many :quotes` whose class is the model the app filled
|
|
187
|
+
the port with. `job.quotes` lists the quotes, and `includes`, `preload`, `joins`, `where`
|
|
188
|
+
and `order` work on the link as on any `has_many`. Every other `has_many` option, such as
|
|
189
|
+
`foreign_key:` or `inverse_of:`, and a scope passed before the options, reach the
|
|
190
|
+
association unchanged, apart from the options below that write to the listed records.
|
|
191
|
+
The class name comes from the port, so one passed is not used.
|
|
192
|
+
|
|
193
|
+
A has-many link is always read-only, and no option makes it writable:
|
|
194
|
+
|
|
195
|
+
- A record listed through the link, loaded, preloaded or queried, raises
|
|
196
|
+
`ActiveRecord::ReadOnlyRecord` when it is saved or destroyed.
|
|
197
|
+
- Adding a record to the list with `<<`, `push`, `append`, `concat` or `replace`, removing
|
|
198
|
+
one with `delete`, `destroy`, `delete_all`, `destroy_all` or `clear`, and building or
|
|
199
|
+
creating one with `build`, `new`, `create` or `create!` raise
|
|
200
|
+
`ActiveRecord::ReadOnlyRecord`, on the list and on a query made from it.
|
|
201
|
+
- Assigning the list with `quotes=` or `quote_ids=` raises `ActiveRecord::ReadOnlyRecord`.
|
|
202
|
+
- Writing the listed records all at once with `update_all`, `update_counters`, `insert`,
|
|
203
|
+
`insert!`, `insert_all`, `insert_all!`, `upsert` or `upsert_all` raises
|
|
204
|
+
`ActiveRecord::ReadOnlyRecord`.
|
|
205
|
+
- `autosave`, `counter_cache` and a `dependent` value that destroys, deletes or nullifies
|
|
206
|
+
the listed records each write to them, so passing any of them raises an `ArgumentError`
|
|
207
|
+
naming it.
|
|
208
|
+
|
|
209
|
+
`dependent: :restrict_with_exception` and `dependent: :restrict_with_error` write nothing
|
|
210
|
+
to the listed records, so the link takes them. Deleting the model while the link lists
|
|
211
|
+
records is refused, the first way by raising `ActiveRecord::DeleteRestrictionError` and
|
|
212
|
+
the second by adding an error and returning false, and deleting it when the link lists
|
|
213
|
+
none goes ahead.
|
|
214
|
+
|
|
215
|
+
The link is defined as soon as both the model has loaded and the port is filled, in
|
|
216
|
+
whichever order those happen, the same way as a `belongs_to_port` link. A boot with eager
|
|
217
|
+
loading on defines it, and after a code reload in development the reloaded model lists
|
|
218
|
+
the reloaded model the app fills the port with.
|
|
219
|
+
|
|
220
|
+
A has-many link refuses a port that is not one of its hub's record ports, and a `port:`
|
|
221
|
+
that is not a hub module, in the same way as a `belongs_to_port` link.
|
|
222
|
+
|
|
170
223
|
### The start check
|
|
171
224
|
Every hub that declares a port or exposes a method is on `HubKernel::Hubs.list`. Call
|
|
172
225
|
`HubKernel::Hubs.check!` after the app fills its ports, and the app refuses to start
|
|
@@ -347,6 +400,30 @@ end
|
|
|
347
400
|
`HubKernel::Ports` or `HubKernel::Exposes`, such as "hubs/suplies holds no hub module",
|
|
348
401
|
so a misspelled or half-made hub folder does not pass unnoticed.
|
|
349
402
|
|
|
403
|
+
### The unlinked id check
|
|
404
|
+
A host's test can check that every link a hub's model makes is an association, so a new
|
|
405
|
+
link to another hub's record cannot be added as a bare id column:
|
|
406
|
+
|
|
407
|
+
```ruby
|
|
408
|
+
require "hub_kernel/conformance/unlinked_ids"
|
|
409
|
+
|
|
410
|
+
class UnlinkedIdsTest < ActiveSupport::TestCase
|
|
411
|
+
include HubKernel::Conformance::UnlinkedIds
|
|
412
|
+
|
|
413
|
+
unlinked_ids except: [ "Billing::Invoice#legacy_id" ]
|
|
414
|
+
end
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
- The test reads every Active Record model under each hub folder's `app/models`, and
|
|
418
|
+
fails naming each column ending in `_id` that no `belongs_to` on that model uses as its
|
|
419
|
+
foreign key, such as "Kitchen::Dish#oven_id has no association using it".
|
|
420
|
+
- A `belongs_to_port` link counts, and so does an ordinary `belongs_to` inside a hub.
|
|
421
|
+
- `except` lists columns the test does not report, each written as the model and the
|
|
422
|
+
column joined by `#`. The `unlinked_ids` line may be left out when there are none.
|
|
423
|
+
- Abstract classes and models whose table does not exist are skipped.
|
|
424
|
+
- The test fails with "The unlinked id check found no model with a table in any hub
|
|
425
|
+
folder" when it finds none, so a wrong setup does not pass unnoticed.
|
|
426
|
+
|
|
350
427
|
## Installation
|
|
351
428
|
Add this line to your application's Gemfile:
|
|
352
429
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
require "hub_kernel/hub_folders"
|
|
3
|
+
|
|
4
|
+
module HubKernel
|
|
5
|
+
module Conformance
|
|
6
|
+
module UnlinkedIds
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
class_methods do
|
|
10
|
+
def unlinked_ids(except: [])
|
|
11
|
+
define_method(:unlinked_id_exceptions) { except }
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
included do
|
|
16
|
+
test "every id column of a hub model has an association" do
|
|
17
|
+
flunk "The unlinked id check found no model with a table in any hub folder" if hub_models.none?
|
|
18
|
+
|
|
19
|
+
found = hub_models.flat_map { |model| unlinked_columns(model).map { |column| "#{model.name}##{column}" } } - unlinked_id_exceptions
|
|
20
|
+
found = found.map { |entry| "#{entry} has no association using it" }
|
|
21
|
+
assert found.empty?, found.join("\n")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def unlinked_id_exceptions = []
|
|
26
|
+
|
|
27
|
+
def hub_models = @hub_models ||= tabled_hub_models
|
|
28
|
+
|
|
29
|
+
def tabled_hub_models
|
|
30
|
+
Dir.glob("#{HubKernel::HubFolders.code_glob}/models/**/*.rb", base: Rails.root).sort
|
|
31
|
+
.map { |path| path.sub(%r{\A.*?/models/}, "").delete_suffix(".rb").camelize.safe_constantize }
|
|
32
|
+
.select { |model| model.is_a?(Class) && model < ActiveRecord::Base && !model.abstract_class? && model.table_exists? }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def unlinked_columns(model)
|
|
36
|
+
linked = model.reflect_on_all_associations(:belongs_to).map { |association| association.foreign_key.to_s }
|
|
37
|
+
model.column_names.select { |column| column.end_with?("_id") }.sort - linked
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -1,27 +1,53 @@
|
|
|
1
1
|
module HubKernel
|
|
2
2
|
module PortAssociations
|
|
3
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
|
|
4
7
|
|
|
5
8
|
def belongs_to_port(name, scope = nil, port:, record:, **options)
|
|
6
9
|
refuse_writing_options(name, options)
|
|
10
|
+
refuse_non_hub(name, port)
|
|
7
11
|
port.link_record(record, [ self.name, name ]) do |model|
|
|
8
12
|
belongs_to name, read_only(scope), **options, class_name: model.name
|
|
9
13
|
refuse_creating_through(name)
|
|
10
14
|
end
|
|
11
15
|
end
|
|
12
16
|
|
|
17
|
+
def has_many_port(name, scope = nil, port:, record:, **options)
|
|
18
|
+
refuse_writing_options(name, options.reject { |option, value| option == :dependent && RESTRICTING.include?(value) }, LIST_WRITING_OPTIONS)
|
|
19
|
+
refuse_non_hub(name, port)
|
|
20
|
+
refusal = "#{self.name}'s #{name} link is read-only, so no record can be written through it"
|
|
21
|
+
port.link_record(record, [ self.name, name ]) do |model|
|
|
22
|
+
has_many name, read_only(scope, refusing_list(refusal)), **options, class_name: model.name
|
|
23
|
+
refuse_replacing_list(name, refusal)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
13
27
|
private
|
|
14
28
|
|
|
15
|
-
def refuse_writing_options(name, options)
|
|
16
|
-
writing = options.keys &
|
|
29
|
+
def refuse_writing_options(name, options, writers = WRITING_OPTIONS)
|
|
30
|
+
writing = options.keys & writers
|
|
17
31
|
raise ArgumentError, "#{name} is a read-only record port link, so it does not take #{writing.to_sentence}" if writing.any?
|
|
18
32
|
end
|
|
19
33
|
|
|
20
|
-
def
|
|
21
|
-
|
|
22
|
-
|
|
34
|
+
def refuse_non_hub(name, port)
|
|
35
|
+
raise ArgumentError, "#{self.name} links #{name} through #{port}, which is not a hub module" unless port.is_a?(HubKernel::Ports)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def read_only(scope, *extensions)
|
|
39
|
+
return -> { readonly.extending(*extensions) } unless scope
|
|
40
|
+
return -> { instance_exec(&scope).readonly.extending(*extensions) } if scope.arity.zero?
|
|
41
|
+
|
|
42
|
+
->(owner) { instance_exec(owner, &scope).readonly.extending(*extensions) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def refusing_list(refusal)
|
|
46
|
+
Module.new { LIST_WRITING_METHODS.each { |method| define_method(method) { |*| raise ActiveRecord::ReadOnlyRecord, refusal } } }
|
|
47
|
+
end
|
|
23
48
|
|
|
24
|
-
|
|
49
|
+
def refuse_replacing_list(name, refusal)
|
|
50
|
+
[ :"#{name}=", :"#{name.to_s.singularize}_ids=" ].each { |method| define_method(method) { |*| raise ActiveRecord::ReadOnlyRecord, refusal } }
|
|
25
51
|
end
|
|
26
52
|
|
|
27
53
|
def refuse_creating_through(name)
|
data/lib/hub_kernel/ports.rb
CHANGED
|
@@ -22,6 +22,8 @@ module HubKernel
|
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
def link_record(name, model_and_link, &link)
|
|
25
|
+
refuse_link(name, model_and_link, "#{self.name} does not declare") unless declared_ports.include?(name)
|
|
26
|
+
refuse_link(name, model_and_link, "is a call port, not a record port") unless record_ports.include?(name)
|
|
25
27
|
links_to(name)[model_and_link] = link
|
|
26
28
|
filling = public_send(name)
|
|
27
29
|
link.call(filling) if active_record_model?(filling)
|
|
@@ -50,7 +52,11 @@ module HubKernel
|
|
|
50
52
|
|
|
51
53
|
def record_ports = @record_ports ||= []
|
|
52
54
|
|
|
53
|
-
def
|
|
55
|
+
def refuse_link(name, (model, link), reason)
|
|
56
|
+
raise ArgumentError, "#{model} links #{link} through #{self.name} #{name}, which #{reason}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def links_to(name) =(@record_links ||= {})[name] ||= {}
|
|
54
60
|
|
|
55
61
|
def fill_record_port(name, model)
|
|
56
62
|
instance_variable_set(:"@#{name}", model)
|
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.27.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- tylercschneider
|
|
@@ -59,6 +59,7 @@ files:
|
|
|
59
59
|
- lib/hub_kernel/conformance/exposed.rb
|
|
60
60
|
- lib/hub_kernel/conformance/hub.rb
|
|
61
61
|
- lib/hub_kernel/conformance/partial.rb
|
|
62
|
+
- lib/hub_kernel/conformance/unlinked_ids.rb
|
|
62
63
|
- lib/hub_kernel/crossings.rb
|
|
63
64
|
- lib/hub_kernel/engine.rb
|
|
64
65
|
- lib/hub_kernel/follow_up.rb
|