How to specify custom Types when backed by SQL

require 'bundler/inline'

gemfile do
  source 'https://rubygems.org'
  gem 'dry-types'
  gem 'rom'
  gem 'rom-sql'
  gem 'sqlite3'
end

module Marv
  module Girt
    module Entities
      Types = Dry.Types() # Note sure how to use ROM::Struct here?
      module Enums
        ALERT_TYPE = Types::Symbol.enum(
          SMS: 1,
          EMAIL: 2,
          )
      end
    end
  end
end

module Marv
  module Girt
    module Entities
      class Base < ROM::Struct
        include Enums
        transform_keys(&:to_sym)
        schema(schema.strict)
      end
    end
  end
end

module Marv
  module Girt
    module Entities
      # It is instantiating this User object, but not using these attributes for validation
      class User < Base
        attribute(:id, Types::Integer)
        attribute(:name, Types::String)
        attribute(:email, Types::String.optional)
        # Not sure how to get this to validate?
        attribute(:alert_type, ALERT_TYPE)
      end
    end
  end
end

rom = ROM.container(:sql, 'sqlite::memory') do |conf|
  conf.default.create_table(:users) do
    primary_key :id
    column :name, String, null: false
    column :email, String
    column :alert_type, String
  end

  class Users < ROM::Relation[:sql]
    schema(:users, infer: true)

    auto_struct true
  end

  conf.register_relation(Users)
end

class UserRepo < ROM::Repository[:users]
  struct_namespace Marv::Girt::Entities
  commands :create, update: :by_pk, delete: :by_pk
end

user_repo = UserRepo.new(rom)

# get auto-generated User struct
model = user_repo.users.mapper.model
puts "model: #{model}"
# => Marv::Girt::Entities::User

puts model.schema.key(:id)
# => #<Dry::Types[id: Nominal<Integer meta={primary_key: true, alias: nil, source: :users}>]>

# Allows create with no "alert_type" specified
puts user = user_repo.create(name: "Jane", email: "jane@doe.org")
# => #<Marv::Girt::Entities::User:0x000000015aa2bce0>
puts user.name
# => Jane
puts user.alert_type
# =>

# Allows update with invalid "alert_type" specified
puts updated_user = user_repo.update(user.id, name: "Jane Doe", alert_type: "FISH")
# => #<Marv::Girt::Entities::User:0x000000015aa0a6a8>
puts updated_user.name
# => Jane Doe
puts updated_user.alert_type
# => FISH

puts count = user_repo.relations[:users].count
# => 1

puts user2 = user_repo.create(name: "Wayne", email: "wayne@doe.org")
# => #<Marv::Girt::Entities::User:0x000000015aa00270>
puts user2.name
# => Wayne

puts count2 = user_repo.relations[:users].count
# => 2

My Types are not used at all for validation. It’s using the class name, but otherwise, having no effect. Also, I am not sure how to use ROM::Types at all, in the manner I have been using Dry::Types, and I can’t find any relevant documentation.

The reason I want to do this is to be able to share my core set of entities across repositories that are backed by rom-http, rom-sql, and rom-json.

Hi @pboling, thanks for providing a very clear reproduction of the problem.

I am not an authoritative source of information on ROM, but I have been using it to build a modestly complicated system for a couple years, so I have some advice.

First, here’s a minimal implementation of how I would approach this

module ROM
  module Types
    ALERT_TYPE = String.enum("SMS", "EMAIL")
  end
end

module Marv
  module Girt
    module Entities
      class User < ROM::Struct
        def alert_type = attributes[:alert_type].to_sym
      end
    end
  end
end

rom = ROM.container(:sql, 'sqlite::memory') do |conf|
  conf.default.create_table(:users) do
    primary_key :id
    column :name, String, null: false
    column :email, String
    column :alert_type, String
    check(alert_type: ROM::Types::ALERT_TYPE.values)
  end

  class Users < ROM::Relation[:sql]
    schema(:users, infer: true) do
      attribute :alert_type, Types::ALERT_TYPE
    end
  end

  conf.register_relation(Users)
end

class UserRepo < ROM::Repository[:users]
  struct_namespace Marv::Girt::Entities
  commands :create, update: :by_pk, delete: :by_pk
end

user_repo = UserRepo.new(rom)

Type Declaration

ROM::Types are just Dry::Types. I keep my application types separate from my database types, but in this case I’m treating ALERT_TYPE as a database type.

An Enum type means that there is a finite number of states, and anything outside of those states are not allowed. (I have strong opinions about enums and database fields)

So I believe this constraint should ultimately be enforced at the DB level. To that end I added a CHECK constraint for this, since Sqlite does not have native enum support. This is not the only level of data validation, but it should be validated in depth.

Since you are storing the value as a string field, I see no reason at all why an integer mapping is necessary.

This implementation of ALERT_TYPE results in a schema validation failure when attempting to write invalid data

[1] pry(main)> user = user_repo.create(name: "Jane", email: "jane@doe.org", alert_type: "SMS")
=> #<Marv::Girt::Entities::User alert_type="SMS" id=1 name="Jane" email="jane@doe.org">
[2] pry(main)> user_repo.update(user.id, alert_type: "FISH")
Dry::Types::SchemaError: "FISH" (String) has invalid type for :alert_type violates constraints (incl
uded_in?(["SMS", "EMAIL"], "FISH") failed)
from /Users/adam.lassek/.gem/ruby/3.2.1/gems/dry-types-1.7.1/lib/dry/types/schema.rb:332:in `rescue 
in block in resolve_unsafe'
Caused by Dry::Types::ConstraintError: "FISH" violates constraints (included_in?(["SMS", "EMAIL"], "
FISH") failed)
from /Users/adam.lassek/.gem/ruby/3.2.1/gems/dry-types-1.7.1/lib/dry/types/constrained.rb:37:in `cal
l_unsafe'

You could make this type automatically coerce the value into symbols like you were doing, but I recommend against this because it would make the attribute more annoying to write.

module ROM
  module Types
    ALERT_TYPE = Coercible::Symbol.enum(:SMS, :EMAIL)
  end
end

rom = ROM.container(:sql, 'sqlite::memory') do |conf|
  conf.default.create_table(:users) do
    primary_key :id
    column :name, String, null: false
    column :email, String
    column :alert_type, String
    check(alert_type: ROM::Types::ALERT_TYPE.values.map(&:to_s))
  end
end

class Users < ROM::Relation[:sql]
  schema(:users, infer: true) do
    attribute :alert_type, Types::ALERT_TYPE, write: Types::String
  end
end

Entity Struct

Instead of automatically coercing ALERT_TYPE to symbol, I demonstrated what struct classes are actually used for: simple helper methods.

Entities are not models. They do not validate inputs, they are just data containers. In most cases you don’t even need to declare your own struct class, but if there is one it will become the base class that the struct builder starts from.

1 Like