I’m trying to figure out how to serialize attribute to store in the db.
I have Items, they can have multiple tags. On the app side I want them represented as an array of strings but in the db it’s stored in a string column.
As an example let’s use JSON.
In Hanami docs I saw this example that I use as a basis:
module Bookshelf
module Relations
class Credentials < Hanami::DB::Relation
JWKS = Types.define(JWT::JWK::Set) do
input { |jwks| Types::PG::JSONB[jwks.export] }
output { |jsonb| JWT::JWK::Set.new(jsonb.to_h) }
end
schema infer: true do
attribute :jwks, JWKS
end
end
end
end
I came up with this:
module Example
module Relations
class Items < Example::DB::Relation
JSON = ROM::SQL::Types.define(Array) do
input { |tags| ::JSON.dump(Array(tags)) }
output { |json| Array(::JSON.parse(json)) }
end
schema :items, infer: true do
attribute :tags, JSON
end
end
end
end
Now, I encountered a few issue that I want to understand.
- Hanami uses
books.insert
to create new records. It doesn’t use type coercion in any way. I figured out that it delegates straight to a dataset method (that isSequel::Dataset
) so it’s kinda understandable that it wouldn’t use any of the ROM features but it’s exposed on a ROM object so at least a warning would be nice, I guess.
.
- I also figured out that the proper way is to use a repo command. And it sort of works. However, it seems the
output
part is called twice: once with a JSON string that comes from the DB, and the second time with already deserialized value.
I added value an call stack output to the input
and output
blocks of the type definition. Here’s an example from the Hanami console:
example[development]> Hanami.app["repos.items_repo"].create(tags: 'ney')
# input
[example] [INFO] [2025-06-23 12:26:50 +0300] Loaded :sqlite in 1ms INSERT INTO `items` (`tags`) VALUES ('["ney"]')
# output: "[\"ney\"]"
["~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/dry-types-1.8.3/lib/dry/types/constructor/function.rb:17:in 'Dry::Types::Constructor::Function::Safe#call'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/dry-types-1.8.3/lib/dry/types/constructor.rb:80:in 'Dry::Types::Constructor#call_unsafe'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/dry-types-1.8.3/lib/dry/types/schema/key.rb:44:in 'Dry::Types::Schema::Key#call_unsafe'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/dry-types-1.8.3/lib/dry/types/schema.rb:328:in 'block in Dry::Types::Schema#resolve_unsafe'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/dry-types-1.8.3/lib/dry/types/schema.rb:322:in 'Hash#each'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/dry-types-1.8.3/lib/dry/types/schema.rb:322:in 'Dry::Types::Schema#resolve_unsafe'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/dry-types-1.8.3/lib/dry/types/schema.rb:60:in 'Dry::Types::Schema#call_unsafe'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/dry-types-1.8.3/lib/dry/types/constructor.rb:80:in 'Dry::Types::Constructor#call_unsafe'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/dry-types-1.8.3/lib/dry/types/type.rb:47:in 'Dry::Types::Type#call'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/rom-core-5.4.0/lib/rom/relation.rb:224:in 'block in ROM::Relation#each'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/sequel-5.93.0/lib/sequel/dataset/actions.rb:164:in 'block in Sequel::Dataset#each'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/sequel-5.93.0/lib/sequel/adapters/sqlite.rb:425:in 'block (2 levels) in Sequel::SQLite::Dataset#fetch_rows'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/sqlite3-2.7.0-x86_64-darwin/lib/sqlite3/resultset.rb:50:in 'SQLite3::ResultSet#each'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/sequel-5.93.0/lib/sequel/adapters/sqlite.rb:414:in 'block in Sequel::SQLite::Dataset#fetch_rows'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/sqlite3-2.7.0-x86_64-darwin/lib/sqlite3/database.rb:356:in 'SQLite3::Database#query'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/sequel-5.93.0/lib/sequel/adapters/sqlite.rb:259:in 'block (2 levels) in Sequel::SQLite::Database#_execute'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/sequel-5.93.0/lib/sequel/extensions/error_sql.rb:63:in 'Sequel::ErrorSQL#log_connection_yield'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/rom-sql-3.7.0/lib/rom/plugins/relation/sql/instrumentation.rb:83:in 'block (2 levels) in define_log_connection_yield'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/dry-monitor-1.0.1/lib/dry/monitor/clock.rb:15:in 'Dry::Monitor::Clock#measure'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/dry-monitor-1.0.1/lib/dry/monitor/notifications.rb:29:in 'Dry::Monitor::Notifications#instrument'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/rom-sql-3.7.0/lib/rom/plugins/relation/sql/instrumentation.rb:82:in 'block in define_log_connection_yield'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/sequel-5.93.0/lib/sequel/adapters/sqlite.rb:259:in 'block in Sequel::SQLite::Database#_execute'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/sequel-5.93.0/lib/sequel/connection_pool/timed_queue.rb:90:in 'Sequel::TimedQueueConnectionPool#hold'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/sequel-5.93.0/lib/sequel/database/connecting.rb:283:in 'Sequel::Database#synchronize'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/sequel-5.93.0/lib/sequel/adapters/sqlite.rb:252:in 'Sequel::SQLite::Database#_execute'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/sequel-5.93.0/lib/sequel/adapters/sqlite.rb:170:in 'Sequel::SQLite::Database#execute'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/sequel-5.93.0/lib/sequel/dataset/actions.rb:1197:in 'Sequel::Dataset#execute'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/sequel-5.93.0/lib/sequel/adapters/sqlite.rb:407:in 'Sequel::SQLite::Dataset#fetch_rows'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/sequel-5.93.0/lib/sequel/dataset/actions.rb:164:in 'Sequel::Dataset#each'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/sequel-5.93.0/lib/sequel/dataset/actions.rb:474:in 'Enumerable#map'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/sequel-5.93.0/lib/sequel/dataset/actions.rb:474:in 'Sequel::Dataset#map'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/rom-core-5.4.0/lib/rom/relation.rb:224:in 'ROM::Relation#each'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/rom-core-5.4.0/lib/rom/relation.rb:353:in 'Enumerator#each'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/rom-core-5.4.0/lib/rom/relation.rb:353:in 'Enumerable#to_a'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/rom-core-5.4.0/lib/rom/relation.rb:353:in 'ROM::Relation#to_a'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/rom-sql-3.7.0/lib/rom/sql/commands/create.rb:49:in 'ROM::SQL::Commands::Create#insert'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/rom-sql-3.7.0/lib/rom/sql/commands/create.rb:33:in 'ROM::SQL::Commands::Create#execute'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/rom-core-5.4.0/lib/rom/command.rb:280:in 'ROM::Command#call'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/rom-sql-3.7.0/lib/rom/sql/commands/error_wrapper.rb:18:in 'ROM::SQL::Commands::ErrorWrapper#call'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/rom-core-5.4.0/lib/rom/commands/composite.rb:21:in 'ROM::Commands::Composite#call'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/rom-repository-5.4.2/lib/rom/repository/class_interface.rb:154:in 'block in Example::Repos::ItemsRepo#define_command_method'",
"(irb):1:in '<top (required)>'",
"~/.rbenv/versions/3.4.1/lib/ruby/3.4.0/irb/workspace.rb:101:in 'Kernel#eval'",
"~/.rbenv/versions/3.4.1/lib/ruby/3.4.0/irb/workspace.rb:101:in 'IRB::WorkSpace#evaluate'",
"~/.rbenv/versions/3.4.1/lib/ruby/3.4.0/irb/context.rb:631:in 'IRB::Context#evaluate_expression'",
"~/.rbenv/versions/3.4.1/lib/ruby/3.4.0/irb/context.rb:599:in 'IRB::Context#evaluate'",
"~/.rbenv/versions/3.4.1/lib/ruby/3.4.0/irb.rb:1053:in 'block (2 levels) in IRB::Irb#eval_input'",
"~/.rbenv/versions/3.4.1/lib/ruby/3.4.0/irb.rb:1365:in 'IRB::Irb#signal_status'",
"~/.rbenv/versions/3.4.1/lib/ruby/3.4.0/irb.rb:1045:in 'block in IRB::Irb#eval_input'",
"~/.rbenv/versions/3.4.1/lib/ruby/3.4.0/irb.rb:1124:in 'block in IRB::Irb#each_top_level_statement'",
"<internal:kernel>:168:in 'Kernel#loop'",
"~/.rbenv/versions/3.4.1/lib/ruby/3.4.0/irb.rb:1121:in 'IRB::Irb#each_top_level_statement'",
"~/.rbenv/versions/3.4.1/lib/ruby/3.4.0/irb.rb:1044:in 'IRB::Irb#eval_input'",
"~/.rbenv/versions/3.4.1/lib/ruby/3.4.0/irb.rb:1025:in 'block in IRB::Irb#run'",
"~/.rbenv/versions/3.4.1/lib/ruby/3.4.0/irb.rb:1024:in 'Kernel#catch'",
"~/.rbenv/versions/3.4.1/lib/ruby/3.4.0/irb.rb:1024:in 'IRB::Irb#run'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/hanami-cli-2.2.1/lib/hanami/cli/repl/irb.rb:34:in 'Hanami::CLI::Repl::Irb#start'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/hanami-cli-2.2.1/lib/hanami/cli/commands/app/console.rb:48:in 'Hanami::CLI::Commands::App::Console#call'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/hanami-cli-2.2.1/lib/hanami/cli/commands/app/command.rb:46:in 'Hanami::CLI::Commands::App::Command::Environment#call'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/dry-cli-1.2.0/lib/dry/cli.rb:117:in 'Dry::CLI#perform_registry'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/dry-cli-1.2.0/lib/dry/cli.rb:66:in 'Dry::CLI#call'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/hanami-cli-2.2.1/exe/hanami:11:in '<top (required)>'",
"~/.rbenv/versions/3.4.1/bin/hanami:25:in 'Kernel#load'",
"~/.rbenv/versions/3.4.1/bin/hanami:25:in '<main>'"]
[example] [INFO] [2025-06-23 12:26:50 +0300] Loaded :sqlite in 3ms SELECT `items`.`tags`, `items`.`id` FROM `items` WHERE (`id` IN (10)) ORDER BY `items`.`id`
# output: ["ney"]
["~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/dry-types-1.8.3/lib/dry/types/constructor/function.rb:17:in 'Dry::Types::Constructor::Function::Safe#call'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/dry-types-1.8.3/lib/dry/types/constructor.rb:80:in 'Dry::Types::Constructor#call_unsafe'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/dry-types-1.8.3/lib/dry/types/schema/key.rb:44:in 'Dry::Types::Schema::Key#call_unsafe'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/dry-types-1.8.3/lib/dry/types/schema.rb:328:in 'block in Dry::Types::Schema#resolve_unsafe'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/dry-types-1.8.3/lib/dry/types/schema.rb:322:in 'Hash#each'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/dry-types-1.8.3/lib/dry/types/schema.rb:322:in 'Dry::Types::Schema#resolve_unsafe'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/dry-types-1.8.3/lib/dry/types/schema.rb:60:in 'Dry::Types::Schema#call_unsafe'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/dry-types-1.8.3/lib/dry/types/constructor.rb:80:in 'Dry::Types::Constructor#call_unsafe'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/dry-types-1.8.3/lib/dry/types/type.rb:47:in 'Dry::Types::Type#call'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/rom-sql-3.7.0/lib/rom/sql/commands/create.rb:41:in 'block in ROM::SQL::Commands::Create#finalize'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/rom-sql-3.7.0/lib/rom/sql/commands/create.rb:41:in 'Array#map'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/rom-sql-3.7.0/lib/rom/sql/commands/create.rb:41:in 'ROM::SQL::Commands::Create#finalize'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/rom-core-5.4.0/lib/rom/command.rb:461:in 'block in ROM::Command#apply_hooks'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/rom-core-5.4.0/lib/rom/command.rb:456:in 'Array#each'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/rom-core-5.4.0/lib/rom/command.rb:456:in 'Enumerable#reduce'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/rom-core-5.4.0/lib/rom/command.rb:456:in 'ROM::Command#apply_hooks'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/rom-core-5.4.0/lib/rom/command.rb:291:in 'ROM::Command#call'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/rom-sql-3.7.0/lib/rom/sql/commands/error_wrapper.rb:18:in 'ROM::SQL::Commands::ErrorWrapper#call'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/rom-core-5.4.0/lib/rom/commands/composite.rb:21:in 'ROM::Commands::Composite#call'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/rom-repository-5.4.2/lib/rom/repository/class_interface.rb:154:in 'block in Example::Repos::ItemsRepo#define_command_method'",
"(irb):1:in '<top (required)>'",
"~/.rbenv/versions/3.4.1/lib/ruby/3.4.0/irb/workspace.rb:101:in 'Kernel#eval'",
"~/.rbenv/versions/3.4.1/lib/ruby/3.4.0/irb/workspace.rb:101:in 'IRB::WorkSpace#evaluate'",
"~/.rbenv/versions/3.4.1/lib/ruby/3.4.0/irb/context.rb:631:in 'IRB::Context#evaluate_expression'",
"~/.rbenv/versions/3.4.1/lib/ruby/3.4.0/irb/context.rb:599:in 'IRB::Context#evaluate'",
"~/.rbenv/versions/3.4.1/lib/ruby/3.4.0/irb.rb:1053:in 'block (2 levels) in IRB::Irb#eval_input'",
"~/.rbenv/versions/3.4.1/lib/ruby/3.4.0/irb.rb:1365:in 'IRB::Irb#signal_status'",
"~/.rbenv/versions/3.4.1/lib/ruby/3.4.0/irb.rb:1045:in 'block in IRB::Irb#eval_input'",
"~/.rbenv/versions/3.4.1/lib/ruby/3.4.0/irb.rb:1124:in 'block in IRB::Irb#each_top_level_statement'",
"<internal:kernel>:168:in 'Kernel#loop'",
"~/.rbenv/versions/3.4.1/lib/ruby/3.4.0/irb.rb:1121:in 'IRB::Irb#each_top_level_statement'",
"~/.rbenv/versions/3.4.1/lib/ruby/3.4.0/irb.rb:1044:in 'IRB::Irb#eval_input'",
"~/.rbenv/versions/3.4.1/lib/ruby/3.4.0/irb.rb:1025:in 'block in IRB::Irb#run'",
"~/.rbenv/versions/3.4.1/lib/ruby/3.4.0/irb.rb:1024:in 'Kernel#catch'",
"~/.rbenv/versions/3.4.1/lib/ruby/3.4.0/irb.rb:1024:in 'IRB::Irb#run'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/hanami-cli-2.2.1/lib/hanami/cli/repl/irb.rb:34:in 'Hanami::CLI::Repl::Irb#start'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/hanami-cli-2.2.1/lib/hanami/cli/commands/app/console.rb:48:in 'Hanami::CLI::Commands::App::Console#call'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/hanami-cli-2.2.1/lib/hanami/cli/commands/app/command.rb:46:in 'Hanami::CLI::Commands::App::Command::Environment#call'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/dry-cli-1.2.0/lib/dry/cli.rb:117:in 'Dry::CLI#perform_registry'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/dry-cli-1.2.0/lib/dry/cli.rb:66:in 'Dry::CLI#call'",
"~/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/hanami-cli-2.2.1/exe/hanami:11:in '<top (required)>'",
"~/.rbenv/versions/3.4.1/bin/hanami:25:in 'Kernel#load'",
"~/.rbenv/versions/3.4.1/bin/hanami:25:in '<main>'"]
I would like to understand the second point. Specifically when the type coercion happens and in which way. Is this expected and do I need to accept all possible inputs to both input
and output
or can I expect specific inputs to each?