ROM::MapperMissingError in simple scenario

Scenario:
Brand new Rails 6.1 project with postgres database using rom-rails 2.3.0, rom 5.2.6, rom-sql 3.5.0.

Code:

class MembersRelation < ROM::Relation[:sql]
  schema(:members, infer: true)
  auto_map true
end

class MembersMapper < ROM::Transformer
  relation :members, as: :members_mapper

  map do
    nest(:profile, %i[first_name, last_name, email])
    map_value(:profile, &:map_profile)
  end

  def map_profile(profile_attrs)
    Members::Profile.new(*profile_attrs)
  end
end

In rails console run:

ds = ROM::Memory::Dataset.new([{id: SecureRandom.uuid, first_name: 'bob'}])
rel = MembersRelation.new(ds)
rel.map_with(:members_mapper)
Traceback (most recent call last):
        2: from (irb):10
        1: from (irb):11:in `rescue in irb_binding'
ROM::MapperMissingError (:members_mapper doesn't exist in ROM::MapperRegistry registry)

Gives a MapperMissingError even though ROM.env.mappers[:members][:members_mapper] returns the mapper.

What am I missing or doing wrong?

You need to set up your relation using configuration rather than instantiating it manually. Things are wired together through configuration, this way relation will know about its mapper.

Thank you!

I knew it had to be something dumb I was doing. This probably should have been obvious in retrospect, but it’s been probably 10 years since I’ve done any significant amount of work involving DI / service location and containers.

One follow-up question if you get a moment –

How would you configure the ‘default mapper’ for a relation? In my example above I want my default mapping for members to use the MembersMapper because that table contains columns I want to hydrate into a Profile object in the domain model?

Nothing dumb about it :slightly_smiling_face: There’s a bit of a learning curve with ROM setup because it doesn’t rely on global state outside of the setup. Mappers are “detached” from relations that’s why you need to wire things together, which is one of the things that the setup does.

How would you configure the ‘default mapper’ for a relation?

If you call it the same as the relation then it will be used by default. Just do relation :members in the mapper class definition.

The default mapper trick doesn’t seem to be working for me.

Given the scenario:

class MembersRelation < ROM::Relation[:sql]
  schema(:members, infer: true)
  auto_map true
end

class MembersMapper < ROM::Transformer
  relation :members

  map do
    nest(:profile, %i[first_name, last_name, email])
    map_value(:profile, &:map_profile)
  end

  def map_profile(profile_attrs)
    Members::Profile.new(profile_attrs)
  end
end

In the console I do:

rel = ROM.env.relations[:members]
rel.to_a
=>
[{:id=>"eef7ce37-4666-4b0b-a043-2ffa419595f4",
  :company_id=>"f9868b39-d34d-4969-ad8a-9c3f36fa8676",
  :first_name=>"joe",
  :last_name=>"schmoe",
  :email=>"js@example.com",
  :created_at=>2022-03-22 16:03:01.629601 -0400,
  :updated_at=>2022-03-22 16:03:01.629616 -0400}]

I’ve also tried relation :members, as: :members in the MembersRelation with no luck as well. If I’m thinking about this wrong and there is a better way without needing a default mapper to go from a flat table structure like:

members
 - id
 - company_id
 - first_name
 - last_name
 - email

To models like:

module Members
  class Member < ROM::Struct
    attribute :id, Types::Integer
    attribute :company_id, Types::Integer
    attribute :profile, Types.Instance(Profile)
  end
end

module Members
  class Profile < ROM::Struct
    attribute :first_name, Types::String
    attribute :last_name, Types::String
    attribute :email, Types::String
  end
end

I’m definitely open to hearing it!

Ah you enabled auto_map too, IIRC that’s why it doesn’t pick up any mapper because it infers a default mapper for you through auto-mapping. You can just do members.map_with(:your_mapper_name) to explicitly tell the relation which mapper it should use.

hmm… I’ve been trying various combinations of map_with as well. I can map_with directly on the relation in the console and it works just fine. If I however try and define a new method on the repository that uses map_with on the relation it appears to not actually be executing the mapper before trying to instantiate the Member object. :man_shrugging:

Maybe I should ask the question a different way and state the goal I’m going for and just get your advice on what you think the best way to use the various pieces of ROM is to achieve it.

What I want is to take a table like members below and hydrate it into a domain model where the first_name, last_name, email are part of a Profile attached to a Member. If possible I would like to be able to do this in such a way that I don’t need to remember to call map_with everywhere and that the repository just returns correctly hydrated domain objects.

You need to disable auto-mapping, in your repo, you can do this:

def members
  super.with(auto_map: false).map_with(:your_mapper)
end

I hope this works :slightly_smiling_face:

Unfortunately still does not seem to be working. With all the functionality available in ROM this seems like it shouldn’t be a difficult thing to do so I feel like I’m either thinking about the problem wrong or just misconfiguring something that’s not obvious from my questions.

I think I’ll post a minimal reproduction repo on github for inspection when I get a chance. Really appreciate your time looking into this so far!

Please do, if you could provide a gist or a repo that shows what you’re trying to achieve, I will be able to help much faster :slightly_smiling_face:

Apologies @swalke16 , I don’t mean to hijack your topic, but I might have a similar case. Although I’m adding a postgres jsonb column to the mix.

I believe this to be a minimal code sample to reproduce my issue:

require "rom"
require "rom-sql"

module Types
  include Dry.Types()
end

module Models
  class Profile < ROM::Struct
    attribute :name, Types::String
    attribute :contacts, Types::Array do
      attribute :email, Types::String
    end
  end

  class Member < ROM::Struct
    attribute :id, Types::Integer
    attribute :username, Types::String
    attribute :profile, Profile

    def display_name
      "#{username} (id: #{id})"
    end
  end
end

class Members < ROM::Relation[:sql]
  schema(infer: true)
end

class MembersRepo < ROM::Repository::Root
  root :members
  commands :create
  struct_namespace Models

  def by_id(id)
    members.where(id: id).one
  end
end

rom = ROM.container(:sql, "postgres://postgres@localhost/rom_test") do |conf|
  conf.default.tap do |it|
    it.run("DROP TABLE IF EXISTS members")
    it.create_table(:members) do
      primary_key :id
      column :username, :text
      column :profile, :jsonb
    end
  end

  conf.register_relation(Members)
end

m = Models::Member.new(
  id: 1,
  username: "alice",
  profile: { name: "Ms Alice", contacts: [{ email: "alice@example.com" }]}
)
puts m.display_name
puts m.profile.contacts.first.email

repo = MembersRepo.new(rom)
repo.create(
  id: 2,
  username: "bob",
  profile: { name: "Mr Bob", contacts: [{ email: "bob@example.com" }] }
)

m = repo.by_id(2)
puts m.display_name
puts m.profile.contacts.first.email

With results:

alice (id: 1)
alice@example.com
bob (id: 2)
Traceback (most recent call last):
rom_test.rb:71:in `<main>': undefined method `contacts' for {"name"=>"Mr Bob", "contacts"=>[{"email"=>"bob@example.com"}]}:Hash (NoMethodError)

On:

ruby 2.7.0
rom-core 5.2.6
rom-sql 3.5.0
dry-types 1.5.1
dry-struct 1.4.0
sequel 5.54.0

As per the sample run above, coercion works properly when manually instantiating a Models::Member. I was expecting the same to happen when reading from the database and having the repository wrapping the result.

I’m not really sure what I’m doing wrong here. Any help would be greatly appreciated. Please let me know if there’s anything else I can do to help debugging.