Proposal
Remove Utils::Hash
and Utils::String
as types that can be instantiated and make the current operations available as class methods.
Hanami::Utils::Hash.symbolize("a" => 1)
# => {:a=>1}
Background
The need for Utils::Hash and Utils::String
as types
The initial idea of Utils::Hash
(and Utils::String
) was demanded to the need of perform hash operations that aren’t provided by Ruby’s Hash
.
One example is “symbolization”: transform hash keys from strings into symbols.
Hanami::Utils::Hash.new("a" => 1).symbolize!
# => {:a=>1}
The need of hold a Hash
inside Utils::Hash
was demanded by “chainability”: perform one operation using the mutated state of the internal Hash
as input for the next operation.
One example is “deep duplication” and then “symbolization”:
Hanami::Utils::Hash.new("a" => 1).deep_dup.symbolize!
# => {:a=>1}
Problems with this approach
Uknown types at the boundary of the framework
Introducing new types with the purpose of making them interchangeable with their Ruby’s counterparts Hash
and String
requires special attention for our internals and it’s easy to make mistakes.
Using custom data types at the boundary of our framework can be source of bugs or crashes. For instance, if we put Utils::String
inside a Rack env, we can’t expect it to work.
We want to make sure to use Hash
and String
at the boundary of our framework.
Method Missing
To fix the previous problem we can make sure that operations like Utils::String#classify
to return a String
instance.
But there is more: because we use method_missing
to proxy common String
operations and then we use to wrap it into Utils::String
:
string = Hanami::Utils::String.new("book")
string.upcase.class # => Hanami::Utils::String
We can NOT really control the boundary of our framework if method_missing
keeps to return instances of Utils::String
.
But if we fix method_missing
we’ll break the existing behavior:
string = Hanami::Utils::String.new("book")
# actual
string.upcase.pluralize
# => "BOOKS"
# "fixed" method_missing
string.upcase.pluralize
# => NoMethodError: undefined method `pluralize' for "BOOK":String
The banana problem
You wanted a banana but what you got was a gorilla holding the banana and the entire jungle.
What we want are data transformations, adding types is only a natural way to proceed in OOP, but it could be unnecessary infrastructure.
Ruby’s frozen string
Some Utils::String
operations allows string mutations. It could be the internal string, it could be an intermediary string, it could be that Utils::String#gsub!
is exposed as effect of method_missing
(see above).
Starting from Ruby 3.0, strings mutations won’t be allowed anymore. We want to provide a future proof API for string transformations.
Aggressive transformations
Utils::Hash#initialize
tries to coerce any input that respond to #to_hash
. If we pass a Hash
, or a Utils::Hash
, it’s fine.
When we pass complex data structures like Hanami::Entity
, because they respond to that coercion protocol (#to_hash
), we get the side effect of serializing the entity into a Hash, but this isn’t what we wanted. See schema calls `deep_symbolize` turning everything into hashes · Issue #394 · hanami/model · GitHub
Proposed solution
Transform Utils::String
/ Utils::Hash
into modules and expose transformations at the class level.
result = Hanami::Utils::String.classify("book")
result # => "Book"
result.class # => String
Pros
Strict boundaries
If we get rid of these types we have solved the boundary problem described above: we’ll always use Ruby’s String
and Hash
as data to pass around.
Fine grained interfaces
Removing Utils::String
as type, method_missing
will be gone too. This allows us to expose fine grained interface.
As now, Utils::String
respond to 186
methods. Most of them are “inherited” via method_missing
from String
, which has 175
. But still we have this “implicit” public interface for Utils::String
that we don’t even test.
The goal is to slim down the public interface to only to the dozen operations that we support.
No more mutations
We can ensure that string mutations don’t happen, by changing the implementation of these new functions.
Controlled transformations
Because we don’t need to provide intermediary objects and/or wrap into Utils::String
/ Utils::Hash
, the transformations don’t accidentally coerce complex objects like Hanami::Entity
into Utils::Hash
.
Make hard them to be used the wrong way
The main purpose of Utils::String
and Utils::Hash
is to build the internals of the framework.
Developers started using these objects as a counterpart of ActiveSupport
, but without the monkey-patching.
But this has driven to a problem that affects both our internals and existing Hanami projects. Meaningless chain of methods:
result = Hanami::Utils::String.new(controller_and_action_name).underscore.gsub(QUOTED_NAME, '')
What is this about? It isn’t clear.
In the same way we forbid repositories to have meaningless chain of query methods leaked on the outside, we should prevent the same problem here.
I’d like to encourage explicit composability. Let’s rewrite that line above:
module Hanami
module Commands
module Generate
module ControllerActionEndpointName
QUOTED_NAME = /(\"|\'|\\)/
QUOTED_NAME_REPLACEMENT = "".freeze
def self.call(input)
Utils::String.underscore(input).gsub(QUOTED_NAME, QUOTED_NAME_REPLACEMENT)
end
end
end
end
end
result = Hanami::Commands::Generate::ControllerActionEndpointName.call(controller_and_action_name)
It’s really easy to reason about this.
In the same way, I’d like developers to be forced to wrap chains of methods behind explicit, meaningful, reusable operations.