Attribute serialization

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.

  1. 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 is Sequel::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.

.

  1. 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?

It doesn’t work because you’re using SQLite, something about that adapter is causing the type error and I’m not certain why. The example does work with PostgreSQL.

Here’s another way you might approach it:

#!/usr/bin/env ruby
# frozen_string_literal: true

require 'bundler/inline'

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

require 'rom'
require 'rom/sql'

rom_config = ROM::Configuration.new(:sql, 'sqlite::memory')
gateway    = rom_config.default_gateway

migration = gateway.migration do
  change do
    create_table :items do
      primary_key :id
      column :tags, 'text', null: false
    end
  end
end

migration.apply(gateway.connection, :up)

module Structs
  class Item < ROM::Struct
    def tags
      @tags ||= JSON.parse(attributes[:tags])
    end
  end
end

class Items < ROM::Relation[:sql]
  Tags = Types.define(Types::String) do
    input { |tags| ::JSON.dump(Array(tags)) }
    output { |json| json }
  end

  schema :items, infer: true do
    attribute :tags, Tags
  end
end

rom_config.register_relation(Items)

container = Dry::Core::Container.new
container.register("persistence.rom", ROM.container(rom_config))

Deps = Dry::AutoInject(container)

class Repository < ROM::Repository::Root
  include Deps[container: "persistence.rom"]
  def find(id) = root.by_pk(id).one
end

class ItemRepo < Repository[:items]
  struct_namespace Structs
  commands :create, update: :by_pk, delete: :by_pk
end

repo = ItemRepo.new

Pry.start(self)
[1] pry(main)> item = repo.create(tags: %w[foo bar])
=> #<Structs::Item tags="[\"foo\",\"bar\"]" id=1>
[2] pry(main)> item.tags
=> ["foo", "bar"]
[3] pry(main)> item = repo.update(item.id, tags: item.tags + ['baz'])
=> #<Structs::Item tags="[\"foo\",\"bar\",\"baz\"]" id=1>
[4] pry(main)> item.tags
=> ["foo", "bar", "baz"]

In the db the column is text NOT NULL. SQLite has native support for JSON columns but as far as I can tell ROM doesn’t use it.

Thank you for the workaround.

BTW, since you mention there’s some sort of error do you think it’s worth filing an issue?

SQLite does not have JSON datatypes as far as I can tell. It has built-in helper functions for working with JSON & JSONB, but the underlying column type is TEXT or BLOB. That makes it more difficult for an adapter to transparently support them as JSON, since it can’t differentiate unstructured TEXT from JSON TEXT.

However, it should be possible to override the behavior with the correct serialization rules, as you are trying to do. I would suggest opening a ticket, since this does appear to be a bug somewhere, but I can’t guarantee it will be looked at in the near term.