Transform Utils::String and Utils::Hash to modules

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

Quoting Joe Armstrong:

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.