Skip to content

refactor!: introduce the adapter contract - #8

Merged
Halvanhelv merged 9 commits into
mainfrom
refactor/adapter-contract
Sep 7, 2026
Merged

Halvanhelv merged 9 commits into
mainfrom
refactor/adapter-contract

Conversation

@Halvanhelv

Copy link
Copy Markdown
Owner

Six tasks of the generalisation work. The rename to translation_diff follows in a separate PR.

The gem has contained no reference to DeepL since 2.0.0 — the API object is supplied by the application and duck typed on one method. But the duck type was never written down (it was whatever deepl-rb happened to expose), and DeepL's request limits were hard-coded into the chunker. This PR fixes both, and one correctness bug that generalising exposed.

The adapter contract

AdapterContract is the interface as an executable test module. Any adapter is validated by including it and defining adapter. Two adapters ship: Adapters::DeepL (current behaviour) and Adapters::Null (tests).

deepl-rb does not become a dependency. The adapter takes its client as an argument and ::DeepL resolves at call time — the same technique that already keeps redis-namespace and ratelimit out of the gemspec. Runtime dependencies remain ox and punkt-segmenter.

Detection is its own method, and optional

Before this, api.translate(sample, nil, to) returned an object of a different type than the same method with a non-nil from — one method, two return types, told apart by an argument's value. Now detect(text) is separate, and an adapter that does not implement it makes from: required with a clear message instead of dying with NoMethodError.

Limits belong to the provider

MAX_CHUNK_SIZE = 1700 and COUNT_LIMIT = 300 were DeepL's numbers living in the core. They are now declared by the adapter and required by Chunker. With one provider a default looks meaningful; with two it silently lies about the second — and this file already shipped a bug of exactly that shape in 2.1.0.

Options are keyword arguments

translate(values, from:, to:, **options). This deletes the options.dup added in 2.1.0 along with the class of bug it worked around: keyword arguments collect a fresh hash per call, so the caller's hash can no longer be consumed.

Cache keys, and a bug generalising exposed

The key gains the provider, a digest of the provider options, and normalised language codes:

before  en:ru:<md5>
after   deepl:en:ru:<digest>:<md5>

Without this, switching providers against the same Redis silently returns the first provider's translations, and formality: :less shares a key with the default. from: "EN" and from: :en also stopped producing two entries for identical work.

The options digest is built on a canonical encoding, not #inspect. Ruby 3.4 changed how symbol-keyed hashes render, so an #inspect-based digest gives different keys on different supported Rubies — upgrading Ruby would have silently re-paid for every translation. Verified that 3.2.4 and 4.0.5 now produce byte-identical keys. Any option value that cannot be encoded stably raises rather than producing an unstable key.

Important

Every cache key changes. Nothing cached by earlier versions is reused.

Test plan

  • bundle exec rake test — 74 runs, 126 assertions, 0 failures (was 46 before this work).
  • bundle exec rubocop — no offences, with no cop disabled anywhere to achieve it.
  • Every task was reviewed independently; three needed a fix round. Two reviews caught tests that passed for the wrong reason and were replaced with ones that fail on the old code.

The interface an api object had to satisfy was never written down: it
was whatever deepl-rb happened to expose. AdapterContract is that
interface as a test module, so any adapter is validated by including it.
Extract DeepLDiff::Adapters into its own file (lib/deepl_diff/adapters.rb)
instead of declaring the module inline in lib/deepl_diff.rb behind a
Style/OneClassPerFile disable. The disable masked a real fix: giving the
module its own file removes the second top-level module declaration that
tripped the cop, with no behavior change.

Replace test_translate_preserves_order's refute_equal check, which only
proved two outputs differ (a reordering adapter passed it), with a test
that compares a batched translate call against per-element individual
calls at the same positions. This also catches ordering bugs that a
naive reversed-input/reversed-output check would miss, since a full
array reversal is its own inverse and cancels out under that comparison.
Unwrapping #text and downcasing the detected language move out of
Request and into the adapter, where they belong. deepl-rb stays out of
the gemspec: the client is an argument and ::DeepL resolves at call time.
MAX_CHUNK_SIZE and COUNT_LIMIT were DeepL's numbers hard-coded into the
core. Every provider has its own, so the adapter declares them and the
chunker requires them.
from and to were keys to be dug out of a positional hash with
Hash#delete, which is what made 2.1.0 copy the caller's hash. Keywords
collect a fresh hash per call, so both the copy and the bug it worked
around are gone.
The prior test only re-verified 2.1.0's dup-the-hash fix, which cannot
distinguish itself from this task's change; add a test asserting the
positional options hash is rejected with ArgumentError, and rename the
old test to say what it actually covers.

Also stop erasing from/to from DeepLDiff.translate's signature: fully
anonymous keyword forwarding satisfied rubocop but made the gem's only
public entry point self-undocumenting. Name from: and to: explicitly
and forward the rest anonymously.
api.translate(sample, nil, to) returned a different type than the same
method with a non-nil from -- one method, two return types, told apart
by an argument value. Detection is its own method now, and is optional:
an adapter without it makes from: required, with a message that says so.
Generalising exposed what one provider hid. The key knew nothing about
the provider, so switching providers against the same Redis returned the
old one's translations, and formality: :less shared a key with the
default. Language codes are downcased while we are here, since this
release invalidates every existing entry anyway.
Hash#inspect changed how it renders symbol-keyed hashes in Ruby 3.4, and
an option value without its own #inspect embeds a memory address.
Either silently changes the cache key across a Ruby upgrade or between
processes, making the application pay for every translation again.
Canonicalise primitives, arrays and hashes explicitly instead, and raise
on anything else so an unstable key is impossible rather than surprising.

Also pins the positional contract between cached_and_missing's keys and
the store's response, since a database-backed store answering an IN
query will not return rows in request order.
@Halvanhelv
Halvanhelv merged commit 94ea541 into main Sep 7, 2026
5 checks passed
@Halvanhelv
Halvanhelv deleted the refactor/adapter-contract branch September 7, 2026 21:42
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