Skip to content

Define #hash alongside the eql?/== pairs that lacked one - #2836

Open
ericproulx wants to merge 1 commit into
masterfrom
eql-hash-consistency
Open

Define #hash alongside the eql?/== pairs that lacked one#2836
ericproulx wants to merge 1 commit into
masterfrom
eql-hash-consistency

Conversation

@ericproulx

Copy link
Copy Markdown
Contributor

Summary

Five classes alias eql? to == without defining hash:

Class == keys on
Grape::Endpoint config, inheritable_setting
Grape::Util::InheritableSetting own state, or resolved state across chains
Grape::Middleware::Stack::Middleware the wrapped class
Grape::ServeStream::StreamResponse stream
Grape::ServeStream::FileBody path

Ruby requires equal objects to hash alike. Without hash, each falls back to identity, so equal objects land in different buckets and never dedup in a Hash, a Set or under uniq. Grape::Namespace and Grape::Util::MediaType already pair the two — this brings the rest in line.

Deriving each hash

Each hash mirrors exactly what its == compares, and nothing more:

  • StreamResponse / FileBody key on stream / path alone. Their == does not check the class, so the class stays out of the hash — including it would break pairs that == accepts.

  • Middleware keys on the wrapped class, which also makes it agree with the class itself, something its == accepts. The name.nil? && klass.superclass == other fallback can't be honoured (a class and its superclass hash differently); it only ever serves #index / #include?, which compare with == rather than by hash.

  • Endpoint keys on config and inheritable_setting, omitting the class: its == admits a subclass instance through is_a?, and such a pair must hash alike. The block (#source) is in neither, matching the long-standing duplicate-route check in DSL::Routing#route.

  • InheritableSetting keys on the resolved state (#to_hash). This is the subtle one: its == accepts two instances whose own stores differ as long as their chains resolve alike, so hashing own state would tell those apart. The same-parent fast path implies equal resolved state, so it agrees too.

    This is the cold path — nothing in Grape uses a setting as a Hash key or in a Set, and == keeps avoiding to_hash wherever it can (Compare InheritableSetting without serializing both chains #2828 is untouched).

Notes

  • 10 specs added (2 per class), including one pinning the case that rules out the cheaper option: two InheritableSettings with different own stores that resolve to the same values are == and must hash alike.
  • Verified in both directions: all 10 fail without the change (identity hashes, Set size 2 instead of 1) and pass with it.
  • Middleware's specs live in a new spec/grape/middleware/stack/middleware_spec.rb rather than nested in stack_spec.rb, whose before { subject.use ... } hook and described_class don't apply to the inner class.
  • Full bundle exec rubocop and bundle exec rspec (2526 examples) pass.
  • No caller in Grape relies on hash lookups for these, so this closes a latent hole rather than fixing an observed failure.

🤖 Generated with Claude Code

Five classes alias eql? to == without defining hash: Grape::Endpoint,
Grape::Util::InheritableSetting, Grape::Middleware::Stack::Middleware,
Grape::ServeStream::StreamResponse and Grape::ServeStream::FileBody.
Ruby requires equal objects to hash alike, so each of them silently
misbehaves in a Hash, a Set or under uniq -- equal objects land in
different buckets and never dedup. Grape::Namespace and
Grape::Util::MediaType already pair the two; this brings the rest in line.

Each hash mirrors exactly what its == compares, and nothing more:

- StreamResponse / FileBody key on stream / path alone. Their == does not
  check the class, so the class stays out of the hash.
- Middleware keys on the wrapped class, which also makes it agree with the
  class itself -- something its == accepts. The superclass fallback cannot
  be honoured (a class and its superclass hash differently); it only ever
  serves #index / #include?, which compare with == rather than by hash.
- Endpoint keys on config and inheritable_setting, omitting the class,
  since its == admits a subclass through is_a? and such a pair must agree.
- InheritableSetting keys on the resolved state (#to_hash). Its == accepts
  two instances whose own stores differ as long as their chains resolve
  alike, so hashing own state would tell those apart. The same-parent fast
  path implies equal resolved state, so it agrees too. This is the cold
  path -- nothing in Grape uses a setting as a Hash key, and == keeps
  avoiding to_hash wherever it can.

No caller in Grape relies on hash lookups for these, so this closes a
latent hole rather than fixing an observed failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Danger Report

No issues found.

View run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant