From 0680212eb8c3c62167a1bef3edb0d3f492e18470 Mon Sep 17 00:00:00 2001 From: Sang Date: Mon, 28 Sep 2026 20:15:24 +0700 Subject: [PATCH] Guard enum_for's pluralized accessor with respond_to? MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Generator#enum_for` decided whether to emit `Model.pluralized_name.keys` by checking only that the pluralized name matched a Ruby identifier regex, duplicating (but not fully mirroring) ColumnGuard's `enum_keys_expr`. A field whose plural form is a valid identifier but has no actual accessor on the model — renamed or otherwise excluded — made the generator emit a call that raises NoMethodError when the draft loads, a case ColumnGuard's sibling code already guards against via `klass.respond_to?(reader)`. Add the same respond_to? check before emitting the pluralized-accessor expression, falling back to the `defined_enums[...]` spelling exactly as enum_keys_expr does. Co-Authored-By: Claude Sonnet 5 --- lib/permittable/generator.rb | 12 ++++++++++-- spec/generator_spec.rb | 23 +++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/lib/permittable/generator.rb b/lib/permittable/generator.rb index ec28371..6bb8961 100644 --- a/lib/permittable/generator.rb +++ b/lib/permittable/generator.rb @@ -507,7 +507,11 @@ def default_literal(value) # can be CALLED as `Model.name`: a `first-status` enum's # `Order.first-statuses.keys` parses as `Order.first - statuses.keys`, # which runs a query when the draft loads. defined_enums reaches the - # same mapping by name. + # same mapping by name. The model is also checked for the accessor + # itself (mirrors ColumnGuard.enum_keys_expr): a name can pluralize to a + # valid identifier that the model does not actually answer to — renamed + # or otherwise excluded — and calling it would raise NoMethodError when + # the draft loads. def enum_for(model, name) return nil unless model.respond_to?(:defined_enums) @@ -515,7 +519,11 @@ def enum_for(model, name) return nil unless mapping plural = name.pluralize - accessor = METHOD_NAME.match?(plural) ? "#{model.name}.#{plural}" : "#{model.name}.defined_enums[#{name.inspect}]" + accessor = if METHOD_NAME.match?(plural) && model.respond_to?(plural) + "#{model.name}.#{plural}" + else + "#{model.name}.defined_enums[#{name.inspect}]" + end { mapping: mapping, accessor: accessor } end diff --git a/spec/generator_spec.rb b/spec/generator_spec.rb index c2f41e4..6c8f7d6 100644 --- a/spec/generator_spec.rb +++ b/spec/generator_spec.rb @@ -484,6 +484,29 @@ def lock_default expect(violations(klass, { gen_odd_enum: { "first-status" => "ajar" } }, action: "update")) .to eq([{ param: "gen_odd_enum.first-status", code: "inclusion" }]) end + + it "reaches an enum whose pluralized accessor the model does not answer to, so the draft loads" do + stub_const("GenOrderNoReader", Class.new(TestModel) do + self.table_name = "gen_orders" + if ActiveRecord.version >= Gem::Version.new("7.0") + enum :status, { pending: 0, shipped: 1 } + else + enum status: { pending: 0, shipped: 1 } + end + # Simulate an accessor renamed or excluded out from under the enum: + # "statuses" matches the identifier regex, but the model no longer + # responds to it. + singleton_class.send(:undef_method, :statuses) + end) + + draft = described_class.draft(model: GenOrderNoReader) + expect(draft).to include('optional :status, :string, in: GenOrderNoReader.defined_enums["status"].keys') + + klass = load_draft(draft) + expect(violations(klass, { gen_order_no_reader: { status: "shipped" } }, action: "update")).to eq([]) + expect(violations(klass, { gen_order_no_reader: { status: "lost" } }, action: "update")) + .to eq([{ param: "gen_order_no_reader.status", code: "inclusion" }]) + end end context "with defaults the model declares rather than the database" do