well_formed 0.1.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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +12 -0
  4. data/.standard.yml +3 -0
  5. data/CHANGELOG.md +5 -0
  6. data/CODE_OF_CONDUCT.md +132 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +869 -0
  9. data/Rakefile +36 -0
  10. data/Steepfile +17 -0
  11. data/gems/well_formed-pundit/.rspec +3 -0
  12. data/gems/well_formed-pundit/Gemfile +13 -0
  13. data/gems/well_formed-pundit/README.md +73 -0
  14. data/gems/well_formed-pundit/Rakefile +12 -0
  15. data/gems/well_formed-pundit/lib/well_formed/pundit/version.rb +7 -0
  16. data/gems/well_formed-pundit/lib/well_formed/pundit.rb +25 -0
  17. data/gems/well_formed-pundit/lib/well_formed-pundit.rb +8 -0
  18. data/gems/well_formed-pundit/spec/spec_helper.rb +13 -0
  19. data/gems/well_formed-pundit/spec/well_formed/pundit_integration_spec.rb +101 -0
  20. data/gems/well_formed-pundit/spec/well_formed/pundit_spec.rb +80 -0
  21. data/gems/well_formed-pundit/well_formed-pundit.gemspec +28 -0
  22. data/lib/generators/resource_form_generator.rb +26 -0
  23. data/lib/generators/templates/form.rb.tt +28 -0
  24. data/lib/well_formed/action_form.rb +7 -0
  25. data/lib/well_formed/attribute_assignment.rb +42 -0
  26. data/lib/well_formed/collections.rb +114 -0
  27. data/lib/well_formed/errors.rb +16 -0
  28. data/lib/well_formed/initializer.rb +31 -0
  29. data/lib/well_formed/nested_attributes.rb +194 -0
  30. data/lib/well_formed/nested_form.rb +14 -0
  31. data/lib/well_formed/performer.rb +57 -0
  32. data/lib/well_formed/persistence.rb +61 -0
  33. data/lib/well_formed/railtie.rb +9 -0
  34. data/lib/well_formed/record_identity.rb +32 -0
  35. data/lib/well_formed/resource_form.rb +7 -0
  36. data/lib/well_formed/simple_action.rb +14 -0
  37. data/lib/well_formed/simple_nested_form.rb +12 -0
  38. data/lib/well_formed/simple_resource.rb +14 -0
  39. data/lib/well_formed/simple_struct.rb +29 -0
  40. data/lib/well_formed/struct.rb +7 -0
  41. data/lib/well_formed/transactional.rb +38 -0
  42. data/lib/well_formed/translations.rb +30 -0
  43. data/lib/well_formed/version.rb +5 -0
  44. data/lib/well_formed/with_user.rb +43 -0
  45. data/lib/well_formed.rb +38 -0
  46. data/rbs_collection.yaml +19 -0
  47. data/sig/well_formed.rbs +129 -0
  48. metadata +105 -0
data/Rakefile ADDED
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new(:rubocop)
11
+
12
+ EXTENSION_GEMS = Dir.glob(File.join(__dir__, "gems", "*")).select { |d| File.directory?(d) }
13
+
14
+ namespace :gems do
15
+ task install: :environment do
16
+ EXTENSION_GEMS.each do |gem_dir|
17
+ sh "bundle install", chdir: gem_dir
18
+ end
19
+ end
20
+
21
+ task spec: :environment do
22
+ EXTENSION_GEMS.each do |gem_dir|
23
+ sh "bundle exec rake spec", chdir: gem_dir
24
+ end
25
+ end
26
+
27
+ task rubocop: :environment do
28
+ EXTENSION_GEMS.each do |gem_dir|
29
+ sh "bundle exec rake rubocop", chdir: gem_dir
30
+ end
31
+ end
32
+
33
+ task all: %i[spec rubocop]
34
+ end
35
+
36
+ task default: %i[spec rubocop]
data/Steepfile ADDED
@@ -0,0 +1,17 @@
1
+ D = Steep::Diagnostic
2
+
3
+ target :lib do
4
+ signature "sig"
5
+ check "lib"
6
+ configure_code_diagnostics(D::Ruby.lenient)
7
+ end
8
+
9
+ # target :test do
10
+ # unreferenced! # Skip type checking the `lib` code when types in `test` target is changed
11
+ # signature "sig/test" # Put RBS files for tests under `sig/test`
12
+ # check "test" # Type check Ruby scripts under `test`
13
+ #
14
+ # configure_code_diagnostics(D::Ruby.lenient) # Weak type checking for test code
15
+ #
16
+ # # library "pathname" # Standard libraries
17
+ # end
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec
6
+
7
+ gem "well_formed", path: "../../"
8
+
9
+ gem "rake", "~> 13.0"
10
+ gem "rspec", "~> 3.0"
11
+ gem "activerecord", require: false
12
+ gem "standard", "~> 1.3"
13
+ gem "rubocop-rails", require: false
@@ -0,0 +1,73 @@
1
+ # well_formed-pundit
2
+
3
+ [Pundit](https://github.com/varvet/pundit) authorization integration for [WellFormed](https://github.com/bmorrall/well_formed) form objects.
4
+
5
+ Adds `policy`, `authorize!`, and `policy_scope` helpers directly to any WellFormed form, using the form's built-in `resource` and `user` references.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ bundle add well_formed-pundit
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ Require the gem in your application:
16
+
17
+ ```ruby
18
+ require "well_formed-pundit"
19
+ ```
20
+
21
+ `WellFormed::Pundit` is automatically included into all WellFormed forms — no `include` required.
22
+
23
+ ### `authorize!`
24
+
25
+ Raise `Pundit::NotAuthorizedError` if the user is not permitted to perform an action on the resource:
26
+
27
+ ```ruby
28
+ class CreateArticleForm < WellFormed::ResourceForm
29
+ resource_alias :article
30
+
31
+ attribute :title, :string
32
+ attribute :body, :string
33
+
34
+ validates :title, presence: true
35
+
36
+ def perform
37
+ authorize!(:create?) # authorizes resource
38
+ authorize!(parent_record, :update?) # authorizes a different record
39
+ # proceed with save ...
40
+ end
41
+ end
42
+ ```
43
+
44
+ ### `policy`
45
+
46
+ Access the resolved Pundit policy instance directly. Defaults to the form's `resource`, but accepts an optional record argument:
47
+
48
+ ```ruby
49
+ form.policy # => ArticlePolicy for resource
50
+ form.policy.create? # => true / false
51
+ form.policy(other) # => policy resolved for a different record
52
+ ```
53
+
54
+ ### `policy_scope`
55
+
56
+ Resolve a scoped collection for the current user:
57
+
58
+ ```ruby
59
+ articles = form.policy_scope(Article.all)
60
+ ```
61
+
62
+ ## API
63
+
64
+ | Method | Description |
65
+ |--------|-------------|
66
+ | `policy(record = resource)` | Returns the Pundit policy instance for `record` and `user` |
67
+ | `authorize!(query)` | Raises `Pundit::NotAuthorizedError` unless the user is authorized for `resource` |
68
+ | `authorize!(record, query)` | Raises `Pundit::NotAuthorizedError` unless the user is authorized for `record` |
69
+ | `policy_scope(collection)` | Returns the policy scope resolved for `user` |
70
+
71
+ ## License
72
+
73
+ MIT
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new(:rubocop)
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WellFormed
4
+ module Pundit
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WellFormed
4
+ module Pundit
5
+ # Returns the Pundit policy instance for the given record (defaults to resource).
6
+ def policy(record = resource)
7
+ ::Pundit::PolicyFinder.new(record).policy!.new(user, record)
8
+ end
9
+
10
+ # Returns the scoped collection for the current user.
11
+ def policy_scope(collection)
12
+ ::Pundit::PolicyFinder.new(collection).scope!.new(user, collection).resolve
13
+ end
14
+
15
+ # Raises Pundit::NotAuthorizedError if the user is not authorized for the given query.
16
+ # Optionally pass an explicit record as the first argument; defaults to resource.
17
+ def authorize!(record_or_query, query = nil)
18
+ if query.nil?
19
+ ::Pundit.authorize(user, resource, record_or_query)
20
+ else
21
+ ::Pundit.authorize(user, record_or_query, query)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "well_formed"
4
+ require "pundit"
5
+ require_relative "well_formed/pundit/version"
6
+ require_relative "well_formed/pundit"
7
+
8
+ WellFormed::WithUser.register_extension(WellFormed::Pundit)
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "well_formed-pundit"
4
+
5
+ RSpec.configure do |config|
6
+ config.example_status_persistence_file_path = ".rspec_status"
7
+
8
+ config.disable_monkey_patching!
9
+
10
+ config.expect_with :rspec do |c|
11
+ c.syntax = :expect
12
+ end
13
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Integration spec: exercises real Pundit policy resolution (no doubles)
4
+
5
+ Article = Struct.new(:id)
6
+
7
+ class ArticlePolicy
8
+ attr_reader :user, :record
9
+
10
+ def initialize(user, record)
11
+ @user = user
12
+ @record = record
13
+ end
14
+
15
+ def create? = user[:admin]
16
+ def update? = user[:admin]
17
+
18
+ class Scope
19
+ attr_reader :user, :scope
20
+
21
+ def initialize(user, scope)
22
+ @user = user
23
+ @scope = scope
24
+ end
25
+
26
+ def resolve
27
+ user[:admin] ? :all : :restricted
28
+ end
29
+ end
30
+ end
31
+
32
+ RSpec.describe WellFormed::Pundit, :integration do
33
+ let(:article) { Article.new(1) }
34
+ let(:admin) { {admin: true} }
35
+ let(:guest) { {admin: false} }
36
+
37
+ let(:form_class) do
38
+ stub_const("UpdateArticleForm", Class.new(WellFormed::ResourceForm) do
39
+ attribute :title, :string
40
+ end)
41
+ end
42
+
43
+ describe "#policy" do
44
+ subject(:form) { form_class.new(article, admin) }
45
+
46
+ it "returns the ArticlePolicy instance" do
47
+ expect(form.policy).to be_a(ArticlePolicy)
48
+ end
49
+
50
+ it "passes user and record to the policy" do
51
+ expect(form.policy.user).to eq(admin)
52
+ expect(form.policy.record).to eq(article)
53
+ end
54
+
55
+ it "uses an explicit record when provided" do
56
+ other_article = Article.new(2)
57
+ expect(form.policy(other_article).record).to eq(other_article)
58
+ end
59
+ end
60
+
61
+ describe "#authorize!" do
62
+ context "when the user is authorized" do
63
+ subject(:form) { form_class.new(article, admin) }
64
+
65
+ it "does not raise" do
66
+ expect { form.authorize!(:update?) }.not_to raise_error
67
+ end
68
+
69
+ it "accepts an explicit record as the first argument" do
70
+ other_article = Article.new(2)
71
+ expect { form.authorize!(other_article, :update?) }.not_to raise_error
72
+ end
73
+ end
74
+
75
+ context "when the user is not authorized" do
76
+ subject(:form) { form_class.new(article, guest) }
77
+
78
+ it "raises Pundit::NotAuthorizedError" do
79
+ expect { form.authorize!(:update?) }.to raise_error(::Pundit::NotAuthorizedError)
80
+ end
81
+ end
82
+ end
83
+
84
+ describe "#policy_scope" do
85
+ context "when the user is an admin" do
86
+ subject(:form) { form_class.new(article, admin) }
87
+
88
+ it "returns :all" do
89
+ expect(form.policy_scope(Article)).to eq(:all)
90
+ end
91
+ end
92
+
93
+ context "when the user is a guest" do
94
+ subject(:form) { form_class.new(article, guest) }
95
+
96
+ it "returns :restricted" do
97
+ expect(form.policy_scope(Article)).to eq(:restricted)
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe WellFormed::Pundit do
4
+ it "has a version number" do
5
+ expect(WellFormed::Pundit::VERSION).not_to be_nil
6
+ end
7
+
8
+ let(:resource) { double("resource") }
9
+ let(:user) { double("user") }
10
+
11
+ let(:form_class) do
12
+ stub_const("TestForm", Class.new(WellFormed::ResourceForm) do
13
+ attribute :title, :string
14
+ end)
15
+ end
16
+
17
+ subject(:form) { form_class.new(resource, user) }
18
+
19
+ describe "auto-include via WithUser" do
20
+ it "is included automatically when WithUser is prepended" do
21
+ expect(form_class.ancestors).to include(WellFormed::Pundit)
22
+ end
23
+ end
24
+
25
+ describe "#policy" do
26
+ it "returns the Pundit policy for the resource and user" do
27
+ policy_instance = double("policy_instance")
28
+ policy_class = double("policy_class", new: policy_instance)
29
+ policy_finder = double("policy_finder", policy!: policy_class)
30
+
31
+ allow(::Pundit::PolicyFinder).to receive(:new).with(resource).and_return(policy_finder)
32
+
33
+ expect(form.policy).to eq(policy_instance)
34
+ end
35
+
36
+ it "accepts an explicit record argument" do
37
+ other_record = double("other_record")
38
+ policy_instance = double("policy_instance")
39
+ policy_class = double("policy_class", new: policy_instance)
40
+ policy_finder = double("policy_finder", policy!: policy_class)
41
+
42
+ allow(::Pundit::PolicyFinder).to receive(:new).with(other_record).and_return(policy_finder)
43
+
44
+ expect(form.policy(other_record)).to eq(policy_instance)
45
+ end
46
+ end
47
+
48
+ describe "#authorize!" do
49
+ it "delegates to Pundit.authorize with resource" do
50
+ allow(::Pundit).to receive(:authorize).with(user, resource, :create?).and_return(true)
51
+ expect { form.authorize!(:create?) }.not_to raise_error
52
+ end
53
+
54
+ it "raises Pundit::NotAuthorizedError when not authorized" do
55
+ allow(::Pundit).to receive(:authorize).with(user, resource, :create?)
56
+ .and_raise(::Pundit::NotAuthorizedError)
57
+ expect { form.authorize!(:create?) }.to raise_error(::Pundit::NotAuthorizedError)
58
+ end
59
+
60
+ it "accepts an explicit record as the first argument" do
61
+ other_record = double("other_record")
62
+ allow(::Pundit).to receive(:authorize).with(user, other_record, :update?).and_return(true)
63
+ expect { form.authorize!(other_record, :update?) }.not_to raise_error
64
+ end
65
+ end
66
+
67
+ describe "#policy_scope" do
68
+ it "resolves the scoped collection for the user" do
69
+ collection = double("collection")
70
+ resolved = double("resolved")
71
+ scope_instance = double("scope_instance", resolve: resolved)
72
+ scope_class = double("scope_class", new: scope_instance)
73
+ scope_finder = double("scope_finder", scope!: scope_class)
74
+
75
+ allow(::Pundit::PolicyFinder).to receive(:new).with(collection).and_return(scope_finder)
76
+
77
+ expect(form.policy_scope(collection)).to eq(resolved)
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/well_formed/pundit/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "well_formed-pundit"
7
+ spec.version = WellFormed::Pundit::VERSION
8
+ spec.authors = ["Ben Morrall"]
9
+ spec.email = ["bemo56@hotmail.com"]
10
+
11
+ spec.summary = "Pundit authorization integration for well_formed"
12
+ spec.homepage = "https://github.com/bmorrall/well_formed"
13
+ spec.license = "MIT"
14
+ spec.required_ruby_version = ">= 3.1.0"
15
+
16
+ gemspec = File.basename(__FILE__)
17
+ spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls|
18
+ ls.readlines("\x0", chomp: true).reject do |f|
19
+ (f == gemspec) ||
20
+ f.start_with?(*%w[spec/ .git Gemfile])
21
+ end
22
+ end
23
+
24
+ spec.require_paths = ["lib"]
25
+
26
+ spec.add_dependency "well_formed", ">= 0.1.0"
27
+ spec.add_dependency "pundit", ">= 2.0"
28
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/named_base"
5
+
6
+ class ResourceFormGenerator < Rails::Generators::NamedBase
7
+ source_root File.expand_path("templates", __dir__)
8
+
9
+ def create_form_file
10
+ template "form.rb.tt", File.join("app/forms", class_path, "#{base_name}_form.rb")
11
+ end
12
+
13
+ private
14
+
15
+ def form_class_name
16
+ [*class_path.map(&:camelize), "#{base_name.camelize}Form"].join("::")
17
+ end
18
+
19
+ def base_name
20
+ file_name.delete_suffix("_form")
21
+ end
22
+
23
+ def resource_alias_name
24
+ base_name
25
+ end
26
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= form_class_name %> < WellFormed::ResourceForm
4
+ # resource_alias :<%= resource_alias_name %>
5
+
6
+ # attribute :title, :string
7
+
8
+ # merge_model_errors
9
+
10
+ # validates :title, presence: true
11
+
12
+ # save_within_transaction
13
+
14
+ # before_save :set_defaults
15
+ # after_save :notify
16
+ # after_save_commit :notify_async
17
+
18
+ # private
19
+
20
+ # def set_defaults
21
+ # end
22
+
23
+ # def notify
24
+ # end
25
+
26
+ # def notify_async
27
+ # end
28
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WellFormed
4
+ class ActionForm < SimpleAction
5
+ prepend WithUser
6
+ end
7
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WellFormed
4
+ module AttributeAssignment
5
+ def self.included(base)
6
+ base.extend(ClassMethods)
7
+ end
8
+
9
+ module ClassMethods
10
+ def unmatched_attributes(policy)
11
+ raise ArgumentError, "policy must be :ignore, :warn, or :raise" unless %i[ignore warn raise].include?(policy)
12
+
13
+ @unmatched_attributes_policy = policy
14
+ end
15
+
16
+ def unmatched_attributes_policy
17
+ @unmatched_attributes_policy || :ignore
18
+ end
19
+ end
20
+
21
+ def assign_attributes_to(resource)
22
+ matched = attributes.select { |attr, _| resource.respond_to?("#{attr}=") }
23
+ unmatched_keys = attributes.keys - matched.keys
24
+
25
+ if unmatched_keys.any?
26
+ case self.class.unmatched_attributes_policy
27
+ when :warn
28
+ warn "#{self.class} has attributes with no setter on resource: #{unmatched_keys.join(", ")}"
29
+ when :raise
30
+ raise UnmatchedAttributesError,
31
+ "#{self.class} has attributes with no setter on resource: #{unmatched_keys.join(", ")}"
32
+ end
33
+ end
34
+
35
+ if resource.respond_to?(:assign_attributes)
36
+ resource.assign_attributes(matched)
37
+ else
38
+ matched.each { |attr, value| resource.public_send(:"#{attr}=", value) }
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WellFormed
4
+ module Collections
5
+ def self.included(base)
6
+ base.extend(ClassMethods)
7
+ end
8
+
9
+ module ClassMethods
10
+ # Declares a scoped collection for a form attribute, with optional inclusion validation
11
+ # and optional code-to-id resolution.
12
+ #
13
+ # Generates a +collection_for_<name>+ instance method that returns an ActiveRecord relation.
14
+ # The block is evaluated in the context of the form instance, so +user+, +resource+, and
15
+ # any other instance methods are available.
16
+ #
17
+ # @param name [Symbol] the attribute name (e.g. :user_id)
18
+ # @param validate [false, true, Symbol] when truthy, adds an inclusion validator.
19
+ # - +true+ — validates that the attribute value exists in the collection matched by +:id+
20
+ # - Symbol — validates matched by the given field (e.g. <tt>validate: :code</tt>)
21
+ # @param resolves_to [false, true, Symbol] when truthy, the attribute accepts the +validate+
22
+ # field value as external input (e.g. a code string) and resolves it to the +resolves_to+
23
+ # field value (e.g. integer id) via an +after_validation+ callback. Also overrides
24
+ # +resource_defaults+ to reverse-populate the attribute with the +validate+ field value
25
+ # for edit forms. Requires +validate:+ to be a Symbol.
26
+ # - +true+ — resolves to +:id+
27
+ # - Symbol — resolves to the given field (e.g. <tt>resolves_to: :uuid</tt>)
28
+ # @yieldreturn [ActiveRecord::Relation] the scoped collection
29
+ #
30
+ # Example (basic):
31
+ # class CreatePostForm < WellFormed::ResourceForm
32
+ # attribute :user_id, :integer
33
+ #
34
+ # collection_for :user_id, validate: true do
35
+ # User.all
36
+ # end
37
+ # end
38
+ #
39
+ # Example (code-to-id resolution):
40
+ # class CreatePostForm < WellFormed::ResourceForm
41
+ # attribute :user_id # must not be typed :integer — accepts code strings
42
+ #
43
+ # collection_for :user_id, validate: :code, resolves_to: :id do
44
+ # User.all
45
+ # end
46
+ # end
47
+ def collection_for(name, validate: false, resolves_to: false, &block)
48
+ raise ArgumentError, "collection_for :#{name} requires a block" unless block
49
+
50
+ define_method(:"collection_for_#{name}", &block)
51
+
52
+ if resolves_to
53
+ unless validate.is_a?(Symbol)
54
+ raise ArgumentError,
55
+ "collection_for :#{name} requires validate: <Symbol> when resolves_to: is set"
56
+ end
57
+
58
+ match_field = validate
59
+ resolve_field = (resolves_to == true) ? :id : resolves_to
60
+ collection_method = :"collection_for_#{name}"
61
+
62
+ # Define a single validate method that both validates the input field
63
+ # value and, on success, transforms it to the resolve_field value.
64
+ # Uses :validate callbacks (not :validation) which are reliably set up
65
+ # by ActiveModel::Validations in all contexts.
66
+ resolve_method = :"_resolve_#{name}_to_#{resolve_field}"
67
+
68
+ define_method(resolve_method) do
69
+ val = public_send(name)
70
+ return if val.blank?
71
+
72
+ record = public_send(collection_method).find_by(match_field => val)
73
+ if record.nil?
74
+ errors.add(name, :inclusion)
75
+ else
76
+ public_send(:"#{name}=", record.public_send(resolve_field))
77
+ end
78
+ end
79
+
80
+ validate resolve_method
81
+
82
+ resolve_attr = name.to_s
83
+
84
+ prepend(Module.new do
85
+ define_method(:resource_defaults) do
86
+ defaults = super()
87
+ stored = resource&.public_send(resolve_attr)
88
+ return defaults if stored.blank?
89
+
90
+ code_value = public_send(collection_method)
91
+ .find_by(resolve_field => stored)
92
+ &.public_send(match_field)
93
+ defaults.merge(resolve_attr => code_value)
94
+ end
95
+ end)
96
+
97
+ return
98
+ end
99
+
100
+ return unless validate
101
+
102
+ match_field = (validate == true) ? :id : validate
103
+ collection_method = :"collection_for_#{name}"
104
+
105
+ validates name, inclusion: {
106
+ in: ->(record) {
107
+ record.public_send(collection_method).where(match_field => record.public_send(name)).pluck(match_field)
108
+ },
109
+ allow_blank: true
110
+ }
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WellFormed
4
+ class Error < StandardError; end
5
+ class UnmatchedAttributesError < Error; end
6
+
7
+ class RecordInvalid < Error
8
+ attr_reader :record
9
+
10
+ def initialize(record)
11
+ @record = record
12
+ messages = record.errors.full_messages
13
+ super(messages.empty? ? "Record invalid" : "Validation failed: #{messages.join(", ")}")
14
+ end
15
+ end
16
+ end