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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3e8e24b52cfa8070ea79fc420ec714555b3e81377725ac9ea8e8a3a20ac280bb
4
- data.tar.gz: 130ba409bb6db19a6cd0d1c34ffc535407ba03255f8e282bd2f333c53bef6000
3
+ metadata.gz: d5519634e5f01317012e931057dc2caa508996a18ee0f952ee6e4de867f285ff
4
+ data.tar.gz: 5477988282902fb115811f3f08f6a7eebe8547eee1f7a5ec9c56a3b8f758272c
5
5
  SHA512:
6
- metadata.gz: d159cfc7e9b03980d03de84433ca09d3f1b5a11d758b10a3412a54eff96b8636b1187d9b2e4d54d2582ef7610d43c8df0e63c8a99de1553f17cb698175b34e9a
7
- data.tar.gz: 7e075af8fa7fbe2cc6b7952a45c5a710c7702a39686cf38b0b121166c39f89f06f53e3e9114e3543e606cb22e9628c42ca09f96ba70ceafe31922b74bf491dce
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 & WRITING_OPTIONS
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 read_only(scope)
21
- return -> { readonly } unless scope
22
- return -> { instance_exec(&scope).readonly } if scope.arity.zero?
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
- ->(owner) { instance_exec(owner, &scope).readonly }
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)
@@ -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 links_to(name) = (@record_links ||= {})[name] ||= {}
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)
@@ -1,3 +1,3 @@
1
1
  module HubKernel
2
- VERSION = "0.25.0"
2
+ VERSION = "0.27.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.25.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